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/README.md CHANGED
@@ -168,6 +168,18 @@ ArtifactKit.createApp({
168
168
  Field keys must be unique across all steps (they share one data pool); a
169
169
  duplicate throws at creation, as does an unknown step key.
170
170
 
171
+ A result step can end on more than stats: give it `blocks` and any block
172
+ set (a chart of the answers, explanatory text, a list) renders under the
173
+ result grid:
174
+
175
+ ```js
176
+ { title: "Resultado",
177
+ result: (d) => [{ label: "Libre por mes", value: libre(d), format: "money", big: true }],
178
+ blocks: [{ title: "Distribución", chart: (d) => ({ type: "donut", format: "money", items: porRubro(d) }) }] }
179
+ ```
180
+
181
+ `blocks` on a non-result step throws.
182
+
171
183
  ### The `chart` block — bar, line, donut
172
184
 
173
185
  Same vocabulary as `output` (items of `{ label, value }` plus an optional
@@ -343,12 +355,13 @@ blocks: [
343
355
  ```
344
356
 
345
357
  Action keys: `label`, `run`, optional `when` (hides the button when false)
346
- and `kind` (`"primary"` / `"danger"`). Row actions receive
347
- `(record, data, viewerId)`, block actions `(data, viewerId)`. Stopping
348
- early is fine (`run: (r) => ...`), but **never skip a middle argument**:
349
- `run: (r, viewerId)` reads `data` as your viewer id and breaks silently —
350
- if you need `viewerId`, name everything before it: `run: (r, d, viewerId)`.
351
- Changes persist like any other update.
358
+ and `kind` (`"primary"` / `"danger"`). Row actions — `when` and `run`
359
+ alike — receive `(record, data, viewerId)`; block actions
360
+ `(data, viewerId)`. Stopping early is fine (`run: (r) => ...`), and the
361
+ framework matches the viewer id **by name**: a second parameter literally
362
+ named `viewerId` receives the viewer id even though `data` was skipped
363
+ `run: (r, viewerId) => ...` works as written. Still, prefer the full
364
+ order: `run: (r, d, viewerId)`. Changes persist like any other update.
352
365
  Give the constructive action `kind: "primary"` — a "Vote"/"Add"/"Done" left
353
366
  as the neutral default reads weaker than a `"danger"` next to it. And a
354
367
  smell to avoid: **a number that changes by pressing is an `action`, not an
@@ -422,6 +435,30 @@ The active tab and each accordion's open/closed state are framework-owned
422
435
  view state — they survive reload, per viewer. Tabs cannot nest inside tabs;
423
436
  `when` must be a function; both throw otherwise.
424
437
 
438
+ **When tabs ARE the app** — one tabs block as the whole UI, 4-5 sections
439
+ the user hops between on a phone — declare bottom navigation:
440
+
441
+ ```js
442
+ ArtifactKit.createApp({
443
+ title: "Mi Gym",
444
+ mobile: { nav: "bottom" },
445
+ blocks: [
446
+ { tabs: [
447
+ { label: "Hoy", icon: "💪", blocks: [...] },
448
+ { label: "Progreso", icon: "📈", blocks: [...] },
449
+ // ...up to ~5 sections
450
+ ], wide: true },
451
+ ],
452
+ })
453
+ ```
454
+
455
+ On a phone the first top-level tabs block's bar pins to the bottom of the
456
+ screen (icons above labels, every section visible and thumb-reachable);
457
+ on desktop nothing changes. Measured reason to bother: a top tab bar with
458
+ 5 sections overflows sideways at phone width and silently hides the last
459
+ sections behind a scroll nobody sees. Declaring `mobile: { nav: "bottom" }`
460
+ without a top-level tabs block throws.
461
+
425
462
  ### The `machine` — declared phases instead of hand-rolled state logic
426
463
 
427
464
  If your app has **phases** — a game (choosing → comparing → won), a turn
@@ -832,7 +869,8 @@ Everything an app can declare, in one place.
832
869
 
833
870
  **Top-level keys**: `title`, `subtitle`, `theme: { seed }`, `layout`, `blocks`,
834
871
  `records`, `steps`, `chat`, `main`, `sidebar` (workspace panes), `documents`,
835
- `data`, `shared`, `libs`. Anything else throws.
872
+ `data`, `shared`, `libs`, `machine`, `brand`, `mobile: { nav: "bottom" }`.
873
+ Anything else throws.
836
874
 
837
875
  **Layouts** (`layout:`): `panel` (default — grid of blocks), `records` (CRUD
838
876
  list), `steps` (wizard), `chat` (conversation with Claude), `workspace`
@@ -968,8 +1006,9 @@ The rules that decide whether a generated app works, all in one place
968
1006
  or `ctx.files`.
969
1007
  5. **Phases belong to the `machine`** — never track game/process phases
970
1008
  with hand-rolled flags and `setTimeout`; `ctx.send` is the only door.
971
- 6. **Never skip a middle positional argument** — `run: (r, d, viewerId)`,
972
- never `run: (r, viewerId)`.
1009
+ 6. **Row callbacks are `(record, data, viewerId)`** — `when` and `run`
1010
+ alike. Prefer the full order; a second parameter literally named
1011
+ `viewerId` is matched by name and still gets the viewer id.
973
1012
  7. **One `big: true` per screen** — it marks THE result, not every stat.
974
1013
  8. **The constructive action takes `kind: "primary"`**; a counter changed
975
1014
  by pressing is an `action`, not an editable `number` field.
package/dist/index.esm.js CHANGED
@@ -1070,6 +1070,19 @@ function recordDefaults(fields) {
1070
1070
  function makeId() {
1071
1071
  return "r" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1072
1072
  }
1073
+ var rowCallLegacy = /* @__PURE__ */ new WeakMap();
1074
+ function rowCall(fn, rec, data, viewerId) {
1075
+ let legacy = rowCallLegacy.get(fn);
1076
+ if (legacy === void 0) {
1077
+ const src = String(fn);
1078
+ const arrow = src.match(/^\s*(?:async\s+)?([\w$]+)\s*=>/);
1079
+ const parens = src.match(/^[^(]*\(([^)]*)\)/);
1080
+ const params = arrow ? [arrow[1]] : parens ? parens[1].split(",").map((s) => s.trim().split(/[=\s:]/)[0]) : [];
1081
+ legacy = /viewer|voter|usuario|user/i.test(params[1] || "");
1082
+ rowCallLegacy.set(fn, legacy);
1083
+ }
1084
+ return legacy ? fn(rec, viewerId) : fn(rec, data, viewerId);
1085
+ }
1073
1086
  function Avatar({ title, ctx }) {
1074
1087
  const initials = String(title).split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase() || "?";
1075
1088
  let hash = 0;
@@ -1174,7 +1187,7 @@ function ListScreen({ spec, ctx }) {
1174
1187
  spec.actions ? createElement(
1175
1188
  "div",
1176
1189
  { className: "caf-row-actions" },
1177
- spec.actions.filter((a) => !a.when || a.when(rec, ctx.viewerId)).map(
1190
+ spec.actions.filter((a) => !a.when || rowCall(a.when, rec, ctx.data, ctx.viewerId)).map(
1178
1191
  (a) => createElement(
1179
1192
  "button",
1180
1193
  {
@@ -1183,7 +1196,7 @@ function ListScreen({ spec, ctx }) {
1183
1196
  className: a.kind === "danger" ? "caf-btn caf-btn-danger caf-btn-sm" : "caf-btn caf-btn-sm",
1184
1197
  onClick: () => ctx.update((d) => {
1185
1198
  const live = d.records.find((x) => x.id === rec.id);
1186
- if (live) a.run(live, d, ctx.viewerId);
1199
+ if (live) rowCall(a.run, live, d, ctx.viewerId);
1187
1200
  })
1188
1201
  },
1189
1202
  a.label
@@ -1354,23 +1367,31 @@ function stepErrors(step, data) {
1354
1367
  }
1355
1368
  function ResultScreen({ step, ctx }) {
1356
1369
  const items = step.result(ctx.data) || [];
1370
+ const Block2 = ctx._Block;
1357
1371
  return createElement(
1358
1372
  "div",
1359
- { className: "caf-block caf-output" },
1360
- step.title ? createElement("h2", { className: "caf-block-title" }, step.title) : null,
1373
+ { className: "caf-steps-result" },
1361
1374
  createElement(
1362
1375
  "div",
1363
- { className: "caf-output-grid" },
1364
- items.map(
1365
- (item, i) => createElement(
1366
- "div",
1367
- { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
1368
- createElement("span", { className: "caf-stat-label" }, item.label),
1369
- createElement("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
1370
- item.hint ? createElement("small", { className: "caf-hint" }, item.hint) : null
1376
+ { className: "caf-block caf-output" },
1377
+ step.title ? createElement("h2", { className: "caf-block-title" }, step.title) : null,
1378
+ createElement(
1379
+ "div",
1380
+ { className: "caf-output-grid" },
1381
+ items.map(
1382
+ (item, i) => createElement(
1383
+ "div",
1384
+ { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
1385
+ createElement("span", { className: "caf-stat-label" }, item.label),
1386
+ createElement("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
1387
+ item.hint ? createElement("small", { className: "caf-hint" }, item.hint) : null
1388
+ )
1371
1389
  )
1372
1390
  )
1373
1391
  ),
1392
+ // A result can end on more than stats: any declared blocks (charts,
1393
+ // text, lists) render under the grid with full block services.
1394
+ (step.blocks || []).map((b, i) => createElement(Block2, { key: "rb" + i, block: b, ctx })),
1374
1395
  createElement(
1375
1396
  "div",
1376
1397
  { className: "caf-step-nav" },
@@ -1438,7 +1459,8 @@ var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "colla
1438
1459
  var BRAND_KEYS = ["logo", "footer"];
1439
1460
  var BANNER_KEYS = ["image", "title", "subtitle"];
1440
1461
  var IMAGE_KEYS = ["src", "caption", "fit", "height"];
1441
- var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand"];
1462
+ var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand", "mobile"];
1463
+ var MOBILE_KEYS = ["nav"];
1442
1464
  var MACHINE_KEYS = ["key", "initial", "states", "events"];
1443
1465
  var MACHINE_STATE_KEYS = ["after"];
1444
1466
  var MACHINE_AFTER_KEYS = ["seconds", "then", "do"];
@@ -1641,6 +1663,14 @@ function checkMachine(m) {
1641
1663
  }
1642
1664
  }
1643
1665
  }
1666
+ function findNavTabs(spec) {
1667
+ for (const list of [spec.blocks, spec.main]) {
1668
+ if (!Array.isArray(list)) continue;
1669
+ const found = list.find((b) => b && Array.isArray(b.tabs));
1670
+ if (found) return found;
1671
+ }
1672
+ return null;
1673
+ }
1644
1674
  function validate(spec) {
1645
1675
  const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
1646
1676
  if (unknownTop.length) {
@@ -1665,6 +1695,22 @@ function validate(spec) {
1665
1695
  throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
1666
1696
  }
1667
1697
  }
1698
+ if (spec.mobile !== void 0) {
1699
+ const unknownM = Object.keys(spec.mobile || {}).filter((k) => !MOBILE_KEYS.includes(k));
1700
+ if (unknownM.length) {
1701
+ throw new Error(
1702
+ `claude-artifact-framework: mobile has unknown keys (${unknownM.join(", ")}). Valid keys: ${MOBILE_KEYS.join(", ")}.`
1703
+ );
1704
+ }
1705
+ if (spec.mobile.nav !== void 0 && spec.mobile.nav !== "bottom") {
1706
+ throw new Error('claude-artifact-framework: mobile.nav only accepts "bottom" (omit `mobile` for the default top tab bar).');
1707
+ }
1708
+ if (spec.mobile.nav === "bottom" && !findNavTabs(spec)) {
1709
+ throw new Error(
1710
+ 'claude-artifact-framework: mobile.nav: "bottom" moves a top-level `tabs` block\'s bar to the bottom of the phone screen \u2014 this spec has no top-level tabs block (in `blocks` or `main`) to move.'
1711
+ );
1712
+ }
1713
+ }
1668
1714
  const layout = spec.layout || "panel";
1669
1715
  if (!LAYOUTS.includes(layout)) {
1670
1716
  throw new Error(
@@ -1676,7 +1722,7 @@ function validate(spec) {
1676
1722
  if (!Array.isArray(steps) || steps.length === 0) {
1677
1723
  throw new Error('claude-artifact-framework: layout "steps" needs `steps: [...]` with at least one step.');
1678
1724
  }
1679
- const STEP_KEYS = ["title", "text", "fields", "result"];
1725
+ const STEP_KEYS = ["title", "text", "fields", "result", "blocks"];
1680
1726
  const seen = /* @__PURE__ */ new Set();
1681
1727
  steps.forEach((st, i) => {
1682
1728
  if (!st || typeof st !== "object") {
@@ -1691,6 +1737,12 @@ function validate(spec) {
1691
1737
  if (!st.fields && !st.text && !st.result) {
1692
1738
  throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
1693
1739
  }
1740
+ if (st.blocks !== void 0) {
1741
+ if (typeof st.result !== "function") {
1742
+ throw new Error(`claude-artifact-framework: steps[${i}].blocks only renders on a result step \u2014 add \`result\` or move the blocks to the step's fields.`);
1743
+ }
1744
+ checkBlocks(st.blocks);
1745
+ }
1694
1746
  checkFields(st.fields, `steps[${i}]`);
1695
1747
  for (const f of st.fields || []) {
1696
1748
  if (seen.has(f.key)) {
@@ -1946,17 +1998,18 @@ function boundary(React2) {
1946
1998
  }
1947
1999
  return BlockBoundary;
1948
2000
  }
1949
- function TabsBlock({ spec, ctx }) {
2001
+ function TabsBlock({ spec, ctx, bottomNav }) {
1950
2002
  const tabs = spec.tabs;
1951
2003
  const key = tabs.map((t) => t.label).join("|");
1952
2004
  const stored = ctx.view.tabs ? ctx.view.tabs[key] : void 0;
1953
2005
  const active = Math.min(stored === void 0 ? 0 : stored, tabs.length - 1);
2006
+ const bottom = !!bottomNav && ctx.viewport === "mobile";
1954
2007
  return createElement(
1955
2008
  "div",
1956
2009
  { className: "caf-tabs" },
1957
2010
  createElement(
1958
2011
  "div",
1959
- { className: "caf-tabbar", role: "tablist" },
2012
+ { className: bottom ? "caf-tabbar caf-tabbar-bottom" : "caf-tabbar", role: "tablist" },
1960
2013
  tabs.map(
1961
2014
  (t, i) => createElement(
1962
2015
  "button",
@@ -1968,7 +2021,7 @@ function TabsBlock({ spec, ctx }) {
1968
2021
  className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
1969
2022
  onClick: () => ctx.setTab(key, i)
1970
2023
  },
1971
- t.icon ? `${t.icon} ${t.label}` : t.label
2024
+ bottom && t.icon ? [createElement("span", { key: "i", className: "caf-tab-ico", "aria-hidden": true }, t.icon), createElement("span", { key: "l", className: "caf-tab-lbl" }, t.label)] : t.icon ? `${t.icon} ${t.label}` : t.label
1972
2025
  )
1973
2026
  )
1974
2027
  ),
@@ -1984,13 +2037,14 @@ var ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
1984
2037
  function Block({ block, ctx }) {
1985
2038
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
1986
2039
  if (block.state !== void 0 && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
2040
+ const bottomNav = ctx._navTabs !== void 0 && ctx._navTabs === block;
1987
2041
  if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
1988
2042
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
1989
2043
  const Component2 = ALL_BLOCKS[name];
1990
2044
  const inner = createElement(
1991
2045
  boundary(ctx.React),
1992
2046
  null,
1993
- createElement(Component2, { spec: block.collapsible ? { ...block, title: void 0 } : block, ctx })
2047
+ createElement(Component2, { spec: block.collapsible ? { ...block, title: void 0 } : block, ctx, bottomNav })
1994
2048
  );
1995
2049
  if (!block.collapsible) return inner;
1996
2050
  const key = typeof block.title === "string" && block.title ? block.title : name;
@@ -2194,7 +2248,10 @@ function DocumentsLayout({ spec, ctx }) {
2194
2248
  { className: "caf-documents" },
2195
2249
  createElement(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
2196
2250
  typePicker,
2197
- createElement(Panel, { spec: { blocks: aspec.blocks }, ctx: scoped })
2251
+ // Keyed by document id: switching tabs REMOUNTS the pane, so DOM-held
2252
+ // state can't leak across documents. Measured: the native file input
2253
+ // kept showing the previous document's filename.
2254
+ createElement(Panel, { key: active.id, spec: { blocks: aspec.blocks }, ctx: scoped })
2198
2255
  );
2199
2256
  }
2200
2257
  function DocTabbar({ docs, active, dspec, types, close, onAdd, label, ctx }) {
@@ -2376,6 +2433,9 @@ function createApp(spec = {}) {
2376
2433
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
2377
2434
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
2378
2435
  viewport: narrow ? "mobile" : "desktop",
2436
+ _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
2437
+ _Block: Block,
2438
+ // for layouts in other modules (steps) that render blocks
2379
2439
  machine: spec.machine || null,
2380
2440
  send: spec.machine ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload) : () => {
2381
2441
  throw new Error("claude-artifact-framework: ctx.send() needs a `machine` declared at the top level.");
@@ -2392,7 +2452,7 @@ function createApp(spec = {}) {
2392
2452
  "div",
2393
2453
  // The layout class lets shared CSS follow the active container width —
2394
2454
  // e.g. the header must share the workspace's wider axis, not panel's.
2395
- { className: `caf-root caf-layout-${layout}` },
2455
+ { className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
2396
2456
  createElement("style", { dangerouslySetInnerHTML: { __html: css } }),
2397
2457
  createElement(
2398
2458
  "header",
@@ -2410,7 +2470,13 @@ function createApp(spec = {}) {
2410
2470
  { className: "caf-block caf-block-error" },
2411
2471
  createElement("strong", null, "A library failed to load"),
2412
2472
  createElement("code", null, libsError)
2413
- ) : !state.isHydrated() || libsState === "loading" ? createElement("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries\u2026" : "Loading\u2026") : createElement(Layout, { spec, ctx }),
2473
+ ) : !state.isHydrated() || libsState === "loading" ? createElement("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries\u2026" : "Loading\u2026") : (
2474
+ // The same containment blocks get, one level up: a crash inside a
2475
+ // layout (a throwing sort, a bad row callback) shows a named error
2476
+ // under the header instead of killing the whole app. Measured: an
2477
+ // unhandled row-action throw once blanked an entire blind app.
2478
+ createElement(boundary(ctx.React), null, createElement(Layout, { spec, ctx }))
2479
+ ),
2414
2480
  spec.brand && spec.brand.footer ? createElement("footer", { className: "caf-footer" }, spec.brand.footer) : null
2415
2481
  );
2416
2482
  };
@@ -2533,6 +2599,7 @@ var BASE_CSS = `
2533
2599
  .caf-timer-controls { display: flex; gap: 0.6rem; }
2534
2600
  .caf-btn:disabled { opacity: 0.45; cursor: not-allowed; }
2535
2601
  .caf-steps-progress { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.2rem; }
2602
+ .caf-steps-result { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
2536
2603
  .caf-steps-fill { transition: width 0.25s ease; }
2537
2604
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
2538
2605
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
@@ -2662,6 +2729,27 @@ var BASE_CSS = `
2662
2729
  /* Never-clip guarantee: content inside a custom block that is wider than the
2663
2730
  phone (column grids, week views) must scroll, not disappear off-screen. */
2664
2731
  .caf-custom { overflow-x: auto; }
2732
+ /* mobile.nav: "bottom" \u2014 the app-shell tab bar pinned to the thumb zone.
2733
+ Tabs share the width evenly so every section stays visible; the root
2734
+ reserves space so the bar never covers the end of the content. */
2735
+ .caf-has-bottomnav { padding-bottom: 76px; }
2736
+ .caf-tabbar-bottom {
2737
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
2738
+ gap: 0; margin: 0; overflow-x: visible;
2739
+ background: var(--caf-surface); border-top: 1px solid var(--caf-border); border-bottom: none;
2740
+ padding: 0.25rem 0.4rem calc(0.3rem + env(safe-area-inset-bottom, 0px));
2741
+ box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.07);
2742
+ }
2743
+ .caf-tabbar-bottom .caf-tab {
2744
+ flex: 1 1 0; min-width: 0; min-height: 48px;
2745
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
2746
+ gap: 0.15rem; padding: 0.25rem 0.2rem; margin-bottom: 0;
2747
+ font-size: 0.66rem; border-bottom: none;
2748
+ overflow: hidden; text-overflow: ellipsis;
2749
+ }
2750
+ .caf-tabbar-bottom .caf-tab-ico { font-size: 1.25rem; line-height: 1; }
2751
+ .caf-tabbar-bottom .caf-tab-lbl { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2752
+ .caf-tabbar-bottom .caf-tab-active, .caf-tabbar-bottom .caf-tab-active:hover { border-bottom-color: transparent; }
2665
2753
  /* Touch devices get tappable framework controls regardless of the spec. */
2666
2754
  @media (pointer: coarse) {
2667
2755
  .caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }