claude-artifact-framework 0.19.2 → 0.20.1

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
@@ -84,7 +84,9 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
84
84
  3. Icons: all siblings or none, one style (emoji), never half a set.
85
85
  4. Mid-tone saturated seed that names the domain; no grays/near-blacks.
86
86
  5. Never duplicate what the framework renders (title, totals, empties).
87
- 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);
88
90
  secondary content into tabs/collapsible; >5 cards on one screen = no
89
91
  hierarchy.
90
92
  7. State via when:, not more blocks.
@@ -121,3 +123,7 @@ Chat with tools (workspace copilot): sidebar block
121
123
  ctx.update((d) => { /* mutate */ }); return { ok: true }; } } } } }
122
124
  — the artifact runtime proxies api.anthropic.com with no API key; default
123
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.2",
3
+ "version": "0.20.1",
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); }
@@ -1433,11 +1477,22 @@ const BASE_CSS = `
1433
1477
  .caf-doc-close:hover { color: var(--caf-danger); }
1434
1478
  .caf-doc-add { font-size: 1rem; font-weight: 650; }
1435
1479
  .caf-empty .caf-btn { margin-top: 0.6rem; }
1436
- .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1480
+ /* Tight gap: the block title is a SECTION head for the cards below — it
1481
+ must hug its group, not float equidistant (audited on the retro board). */
1482
+ .caf-records-block { display: flex; flex-direction: column; gap: 0.6rem; min-width: 0; }
1483
+ .caf-stat-neg, .caf-stat-big .caf-stat-value.caf-stat-neg { color: var(--caf-danger); }
1437
1484
  .caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
1438
1485
  .caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
1486
+ /* Where the page has a gutter, the logo lives in it: the title keeps the
1487
+ exact left axis of tabs and cards below (audited twice — a logo pushed
1488
+ every heading onto a third axis). On phones there is no gutter, so the
1489
+ logo stays inline. */
1490
+ @media (min-width: 720px) {
1491
+ .caf-header-brand { position: relative; }
1492
+ .caf-header-brand .caf-logo { position: absolute; right: 100%; margin-right: 0.85rem; top: 0.1rem; }
1493
+ }
1439
1494
  .caf-header-text { min-width: 0; }
1440
- .caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
1495
+ .caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; overflow: hidden; }
1441
1496
  .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); }
1442
1497
  .caf-logo-mono {
1443
1498
  display: flex; align-items: center; justify-content: center;
@@ -1455,7 +1510,9 @@ const BASE_CSS = `
1455
1510
  .caf-image { margin: 0; }
1456
1511
  .caf-image img { width: 100%; border-radius: 7px; display: block; }
1457
1512
  .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; }
1513
+ /* Left, like every other axis on the page a centered footer was the one
1514
+ element breaking the single left axis (audited). */
1515
+ .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
1516
  .caf-layout-workspace .caf-footer { max-width: 1200px; }
1460
1517
  .caf-avatar {
1461
1518
  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
@@ -63,7 +63,13 @@ export function validateField(field, value) {
63
63
  }
64
64
 
65
65
  export function Field({ field, value, onChange, data }) {
66
- const error = validateField(field, value);
66
+ // A pristine empty field must not shout "Required" before the user ever
67
+ // touched it (audited on first load of a quick-add form). Range/format
68
+ // errors imply a non-empty value, so only the empty-required case waits.
69
+ const [touched, setTouched] = useState(false);
70
+ const rawError = validateField(field, value);
71
+ const pristineEmpty = !touched && (value === "" || value === null || value === undefined);
72
+ const error = pristineEmpty ? null : rawError;
67
73
  const id = `caf-f-${field.key}`;
68
74
  const numeric = field.type === "number" || field.type === "money" || field.type === "percent";
69
75
 
@@ -108,6 +114,7 @@ export function Field({ field, value, onChange, data }) {
108
114
  id,
109
115
  value: value === null || value === undefined ? "" : value,
110
116
  "aria-invalid": error ? "true" : undefined,
117
+ onBlur: () => setTouched(true),
111
118
  onChange: (e) => onChange(numeric ? toNumber(e.target.value) : e.target.value),
112
119
  };
113
120
 
@@ -120,7 +127,9 @@ export function Field({ field, value, onChange, data }) {
120
127
  const options = typeof field.options === "function" ? field.options(data) || [] : field.options || [];
121
128
  control = h(
122
129
  "select",
123
- common,
130
+ // Unchosen reads muted, like a text placeholder — "has a value" and
131
+ // "is empty" must differ at a glance (audited on the Select… state).
132
+ { ...common, className: value ? undefined : "caf-select-empty" },
124
133
  h("option", { value: "" }, field.placeholder || "Select…"),
125
134
  options.map((opt) => {
126
135
  const val = typeof opt === "string" ? opt : opt.value;
@@ -169,6 +178,11 @@ export function writeField(ctx, key, field, v, setter) {
169
178
 
170
179
  export function FieldsBlock({ spec, ctx }) {
171
180
  const fields = spec.fields || [];
181
+ // A fields block may carry its own `actions`: they render as the card's
182
+ // footer, visually owned by the inputs they submit. Measured miss (round
183
+ // 21): the quick-add CTA as a separate block was an orphan card,
184
+ // equidistant from its form and from the next section.
185
+ const footer = (spec.actions || []).filter((a) => !a.when || a.when(ctx.data, ctx.viewerId));
172
186
  return h(
173
187
  "div",
174
188
  { className: "caf-block caf-fields" },
@@ -181,7 +195,30 @@ export function FieldsBlock({ spec, ctx }) {
181
195
  value: ctx.data[field.key],
182
196
  onChange: (v) => writeField(ctx, field.key, field, v, (d, val) => { d[field.key] = val; }),
183
197
  })
184
- )
198
+ ),
199
+ footer.length
200
+ ? h(
201
+ "div",
202
+ { className: "caf-actions-row caf-fields-footer" },
203
+ footer.map((a) =>
204
+ h(
205
+ "button",
206
+ {
207
+ key: a.label,
208
+ type: "button",
209
+ className:
210
+ a.kind === "danger"
211
+ ? "caf-btn caf-btn-danger"
212
+ : a.kind === "primary"
213
+ ? "caf-btn caf-btn-primary"
214
+ : "caf-btn",
215
+ onClick: () => ctx.update((d) => a.run(d, ctx.viewerId)),
216
+ },
217
+ a.label
218
+ )
219
+ )
220
+ )
221
+ : null
185
222
  );
186
223
  }
187
224
 
@@ -199,15 +236,19 @@ export function StatGrid({ items, title }) {
199
236
  h(
200
237
  "div",
201
238
  { className: "caf-output-grid" },
202
- items.map((item, i) =>
203
- h(
239
+ items.map((item, i) => {
240
+ // A negative money/number stat reads as a loss everywhere else on
241
+ // screen (list rows already color by sign) — the hero must agree,
242
+ // not paint a deficit in cheerful brand accent (audited).
243
+ const neg = (item.format === "money" || item.format === "number") && Number(item.value) < 0;
244
+ return h(
204
245
  "div",
205
246
  { key: item.label || i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
206
247
  h("span", { className: "caf-stat-label" }, item.label),
207
- h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
248
+ h("span", { className: neg ? "caf-stat-value caf-stat-neg" : "caf-stat-value" }, formatValue(item.value, item.format)),
208
249
  item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
209
- )
210
- )
250
+ );
251
+ })
211
252
  )
212
253
  );
213
254
  }
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,7 +18,7 @@
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";
21
+ import { createElement as h, useState, useEffect, useRef } from "react";
22
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
@@ -141,6 +141,22 @@ function ListScreen({ spec, ctx }) {
141
141
  ctx.go("record", rec.id);
142
142
  }
143
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
+
144
160
  return h(
145
161
  "div",
146
162
  { className: "caf-block caf-list" },
@@ -176,7 +192,9 @@ function ListScreen({ spec, ctx }) {
176
192
  "button",
177
193
  {
178
194
  type: "button",
179
- 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" : ""),
180
198
  onClick: () => ctx.go("record", rec.id),
181
199
  },
182
200
  spec.avatar ? h(Avatar, { title: rowTitle(spec, rec), ctx }) : null,
@@ -368,6 +386,7 @@ export function RecordsBlock({ spec, ctx }) {
368
386
  update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
369
387
  go: (_screen, id) => ctx.setTab(stateKey, id),
370
388
  back: () => ctx.setTab(stateKey, undefined),
389
+ _recordsKey: key, // so remote-change detection matches the REAL pool key
371
390
  };
372
391
 
373
392
  // A deleted or stale open record falls back to the list, never a blank pane.