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/llms.txt CHANGED
@@ -32,7 +32,8 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
32
32
  (+ key when used as a block)
33
33
  - documents: label, title, blocks OR types, empty; machine: key, initial,
34
34
  states, events (event: from, do, then); chat: system, greeting, placeholder,
35
- 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)
36
37
  - Formats: money, percent (0-100), number, date. Output/chart/compute items:
37
38
  { label, value, format, big }
38
39
 
@@ -50,8 +51,9 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
50
51
  5. Phases (games, turns, processes) belong to `machine` — ctx.send(event,
51
52
  payload) is the only door; events are no-ops outside their `from` states;
52
53
  `after: {seconds, then, do}` timers are framework-owned.
53
- 6. Row actions receive (record, data, viewerId); block actions (data,
54
- 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.
55
57
  7. One big:true per screen; the constructive action takes kind:"primary"; a
56
58
  counter changed by pressing is an action, not an editable number field.
57
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.17.0",
3
+ "version": "0.19.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
@@ -312,7 +312,7 @@ function validate(spec) {
312
312
  if (!Array.isArray(steps) || steps.length === 0) {
313
313
  throw new Error('claude-artifact-framework: layout "steps" needs `steps: [...]` with at least one step.');
314
314
  }
315
- const STEP_KEYS = ["title", "text", "fields", "result"];
315
+ const STEP_KEYS = ["title", "text", "fields", "result", "blocks"];
316
316
  const seen = new Set();
317
317
  steps.forEach((st, i) => {
318
318
  if (!st || typeof st !== "object") {
@@ -327,6 +327,15 @@ function validate(spec) {
327
327
  if (!st.fields && !st.text && !st.result) {
328
328
  throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
329
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
+ }
330
339
  checkFields(st.fields, `steps[${i}]`);
331
340
  for (const f of st.fields || []) {
332
341
  if (seen.has(f.key)) {
@@ -920,7 +929,10 @@ function DocumentsLayout({ spec, ctx }) {
920
929
  { className: "caf-documents" },
921
930
  h(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
922
931
  typePicker,
923
- 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 })
924
936
  );
925
937
  }
926
938
 
@@ -1133,6 +1145,7 @@ export function createApp(spec = {}) {
1133
1145
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
1134
1146
  viewport: narrow ? "mobile" : "desktop",
1135
1147
  _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
1148
+ _Block: Block, // for layouts in other modules (steps) that render blocks
1136
1149
  machine: spec.machine || null,
1137
1150
  send: spec.machine
1138
1151
  ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
@@ -1157,13 +1170,15 @@ export function createApp(spec = {}) {
1157
1170
  : h(
1158
1171
  "span",
1159
1172
  { className: "caf-logo caf-logo-mono", "aria-hidden": true },
1173
+ // First letter/digit of each word — punctuation "words" like the
1174
+ // "-" in "Caja - Camión" must not become half the monogram.
1160
1175
  (spec.title || "?")
1161
1176
  .split(/\s+/)
1162
- .map((w) => w[0])
1177
+ .map((w) => (w.match(/[\p{L}\p{N}]/u) || [])[0])
1163
1178
  .filter(Boolean)
1164
1179
  .slice(0, 2)
1165
1180
  .join("")
1166
- .toUpperCase()
1181
+ .toUpperCase() || "?"
1167
1182
  );
1168
1183
 
1169
1184
  return h(
@@ -1192,7 +1207,11 @@ export function createApp(spec = {}) {
1192
1207
  )
1193
1208
  : !state.isHydrated() || libsState === "loading"
1194
1209
  ? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
1195
- : h(Layout, { spec, ctx }),
1210
+ : // The same containment blocks get, one level up: a crash inside a
1211
+ // layout (a throwing sort, a bad row callback) shows a named error
1212
+ // under the header instead of killing the whole app. Measured: an
1213
+ // unhandled row-action throw once blanked an entire blind app.
1214
+ h(boundary(ctx.React), null, h(Layout, { spec, ctx })),
1196
1215
  spec.brand && spec.brand.footer ? h("footer", { className: "caf-footer" }, spec.brand.footer) : null
1197
1216
  );
1198
1217
  };
@@ -1204,7 +1223,9 @@ const BASE_CSS = `
1204
1223
  --caf-control-radius: 7px; /* one radius for every control: buttons, inputs, rows */
1205
1224
  --caf-btn-pad-y: 0.45rem;
1206
1225
  --caf-btn-pad-x: 0.8rem;
1207
- --caf-num-primary: 2.1rem; /* the one size for a block's dominant number */
1226
+ --caf-num-primary: 2.6rem; /* the one size for a block's dominant number — the largest thing on screen, bigger than the H1 */
1227
+ --caf-accent-tint: color-mix(in srgb, var(--caf-accent) 7%, var(--caf-surface)); /* soft accent surface for the feature card */
1228
+ --caf-block-gap: 1.75rem; /* ONE value for air between blocks, every layout (audited: workspace used a smaller gap for the same visual role) */
1208
1229
  box-sizing: border-box;
1209
1230
  min-height: 100vh;
1210
1231
  padding: 2rem 1.25rem 4rem;
@@ -1220,7 +1241,9 @@ const BASE_CSS = `
1220
1241
  .caf-header h1 { margin: 0; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.01em; text-wrap: balance; }
1221
1242
  .caf-header p { margin: 0.4rem 0 0; color: var(--caf-muted); font-size: 0.95rem; line-height: 1.5; }
1222
1243
  /* Between-card air must beat within-card padding (1.1rem), or groups dissolve. */
1223
- .caf-panel { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 1.75rem 1rem; }
1244
+ /* align-items: start a short card must not stretch to its tall neighbor's
1245
+ height; the stretched version reads as dead air (audited). */
1246
+ .caf-panel { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: var(--caf-block-gap) 1rem; align-items: start; }
1224
1247
  .caf-slot { grid-column: span 2; min-width: 0; }
1225
1248
  @media (min-width: 720px) { .caf-slot { grid-column: span 1; } .caf-slot-wide { grid-column: span 2; } }
1226
1249
  .caf-block {
@@ -1314,8 +1337,11 @@ const BASE_CSS = `
1314
1337
  .caf-timer-done { color: var(--caf-accent); }
1315
1338
  .caf-timer-fill { transition: width 0.9s linear; }
1316
1339
  .caf-timer-controls { display: flex; gap: 0.6rem; }
1317
- .caf-btn:disabled { opacity: 0.45; cursor: not-allowed; }
1340
+ /* Disabled goes NEUTRAL, not pale-accent: a washed-out primary reads as
1341
+ broken (audited on the chat Send with an empty input). */
1342
+ .caf-btn:disabled { cursor: not-allowed; background: var(--caf-sunken); color: var(--caf-muted); border-color: var(--caf-border); opacity: 1; }
1318
1343
  .caf-steps-progress { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.2rem; }
1344
+ .caf-steps-result { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1319
1345
  .caf-steps-fill { transition: width 0.25s ease; }
1320
1346
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
1321
1347
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
@@ -1345,7 +1371,9 @@ const BASE_CSS = `
1345
1371
  .caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
1346
1372
  .caf-row-wrap .caf-row { flex: 1; min-width: 0; }
1347
1373
  .caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
1348
- .caf-btn-sm { font-size: 0.78rem; padding: calc(var(--caf-btn-pad-y) * 0.67) calc(var(--caf-btn-pad-x) * 0.75); }
1374
+ /* min-width keeps row actions' left edges aligned across rows even when
1375
+ labels differ ("Hecho" vs "Desmarcar" — audited misalignment). */
1376
+ .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; }
1349
1377
  .caf-items { display: flex; flex-direction: column; gap: 0.5rem; border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
1350
1378
  .caf-item-row { display: flex; align-items: flex-end; gap: 0.5rem; }
1351
1379
  .caf-item-row .caf-field { flex: 1; min-width: 0; }
@@ -1359,8 +1387,8 @@ const BASE_CSS = `
1359
1387
  .caf-md-li { display: block; padding-left: 1rem; position: relative; }
1360
1388
  .caf-md-li::before { content: "•"; position: absolute; left: 0.2rem; opacity: 0.6; }
1361
1389
  .caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
1362
- .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
1363
- .caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: 1rem; position: sticky; top: 1rem; }
1390
+ .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--caf-block-gap); }
1391
+ .caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: var(--caf-block-gap); position: sticky; top: 1rem; }
1364
1392
  .caf-ws-slot { min-width: 0; }
1365
1393
  .caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
1366
1394
  .caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
@@ -1445,6 +1473,11 @@ const BASE_CSS = `
1445
1473
  /* Never-clip guarantee: content inside a custom block that is wider than the
1446
1474
  phone (column grids, week views) must scroll, not disappear off-screen. */
1447
1475
  .caf-custom { overflow-x: auto; }
1476
+ /* The one differentiated surface per screen: accent top rule + tinted bg on
1477
+ the block holding the hero number. Everything-is-the-same-card was the
1478
+ audited root cause of the "generic template" look. (Late in the sheet on
1479
+ purpose — it must win over .caf-block's border/background.) */
1480
+ .caf-block-feature { border-top: 3px solid var(--caf-accent); background: var(--caf-accent-tint); }
1448
1481
  /* mobile.nav: "bottom" — the app-shell tab bar pinned to the thumb zone.
1449
1482
  Tabs share the width evenly so every section stays visible; the root
1450
1483
  reserves space so the bar never covers the end of the content. */
package/src/blocks.js CHANGED
@@ -178,9 +178,14 @@ export function OutputBlock({ spec, ctx }) {
178
178
  if (!items.length) {
179
179
  return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
180
180
  }
181
+ // The block holding the screen's hero number (a `big` stat) gets the one
182
+ // differentiated surface — an accent-tinted feature card. Audited miss:
183
+ // with a single card chrome, the most important number on screen weighed
184
+ // exactly the same as everything else.
185
+ const hero = items.some((it) => it && it.big);
181
186
  return h(
182
187
  "div",
183
- { className: "caf-block caf-output" },
188
+ { className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
184
189
  spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
185
190
  h(
186
191
  "div",
@@ -202,12 +207,14 @@ export function OutputBlock({ spec, ctx }) {
202
207
  // with nothing uploaded (artifacts can't persist real images — the ~5MB
203
208
  // pool). banner's default face is a gradient derived from the theme seed;
204
209
  // image accepts a URL or an inline SVG string (LLMs generate SVG well).
205
- export function BannerBlock({ spec, ctx }) {
210
+ export function BannerBlock({ spec }) {
206
211
  const b = typeof spec.banner === "string" ? { title: spec.banner } : spec.banner || {};
207
- const [c1, c2] = ctx.colors(2);
212
+ // Tonal gradient, one hue family: the golden-angle series palette is for
213
+ // CHARTS (categorical distinction) — on a brand surface its second hue
214
+ // clashes (audited: an orange seed produced an orange→green banner).
208
215
  const background = b.image
209
216
  ? `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})`;
217
+ : `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))`;
211
218
  return h(
212
219
  "div",
213
220
  { className: "caf-banner", style: { background } },
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 }) {
@@ -82,9 +103,13 @@ function Summary({ spec, records }) {
82
103
  if (!spec.summary) return null;
83
104
  const items = spec.summary(records) || [];
84
105
  if (!items.length) return null;
106
+ // Same rule as OutputBlock: the hero (big) stat promotes its card to the
107
+ // screen's feature surface. Re-audit caught this second render path
108
+ // missing the treatment — the rule must hold wherever big stats render.
109
+ const hero = items.some((it) => it && it.big);
85
110
  return h(
86
111
  "div",
87
- { className: "caf-block caf-output" },
112
+ { className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
88
113
  h(
89
114
  "div",
90
115
  { className: "caf-output-grid" },
@@ -184,7 +209,7 @@ function ListScreen({ spec, ctx }) {
184
209
  "div",
185
210
  { className: "caf-row-actions" },
186
211
  spec.actions
187
- .filter((a) => !a.when || a.when(rec, ctx.viewerId))
212
+ .filter((a) => !a.when || rowCall(a.when, rec, ctx.data, ctx.viewerId))
188
213
  .map((a) =>
189
214
  h(
190
215
  "button",
@@ -195,7 +220,7 @@ function ListScreen({ spec, ctx }) {
195
220
  onClick: () =>
196
221
  ctx.update((d) => {
197
222
  const live = d.records.find((x) => x.id === rec.id);
198
- if (live) a.run(live, d, ctx.viewerId);
223
+ if (live) rowCall(a.run, live, d, ctx.viewerId);
199
224
  }),
200
225
  },
201
226
  a.label
package/src/steps.js CHANGED
@@ -22,23 +22,32 @@ 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
+ // Same rule as OutputBlock: the hero (big) result gets the feature card.
32
+ { className: items.some((it) => it && it.big) ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
33
+ step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
34
+ h(
35
+ "div",
36
+ { className: "caf-output-grid" },
37
+ items.map((item, i) =>
38
+ h(
39
+ "div",
40
+ { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
41
+ h("span", { className: "caf-stat-label" }, item.label),
42
+ h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
43
+ item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
44
+ )
39
45
  )
40
46
  )
41
47
  ),
48
+ // A result can end on more than stats: any declared blocks (charts,
49
+ // text, lists) render under the grid with full block services.
50
+ (step.blocks || []).map((b, i) => h(Block, { key: "rb" + i, block: b, ctx })),
42
51
  h(
43
52
  "div",
44
53
  { className: "caf-step-nav" },