claude-artifact-framework 0.14.1 → 0.16.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 CHANGED
@@ -12,16 +12,21 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
12
12
  ## Registries (any other name throws, naming the valid set)
13
13
 
14
14
  - Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
15
- steps, chat, main, sidebar, documents, data, shared, libs, machine
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)
16
18
  - Layouts: panel (default), records, steps, chat, workspace, documents
17
- - Block types: fields, output, text, list, chart, timer, actions, chat, tabs,
18
- records, customplus per-block title, when:(d)=>bool, state:"fase",
19
- collapsible, wide, fill (custom only)
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)
20
24
  - Field types: text, textarea, number, money, percent, check, date, select,
21
25
  file — attrs: key, label, value, required, min, max, step, placeholder,
22
26
  hint, rows, options (array or (d)=>array), accept
23
27
  - records spec: label, fields, title, subtitle, sort, summary, empty, search,
24
- compute, items, actions (+ key when used as a block)
28
+ compute, items, actions, avatar (true = initials circles per row)
29
+ (+ key when used as a block)
25
30
  - documents: label, title, blocks OR types, empty; machine: key, initial,
26
31
  states, events (event: from, do, then); chat: system, greeting, placeholder,
27
32
  tools, model, maxTokens; steps step: title, text, fields, result
@@ -56,6 +61,13 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
56
61
  ref + React.useEffect for libs that write into an element); hooks are
57
62
  allowed; persistent state via ctx.update, ephemeral via React.useState,
58
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
+ 14. Column grids in custom (kanban, week views) branch on ctx.viewport
68
+ ("mobile" < 720px | "desktop", reactive) — stack columns on mobile; half
69
+ the viewers are on a phone. The framework never clips (wide custom
70
+ content scrolls), but scrolling is the fallback, not the design.
59
71
 
60
72
  ## Canonical example
61
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.14.1",
3
+ "version": "0.16.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",
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;
@@ -991,6 +1029,17 @@ export function createApp(spec = {}) {
991
1029
  );
992
1030
  }
993
1031
  const [, force] = useState(0);
1032
+ // ctx.viewport shares the 720px breakpoint the block grid already uses,
1033
+ // so a custom block that branches on it flips in sync with the layout.
1034
+ const [narrow, setNarrow] = useState(
1035
+ () => typeof window !== "undefined" && window.matchMedia("(max-width: 719px)").matches
1036
+ );
1037
+ useEffect(() => {
1038
+ const mq = window.matchMedia("(max-width: 719px)");
1039
+ const onChange = (e) => setNarrow(e.matches);
1040
+ mq.addEventListener("change", onChange);
1041
+ return () => mq.removeEventListener("change", onChange);
1042
+ }, []);
994
1043
  const [libsState, setLibsState] = useState(spec.libs && spec.libs.length ? "loading" : "ready");
995
1044
  const [libsError, setLibsError] = useState(null);
996
1045
  useEffect(() => {
@@ -1043,6 +1092,7 @@ export function createApp(spec = {}) {
1043
1092
  setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
1044
1093
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
1045
1094
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
1095
+ viewport: narrow ? "mobile" : "desktop",
1046
1096
  machine: spec.machine || null,
1047
1097
  send: spec.machine
1048
1098
  ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
@@ -1053,6 +1103,29 @@ export function createApp(spec = {}) {
1053
1103
 
1054
1104
  const Layout = LAYOUT_COMPONENTS[layout];
1055
1105
 
1106
+ // Zero-asset-first logo ladder: emoji → automatic monogram (initials over
1107
+ // the accent) → inline SVG → URL. The brand never NEEDS an uploaded file.
1108
+ const logo = spec.brand && spec.brand.logo;
1109
+ const logoEl = !spec.brand
1110
+ ? null
1111
+ : logo && logo.trim().startsWith("<svg")
1112
+ ? h("span", { className: "caf-logo caf-logo-svg", dangerouslySetInnerHTML: { __html: logo } })
1113
+ : logo && /^https?:\/\//.test(logo)
1114
+ ? h("img", { className: "caf-logo", src: logo, alt: "" })
1115
+ : logo
1116
+ ? h("span", { className: "caf-logo caf-logo-emoji", "aria-hidden": true }, logo)
1117
+ : h(
1118
+ "span",
1119
+ { className: "caf-logo caf-logo-mono", "aria-hidden": true },
1120
+ (spec.title || "?")
1121
+ .split(/\s+/)
1122
+ .map((w) => w[0])
1123
+ .filter(Boolean)
1124
+ .slice(0, 2)
1125
+ .join("")
1126
+ .toUpperCase()
1127
+ );
1128
+
1056
1129
  return h(
1057
1130
  "div",
1058
1131
  // The layout class lets shared CSS follow the active container width —
@@ -1061,9 +1134,14 @@ export function createApp(spec = {}) {
1061
1134
  h("style", { dangerouslySetInnerHTML: { __html: css } }),
1062
1135
  h(
1063
1136
  "header",
1064
- { className: "caf-header" },
1065
- spec.title ? h("h1", null, spec.title) : null,
1066
- spec.subtitle ? h("p", null, spec.subtitle) : null
1137
+ { className: spec.brand ? "caf-header caf-header-brand" : "caf-header" },
1138
+ logoEl,
1139
+ h(
1140
+ "div",
1141
+ { className: "caf-header-text" },
1142
+ spec.title ? h("h1", null, spec.title) : null,
1143
+ spec.subtitle ? h("p", null, spec.subtitle) : null
1144
+ )
1067
1145
  ),
1068
1146
  libsState === "error"
1069
1147
  ? h(
@@ -1074,7 +1152,8 @@ export function createApp(spec = {}) {
1074
1152
  )
1075
1153
  : !state.isHydrated() || libsState === "loading"
1076
1154
  ? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
1077
- : h(Layout, { spec, ctx })
1155
+ : h(Layout, { spec, ctx }),
1156
+ spec.brand && spec.brand.footer ? h("footer", { className: "caf-footer" }, spec.brand.footer) : null
1078
1157
  );
1079
1158
  };
1080
1159
  }
@@ -1288,10 +1367,47 @@ const BASE_CSS = `
1288
1367
  .caf-empty .caf-btn { margin-top: 0.6rem; }
1289
1368
  .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1290
1369
  .caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
1370
+ .caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
1371
+ .caf-header-text { min-width: 0; }
1372
+ .caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
1373
+ .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); }
1374
+ .caf-logo-mono {
1375
+ display: flex; align-items: center; justify-content: center;
1376
+ background: var(--caf-accent); color: var(--caf-accentText);
1377
+ font-size: 0.95rem; font-weight: 700; letter-spacing: 0.02em;
1378
+ }
1379
+ .caf-logo-svg { display: flex; align-items: center; justify-content: center; overflow: hidden; }
1380
+ .caf-logo-svg svg { width: 100%; height: 100%; }
1381
+ .caf-banner {
1382
+ border-radius: var(--caf-radius); padding: 1.6rem 1.4rem; min-height: 110px;
1383
+ display: flex; flex-direction: column; justify-content: flex-end; gap: 0.2rem;
1384
+ }
1385
+ .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); }
1386
+ .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); }
1387
+ .caf-image { margin: 0; }
1388
+ .caf-image img { width: 100%; border-radius: 7px; display: block; }
1389
+ .caf-image-svg svg { width: 100%; height: auto; display: block; }
1390
+ .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; }
1391
+ .caf-layout-workspace .caf-footer { max-width: 1200px; }
1392
+ .caf-avatar {
1393
+ flex: none; width: 30px; height: 30px; border-radius: 50%;
1394
+ display: flex; align-items: center; justify-content: center;
1395
+ color: #fff; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.02em;
1396
+ }
1397
+ .caf-row-avatar { flex-direction: row; align-items: center; gap: 0.6rem; }
1398
+ .caf-row-main { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
1291
1399
  .caf-chevron-closed { transform: rotate(-90deg); }
1292
1400
  .caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
1293
1401
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
1294
1402
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
1295
1403
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
1296
1404
  .caf-block-error code { font-size: 0.78rem; color: var(--caf-muted); word-break: break-word; }
1405
+ /* Never-clip guarantee: content inside a custom block that is wider than the
1406
+ phone (column grids, week views) must scroll, not disappear off-screen. */
1407
+ .caf-custom { overflow-x: auto; }
1408
+ /* Touch devices get tappable framework controls regardless of the spec. */
1409
+ @media (pointer: coarse) {
1410
+ .caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }
1411
+ .caf-btn-sm, .caf-doc-close, .caf-item-remove { min-height: 40px; }
1412
+ }
1297
1413
  `;
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(
@@ -449,7 +485,10 @@ export function CustomBlock({ spec, ctx }) {
449
485
  const rendered = spec.custom(ctx);
450
486
  // `fill: true` frees the block from the card chrome so it can own a large
451
487
  // scrollable surface (a viewer, a canvas) — the workspace main-area case.
452
- const cls = spec.fill ? "caf-block caf-block-fill" : "caf-block";
488
+ // caf-custom guarantees a custom block can never CLIP its content on a
489
+ // narrow screen: wide hand-built grids become scrollable, not amputated
490
+ // (measured: a week grid cut off its last column at 390px with no way in).
491
+ const cls = spec.fill ? "caf-block caf-block-fill caf-custom" : "caf-block caf-custom";
453
492
  if (typeof rendered === "string") {
454
493
  return h("div", { className: cls, dangerouslySetInnerHTML: { __html: rendered } });
455
494
  }
@@ -464,6 +503,8 @@ export const BLOCKS = {
464
503
  chart: ChartBlock,
465
504
  timer: TimerBlock,
466
505
  actions: ActionsBlock,
506
+ banner: BannerBlock,
507
+ image: ImageBlock,
467
508
  chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx, title: spec.title }),
468
509
  custom: CustomBlock,
469
510
  };
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(