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/README.md +28 -8
- package/dist/index.esm.js +150 -54
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +56 -13
- package/dist/index.global.js.map +3 -3
- package/llms.txt +7 -1
- package/package.json +1 -1
- package/src/app.js +68 -11
- package/src/appState.js +12 -1
- package/src/blocks.js +49 -8
- package/src/chat.js +31 -23
- package/src/records.js +21 -2
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.
|
|
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
|
-
|
|
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); }
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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,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:
|
|
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.
|