claude-artifact-framework 0.16.0 → 0.18.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 +48 -9
- package/dist/index.esm.js +109 -21
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +34 -12
- package/dist/index.global.js.map +3 -3
- package/llms.txt +9 -4
- package/package.json +1 -1
- package/src/app.js +88 -9
- package/src/records.js +23 -2
- package/src/steps.js +18 -10
package/llms.txt
CHANGED
|
@@ -14,7 +14,10 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
|
|
|
14
14
|
- Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
|
|
15
15
|
steps, chat, main, sidebar, documents, data, shared, libs, machine,
|
|
16
16
|
brand:{logo, footer} (logo: emoji | inline <svg> | https URL | omit for an
|
|
17
|
-
automatic monogram)
|
|
17
|
+
automatic monogram), mobile:{nav:"bottom"} (phone: pins the first
|
|
18
|
+
top-level tabs block's bar to the bottom — declare it when one tabs
|
|
19
|
+
block with 4+ sections IS the app; a top bar overflows sideways at
|
|
20
|
+
phone width and hides the last sections)
|
|
18
21
|
- Layouts: panel (default), records, steps, chat, workspace, documents
|
|
19
22
|
- Block types: fields, output, text, list, chart, timer, actions, banner
|
|
20
23
|
({image?, title, subtitle} — no image paints a seed-derived gradient),
|
|
@@ -29,7 +32,8 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
|
|
|
29
32
|
(+ key when used as a block)
|
|
30
33
|
- documents: label, title, blocks OR types, empty; machine: key, initial,
|
|
31
34
|
states, events (event: from, do, then); chat: system, greeting, placeholder,
|
|
32
|
-
tools, model, maxTokens; steps step: title, text, fields, result
|
|
35
|
+
tools, model, maxTokens; steps step: title, text, fields, result,
|
|
36
|
+
blocks (result steps only — end a wizard on charts/text under the stats)
|
|
33
37
|
- Formats: money, percent (0-100), number, date. Output/chart/compute items:
|
|
34
38
|
{ label, value, format, big }
|
|
35
39
|
|
|
@@ -47,8 +51,9 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
|
|
|
47
51
|
5. Phases (games, turns, processes) belong to `machine` — ctx.send(event,
|
|
48
52
|
payload) is the only door; events are no-ops outside their `from` states;
|
|
49
53
|
`after: {seconds, then, do}` timers are framework-owned.
|
|
50
|
-
6. Row
|
|
51
|
-
viewerId). Stop early if you like
|
|
54
|
+
6. Row callbacks (when AND run) receive (record, data, viewerId); block
|
|
55
|
+
actions (data, viewerId). Stop early if you like; a 2nd param literally
|
|
56
|
+
named viewerId is matched by name and gets the viewer id.
|
|
52
57
|
7. One big:true per screen; the constructive action takes kind:"primary"; a
|
|
53
58
|
counter changed by pressing is an action, not an editable number field.
|
|
54
59
|
8. Parts of a record are `items` — never JSON in a textarea.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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
|
@@ -26,7 +26,8 @@ const IMAGE_KEYS = ["src", "caption", "fit", "height"];
|
|
|
26
26
|
// Frontier models still invent identifiers a few percent of the time, so an
|
|
27
27
|
// unknown name has to fail loudly and name the alternatives rather than
|
|
28
28
|
// silently rendering nothing — a blank pane is indistinguishable from a bug.
|
|
29
|
-
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand"];
|
|
29
|
+
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand", "mobile"];
|
|
30
|
+
const MOBILE_KEYS = ["nav"];
|
|
30
31
|
|
|
31
32
|
const MACHINE_KEYS = ["key", "initial", "states", "events"];
|
|
32
33
|
const MACHINE_STATE_KEYS = ["after"];
|
|
@@ -248,6 +249,18 @@ function checkMachine(m) {
|
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
// The tabs block mobile.nav moves: the first one at the top level of the
|
|
253
|
+
// panel's `blocks` or the workspace's `main`. Nested tabs never qualify —
|
|
254
|
+
// a bottom bar is app-shell navigation, not in-page structure.
|
|
255
|
+
function findNavTabs(spec) {
|
|
256
|
+
for (const list of [spec.blocks, spec.main]) {
|
|
257
|
+
if (!Array.isArray(list)) continue;
|
|
258
|
+
const found = list.find((b) => b && Array.isArray(b.tabs));
|
|
259
|
+
if (found) return found;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
251
264
|
function validate(spec) {
|
|
252
265
|
const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
|
|
253
266
|
if (unknownTop.length) {
|
|
@@ -272,6 +285,22 @@ function validate(spec) {
|
|
|
272
285
|
throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
|
|
273
286
|
}
|
|
274
287
|
}
|
|
288
|
+
if (spec.mobile !== undefined) {
|
|
289
|
+
const unknownM = Object.keys(spec.mobile || {}).filter((k) => !MOBILE_KEYS.includes(k));
|
|
290
|
+
if (unknownM.length) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`claude-artifact-framework: mobile has unknown keys (${unknownM.join(", ")}). Valid keys: ${MOBILE_KEYS.join(", ")}.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (spec.mobile.nav !== undefined && spec.mobile.nav !== "bottom") {
|
|
296
|
+
throw new Error('claude-artifact-framework: mobile.nav only accepts "bottom" (omit `mobile` for the default top tab bar).');
|
|
297
|
+
}
|
|
298
|
+
if (spec.mobile.nav === "bottom" && !findNavTabs(spec)) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
'claude-artifact-framework: mobile.nav: "bottom" moves a top-level `tabs` block\'s bar to the bottom of the phone screen — this spec has no top-level tabs block (in `blocks` or `main`) to move.'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
275
304
|
const layout = spec.layout || "panel";
|
|
276
305
|
if (!LAYOUTS.includes(layout)) {
|
|
277
306
|
throw new Error(
|
|
@@ -283,7 +312,7 @@ function validate(spec) {
|
|
|
283
312
|
if (!Array.isArray(steps) || steps.length === 0) {
|
|
284
313
|
throw new Error('claude-artifact-framework: layout "steps" needs `steps: [...]` with at least one step.');
|
|
285
314
|
}
|
|
286
|
-
const STEP_KEYS = ["title", "text", "fields", "result"];
|
|
315
|
+
const STEP_KEYS = ["title", "text", "fields", "result", "blocks"];
|
|
287
316
|
const seen = new Set();
|
|
288
317
|
steps.forEach((st, i) => {
|
|
289
318
|
if (!st || typeof st !== "object") {
|
|
@@ -298,6 +327,15 @@ function validate(spec) {
|
|
|
298
327
|
if (!st.fields && !st.text && !st.result) {
|
|
299
328
|
throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
|
|
300
329
|
}
|
|
330
|
+
// A result step may close on more than stats: `blocks` renders any
|
|
331
|
+
// block set (charts, text, lists) under the result grid. Measured
|
|
332
|
+
// miss: a wizard that wanted to END on a chart had nowhere to put it.
|
|
333
|
+
if (st.blocks !== undefined) {
|
|
334
|
+
if (typeof st.result !== "function") {
|
|
335
|
+
throw new Error(`claude-artifact-framework: steps[${i}].blocks only renders on a result step — add \`result\` or move the blocks to the step's fields.`);
|
|
336
|
+
}
|
|
337
|
+
checkBlocks(st.blocks);
|
|
338
|
+
}
|
|
301
339
|
checkFields(st.fields, `steps[${i}]`);
|
|
302
340
|
for (const f of st.fields || []) {
|
|
303
341
|
if (seen.has(f.key)) {
|
|
@@ -576,17 +614,21 @@ function boundary(React) {
|
|
|
576
614
|
|
|
577
615
|
// Tabs are defined here (not blocks.js) because they render other blocks —
|
|
578
616
|
// keeping the recursion in one file avoids an import cycle.
|
|
579
|
-
function TabsBlock({ spec, ctx }) {
|
|
617
|
+
function TabsBlock({ spec, ctx, bottomNav }) {
|
|
580
618
|
const tabs = spec.tabs;
|
|
581
619
|
const key = tabs.map((t) => t.label).join("|");
|
|
582
620
|
const stored = ctx.view.tabs ? ctx.view.tabs[key] : undefined;
|
|
583
621
|
const active = Math.min(stored === undefined ? 0 : stored, tabs.length - 1);
|
|
622
|
+
// mobile.nav: "bottom" — on a phone, this block's bar becomes a fixed
|
|
623
|
+
// bottom nav: every section stays visible and inside the thumb zone
|
|
624
|
+
// instead of hiding behind the tab bar's sideways scroll.
|
|
625
|
+
const bottom = !!bottomNav && ctx.viewport === "mobile";
|
|
584
626
|
return h(
|
|
585
627
|
"div",
|
|
586
628
|
{ className: "caf-tabs" },
|
|
587
629
|
h(
|
|
588
630
|
"div",
|
|
589
|
-
{ className: "caf-tabbar", role: "tablist" },
|
|
631
|
+
{ className: bottom ? "caf-tabbar caf-tabbar-bottom" : "caf-tabbar", role: "tablist" },
|
|
590
632
|
tabs.map((t, i) =>
|
|
591
633
|
h(
|
|
592
634
|
"button",
|
|
@@ -598,7 +640,11 @@ function TabsBlock({ spec, ctx }) {
|
|
|
598
640
|
className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
|
|
599
641
|
onClick: () => ctx.setTab(key, i),
|
|
600
642
|
},
|
|
601
|
-
|
|
643
|
+
bottom && t.icon
|
|
644
|
+
? [h("span", { key: "i", className: "caf-tab-ico", "aria-hidden": true }, t.icon), h("span", { key: "l", className: "caf-tab-lbl" }, t.label)]
|
|
645
|
+
: t.icon
|
|
646
|
+
? `${t.icon} ${t.label}`
|
|
647
|
+
: t.label
|
|
602
648
|
)
|
|
603
649
|
)
|
|
604
650
|
),
|
|
@@ -618,6 +664,8 @@ function Block({ block, ctx }) {
|
|
|
618
664
|
if (typeof block.when === "function" && !block.when(ctx.data)) return null;
|
|
619
665
|
// `state: "x"` — machine sugar: the block exists only in that phase.
|
|
620
666
|
if (block.state !== undefined && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
|
|
667
|
+
// Identity against the original spec object — before any sugar copies it.
|
|
668
|
+
const bottomNav = ctx._navTabs !== undefined && ctx._navTabs === block;
|
|
621
669
|
// `icon` is sugar: it prefixes the title everywhere titles render.
|
|
622
670
|
if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
|
|
623
671
|
const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
@@ -625,7 +673,7 @@ function Block({ block, ctx }) {
|
|
|
625
673
|
const inner = h(
|
|
626
674
|
boundary(ctx.React),
|
|
627
675
|
null,
|
|
628
|
-
h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx })
|
|
676
|
+
h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx, bottomNav })
|
|
629
677
|
);
|
|
630
678
|
if (!block.collapsible) return inner;
|
|
631
679
|
// Collapsed state is framework-owned view state, keyed by the block's title
|
|
@@ -881,7 +929,10 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
881
929
|
{ className: "caf-documents" },
|
|
882
930
|
h(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
|
|
883
931
|
typePicker,
|
|
884
|
-
|
|
932
|
+
// Keyed by document id: switching tabs REMOUNTS the pane, so DOM-held
|
|
933
|
+
// state can't leak across documents. Measured: the native file input
|
|
934
|
+
// kept showing the previous document's filename.
|
|
935
|
+
h(Panel, { key: active.id, spec: { blocks: aspec.blocks }, ctx: scoped })
|
|
885
936
|
);
|
|
886
937
|
}
|
|
887
938
|
|
|
@@ -1093,6 +1144,8 @@ export function createApp(spec = {}) {
|
|
|
1093
1144
|
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
1094
1145
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
|
|
1095
1146
|
viewport: narrow ? "mobile" : "desktop",
|
|
1147
|
+
_navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
|
|
1148
|
+
_Block: Block, // for layouts in other modules (steps) that render blocks
|
|
1096
1149
|
machine: spec.machine || null,
|
|
1097
1150
|
send: spec.machine
|
|
1098
1151
|
? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
|
|
@@ -1130,7 +1183,7 @@ export function createApp(spec = {}) {
|
|
|
1130
1183
|
"div",
|
|
1131
1184
|
// The layout class lets shared CSS follow the active container width —
|
|
1132
1185
|
// e.g. the header must share the workspace's wider axis, not panel's.
|
|
1133
|
-
{ className: `caf-root caf-layout-${layout}` },
|
|
1186
|
+
{ className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
|
|
1134
1187
|
h("style", { dangerouslySetInnerHTML: { __html: css } }),
|
|
1135
1188
|
h(
|
|
1136
1189
|
"header",
|
|
@@ -1152,7 +1205,11 @@ export function createApp(spec = {}) {
|
|
|
1152
1205
|
)
|
|
1153
1206
|
: !state.isHydrated() || libsState === "loading"
|
|
1154
1207
|
? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
|
|
1155
|
-
:
|
|
1208
|
+
: // The same containment blocks get, one level up: a crash inside a
|
|
1209
|
+
// layout (a throwing sort, a bad row callback) shows a named error
|
|
1210
|
+
// under the header instead of killing the whole app. Measured: an
|
|
1211
|
+
// unhandled row-action throw once blanked an entire blind app.
|
|
1212
|
+
h(boundary(ctx.React), null, h(Layout, { spec, ctx })),
|
|
1156
1213
|
spec.brand && spec.brand.footer ? h("footer", { className: "caf-footer" }, spec.brand.footer) : null
|
|
1157
1214
|
);
|
|
1158
1215
|
};
|
|
@@ -1276,6 +1333,7 @@ const BASE_CSS = `
|
|
|
1276
1333
|
.caf-timer-controls { display: flex; gap: 0.6rem; }
|
|
1277
1334
|
.caf-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
1278
1335
|
.caf-steps-progress { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.2rem; }
|
|
1336
|
+
.caf-steps-result { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
1279
1337
|
.caf-steps-fill { transition: width 0.25s ease; }
|
|
1280
1338
|
.caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
|
|
1281
1339
|
.caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
|
|
@@ -1405,6 +1463,27 @@ const BASE_CSS = `
|
|
|
1405
1463
|
/* Never-clip guarantee: content inside a custom block that is wider than the
|
|
1406
1464
|
phone (column grids, week views) must scroll, not disappear off-screen. */
|
|
1407
1465
|
.caf-custom { overflow-x: auto; }
|
|
1466
|
+
/* mobile.nav: "bottom" — the app-shell tab bar pinned to the thumb zone.
|
|
1467
|
+
Tabs share the width evenly so every section stays visible; the root
|
|
1468
|
+
reserves space so the bar never covers the end of the content. */
|
|
1469
|
+
.caf-has-bottomnav { padding-bottom: 76px; }
|
|
1470
|
+
.caf-tabbar-bottom {
|
|
1471
|
+
position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
|
|
1472
|
+
gap: 0; margin: 0; overflow-x: visible;
|
|
1473
|
+
background: var(--caf-surface); border-top: 1px solid var(--caf-border); border-bottom: none;
|
|
1474
|
+
padding: 0.25rem 0.4rem calc(0.3rem + env(safe-area-inset-bottom, 0px));
|
|
1475
|
+
box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.07);
|
|
1476
|
+
}
|
|
1477
|
+
.caf-tabbar-bottom .caf-tab {
|
|
1478
|
+
flex: 1 1 0; min-width: 0; min-height: 48px;
|
|
1479
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
1480
|
+
gap: 0.15rem; padding: 0.25rem 0.2rem; margin-bottom: 0;
|
|
1481
|
+
font-size: 0.66rem; border-bottom: none;
|
|
1482
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
1483
|
+
}
|
|
1484
|
+
.caf-tabbar-bottom .caf-tab-ico { font-size: 1.25rem; line-height: 1; }
|
|
1485
|
+
.caf-tabbar-bottom .caf-tab-lbl { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1486
|
+
.caf-tabbar-bottom .caf-tab-active, .caf-tabbar-bottom .caf-tab-active:hover { border-bottom-color: transparent; }
|
|
1408
1487
|
/* Touch devices get tappable framework controls regardless of the spec. */
|
|
1409
1488
|
@media (pointer: coarse) {
|
|
1410
1489
|
.caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }
|
package/src/records.js
CHANGED
|
@@ -54,6 +54,27 @@ function makeId() {
|
|
|
54
54
|
return "r" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Call-by-name for row callbacks. The measured 50/50 trap: both
|
|
58
|
+
// `when: (r, viewerId)` (the old documented form) and `run: (r, viewerId)`
|
|
59
|
+
// (the mistake that killed an entire blind app — `data` skipped, so `d`
|
|
60
|
+
// held a viewer-id string) exist in the wild. Positions lie; names don't:
|
|
61
|
+
// if the second declared parameter is named like the viewer id, hand it
|
|
62
|
+
// the viewer id. Every callback gets what its own names ask for, and the
|
|
63
|
+
// uniform (record, data, viewerId) order in the docs stays the one truth.
|
|
64
|
+
const rowCallLegacy = new WeakMap();
|
|
65
|
+
function rowCall(fn, rec, data, viewerId) {
|
|
66
|
+
let legacy = rowCallLegacy.get(fn);
|
|
67
|
+
if (legacy === undefined) {
|
|
68
|
+
const src = String(fn);
|
|
69
|
+
const arrow = src.match(/^\s*(?:async\s+)?([\w$]+)\s*=>/); // one bare param
|
|
70
|
+
const parens = src.match(/^[^(]*\(([^)]*)\)/);
|
|
71
|
+
const params = arrow ? [arrow[1]] : parens ? parens[1].split(",").map((s) => s.trim().split(/[=\s:]/)[0]) : [];
|
|
72
|
+
legacy = /viewer|voter|usuario|user/i.test(params[1] || "");
|
|
73
|
+
rowCallLegacy.set(fn, legacy);
|
|
74
|
+
}
|
|
75
|
+
return legacy ? fn(rec, viewerId) : fn(rec, data, viewerId);
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
// avatar: true — an initials circle per row, colored deterministically from
|
|
58
79
|
// the row title through the theme's series palette. Identity without assets.
|
|
59
80
|
function Avatar({ title, ctx }) {
|
|
@@ -184,7 +205,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
184
205
|
"div",
|
|
185
206
|
{ className: "caf-row-actions" },
|
|
186
207
|
spec.actions
|
|
187
|
-
.filter((a) => !a.when || a.when
|
|
208
|
+
.filter((a) => !a.when || rowCall(a.when, rec, ctx.data, ctx.viewerId))
|
|
188
209
|
.map((a) =>
|
|
189
210
|
h(
|
|
190
211
|
"button",
|
|
@@ -195,7 +216,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
195
216
|
onClick: () =>
|
|
196
217
|
ctx.update((d) => {
|
|
197
218
|
const live = d.records.find((x) => x.id === rec.id);
|
|
198
|
-
if (live) a.run
|
|
219
|
+
if (live) rowCall(a.run, live, d, ctx.viewerId);
|
|
199
220
|
}),
|
|
200
221
|
},
|
|
201
222
|
a.label
|
package/src/steps.js
CHANGED
|
@@ -22,23 +22,31 @@ function stepErrors(step, data) {
|
|
|
22
22
|
|
|
23
23
|
function ResultScreen({ step, ctx }) {
|
|
24
24
|
const items = step.result(ctx.data) || [];
|
|
25
|
+
const Block = ctx._Block;
|
|
25
26
|
return h(
|
|
26
27
|
"div",
|
|
27
|
-
{ className: "caf-
|
|
28
|
-
step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
|
|
28
|
+
{ className: "caf-steps-result" },
|
|
29
29
|
h(
|
|
30
30
|
"div",
|
|
31
|
-
{ className: "caf-output
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
h(
|
|
38
|
-
|
|
31
|
+
{ className: "caf-block caf-output" },
|
|
32
|
+
step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
|
|
33
|
+
h(
|
|
34
|
+
"div",
|
|
35
|
+
{ className: "caf-output-grid" },
|
|
36
|
+
items.map((item, i) =>
|
|
37
|
+
h(
|
|
38
|
+
"div",
|
|
39
|
+
{ key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
40
|
+
h("span", { className: "caf-stat-label" }, item.label),
|
|
41
|
+
h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
42
|
+
item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
|
|
43
|
+
)
|
|
39
44
|
)
|
|
40
45
|
)
|
|
41
46
|
),
|
|
47
|
+
// A result can end on more than stats: any declared blocks (charts,
|
|
48
|
+
// text, lists) render under the grid with full block services.
|
|
49
|
+
(step.blocks || []).map((b, i) => h(Block, { key: "rb" + i, block: b, ctx })),
|
|
42
50
|
h(
|
|
43
51
|
"div",
|
|
44
52
|
{ className: "caf-step-nav" },
|