claude-artifact-framework 0.19.1 → 0.20.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
@@ -34,7 +34,8 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
34
34
  states, events (event: from, do, then); chat: system, greeting, placeholder,
35
35
  tools, model, maxTokens; steps step: title, text, fields, result,
36
36
  blocks (result steps only — end a wizard on charts/text under the stats)
37
- - Formats: money, percent (0-100), number, date. Output/chart/compute items:
37
+ - Formats: money, percent (0-100), number, date, text (no-op; anything else
38
+ throws). NaN/Infinity render as "—". Output/chart/compute items:
38
39
  { label, value, format, big }
39
40
 
40
41
  ## Hard rules
@@ -83,7 +84,9 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
83
84
  3. Icons: all siblings or none, one style (emoji), never half a set.
84
85
  4. Mid-tone saturated seed that names the domain; no grays/near-blacks.
85
86
  5. Never duplicate what the framework renders (title, totals, empties).
86
- 6. Group, don't multiply cards: related inputs in ONE fields block;
87
+ 6. Group, don't multiply cards: related inputs in ONE fields block, and
88
+ their submit button INSIDE it — { fields: [...], actions: [...] } is
89
+ the one legal block-type pair (actions render as the card's footer);
87
90
  secondary content into tabs/collapsible; >5 cards on one screen = no
88
91
  hierarchy.
89
92
  7. State via when:, not more blocks.
@@ -120,3 +123,7 @@ Chat with tools (workspace copilot): sidebar block
120
123
  ctx.update((d) => { /* mutate */ }); return { ok: true }; } } } } }
121
124
  — the artifact runtime proxies api.anthropic.com with no API key; default
122
125
  model claude-sonnet-4-6; conversation persists under data.chat.
126
+
127
+ Shared mode: records rows another viewer just added/edited flash the accent
128
+ automatically; ctx.remote = { keys, at } names the top-level keys the last
129
+ remote change touched (null before any) for custom blocks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.19.1",
3
+ "version": "0.20.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
@@ -114,15 +114,19 @@ function checkBlocks(blocks) {
114
114
  }). Valid block types: ${[...BLOCK_NAMES, "tabs", "records"].join(", ")}.`
115
115
  );
116
116
  }
117
- if (known.length > 1) {
117
+ // ONE legal pair: { fields, actions } — the actions render as the
118
+ // fields card's footer (round 21: the standalone submit button was an
119
+ // orphan card). Every other combination still throws.
120
+ const fieldsWithActions = known.length === 2 && known.includes("fields") && known.includes("actions");
121
+ if (known.length > 1 && !fieldsWithActions) {
118
122
  throw new Error(
119
123
  `claude-artifact-framework: blocks[${i}] declares more than one block type (${known.join(
120
124
  ", "
121
- )}). Split them into separate entries of \`blocks\`.`
125
+ )}). Split them into separate entries of \`blocks\` — the one exception is { fields, actions }, which renders the actions as the fields card's footer.`
122
126
  );
123
127
  }
124
128
  checkFields(block.fields, `blocks[${i}]`);
125
- if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
129
+ if (known.includes("actions")) checkActions(block.actions, `blocks[${i}].actions`);
126
130
  if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
127
131
  if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
128
132
  if (known[0] === "banner" && typeof block.banner === "object" && block.banner !== null) {
@@ -668,7 +672,8 @@ function Block({ block, ctx }) {
668
672
  const bottomNav = ctx._navTabs !== undefined && ctx._navTabs === block;
669
673
  // `icon` is sugar: it prefixes the title everywhere titles render.
670
674
  if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
671
- const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
675
+ // { fields, actions } is one block: fields owns it, actions are its footer.
676
+ const name = block.fields && block.actions ? "fields" : Object.keys(block).find((k) => ALL_NAMES.includes(k));
672
677
  const Component = ALL_BLOCKS[name];
673
678
  const inner = h(
674
679
  boundary(ctx.React),
@@ -749,11 +754,17 @@ function RecordsWithBlocks({ spec, ctx }) {
749
754
  function WorkspaceLayout({ spec, ctx }) {
750
755
  const render = (blocks) =>
751
756
  (blocks || []).map((block, i) => h("section", { key: i, className: "caf-ws-slot" }, h(Block, { block, ctx })));
757
+ // When main opens with a tabs block, its bar consumes height the sidebar
758
+ // doesn't have — the sidebar's first card floated above main's (audited
759
+ // twice). The offset class compensates with the tabbar's own height.
760
+ const mainStartsWithTabs = Array.isArray(spec.main) && spec.main[0] && Array.isArray(spec.main[0].tabs);
752
761
  return h(
753
762
  "div",
754
763
  { className: "caf-workspace" },
755
764
  h("div", { className: "caf-ws-main" }, render(spec.main)),
756
- spec.sidebar && spec.sidebar.length ? h("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
765
+ spec.sidebar && spec.sidebar.length
766
+ ? h("div", { className: mainStartsWithTabs ? "caf-ws-side caf-ws-side-tabs" : "caf-ws-side" }, render(spec.sidebar))
767
+ : null
757
768
  );
758
769
  }
759
770
 
@@ -1144,6 +1155,9 @@ export function createApp(spec = {}) {
1144
1155
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
1145
1156
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
1146
1157
  viewport: narrow ? "mobile" : "desktop",
1158
+ // { keys, at } of the last change another viewer made (shared mode),
1159
+ // null before any. The door to "notice teammates' changes".
1160
+ remote: state.getRemote(),
1147
1161
  _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
1148
1162
  _Block: Block, // for layouts in other modules (steps) that render blocks
1149
1163
  machine: spec.machine || null,
@@ -1255,6 +1269,10 @@ const BASE_CSS = `
1255
1269
  flex-direction: column;
1256
1270
  gap: 0.8rem;
1257
1271
  height: 100%;
1272
+ /* The one depth cue cards get — same as collapsible heads, so every
1273
+ raised surface floats exactly the same amount (audited: buttons had
1274
+ depth, cards didn't). */
1275
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
1258
1276
  }
1259
1277
  .caf-block-title { margin: 0; font-size: 0.78rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--caf-muted); }
1260
1278
  .caf-field { display: flex; flex-direction: column; gap: 0.3rem; }
@@ -1266,6 +1284,11 @@ const BASE_CSS = `
1266
1284
  border-radius: var(--caf-control-radius); border: 1px solid var(--caf-border);
1267
1285
  background: var(--caf-sunken); color: var(--caf-text); width: 100%;
1268
1286
  }
1287
+ /* "Has a value" and "is empty" must read differently at a glance —
1288
+ audited: a filled "0" and the neighboring placeholder shared one gray. */
1289
+ .caf-field input::placeholder, .caf-field textarea::placeholder,
1290
+ .caf-chat-input input::placeholder { color: var(--caf-muted); opacity: 0.75; }
1291
+ .caf-field select.caf-select-empty { color: var(--caf-muted); }
1269
1292
  .caf-field input:focus-visible, .caf-field select:focus-visible, .caf-field textarea:focus-visible,
1270
1293
  .caf-row:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
1271
1294
  .caf-field input[aria-invalid="true"] { border-color: var(--caf-danger); }
@@ -1345,7 +1368,8 @@ const BASE_CSS = `
1345
1368
  .caf-steps-fill { transition: width 0.25s ease; }
1346
1369
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
1347
1370
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
1348
- .caf-chat-log { min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
1371
+ .caf-chat-card { min-width: 0; }
1372
+ .caf-chat-log { display: flex; flex-direction: column; min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
1349
1373
  .caf-msg { max-width: 85%; padding: 0.55rem 0.75rem; border-radius: 12px; font-size: 0.92rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
1350
1374
  .caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
1351
1375
  .caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
@@ -1353,9 +1377,12 @@ const BASE_CSS = `
1353
1377
  .caf-msg-tools { align-self: flex-start; font-size: 0.74rem; color: var(--caf-muted); padding: 0.15rem 0.55rem; border: 1px solid var(--caf-border); border-radius: 999px; }
1354
1378
  .caf-msg-error { align-self: stretch; max-width: none; display: flex; flex-direction: column; gap: 0.2rem; background: transparent; border: 1px solid var(--caf-danger); }
1355
1379
  .caf-msg-error strong { color: var(--caf-danger); font-size: 0.85rem; }
1380
+ /* The composer belongs to the conversation: a hairline continuation of the
1381
+ same card, not a second bordered box floating 1rem below (audited: log
1382
+ and input read as unrelated siblings). */
1356
1383
  .caf-chat-input {
1357
- display: flex; gap: 0.6rem; margin-top: 1rem; padding: 0.6rem;
1358
- background: var(--caf-surface); border: 1px solid var(--caf-border); border-radius: var(--caf-radius);
1384
+ display: flex; gap: 0.6rem; margin-top: 0.2rem; padding: 0.7rem 0 0;
1385
+ background: transparent; border: none; border-top: 1px solid var(--caf-border); border-radius: 0;
1359
1386
  }
1360
1387
  .caf-chat-input input { flex: 1; font: inherit; font-size: 0.95rem; padding: 0.55rem 0.7rem; border-radius: var(--caf-control-radius); border: 1px solid var(--caf-border); background: var(--caf-sunken); color: var(--caf-text); }
1361
1388
  .caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
@@ -1369,6 +1396,14 @@ const BASE_CSS = `
1369
1396
  .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
1370
1397
  .caf-records-blocks { margin-top: 1rem; }
1371
1398
  .caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
1399
+ /* A teammate's change announces itself: rows another viewer just added or
1400
+ edited flash the accent tint once (shared mode — round 20's dropped
1401
+ "notice teammates' changes" requirement, framework-owned). */
1402
+ @keyframes caf-remote-pulse {
1403
+ 0% { background: var(--caf-accent-tint); box-shadow: 0 0 0 2px var(--caf-accent); }
1404
+ 100% { background: transparent; box-shadow: none; }
1405
+ }
1406
+ .caf-row-pulse { animation: caf-remote-pulse 2.4s ease-out; }
1372
1407
  .caf-row-wrap .caf-row { flex: 1; min-width: 0; }
1373
1408
  .caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
1374
1409
  /* min-width keeps row actions' left edges aligned across rows even when
@@ -1381,6 +1416,9 @@ const BASE_CSS = `
1381
1416
  .caf-item-remove { flex: none; margin-bottom: 0.15rem; }
1382
1417
  .caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
1383
1418
  .caf-actions-row { display: flex; flex-wrap: wrap; gap: 0.6rem; }
1419
+ /* Footer actions inside a fields card: a hairline marks "this submits the
1420
+ inputs above", the same attachment grammar as the chat composer. */
1421
+ .caf-fields-footer { border-top: 1px solid var(--caf-border); padding-top: 0.8rem; margin-top: 0.2rem; }
1384
1422
  .caf-code { background: var(--caf-sunken); border-radius: 6px; padding: 0.55rem 0.7rem; font-size: 0.8rem; overflow-x: auto; margin: 0.3rem 0; font-family: ui-monospace, monospace; }
1385
1423
  .caf-msg code { background: color-mix(in srgb, currentColor 12%, transparent); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; font-family: ui-monospace, monospace; }
1386
1424
  .caf-md-h { display: block; margin-top: 0.3rem; }
@@ -1389,6 +1427,12 @@ const BASE_CSS = `
1389
1427
  .caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
1390
1428
  .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--caf-block-gap); }
1391
1429
  .caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: var(--caf-block-gap); position: sticky; top: 1rem; }
1430
+ /* Tab bar height (2×0.45rem pad + ~1.02rem line + 1px border) plus the
1431
+ .caf-tabs gap — so the sidebar's first card tops out level with main's
1432
+ first card, not with the tab bar. Phones stack, no offset needed. */
1433
+ @media (min-width: 901px) {
1434
+ .caf-ws-side-tabs { margin-top: calc(1.92rem + 1px + 0.8rem); }
1435
+ }
1392
1436
  .caf-ws-slot { min-width: 0; }
1393
1437
  .caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
1394
1438
  .caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
@@ -1436,6 +1480,14 @@ const BASE_CSS = `
1436
1480
  .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1437
1481
  .caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
1438
1482
  .caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
1483
+ /* Where the page has a gutter, the logo lives in it: the title keeps the
1484
+ exact left axis of tabs and cards below (audited twice — a logo pushed
1485
+ every heading onto a third axis). On phones there is no gutter, so the
1486
+ logo stays inline. */
1487
+ @media (min-width: 720px) {
1488
+ .caf-header-brand { position: relative; }
1489
+ .caf-header-brand .caf-logo { position: absolute; right: 100%; margin-right: 0.85rem; top: 0.1rem; }
1490
+ }
1439
1491
  .caf-header-text { min-width: 0; }
1440
1492
  .caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
1441
1493
  .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); }
@@ -1455,7 +1507,9 @@ const BASE_CSS = `
1455
1507
  .caf-image { margin: 0; }
1456
1508
  .caf-image img { width: 100%; border-radius: 7px; display: block; }
1457
1509
  .caf-image-svg svg { width: 100%; height: auto; display: block; }
1458
- .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; }
1510
+ /* Left, like every other axis on the page a centered footer was the one
1511
+ element breaking the single left axis (audited). */
1512
+ .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; }
1459
1513
  .caf-layout-workspace .caf-footer { max-width: 1200px; }
1460
1514
  .caf-avatar {
1461
1515
  flex: none; width: 30px; height: 30px; border-radius: 50%;
package/src/appState.js CHANGED
@@ -83,13 +83,23 @@ export function createAppState(defaults, { debounceMs = 400, shared = false, pol
83
83
  }, debounceMs);
84
84
  const persistView = debounce(() => storage.set(VIEW_KEY, view), debounceMs);
85
85
 
86
+ // The last change that arrived from ANOTHER viewer: which top-level keys
87
+ // it touched, and when. Ephemeral, never persisted. This is the minimal
88
+ // vocabulary for "notice teammates' changes" — the requirement a blind
89
+ // agent silently dropped because nothing in the API could express it.
90
+ let lastRemote = null;
86
91
  if (shared) {
87
92
  setInterval(async () => {
88
93
  if (!hydrated || writes > persistedWrites) return; // local edits in flight win
89
94
  try {
90
95
  const remote = await dataStore.get(DATA_KEY, undefined);
91
96
  if (remote !== undefined && JSON.stringify(remote) !== JSON.stringify(data)) {
92
- data = mergeDefaults(defaults, remote);
97
+ const merged = mergeDefaults(defaults, remote);
98
+ const keys = [...new Set([...Object.keys(data || {}), ...Object.keys(merged || {})])].filter(
99
+ (k) => JSON.stringify(data[k]) !== JSON.stringify(merged[k])
100
+ );
101
+ data = merged;
102
+ lastRemote = { keys, at: Date.now() };
93
103
  notify();
94
104
  }
95
105
  } catch {
@@ -139,6 +149,7 @@ export function createAppState(defaults, { debounceMs = 400, shared = false, pol
139
149
  ready,
140
150
  isHydrated: () => hydrated,
141
151
  getViewerId: () => viewerId,
152
+ getRemote: () => lastRemote,
142
153
  getData: () => data,
143
154
  getView: () => view,
144
155
  update,
package/src/blocks.js CHANGED
@@ -17,6 +17,9 @@ const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check",
17
17
 
18
18
  export function formatValue(value, format) {
19
19
  if (value === null || value === undefined || value === "") return "—";
20
+ // A computed NaN/Infinity (0/0 on an empty list is the measured case)
21
+ // must read as "no data yet", never as the string "NaN" on screen.
22
+ if (typeof value === "number" && !Number.isFinite(value)) return "—";
20
23
  const n = Number(value);
21
24
  switch (format) {
22
25
  case "money":
@@ -29,8 +32,17 @@ export function formatValue(value, format) {
29
32
  return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(value);
30
33
  case "date":
31
34
  return value instanceof Date ? value.toLocaleDateString() : String(value);
32
- default:
35
+ case "text": // observed natural usage (round 21) — an explicit no-op
36
+ case undefined:
37
+ case null:
33
38
  return String(value);
39
+ default:
40
+ // Same loud-failure rule as every other registry: a typo must name
41
+ // the valid set, not silently un-format the number. The block
42
+ // boundary contains the throw to the offending card.
43
+ throw new Error(
44
+ `claude-artifact-framework: unknown format "${format}". Valid formats: money, percent, number, date, text.`
45
+ );
34
46
  }
35
47
  }
36
48
 
@@ -108,7 +120,9 @@ export function Field({ field, value, onChange, data }) {
108
120
  const options = typeof field.options === "function" ? field.options(data) || [] : field.options || [];
109
121
  control = h(
110
122
  "select",
111
- common,
123
+ // Unchosen reads muted, like a text placeholder — "has a value" and
124
+ // "is empty" must differ at a glance (audited on the Select… state).
125
+ { ...common, className: value ? undefined : "caf-select-empty" },
112
126
  h("option", { value: "" }, field.placeholder || "Select…"),
113
127
  options.map((opt) => {
114
128
  const val = typeof opt === "string" ? opt : opt.value;
@@ -157,6 +171,11 @@ export function writeField(ctx, key, field, v, setter) {
157
171
 
158
172
  export function FieldsBlock({ spec, ctx }) {
159
173
  const fields = spec.fields || [];
174
+ // A fields block may carry its own `actions`: they render as the card's
175
+ // footer, visually owned by the inputs they submit. Measured miss (round
176
+ // 21): the quick-add CTA as a separate block was an orphan card,
177
+ // equidistant from its form and from the next section.
178
+ const footer = (spec.actions || []).filter((a) => !a.when || a.when(ctx.data, ctx.viewerId));
160
179
  return h(
161
180
  "div",
162
181
  { className: "caf-block caf-fields" },
@@ -169,24 +188,44 @@ export function FieldsBlock({ spec, ctx }) {
169
188
  value: ctx.data[field.key],
170
189
  onChange: (v) => writeField(ctx, field.key, field, v, (d, val) => { d[field.key] = val; }),
171
190
  })
172
- )
191
+ ),
192
+ footer.length
193
+ ? h(
194
+ "div",
195
+ { className: "caf-actions-row caf-fields-footer" },
196
+ footer.map((a) =>
197
+ h(
198
+ "button",
199
+ {
200
+ key: a.label,
201
+ type: "button",
202
+ className:
203
+ a.kind === "danger"
204
+ ? "caf-btn caf-btn-danger"
205
+ : a.kind === "primary"
206
+ ? "caf-btn caf-btn-primary"
207
+ : "caf-btn",
208
+ onClick: () => ctx.update((d) => a.run(d, ctx.viewerId)),
209
+ },
210
+ a.label
211
+ )
212
+ )
213
+ )
214
+ : null
173
215
  );
174
216
  }
175
217
 
176
- export function OutputBlock({ spec, ctx }) {
177
- const items = typeof spec.output === "function" ? spec.output(ctx.data) : spec.output || [];
178
- if (!items.length) {
179
- return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
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.
218
+ // THE stat grid the single render path for output blocks, records
219
+ // summaries, and steps results. It exists because the feature-card rule was
220
+ // once applied to one of three hand-copied versions of this markup and an
221
+ // audit caught the other two bare (the re-audit CRÍTICO of 0.19.0). The
222
+ // block holding a `big` stat gets the screen's one differentiated surface.
223
+ export function StatGrid({ items, title }) {
185
224
  const hero = items.some((it) => it && it.big);
186
225
  return h(
187
226
  "div",
188
227
  { className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
189
- spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
228
+ title ? h("h2", { className: "caf-block-title" }, title) : null,
190
229
  h(
191
230
  "div",
192
231
  { className: "caf-output-grid" },
@@ -203,6 +242,14 @@ export function OutputBlock({ spec, ctx }) {
203
242
  );
204
243
  }
205
244
 
245
+ export function OutputBlock({ spec, ctx }) {
246
+ const items = typeof spec.output === "function" ? spec.output(ctx.data) : spec.output || [];
247
+ if (!items.length) {
248
+ return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
249
+ }
250
+ return h(StatGrid, { items, title: spec.title });
251
+ }
252
+
206
253
  // Brand elements follow the zero-asset-first principle: every form works
207
254
  // with nothing uploaded (artifacts can't persist real images — the ~5MB
208
255
  // pool). banner's default face is a gradient derived from the theme seed;
package/src/chat.js CHANGED
@@ -244,33 +244,40 @@ export function ChatPanel({ cfg, ctx, title }) {
244
244
  }
245
245
  }
246
246
 
247
+ // ONE card holds the whole conversation: title, scrolling log, composer.
248
+ // The composer as a separate box below the card read as an unrelated
249
+ // sibling (audited) — inside the card with a hairline it reads as the
250
+ // conversation's own edge.
247
251
  return h(
248
252
  "div",
249
253
  { className: "caf-chat-panel" },
250
254
  h(
251
255
  "div",
252
- { className: "caf-block caf-chat-log" },
256
+ { className: "caf-block caf-chat-card" },
253
257
  // Every other block opens with its title label — a chat card that
254
258
  // starts straight at a bubble was the one heading-less block on screen.
255
259
  title ? h("h2", { className: "caf-block-title" }, title) : null,
256
- c.greeting
257
- ? h("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } })
258
- : null,
259
- log.map((m, i) => h(Bubble, { key: i, m })),
260
- live !== null
261
- ? h("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "…") } })
262
- : null,
263
- busy && live === null ? h("div", { className: "caf-msg-tools" }, "thinking…") : null,
264
- h("div", { ref: endRef })
265
- ),
266
- // Deliberately NOT a <form>: the artifact iframe's sandbox lacks
267
- // allow-forms, so native form submission is blocked there — and on mobile
268
- // the keyboard's send key triggers exactly that implicit submission. A
269
- // plain div with onClick + Enter via onKeyDown never touches the native
270
- // form machinery, so it can't be sandboxed away.
271
- h(
272
- "div",
273
- { className: "caf-chat-input" },
260
+ h(
261
+ "div",
262
+ { className: "caf-chat-log" },
263
+ c.greeting
264
+ ? h("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } })
265
+ : null,
266
+ log.map((m, i) => h(Bubble, { key: i, m })),
267
+ live !== null
268
+ ? h("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "…") } })
269
+ : null,
270
+ busy && live === null ? h("div", { className: "caf-msg-tools" }, "thinking…") : null,
271
+ h("div", { ref: endRef })
272
+ ),
273
+ // Deliberately NOT a <form>: the artifact iframe's sandbox lacks
274
+ // allow-forms, so native form submission is blocked there — and on mobile
275
+ // the keyboard's send key triggers exactly that implicit submission. A
276
+ // plain div with onClick + Enter via onKeyDown never touches the native
277
+ // form machinery, so it can't be sandboxed away.
278
+ h(
279
+ "div",
280
+ { className: "caf-chat-input" },
274
281
  h("input", {
275
282
  value: draft,
276
283
  placeholder: c.placeholder || "Message…",
@@ -285,10 +292,11 @@ export function ChatPanel({ cfg, ctx, title }) {
285
292
  },
286
293
  "aria-label": "Message",
287
294
  }),
288
- h(
289
- "button",
290
- { type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
291
- "Send"
295
+ h(
296
+ "button",
297
+ { type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
298
+ "Send"
299
+ )
292
300
  )
293
301
  )
294
302
  );
package/src/records.js CHANGED
@@ -18,8 +18,8 @@
18
18
  // in data — restoring it on reload is correct, and deleting the open record
19
19
  // simply falls back to the list.
20
20
 
21
- import { createElement as h, useState, useEffect } from "react";
22
- import { Field, formatValue } from "./blocks.js";
21
+ import { createElement as h, useState, useEffect, useRef } from "react";
22
+ import { Field, formatValue, StatGrid } from "./blocks.js";
23
23
 
24
24
  // Computed fields are derived at render time and never stored, so they can't
25
25
  // go stale. Accepts `{ subtotal: (r) => ... }` or
@@ -61,6 +61,8 @@ function makeId() {
61
61
  // if the second declared parameter is named like the viewer id, hand it
62
62
  // the viewer id. Every callback gets what its own names ask for, and the
63
63
  // uniform (record, data, viewerId) order in the docs stays the one truth.
64
+ // KNOWN LIMIT: minified specs rename parameters, silently defeating this
65
+ // detection — one more reason hard rule 1 bans minified output.
64
66
  const rowCallLegacy = new WeakMap();
65
67
  function rowCall(fn, rec, data, viewerId) {
66
68
  let legacy = rowCallLegacy.get(fn);
@@ -103,26 +105,7 @@ function Summary({ spec, records }) {
103
105
  if (!spec.summary) return null;
104
106
  const items = spec.summary(records) || [];
105
107
  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);
110
- return h(
111
- "div",
112
- { className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
113
- h(
114
- "div",
115
- { className: "caf-output-grid" },
116
- items.map((item, i) =>
117
- h(
118
- "div",
119
- { key: item.label || i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
120
- h("span", { className: "caf-stat-label" }, item.label),
121
- h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format))
122
- )
123
- )
124
- )
125
- );
108
+ return h(StatGrid, { items });
126
109
  }
127
110
 
128
111
  function ListScreen({ spec, ctx }) {
@@ -158,6 +141,22 @@ function ListScreen({ spec, ctx }) {
158
141
  ctx.go("record", rec.id);
159
142
  }
160
143
 
144
+ // Rows another viewer just added or changed pulse once — the framework-
145
+ // owned answer to "notice teammates' changes" (round 20's silently
146
+ // dropped requirement), free wherever the data is records-shaped.
147
+ const prevRef = useRef(null);
148
+ const snapshot = {};
149
+ for (const r of ctx.data.records || []) if (r && r.id) snapshot[r.id] = JSON.stringify(r);
150
+ const remoteFresh =
151
+ ctx.remote && Date.now() - ctx.remote.at < 4000 && ctx.remote.keys.includes(ctx._recordsKey || "records");
152
+ const pulseIds = new Set();
153
+ if (remoteFresh && prevRef.current) {
154
+ for (const id in snapshot) if (prevRef.current[id] !== snapshot[id]) pulseIds.add(id);
155
+ }
156
+ useEffect(() => {
157
+ prevRef.current = snapshot;
158
+ });
159
+
161
160
  return h(
162
161
  "div",
163
162
  { className: "caf-block caf-list" },
@@ -193,7 +192,9 @@ function ListScreen({ spec, ctx }) {
193
192
  "button",
194
193
  {
195
194
  type: "button",
196
- className: spec.avatar ? "caf-row caf-row-avatar" : "caf-row",
195
+ className:
196
+ (spec.avatar ? "caf-row caf-row-avatar" : "caf-row") +
197
+ (pulseIds.has(rec.id) ? " caf-row-pulse" : ""),
197
198
  onClick: () => ctx.go("record", rec.id),
198
199
  },
199
200
  spec.avatar ? h(Avatar, { title: rowTitle(spec, rec), ctx }) : null,
@@ -385,6 +386,7 @@ export function RecordsBlock({ spec, ctx }) {
385
386
  update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
386
387
  go: (_screen, id) => ctx.setTab(stateKey, id),
387
388
  back: () => ctx.setTab(stateKey, undefined),
389
+ _recordsKey: key, // so remote-change detection matches the REAL pool key
388
390
  };
389
391
 
390
392
  // A deleted or stale open record falls back to the list, never a blank pane.
package/src/steps.js CHANGED
@@ -14,7 +14,7 @@
14
14
  // on the step the user was on, holding their answers.
15
15
 
16
16
  import { createElement as h } from "react";
17
- import { Field, validateField, formatValue, writeField } from "./blocks.js";
17
+ import { Field, validateField, writeField, StatGrid } from "./blocks.js";
18
18
 
19
19
  function stepErrors(step, data) {
20
20
  return (step.fields || []).some((f) => validateField(f, data[f.key]) !== null);
@@ -26,25 +26,7 @@ function ResultScreen({ step, ctx }) {
26
26
  return h(
27
27
  "div",
28
28
  { className: "caf-steps-result" },
29
- h(
30
- "div",
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
- )
45
- )
46
- )
47
- ),
29
+ h(StatGrid, { items, title: step.title }),
48
30
  // A result can end on more than stats: any declared blocks (charts,
49
31
  // text, lists) render under the grid with full block services.
50
32
  (step.blocks || []).map((b, i) => h(Block, { key: "rb" + i, block: b, ctx })),