claude-artifact-framework 0.17.0 → 0.19.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
@@ -896,9 +909,10 @@ keys must be unique across steps.
896
909
 
897
910
  **Formats** (`format:` on output/chart/compute items): `money`, `percent`,
898
911
  `number`, `date`. `percent` expects 0–100 (pass `ratio * 100`, not the raw
899
- ratio). `big: true` renders an item at hero size reserve it for the ONE
900
- number that is the screen's main result (a total, a final score), not for
901
- every stat; more than one hero per screen means no hero at all.
912
+ ratio). `big: true` renders an item at hero size AND promotes its block to
913
+ the screen's one feature surface (accent-tinted card) reserve it for the
914
+ ONE number that is the screen's main result (a total, a final score), not
915
+ for every stat; more than one hero per screen means no hero at all.
902
916
 
903
917
  **Quick-add pattern** — a lightweight alternative to `records` when you just
904
918
  need "type something, press a button, it lands in a list":
@@ -993,8 +1007,9 @@ The rules that decide whether a generated app works, all in one place
993
1007
  or `ctx.files`.
994
1008
  5. **Phases belong to the `machine`** — never track game/process phases
995
1009
  with hand-rolled flags and `setTimeout`; `ctx.send` is the only door.
996
- 6. **Never skip a middle positional argument** — `run: (r, d, viewerId)`,
997
- never `run: (r, viewerId)`.
1010
+ 6. **Row callbacks are `(record, data, viewerId)`** — `when` and `run`
1011
+ alike. Prefer the full order; a second parameter literally named
1012
+ `viewerId` is matched by name and still gets the viewer id.
998
1013
  7. **One `big: true` per screen** — it marks THE result, not every stat.
999
1014
  8. **The constructive action takes `kind: "primary"`**; a counter changed
1000
1015
  by pressing is an `action`, not an editable `number` field.
package/dist/index.esm.js CHANGED
@@ -761,9 +761,10 @@ function OutputBlock({ spec, ctx }) {
761
761
  if (!items.length) {
762
762
  return createElement("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
763
763
  }
764
+ const hero = items.some((it) => it && it.big);
764
765
  return createElement(
765
766
  "div",
766
- { className: "caf-block caf-output" },
767
+ { className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
767
768
  spec.title ? createElement("h2", { className: "caf-block-title" }, spec.title) : null,
768
769
  createElement(
769
770
  "div",
@@ -780,10 +781,9 @@ function OutputBlock({ spec, ctx }) {
780
781
  )
781
782
  );
782
783
  }
783
- function BannerBlock({ spec, ctx }) {
784
+ function BannerBlock({ spec }) {
784
785
  const b = typeof spec.banner === "string" ? { title: spec.banner } : spec.banner || {};
785
- const [c1, c2] = ctx.colors(2);
786
- const background = b.image ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${JSON.stringify(b.image)}) center / cover` : `linear-gradient(135deg, ${c1}, ${c2})`;
786
+ const background = b.image ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${JSON.stringify(b.image)}) center / cover` : `linear-gradient(135deg, color-mix(in srgb, var(--caf-accent) 82%, #000), var(--caf-accent) 55%, color-mix(in srgb, var(--caf-accent) 72%, #fff))`;
787
787
  return createElement(
788
788
  "div",
789
789
  { className: "caf-banner", style: { background } },
@@ -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;
@@ -1087,9 +1100,10 @@ function Summary({ spec, records }) {
1087
1100
  if (!spec.summary) return null;
1088
1101
  const items = spec.summary(records) || [];
1089
1102
  if (!items.length) return null;
1103
+ const hero = items.some((it) => it && it.big);
1090
1104
  return createElement(
1091
1105
  "div",
1092
- { className: "caf-block caf-output" },
1106
+ { className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
1093
1107
  createElement(
1094
1108
  "div",
1095
1109
  { className: "caf-output-grid" },
@@ -1174,7 +1188,7 @@ function ListScreen({ spec, ctx }) {
1174
1188
  spec.actions ? createElement(
1175
1189
  "div",
1176
1190
  { className: "caf-row-actions" },
1177
- spec.actions.filter((a) => !a.when || a.when(rec, ctx.viewerId)).map(
1191
+ spec.actions.filter((a) => !a.when || rowCall(a.when, rec, ctx.data, ctx.viewerId)).map(
1178
1192
  (a) => createElement(
1179
1193
  "button",
1180
1194
  {
@@ -1183,7 +1197,7 @@ function ListScreen({ spec, ctx }) {
1183
1197
  className: a.kind === "danger" ? "caf-btn caf-btn-danger caf-btn-sm" : "caf-btn caf-btn-sm",
1184
1198
  onClick: () => ctx.update((d) => {
1185
1199
  const live = d.records.find((x) => x.id === rec.id);
1186
- if (live) a.run(live, d, ctx.viewerId);
1200
+ if (live) rowCall(a.run, live, d, ctx.viewerId);
1187
1201
  })
1188
1202
  },
1189
1203
  a.label
@@ -1354,23 +1368,32 @@ function stepErrors(step, data) {
1354
1368
  }
1355
1369
  function ResultScreen({ step, ctx }) {
1356
1370
  const items = step.result(ctx.data) || [];
1371
+ const Block2 = ctx._Block;
1357
1372
  return createElement(
1358
1373
  "div",
1359
- { className: "caf-block caf-output" },
1360
- step.title ? createElement("h2", { className: "caf-block-title" }, step.title) : null,
1374
+ { className: "caf-steps-result" },
1361
1375
  createElement(
1362
1376
  "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
1377
+ // Same rule as OutputBlock: the hero (big) result gets the feature card.
1378
+ { className: items.some((it) => it && it.big) ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
1379
+ step.title ? createElement("h2", { className: "caf-block-title" }, step.title) : null,
1380
+ createElement(
1381
+ "div",
1382
+ { className: "caf-output-grid" },
1383
+ items.map(
1384
+ (item, i) => createElement(
1385
+ "div",
1386
+ { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
1387
+ createElement("span", { className: "caf-stat-label" }, item.label),
1388
+ createElement("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
1389
+ item.hint ? createElement("small", { className: "caf-hint" }, item.hint) : null
1390
+ )
1371
1391
  )
1372
1392
  )
1373
1393
  ),
1394
+ // A result can end on more than stats: any declared blocks (charts,
1395
+ // text, lists) render under the grid with full block services.
1396
+ (step.blocks || []).map((b, i) => createElement(Block2, { key: "rb" + i, block: b, ctx })),
1374
1397
  createElement(
1375
1398
  "div",
1376
1399
  { className: "caf-step-nav" },
@@ -1701,7 +1724,7 @@ function validate(spec) {
1701
1724
  if (!Array.isArray(steps) || steps.length === 0) {
1702
1725
  throw new Error('claude-artifact-framework: layout "steps" needs `steps: [...]` with at least one step.');
1703
1726
  }
1704
- const STEP_KEYS = ["title", "text", "fields", "result"];
1727
+ const STEP_KEYS = ["title", "text", "fields", "result", "blocks"];
1705
1728
  const seen = /* @__PURE__ */ new Set();
1706
1729
  steps.forEach((st, i) => {
1707
1730
  if (!st || typeof st !== "object") {
@@ -1716,6 +1739,12 @@ function validate(spec) {
1716
1739
  if (!st.fields && !st.text && !st.result) {
1717
1740
  throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
1718
1741
  }
1742
+ if (st.blocks !== void 0) {
1743
+ if (typeof st.result !== "function") {
1744
+ 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.`);
1745
+ }
1746
+ checkBlocks(st.blocks);
1747
+ }
1719
1748
  checkFields(st.fields, `steps[${i}]`);
1720
1749
  for (const f of st.fields || []) {
1721
1750
  if (seen.has(f.key)) {
@@ -2221,7 +2250,10 @@ function DocumentsLayout({ spec, ctx }) {
2221
2250
  { className: "caf-documents" },
2222
2251
  createElement(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
2223
2252
  typePicker,
2224
- createElement(Panel, { spec: { blocks: aspec.blocks }, ctx: scoped })
2253
+ // Keyed by document id: switching tabs REMOUNTS the pane, so DOM-held
2254
+ // state can't leak across documents. Measured: the native file input
2255
+ // kept showing the previous document's filename.
2256
+ createElement(Panel, { key: active.id, spec: { blocks: aspec.blocks }, ctx: scoped })
2225
2257
  );
2226
2258
  }
2227
2259
  function DocTabbar({ docs, active, dspec, types, close, onAdd, label, ctx }) {
@@ -2404,6 +2436,8 @@ function createApp(spec = {}) {
2404
2436
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
2405
2437
  viewport: narrow ? "mobile" : "desktop",
2406
2438
  _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
2439
+ _Block: Block,
2440
+ // for layouts in other modules (steps) that render blocks
2407
2441
  machine: spec.machine || null,
2408
2442
  send: spec.machine ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload) : () => {
2409
2443
  throw new Error("claude-artifact-framework: ctx.send() needs a `machine` declared at the top level.");
@@ -2414,7 +2448,9 @@ function createApp(spec = {}) {
2414
2448
  const logoEl = !spec.brand ? null : logo && logo.trim().startsWith("<svg") ? createElement("span", { className: "caf-logo caf-logo-svg", dangerouslySetInnerHTML: { __html: logo } }) : logo && /^https?:\/\//.test(logo) ? createElement("img", { className: "caf-logo", src: logo, alt: "" }) : logo ? createElement("span", { className: "caf-logo caf-logo-emoji", "aria-hidden": true }, logo) : createElement(
2415
2449
  "span",
2416
2450
  { className: "caf-logo caf-logo-mono", "aria-hidden": true },
2417
- (spec.title || "?").split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()
2451
+ // First letter/digit of each word — punctuation "words" like the
2452
+ // "-" in "Caja - Camión" must not become half the monogram.
2453
+ (spec.title || "?").split(/\s+/).map((w) => (w.match(/[\p{L}\p{N}]/u) || [])[0]).filter(Boolean).slice(0, 2).join("").toUpperCase() || "?"
2418
2454
  );
2419
2455
  return createElement(
2420
2456
  "div",
@@ -2438,7 +2474,13 @@ function createApp(spec = {}) {
2438
2474
  { className: "caf-block caf-block-error" },
2439
2475
  createElement("strong", null, "A library failed to load"),
2440
2476
  createElement("code", null, libsError)
2441
- ) : !state.isHydrated() || libsState === "loading" ? createElement("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries\u2026" : "Loading\u2026") : createElement(Layout, { spec, ctx }),
2477
+ ) : !state.isHydrated() || libsState === "loading" ? createElement("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries\u2026" : "Loading\u2026") : (
2478
+ // The same containment blocks get, one level up: a crash inside a
2479
+ // layout (a throwing sort, a bad row callback) shows a named error
2480
+ // under the header instead of killing the whole app. Measured: an
2481
+ // unhandled row-action throw once blanked an entire blind app.
2482
+ createElement(boundary(ctx.React), null, createElement(Layout, { spec, ctx }))
2483
+ ),
2442
2484
  spec.brand && spec.brand.footer ? createElement("footer", { className: "caf-footer" }, spec.brand.footer) : null
2443
2485
  );
2444
2486
  };
@@ -2449,7 +2491,9 @@ var BASE_CSS = `
2449
2491
  --caf-control-radius: 7px; /* one radius for every control: buttons, inputs, rows */
2450
2492
  --caf-btn-pad-y: 0.45rem;
2451
2493
  --caf-btn-pad-x: 0.8rem;
2452
- --caf-num-primary: 2.1rem; /* the one size for a block's dominant number */
2494
+ --caf-num-primary: 2.6rem; /* the one size for a block's dominant number \u2014 the largest thing on screen, bigger than the H1 */
2495
+ --caf-accent-tint: color-mix(in srgb, var(--caf-accent) 7%, var(--caf-surface)); /* soft accent surface for the feature card */
2496
+ --caf-block-gap: 1.75rem; /* ONE value for air between blocks, every layout (audited: workspace used a smaller gap for the same visual role) */
2453
2497
  box-sizing: border-box;
2454
2498
  min-height: 100vh;
2455
2499
  padding: 2rem 1.25rem 4rem;
@@ -2465,7 +2509,9 @@ var BASE_CSS = `
2465
2509
  .caf-header h1 { margin: 0; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.01em; text-wrap: balance; }
2466
2510
  .caf-header p { margin: 0.4rem 0 0; color: var(--caf-muted); font-size: 0.95rem; line-height: 1.5; }
2467
2511
  /* Between-card air must beat within-card padding (1.1rem), or groups dissolve. */
2468
- .caf-panel { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 1.75rem 1rem; }
2512
+ /* align-items: start \u2014 a short card must not stretch to its tall neighbor's
2513
+ height; the stretched version reads as dead air (audited). */
2514
+ .caf-panel { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: var(--caf-block-gap) 1rem; align-items: start; }
2469
2515
  .caf-slot { grid-column: span 2; min-width: 0; }
2470
2516
  @media (min-width: 720px) { .caf-slot { grid-column: span 1; } .caf-slot-wide { grid-column: span 2; } }
2471
2517
  .caf-block {
@@ -2559,8 +2605,11 @@ var BASE_CSS = `
2559
2605
  .caf-timer-done { color: var(--caf-accent); }
2560
2606
  .caf-timer-fill { transition: width 0.9s linear; }
2561
2607
  .caf-timer-controls { display: flex; gap: 0.6rem; }
2562
- .caf-btn:disabled { opacity: 0.45; cursor: not-allowed; }
2608
+ /* Disabled goes NEUTRAL, not pale-accent: a washed-out primary reads as
2609
+ broken (audited on the chat Send with an empty input). */
2610
+ .caf-btn:disabled { cursor: not-allowed; background: var(--caf-sunken); color: var(--caf-muted); border-color: var(--caf-border); opacity: 1; }
2563
2611
  .caf-steps-progress { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.2rem; }
2612
+ .caf-steps-result { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
2564
2613
  .caf-steps-fill { transition: width 0.25s ease; }
2565
2614
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
2566
2615
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
@@ -2590,7 +2639,9 @@ var BASE_CSS = `
2590
2639
  .caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
2591
2640
  .caf-row-wrap .caf-row { flex: 1; min-width: 0; }
2592
2641
  .caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
2593
- .caf-btn-sm { font-size: 0.78rem; padding: calc(var(--caf-btn-pad-y) * 0.67) calc(var(--caf-btn-pad-x) * 0.75); }
2642
+ /* min-width keeps row actions' left edges aligned across rows even when
2643
+ labels differ ("Hecho" vs "Desmarcar" \u2014 audited misalignment). */
2644
+ .caf-btn-sm { font-size: 0.78rem; padding: calc(var(--caf-btn-pad-y) * 0.67) calc(var(--caf-btn-pad-x) * 0.75); min-width: 76px; text-align: center; }
2594
2645
  .caf-items { display: flex; flex-direction: column; gap: 0.5rem; border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
2595
2646
  .caf-item-row { display: flex; align-items: flex-end; gap: 0.5rem; }
2596
2647
  .caf-item-row .caf-field { flex: 1; min-width: 0; }
@@ -2604,8 +2655,8 @@ var BASE_CSS = `
2604
2655
  .caf-md-li { display: block; padding-left: 1rem; position: relative; }
2605
2656
  .caf-md-li::before { content: "\u2022"; position: absolute; left: 0.2rem; opacity: 0.6; }
2606
2657
  .caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
2607
- .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
2608
- .caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: 1rem; position: sticky; top: 1rem; }
2658
+ .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--caf-block-gap); }
2659
+ .caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: var(--caf-block-gap); position: sticky; top: 1rem; }
2609
2660
  .caf-ws-slot { min-width: 0; }
2610
2661
  .caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
2611
2662
  .caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
@@ -2690,6 +2741,11 @@ var BASE_CSS = `
2690
2741
  /* Never-clip guarantee: content inside a custom block that is wider than the
2691
2742
  phone (column grids, week views) must scroll, not disappear off-screen. */
2692
2743
  .caf-custom { overflow-x: auto; }
2744
+ /* The one differentiated surface per screen: accent top rule + tinted bg on
2745
+ the block holding the hero number. Everything-is-the-same-card was the
2746
+ audited root cause of the "generic template" look. (Late in the sheet on
2747
+ purpose \u2014 it must win over .caf-block's border/background.) */
2748
+ .caf-block-feature { border-top: 3px solid var(--caf-accent); background: var(--caf-accent-tint); }
2693
2749
  /* mobile.nav: "bottom" \u2014 the app-shell tab bar pinned to the thumb zone.
2694
2750
  Tabs share the width evenly so every section stays visible; the root
2695
2751
  reserves space so the bar never covers the end of the content. */