claude-artifact-framework 0.16.0 → 0.18.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
@@ -14,7 +14,10 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
14
14
  - Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
15
15
  steps, chat, main, sidebar, documents, data, shared, libs, machine,
16
16
  brand:{logo, footer} (logo: emoji | inline <svg> | https URL | omit for an
17
- automatic monogram)
17
+ automatic monogram), mobile:{nav:"bottom"} (phone: pins the first
18
+ top-level tabs block's bar to the bottom — declare it when one tabs
19
+ block with 4+ sections IS the app; a top bar overflows sideways at
20
+ phone width and hides the last sections)
18
21
  - Layouts: panel (default), records, steps, chat, workspace, documents
19
22
  - Block types: fields, output, text, list, chart, timer, actions, banner
20
23
  ({image?, title, subtitle} — no image paints a seed-derived gradient),
@@ -29,7 +32,8 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
29
32
  (+ key when used as a block)
30
33
  - documents: label, title, blocks OR types, empty; machine: key, initial,
31
34
  states, events (event: from, do, then); chat: system, greeting, placeholder,
32
- tools, model, maxTokens; steps step: title, text, fields, result
35
+ tools, model, maxTokens; steps step: title, text, fields, result,
36
+ blocks (result steps only — end a wizard on charts/text under the stats)
33
37
  - Formats: money, percent (0-100), number, date. Output/chart/compute items:
34
38
  { label, value, format, big }
35
39
 
@@ -47,8 +51,9 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
47
51
  5. Phases (games, turns, processes) belong to `machine` — ctx.send(event,
48
52
  payload) is the only door; events are no-ops outside their `from` states;
49
53
  `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.
54
+ 6. Row callbacks (when AND run) receive (record, data, viewerId); block
55
+ actions (data, viewerId). Stop early if you like; a 2nd param literally
56
+ named viewerId is matched by name and gets the viewer id.
52
57
  7. One big:true per screen; the constructive action takes kind:"primary"; a
53
58
  counter changed by pressing is an action, not an editable number field.
54
59
  8. Parts of a record are `items` — never JSON in a textarea.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.16.0",
3
+ "version": "0.18.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
@@ -26,7 +26,8 @@ const IMAGE_KEYS = ["src", "caption", "fit", "height"];
26
26
  // Frontier models still invent identifiers a few percent of the time, so an
27
27
  // unknown name has to fail loudly and name the alternatives rather than
28
28
  // silently rendering nothing — a blank pane is indistinguishable from a bug.
29
- const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand"];
29
+ const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand", "mobile"];
30
+ const MOBILE_KEYS = ["nav"];
30
31
 
31
32
  const MACHINE_KEYS = ["key", "initial", "states", "events"];
32
33
  const MACHINE_STATE_KEYS = ["after"];
@@ -248,6 +249,18 @@ function checkMachine(m) {
248
249
  }
249
250
  }
250
251
 
252
+ // The tabs block mobile.nav moves: the first one at the top level of the
253
+ // panel's `blocks` or the workspace's `main`. Nested tabs never qualify —
254
+ // a bottom bar is app-shell navigation, not in-page structure.
255
+ function findNavTabs(spec) {
256
+ for (const list of [spec.blocks, spec.main]) {
257
+ if (!Array.isArray(list)) continue;
258
+ const found = list.find((b) => b && Array.isArray(b.tabs));
259
+ if (found) return found;
260
+ }
261
+ return null;
262
+ }
263
+
251
264
  function validate(spec) {
252
265
  const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
253
266
  if (unknownTop.length) {
@@ -272,6 +285,22 @@ function validate(spec) {
272
285
  throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
273
286
  }
274
287
  }
288
+ if (spec.mobile !== undefined) {
289
+ const unknownM = Object.keys(spec.mobile || {}).filter((k) => !MOBILE_KEYS.includes(k));
290
+ if (unknownM.length) {
291
+ throw new Error(
292
+ `claude-artifact-framework: mobile has unknown keys (${unknownM.join(", ")}). Valid keys: ${MOBILE_KEYS.join(", ")}.`
293
+ );
294
+ }
295
+ if (spec.mobile.nav !== undefined && spec.mobile.nav !== "bottom") {
296
+ throw new Error('claude-artifact-framework: mobile.nav only accepts "bottom" (omit `mobile` for the default top tab bar).');
297
+ }
298
+ if (spec.mobile.nav === "bottom" && !findNavTabs(spec)) {
299
+ throw new Error(
300
+ 'claude-artifact-framework: mobile.nav: "bottom" moves a top-level `tabs` block\'s bar to the bottom of the phone screen — this spec has no top-level tabs block (in `blocks` or `main`) to move.'
301
+ );
302
+ }
303
+ }
275
304
  const layout = spec.layout || "panel";
276
305
  if (!LAYOUTS.includes(layout)) {
277
306
  throw new Error(
@@ -283,7 +312,7 @@ function validate(spec) {
283
312
  if (!Array.isArray(steps) || steps.length === 0) {
284
313
  throw new Error('claude-artifact-framework: layout "steps" needs `steps: [...]` with at least one step.');
285
314
  }
286
- const STEP_KEYS = ["title", "text", "fields", "result"];
315
+ const STEP_KEYS = ["title", "text", "fields", "result", "blocks"];
287
316
  const seen = new Set();
288
317
  steps.forEach((st, i) => {
289
318
  if (!st || typeof st !== "object") {
@@ -298,6 +327,15 @@ function validate(spec) {
298
327
  if (!st.fields && !st.text && !st.result) {
299
328
  throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
300
329
  }
330
+ // A result step may close on more than stats: `blocks` renders any
331
+ // block set (charts, text, lists) under the result grid. Measured
332
+ // miss: a wizard that wanted to END on a chart had nowhere to put it.
333
+ if (st.blocks !== undefined) {
334
+ if (typeof st.result !== "function") {
335
+ throw new Error(`claude-artifact-framework: steps[${i}].blocks only renders on a result step — add \`result\` or move the blocks to the step's fields.`);
336
+ }
337
+ checkBlocks(st.blocks);
338
+ }
301
339
  checkFields(st.fields, `steps[${i}]`);
302
340
  for (const f of st.fields || []) {
303
341
  if (seen.has(f.key)) {
@@ -576,17 +614,21 @@ function boundary(React) {
576
614
 
577
615
  // Tabs are defined here (not blocks.js) because they render other blocks —
578
616
  // keeping the recursion in one file avoids an import cycle.
579
- function TabsBlock({ spec, ctx }) {
617
+ function TabsBlock({ spec, ctx, bottomNav }) {
580
618
  const tabs = spec.tabs;
581
619
  const key = tabs.map((t) => t.label).join("|");
582
620
  const stored = ctx.view.tabs ? ctx.view.tabs[key] : undefined;
583
621
  const active = Math.min(stored === undefined ? 0 : stored, tabs.length - 1);
622
+ // mobile.nav: "bottom" — on a phone, this block's bar becomes a fixed
623
+ // bottom nav: every section stays visible and inside the thumb zone
624
+ // instead of hiding behind the tab bar's sideways scroll.
625
+ const bottom = !!bottomNav && ctx.viewport === "mobile";
584
626
  return h(
585
627
  "div",
586
628
  { className: "caf-tabs" },
587
629
  h(
588
630
  "div",
589
- { className: "caf-tabbar", role: "tablist" },
631
+ { className: bottom ? "caf-tabbar caf-tabbar-bottom" : "caf-tabbar", role: "tablist" },
590
632
  tabs.map((t, i) =>
591
633
  h(
592
634
  "button",
@@ -598,7 +640,11 @@ function TabsBlock({ spec, ctx }) {
598
640
  className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
599
641
  onClick: () => ctx.setTab(key, i),
600
642
  },
601
- t.icon ? `${t.icon} ${t.label}` : t.label
643
+ bottom && t.icon
644
+ ? [h("span", { key: "i", className: "caf-tab-ico", "aria-hidden": true }, t.icon), h("span", { key: "l", className: "caf-tab-lbl" }, t.label)]
645
+ : t.icon
646
+ ? `${t.icon} ${t.label}`
647
+ : t.label
602
648
  )
603
649
  )
604
650
  ),
@@ -618,6 +664,8 @@ function Block({ block, ctx }) {
618
664
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
619
665
  // `state: "x"` — machine sugar: the block exists only in that phase.
620
666
  if (block.state !== undefined && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
667
+ // Identity against the original spec object — before any sugar copies it.
668
+ const bottomNav = ctx._navTabs !== undefined && ctx._navTabs === block;
621
669
  // `icon` is sugar: it prefixes the title everywhere titles render.
622
670
  if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
623
671
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
@@ -625,7 +673,7 @@ function Block({ block, ctx }) {
625
673
  const inner = h(
626
674
  boundary(ctx.React),
627
675
  null,
628
- h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx })
676
+ h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx, bottomNav })
629
677
  );
630
678
  if (!block.collapsible) return inner;
631
679
  // Collapsed state is framework-owned view state, keyed by the block's title
@@ -881,7 +929,10 @@ function DocumentsLayout({ spec, ctx }) {
881
929
  { className: "caf-documents" },
882
930
  h(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
883
931
  typePicker,
884
- h(Panel, { spec: { blocks: aspec.blocks }, ctx: scoped })
932
+ // Keyed by document id: switching tabs REMOUNTS the pane, so DOM-held
933
+ // state can't leak across documents. Measured: the native file input
934
+ // kept showing the previous document's filename.
935
+ h(Panel, { key: active.id, spec: { blocks: aspec.blocks }, ctx: scoped })
885
936
  );
886
937
  }
887
938
 
@@ -1093,6 +1144,8 @@ export function createApp(spec = {}) {
1093
1144
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
1094
1145
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
1095
1146
  viewport: narrow ? "mobile" : "desktop",
1147
+ _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
1148
+ _Block: Block, // for layouts in other modules (steps) that render blocks
1096
1149
  machine: spec.machine || null,
1097
1150
  send: spec.machine
1098
1151
  ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
@@ -1130,7 +1183,7 @@ export function createApp(spec = {}) {
1130
1183
  "div",
1131
1184
  // The layout class lets shared CSS follow the active container width —
1132
1185
  // e.g. the header must share the workspace's wider axis, not panel's.
1133
- { className: `caf-root caf-layout-${layout}` },
1186
+ { className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
1134
1187
  h("style", { dangerouslySetInnerHTML: { __html: css } }),
1135
1188
  h(
1136
1189
  "header",
@@ -1152,7 +1205,11 @@ export function createApp(spec = {}) {
1152
1205
  )
1153
1206
  : !state.isHydrated() || libsState === "loading"
1154
1207
  ? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
1155
- : h(Layout, { spec, ctx }),
1208
+ : // The same containment blocks get, one level up: a crash inside a
1209
+ // layout (a throwing sort, a bad row callback) shows a named error
1210
+ // under the header instead of killing the whole app. Measured: an
1211
+ // unhandled row-action throw once blanked an entire blind app.
1212
+ h(boundary(ctx.React), null, h(Layout, { spec, ctx })),
1156
1213
  spec.brand && spec.brand.footer ? h("footer", { className: "caf-footer" }, spec.brand.footer) : null
1157
1214
  );
1158
1215
  };
@@ -1276,6 +1333,7 @@ const BASE_CSS = `
1276
1333
  .caf-timer-controls { display: flex; gap: 0.6rem; }
1277
1334
  .caf-btn:disabled { opacity: 0.45; cursor: not-allowed; }
1278
1335
  .caf-steps-progress { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.2rem; }
1336
+ .caf-steps-result { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1279
1337
  .caf-steps-fill { transition: width 0.25s ease; }
1280
1338
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
1281
1339
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
@@ -1405,6 +1463,27 @@ const BASE_CSS = `
1405
1463
  /* Never-clip guarantee: content inside a custom block that is wider than the
1406
1464
  phone (column grids, week views) must scroll, not disappear off-screen. */
1407
1465
  .caf-custom { overflow-x: auto; }
1466
+ /* mobile.nav: "bottom" — the app-shell tab bar pinned to the thumb zone.
1467
+ Tabs share the width evenly so every section stays visible; the root
1468
+ reserves space so the bar never covers the end of the content. */
1469
+ .caf-has-bottomnav { padding-bottom: 76px; }
1470
+ .caf-tabbar-bottom {
1471
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
1472
+ gap: 0; margin: 0; overflow-x: visible;
1473
+ background: var(--caf-surface); border-top: 1px solid var(--caf-border); border-bottom: none;
1474
+ padding: 0.25rem 0.4rem calc(0.3rem + env(safe-area-inset-bottom, 0px));
1475
+ box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.07);
1476
+ }
1477
+ .caf-tabbar-bottom .caf-tab {
1478
+ flex: 1 1 0; min-width: 0; min-height: 48px;
1479
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1480
+ gap: 0.15rem; padding: 0.25rem 0.2rem; margin-bottom: 0;
1481
+ font-size: 0.66rem; border-bottom: none;
1482
+ overflow: hidden; text-overflow: ellipsis;
1483
+ }
1484
+ .caf-tabbar-bottom .caf-tab-ico { font-size: 1.25rem; line-height: 1; }
1485
+ .caf-tabbar-bottom .caf-tab-lbl { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1486
+ .caf-tabbar-bottom .caf-tab-active, .caf-tabbar-bottom .caf-tab-active:hover { border-bottom-color: transparent; }
1408
1487
  /* Touch devices get tappable framework controls regardless of the spec. */
1409
1488
  @media (pointer: coarse) {
1410
1489
  .caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }
package/src/records.js CHANGED
@@ -54,6 +54,27 @@ function makeId() {
54
54
  return "r" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
55
55
  }
56
56
 
57
+ // Call-by-name for row callbacks. The measured 50/50 trap: both
58
+ // `when: (r, viewerId)` (the old documented form) and `run: (r, viewerId)`
59
+ // (the mistake that killed an entire blind app — `data` skipped, so `d`
60
+ // held a viewer-id string) exist in the wild. Positions lie; names don't:
61
+ // if the second declared parameter is named like the viewer id, hand it
62
+ // the viewer id. Every callback gets what its own names ask for, and the
63
+ // uniform (record, data, viewerId) order in the docs stays the one truth.
64
+ const rowCallLegacy = new WeakMap();
65
+ function rowCall(fn, rec, data, viewerId) {
66
+ let legacy = rowCallLegacy.get(fn);
67
+ if (legacy === undefined) {
68
+ const src = String(fn);
69
+ const arrow = src.match(/^\s*(?:async\s+)?([\w$]+)\s*=>/); // one bare param
70
+ const parens = src.match(/^[^(]*\(([^)]*)\)/);
71
+ const params = arrow ? [arrow[1]] : parens ? parens[1].split(",").map((s) => s.trim().split(/[=\s:]/)[0]) : [];
72
+ legacy = /viewer|voter|usuario|user/i.test(params[1] || "");
73
+ rowCallLegacy.set(fn, legacy);
74
+ }
75
+ return legacy ? fn(rec, viewerId) : fn(rec, data, viewerId);
76
+ }
77
+
57
78
  // avatar: true — an initials circle per row, colored deterministically from
58
79
  // the row title through the theme's series palette. Identity without assets.
59
80
  function Avatar({ title, ctx }) {
@@ -184,7 +205,7 @@ function ListScreen({ spec, ctx }) {
184
205
  "div",
185
206
  { className: "caf-row-actions" },
186
207
  spec.actions
187
- .filter((a) => !a.when || a.when(rec, ctx.viewerId))
208
+ .filter((a) => !a.when || rowCall(a.when, rec, ctx.data, ctx.viewerId))
188
209
  .map((a) =>
189
210
  h(
190
211
  "button",
@@ -195,7 +216,7 @@ function ListScreen({ spec, ctx }) {
195
216
  onClick: () =>
196
217
  ctx.update((d) => {
197
218
  const live = d.records.find((x) => x.id === rec.id);
198
- if (live) a.run(live, d, ctx.viewerId);
219
+ if (live) rowCall(a.run, live, d, ctx.viewerId);
199
220
  }),
200
221
  },
201
222
  a.label
package/src/steps.js CHANGED
@@ -22,23 +22,31 @@ function stepErrors(step, data) {
22
22
 
23
23
  function ResultScreen({ step, ctx }) {
24
24
  const items = step.result(ctx.data) || [];
25
+ const Block = ctx._Block;
25
26
  return h(
26
27
  "div",
27
- { className: "caf-block caf-output" },
28
- step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
28
+ { className: "caf-steps-result" },
29
29
  h(
30
30
  "div",
31
- { className: "caf-output-grid" },
32
- items.map((item, i) =>
33
- h(
34
- "div",
35
- { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
36
- h("span", { className: "caf-stat-label" }, item.label),
37
- h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
38
- item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
31
+ { className: "caf-block caf-output" },
32
+ step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
33
+ h(
34
+ "div",
35
+ { className: "caf-output-grid" },
36
+ items.map((item, i) =>
37
+ h(
38
+ "div",
39
+ { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
40
+ h("span", { className: "caf-stat-label" }, item.label),
41
+ h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
42
+ item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
43
+ )
39
44
  )
40
45
  )
41
46
  ),
47
+ // A result can end on more than stats: any declared blocks (charts,
48
+ // text, lists) render under the grid with full block services.
49
+ (step.blocks || []).map((b, i) => h(Block, { key: "rb" + i, block: b, ctx })),
42
50
  h(
43
51
  "div",
44
52
  { className: "caf-step-nav" },