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/README.md +32 -10
- package/dist/index.esm.js +154 -89
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +56 -16
- package/dist/index.global.js.map +3 -3
- package/llms.txt +9 -2
- package/package.json +1 -1
- package/src/app.js +63 -9
- package/src/appState.js +12 -1
- package/src/blocks.js +60 -13
- package/src/chat.js +31 -23
- package/src/records.js +25 -23
- package/src/steps.js +2 -20
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
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:
|
|
1358
|
-
background:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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 })),
|