claude-artifact-framework 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt ADDED
@@ -0,0 +1,98 @@
1
+ # claude-artifact-framework
2
+
3
+ > Declarative framework for building apps inside Claude Artifacts. Declare what
4
+ > the app IS (fields, records, steps, chat with tools, workspace, documents);
5
+ > the framework owns persistence, hydration, validation, empty/loading/error
6
+ > states, light+dark theming, navigation, and the Claude tool loop.
7
+ > Full documentation: README.md (this file is the compact digest).
8
+
9
+ Load (classic script, global `ArtifactKit`; call `ArtifactKit.useReact(React)` once):
10
+ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
11
+
12
+ ## Registries (any other name throws, naming the valid set)
13
+
14
+ - Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
15
+ steps, chat, main, sidebar, documents, data, shared, libs, machine,
16
+ brand:{logo, footer} (logo: emoji | inline <svg> | https URL | omit for an
17
+ automatic monogram)
18
+ - Layouts: panel (default), records, steps, chat, workspace, documents
19
+ - Block types: fields, output, text, list, chart, timer, actions, banner
20
+ ({image?, title, subtitle} — no image paints a seed-derived gradient),
21
+ image ({src: url|inline-svg, caption, fit, height}), chat, tabs, records,
22
+ custom — plus per-block title, icon (an emoji, prefixes the title),
23
+ when:(d)=>bool, state:"fase", collapsible, wide, fill (custom only)
24
+ - Field types: text, textarea, number, money, percent, check, date, select,
25
+ file — attrs: key, label, value, required, min, max, step, placeholder,
26
+ hint, rows, options (array or (d)=>array), accept
27
+ - records spec: label, fields, title, subtitle, sort, summary, empty, search,
28
+ compute, items, actions, avatar (true = initials circles per row)
29
+ (+ key when used as a block)
30
+ - documents: label, title, blocks OR types, empty; machine: key, initial,
31
+ states, events (event: from, do, then); chat: system, greeting, placeholder,
32
+ tools, model, maxTokens; steps step: title, text, fields, result
33
+ - Formats: money, percent (0-100), number, date. Output/chart/compute items:
34
+ { label, value, format, big }
35
+
36
+ ## Hard rules
37
+
38
+ 1. Write the spec INDENTED, never minified (measured: minified output drops
39
+ closing braces).
40
+ 2. Theme variables, never hardcoded hex: var(--caf-text), var(--caf-surface),
41
+ var(--caf-accent), var(--caf-border), var(--caf-sunken), var(--caf-muted),
42
+ var(--caf-danger); ctx.colors(n) for series. Hardcoded #fff breaks dark mode.
43
+ 3. Never render a <form> (sandbox lacks allow-forms) — inputs + button onClick.
44
+ 4. Never write big strings (data URLs, file contents) into data — one ~5MB
45
+ storage key holds the whole pool; big things live in React.useState or
46
+ ctx.files.
47
+ 5. Phases (games, turns, processes) belong to `machine` — ctx.send(event,
48
+ payload) is the only door; events are no-ops outside their `from` states;
49
+ `after: {seconds, then, do}` timers are framework-owned.
50
+ 6. Row actions receive (record, data, viewerId); block actions (data,
51
+ viewerId). Stop early if you like, but never skip a middle argument.
52
+ 7. One big:true per screen; the constructive action takes kind:"primary"; a
53
+ counter changed by pressing is an action, not an editable number field.
54
+ 8. Parts of a record are `items` — never JSON in a textarea.
55
+ 9. Don't hand-build empty/loading/error states — the framework owns them.
56
+ 10. CDN libs via libs:[urls]: call the method, not the global (marked.parse);
57
+ pin versions; unsure of the file path → bare package URL
58
+ (https://cdn.jsdelivr.net/npm/<pkg>@<version>).
59
+ 11. Framework record ids are strings — never Number(record.id).
60
+ 12. In custom blocks: return strings/React elements (never DOM elements — use
61
+ ref + React.useEffect for libs that write into an element); hooks are
62
+ allowed; persistent state via ctx.update, ephemeral via React.useState,
63
+ never createStore for UI state.
64
+ 13. Brand is zero-asset-first: monogram/gradient/emoji defaults before URLs;
65
+ inline <svg> over remote images; one icon style per app (emoji-first);
66
+ avatar: true instead of hand-built initials circles.
67
+
68
+ ## Canonical example
69
+
70
+ ArtifactKit.createApp({
71
+ title: "Gastos",
72
+ theme: { seed: "#c2410c" },
73
+ layout: "records",
74
+ shared: false,
75
+ records: {
76
+ label: "gasto",
77
+ fields: [
78
+ { key: "concepto", label: "Concepto", type: "text", required: true },
79
+ { key: "monto", label: "Monto", type: "money", value: 0, min: 0 },
80
+ { key: "categoria", label: "Categoría", type: "select", options: ["comida", "transporte", "otros"] },
81
+ ],
82
+ subtitle: (r) => r.categoria,
83
+ summary: (all) => [
84
+ { label: "Total", value: all.reduce((s, r) => s + (Number(r.monto) || 0), 0), format: "money", big: true },
85
+ ],
86
+ actions: [{ label: "Duplicar", run: (r, d) => { d.records = [...d.records, { ...r, id: undefined }]; } }],
87
+ },
88
+ blocks: [
89
+ { chart: (d) => ({ type: "donut", format: "money", items: porCategoria(d.records) }), title: "Por categoría" },
90
+ ],
91
+ })
92
+
93
+ Chat with tools (workspace copilot): sidebar block
94
+ { title: "Asistente", chat: { system: (d) => "...", tools: { nombre: {
95
+ description: "...", input: { campo: "string" }, run: (input, ctx) => {
96
+ ctx.update((d) => { /* mutate */ }); return { ok: true }; } } } } }
97
+ — the artifact runtime proxies api.anthropic.com with no API key; default
98
+ model claude-sonnet-4-6; conversation persists under data.chat.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Utilities for building apps inside Claude Artifacts: platform storage, chat tool-calling loops, and other primitives verified against the real runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.esm.js",
@@ -12,16 +12,21 @@
12
12
  },
13
13
  "files": [
14
14
  "dist",
15
- "src"
15
+ "src",
16
+ "llms.txt"
16
17
  ],
17
18
  "scripts": {
18
19
  "build": "node scripts/build.mjs",
19
20
  "build:watch": "node scripts/build.mjs --watch",
21
+ "test": "npm run build && node test/e2e.mjs",
20
22
  "prepublishOnly": "npm run build",
21
- "release": "node scripts/check-docs.mjs && bash scripts/publish.sh"
23
+ "release": "node scripts/check-docs.mjs && npm test && bash scripts/publish.sh"
22
24
  },
23
25
  "devDependencies": {
24
- "esbuild": "^0.25.0"
26
+ "esbuild": "^0.25.0",
27
+ "playwright-core": "^1.45.0",
28
+ "react": "^18.3.0",
29
+ "react-dom": "^18.3.0"
25
30
  },
26
31
  "license": "MIT",
27
32
  "author": "Alejandro Cuartas",
package/src/app.js CHANGED
@@ -18,12 +18,15 @@ import { StepsLayout } from "./steps.js";
18
18
  import { ChatLayout, CHAT_KEYS } from "./chat.js";
19
19
 
20
20
  const LAYOUTS = ["panel", "records", "steps", "chat", "workspace", "documents"];
21
- const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill", "state"];
21
+ const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill", "state", "icon"];
22
+ const BRAND_KEYS = ["logo", "footer"];
23
+ const BANNER_KEYS = ["image", "title", "subtitle"];
24
+ const IMAGE_KEYS = ["src", "caption", "fit", "height"];
22
25
 
23
26
  // Frontier models still invent identifiers a few percent of the time, so an
24
27
  // unknown name has to fail loudly and name the alternatives rather than
25
28
  // silently rendering nothing — a blank pane is indistinguishable from a bug.
26
- const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine"];
29
+ const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand"];
27
30
 
28
31
  const MACHINE_KEYS = ["key", "initial", "states", "events"];
29
32
  const MACHINE_STATE_KEYS = ["after"];
@@ -31,7 +34,7 @@ const MACHINE_AFTER_KEYS = ["seconds", "then", "do"];
31
34
  const MACHINE_EVENT_KEYS = ["from", "do", "then"];
32
35
 
33
36
  const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty", "types"];
34
- const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
37
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions", "avatar"];
35
38
  const ITEMS_KEYS = ["label", "fields", "empty"];
36
39
 
37
40
  // An unknown field type used to fall through silently to a text input — the
@@ -121,6 +124,25 @@ function checkBlocks(blocks) {
121
124
  if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
122
125
  if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
123
126
  if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
127
+ if (known[0] === "banner" && typeof block.banner === "object" && block.banner !== null) {
128
+ const unknownBn = Object.keys(block.banner).filter((k) => !BANNER_KEYS.includes(k));
129
+ if (unknownBn.length) {
130
+ throw new Error(
131
+ `claude-artifact-framework: blocks[${i}].banner has unknown keys (${unknownBn.join(", ")}). Valid keys: ${BANNER_KEYS.join(", ")}.`
132
+ );
133
+ }
134
+ }
135
+ if (known[0] === "image" && typeof block.image === "object" && block.image !== null) {
136
+ const unknownIm = Object.keys(block.image).filter((k) => !IMAGE_KEYS.includes(k));
137
+ if (unknownIm.length) {
138
+ throw new Error(
139
+ `claude-artifact-framework: blocks[${i}].image has unknown keys (${unknownIm.join(", ")}). Valid keys: ${IMAGE_KEYS.join(", ")}.`
140
+ );
141
+ }
142
+ }
143
+ if (block.icon !== undefined && typeof block.icon !== "string") {
144
+ throw new Error(`claude-artifact-framework: blocks[${i}].icon must be a string (an emoji works best).`);
145
+ }
124
146
  if (known[0] === "tabs") {
125
147
  const tabs = block.tabs;
126
148
  if (!Array.isArray(tabs) || tabs.length === 0) {
@@ -234,6 +256,17 @@ function validate(spec) {
234
256
  );
235
257
  }
236
258
  if (spec.machine !== undefined) checkMachine(spec.machine);
259
+ if (spec.brand !== undefined) {
260
+ const unknownB = Object.keys(spec.brand || {}).filter((k) => !BRAND_KEYS.includes(k));
261
+ if (unknownB.length) {
262
+ throw new Error(
263
+ `claude-artifact-framework: brand has unknown keys (${unknownB.join(", ")}). Valid keys: ${BRAND_KEYS.join(", ")}.`
264
+ );
265
+ }
266
+ if (spec.brand.logo !== undefined && typeof spec.brand.logo !== "string") {
267
+ throw new Error("claude-artifact-framework: brand.logo must be a string — an emoji, an inline <svg>...</svg>, or an https URL. Omit it for an automatic monogram.");
268
+ }
269
+ }
237
270
  if (spec.libs !== undefined) {
238
271
  if (!Array.isArray(spec.libs) || spec.libs.some((u) => typeof u !== "string" || !/^https?:\/\//.test(u))) {
239
272
  throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
@@ -350,7 +383,7 @@ function validate(spec) {
350
383
  }
351
384
  for (const name of names) {
352
385
  const t = d.types[name];
353
- const TYPE_KEYS = ["label", "title", "blocks", "empty"];
386
+ const TYPE_KEYS = ["label", "title", "blocks", "empty", "icon"];
354
387
  const unknownT = Object.keys(t || {}).filter((k) => !TYPE_KEYS.includes(k));
355
388
  if (unknownT.length) {
356
389
  throw new Error(
@@ -565,7 +598,7 @@ function TabsBlock({ spec, ctx }) {
565
598
  className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
566
599
  onClick: () => ctx.setTab(key, i),
567
600
  },
568
- t.label
601
+ t.icon ? `${t.icon} ${t.label}` : t.label
569
602
  )
570
603
  )
571
604
  ),
@@ -585,6 +618,8 @@ function Block({ block, ctx }) {
585
618
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
586
619
  // `state: "x"` — machine sugar: the block exists only in that phase.
587
620
  if (block.state !== undefined && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
621
+ // `icon` is sugar: it prefixes the title everywhere titles render.
622
+ if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
588
623
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
589
624
  const Component = ALL_BLOCKS[name];
590
625
  const inner = h(
@@ -623,9 +658,9 @@ function Panel({ spec, ctx }) {
623
658
  (spec.blocks || []).map((block, i) =>
624
659
  h(
625
660
  "section",
626
- // A collapsible is always full-width: half-width accordions leave an
627
- // orphaned right edge on desktop and change width across breakpoints.
628
- { key: i, className: block.wide || block.collapsible ? "caf-slot caf-slot-wide" : "caf-slot" },
661
+ // Collapsibles and banners are always full-width: half-width versions
662
+ // leave an orphaned right edge on desktop and change across breakpoints.
663
+ { key: i, className: block.wide || block.collapsible || block.banner ? "caf-slot caf-slot-wide" : "caf-slot" },
629
664
  h(Block, { block, ctx })
630
665
  )
631
666
  )
@@ -650,9 +685,9 @@ function RecordsWithBlocks({ spec, ctx }) {
650
685
  spec.blocks.map((block, i) =>
651
686
  h(
652
687
  "section",
653
- // A collapsible is always full-width: half-width accordions leave an
654
- // orphaned right edge on desktop and change width across breakpoints.
655
- { key: i, className: block.wide || block.collapsible ? "caf-slot caf-slot-wide" : "caf-slot" },
688
+ // Collapsibles and banners are always full-width: half-width versions
689
+ // leave an orphaned right edge on desktop and change across breakpoints.
690
+ { key: i, className: block.wide || block.collapsible || block.banner ? "caf-slot caf-slot-wide" : "caf-slot" },
656
691
  h(Block, { block, ctx })
657
692
  )
658
693
  )
@@ -734,7 +769,10 @@ function DocumentsLayout({ spec, ctx }) {
734
769
  const types = dspec.types || null;
735
770
  const typeNames = types ? Object.keys(types) : [];
736
771
  const specFor = (doc) => (types ? types[doc.type] : dspec);
737
- const typeLabel = (name) => (types[name] && types[name].label) || name;
772
+ const typeLabel = (name) => {
773
+ const t = types[name] || {};
774
+ return (t.icon ? t.icon + " " : "") + (t.label || name);
775
+ };
738
776
  const [picking, setPicking] = useState(false);
739
777
 
740
778
  const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : undefined;
@@ -1053,6 +1091,29 @@ export function createApp(spec = {}) {
1053
1091
 
1054
1092
  const Layout = LAYOUT_COMPONENTS[layout];
1055
1093
 
1094
+ // Zero-asset-first logo ladder: emoji → automatic monogram (initials over
1095
+ // the accent) → inline SVG → URL. The brand never NEEDS an uploaded file.
1096
+ const logo = spec.brand && spec.brand.logo;
1097
+ const logoEl = !spec.brand
1098
+ ? null
1099
+ : logo && logo.trim().startsWith("<svg")
1100
+ ? h("span", { className: "caf-logo caf-logo-svg", dangerouslySetInnerHTML: { __html: logo } })
1101
+ : logo && /^https?:\/\//.test(logo)
1102
+ ? h("img", { className: "caf-logo", src: logo, alt: "" })
1103
+ : logo
1104
+ ? h("span", { className: "caf-logo caf-logo-emoji", "aria-hidden": true }, logo)
1105
+ : h(
1106
+ "span",
1107
+ { className: "caf-logo caf-logo-mono", "aria-hidden": true },
1108
+ (spec.title || "?")
1109
+ .split(/\s+/)
1110
+ .map((w) => w[0])
1111
+ .filter(Boolean)
1112
+ .slice(0, 2)
1113
+ .join("")
1114
+ .toUpperCase()
1115
+ );
1116
+
1056
1117
  return h(
1057
1118
  "div",
1058
1119
  // The layout class lets shared CSS follow the active container width —
@@ -1061,9 +1122,14 @@ export function createApp(spec = {}) {
1061
1122
  h("style", { dangerouslySetInnerHTML: { __html: css } }),
1062
1123
  h(
1063
1124
  "header",
1064
- { className: "caf-header" },
1065
- spec.title ? h("h1", null, spec.title) : null,
1066
- spec.subtitle ? h("p", null, spec.subtitle) : null
1125
+ { className: spec.brand ? "caf-header caf-header-brand" : "caf-header" },
1126
+ logoEl,
1127
+ h(
1128
+ "div",
1129
+ { className: "caf-header-text" },
1130
+ spec.title ? h("h1", null, spec.title) : null,
1131
+ spec.subtitle ? h("p", null, spec.subtitle) : null
1132
+ )
1067
1133
  ),
1068
1134
  libsState === "error"
1069
1135
  ? h(
@@ -1074,7 +1140,8 @@ export function createApp(spec = {}) {
1074
1140
  )
1075
1141
  : !state.isHydrated() || libsState === "loading"
1076
1142
  ? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
1077
- : h(Layout, { spec, ctx })
1143
+ : h(Layout, { spec, ctx }),
1144
+ spec.brand && spec.brand.footer ? h("footer", { className: "caf-footer" }, spec.brand.footer) : null
1078
1145
  );
1079
1146
  };
1080
1147
  }
@@ -1288,6 +1355,35 @@ const BASE_CSS = `
1288
1355
  .caf-empty .caf-btn { margin-top: 0.6rem; }
1289
1356
  .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1290
1357
  .caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
1358
+ .caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
1359
+ .caf-header-text { min-width: 0; }
1360
+ .caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
1361
+ .caf-logo-emoji { display: flex; align-items: center; justify-content: center; font-size: 1.7rem; background: var(--caf-surface); border: 1px solid var(--caf-border); }
1362
+ .caf-logo-mono {
1363
+ display: flex; align-items: center; justify-content: center;
1364
+ background: var(--caf-accent); color: var(--caf-accentText);
1365
+ font-size: 0.95rem; font-weight: 700; letter-spacing: 0.02em;
1366
+ }
1367
+ .caf-logo-svg { display: flex; align-items: center; justify-content: center; overflow: hidden; }
1368
+ .caf-logo-svg svg { width: 100%; height: 100%; }
1369
+ .caf-banner {
1370
+ border-radius: var(--caf-radius); padding: 1.6rem 1.4rem; min-height: 110px;
1371
+ display: flex; flex-direction: column; justify-content: flex-end; gap: 0.2rem;
1372
+ }
1373
+ .caf-banner-title { margin: 0; color: #fff; font-size: 1.4rem; font-weight: 700; letter-spacing: -0.01em; text-shadow: 0 1px 3px rgba(0,0,0,0.35); }
1374
+ .caf-banner-sub { margin: 0; color: rgba(255,255,255,0.92); font-size: 0.9rem; text-shadow: 0 1px 2px rgba(0,0,0,0.35); }
1375
+ .caf-image { margin: 0; }
1376
+ .caf-image img { width: 100%; border-radius: 7px; display: block; }
1377
+ .caf-image-svg svg { width: 100%; height: auto; display: block; }
1378
+ .caf-footer { max-width: 760px; margin: 2rem auto 0; padding-top: 0.8rem; border-top: 1px solid var(--caf-border); color: var(--caf-muted); font-size: 0.78rem; text-align: center; }
1379
+ .caf-layout-workspace .caf-footer { max-width: 1200px; }
1380
+ .caf-avatar {
1381
+ flex: none; width: 30px; height: 30px; border-radius: 50%;
1382
+ display: flex; align-items: center; justify-content: center;
1383
+ color: #fff; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.02em;
1384
+ }
1385
+ .caf-row-avatar { flex-direction: row; align-items: center; gap: 0.6rem; }
1386
+ .caf-row-main { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
1291
1387
  .caf-chevron-closed { transform: rotate(-90deg); }
1292
1388
  .caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
1293
1389
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
package/src/blocks.js CHANGED
@@ -198,6 +198,42 @@ export function OutputBlock({ spec, ctx }) {
198
198
  );
199
199
  }
200
200
 
201
+ // Brand elements follow the zero-asset-first principle: every form works
202
+ // with nothing uploaded (artifacts can't persist real images — the ~5MB
203
+ // pool). banner's default face is a gradient derived from the theme seed;
204
+ // image accepts a URL or an inline SVG string (LLMs generate SVG well).
205
+ export function BannerBlock({ spec, ctx }) {
206
+ const b = typeof spec.banner === "string" ? { title: spec.banner } : spec.banner || {};
207
+ const [c1, c2] = ctx.colors(2);
208
+ const background = b.image
209
+ ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${JSON.stringify(b.image)}) center / cover`
210
+ : `linear-gradient(135deg, ${c1}, ${c2})`;
211
+ return h(
212
+ "div",
213
+ { className: "caf-banner", style: { background } },
214
+ b.title ? h("h2", { className: "caf-banner-title" }, b.title) : null,
215
+ b.subtitle ? h("p", { className: "caf-banner-sub" }, b.subtitle) : null
216
+ );
217
+ }
218
+
219
+ export function ImageBlock({ spec }) {
220
+ const im = typeof spec.image === "string" ? { src: spec.image } : spec.image || {};
221
+ const isSvg = typeof im.src === "string" && im.src.trim().startsWith("<svg");
222
+ return h(
223
+ "figure",
224
+ { className: "caf-block caf-image" },
225
+ spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
226
+ isSvg
227
+ ? h("div", { className: "caf-image-svg", dangerouslySetInnerHTML: { __html: im.src } })
228
+ : h("img", {
229
+ src: im.src,
230
+ alt: im.caption || spec.title || "",
231
+ style: { objectFit: im.fit || "cover", maxHeight: im.height ? `${im.height}px` : undefined },
232
+ }),
233
+ im.caption ? h("figcaption", { className: "caf-hint" }, im.caption) : null
234
+ );
235
+ }
236
+
201
237
  export function TextBlock({ spec, ctx }) {
202
238
  const body = typeof spec.text === "function" ? spec.text(ctx.data) : spec.text;
203
239
  return h(
@@ -464,6 +500,8 @@ export const BLOCKS = {
464
500
  chart: ChartBlock,
465
501
  timer: TimerBlock,
466
502
  actions: ActionsBlock,
503
+ banner: BannerBlock,
504
+ image: ImageBlock,
467
505
  chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx, title: spec.title }),
468
506
  custom: CustomBlock,
469
507
  };
package/src/records.js CHANGED
@@ -54,6 +54,22 @@ function makeId() {
54
54
  return "r" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
55
55
  }
56
56
 
57
+ // avatar: true — an initials circle per row, colored deterministically from
58
+ // the row title through the theme's series palette. Identity without assets.
59
+ function Avatar({ title, ctx }) {
60
+ const initials = String(title)
61
+ .split(/\s+/)
62
+ .map((w) => w[0])
63
+ .filter(Boolean)
64
+ .slice(0, 2)
65
+ .join("")
66
+ .toUpperCase() || "?";
67
+ let hash = 0;
68
+ for (const ch of String(title)) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
69
+ const palette = ctx.colors(8);
70
+ return h("span", { className: "caf-avatar", style: { background: palette[hash % palette.length] } }, initials);
71
+ }
72
+
57
73
  function rowTitle(spec, rec) {
58
74
  if (spec.title) return spec.title(rec);
59
75
  // Default: the first text-ish field, which is what a human would label by.
@@ -152,11 +168,16 @@ function ListScreen({ spec, ctx }) {
152
168
  "button",
153
169
  {
154
170
  type: "button",
155
- className: "caf-row",
171
+ className: spec.avatar ? "caf-row caf-row-avatar" : "caf-row",
156
172
  onClick: () => ctx.go("record", rec.id),
157
173
  },
158
- h("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
159
- spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
174
+ spec.avatar ? h(Avatar, { title: rowTitle(spec, rec), ctx }) : null,
175
+ h(
176
+ "span",
177
+ { className: "caf-row-main" },
178
+ h("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
179
+ spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
180
+ )
160
181
  ),
161
182
  spec.actions
162
183
  ? h(