claude-artifact-framework 0.3.0 → 0.4.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 +55 -3
- package/dist/index.esm.js +316 -6
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +40 -9
- package/dist/index.global.js.map +4 -4
- package/package.json +1 -1
- package/src/app.js +70 -4
- package/src/blocks.js +177 -3
- package/src/steps.js +106 -0
- package/src/theme.js +9 -0
package/src/blocks.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
// Bound lazily rather than snapshotted as `React.createElement`, because this
|
|
12
12
|
// runs at module load — before the host has had a chance to hand React over.
|
|
13
|
-
import { createElement as h } from "react";
|
|
13
|
+
import { createElement as h, useState, useEffect } from "react";
|
|
14
14
|
|
|
15
15
|
const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
|
|
16
16
|
|
|
@@ -158,8 +158,8 @@ export function OutputBlock({ spec, ctx }) {
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
export function TextBlock({ spec }) {
|
|
162
|
-
const body = typeof spec.text === "function" ? spec.text() : spec.text;
|
|
161
|
+
export function TextBlock({ spec, ctx }) {
|
|
162
|
+
const body = typeof spec.text === "function" ? spec.text(ctx.data) : spec.text;
|
|
163
163
|
return h(
|
|
164
164
|
"div",
|
|
165
165
|
{ className: "caf-block caf-text" },
|
|
@@ -193,6 +193,178 @@ export function ListBlock({ spec, ctx }) {
|
|
|
193
193
|
);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
const CHART_TYPES = ["bar", "line", "donut"];
|
|
197
|
+
|
|
198
|
+
// Charts share the output block's vocabulary — items of { label, value } plus
|
|
199
|
+
// an optional format — so declaring one teaches nothing new. Colors come from
|
|
200
|
+
// ctx.colors(n), derived from the theme seed, so a chart can't clash with the
|
|
201
|
+
// app it lives in.
|
|
202
|
+
export function ChartBlock({ spec, ctx }) {
|
|
203
|
+
const c = typeof spec.chart === "function" ? spec.chart(ctx.data) : spec.chart;
|
|
204
|
+
const items = (c && c.items) || [];
|
|
205
|
+
if (!items.length) {
|
|
206
|
+
return h("div", { className: "caf-block caf-empty" }, spec.empty || "No data to chart yet.");
|
|
207
|
+
}
|
|
208
|
+
const type = c.type || "bar";
|
|
209
|
+
if (!CHART_TYPES.includes(type)) {
|
|
210
|
+
// Thrown, not swallowed: the block boundary turns this into a visible,
|
|
211
|
+
// named error instead of an empty pane.
|
|
212
|
+
throw new Error(`unknown chart type "${type}". Valid types: ${CHART_TYPES.join(", ")}.`);
|
|
213
|
+
}
|
|
214
|
+
const colors = ctx.colors(items.length);
|
|
215
|
+
const body =
|
|
216
|
+
type === "bar" ? barChart(items, c, colors) : type === "line" ? lineChart(items, c) : donutChart(items, c, colors);
|
|
217
|
+
return h(
|
|
218
|
+
"div",
|
|
219
|
+
{ className: "caf-block caf-chart" },
|
|
220
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
221
|
+
body
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function barChart(items, c, colors) {
|
|
226
|
+
const max = Math.max(...items.map((it) => Number(it.value) || 0), 0) || 1;
|
|
227
|
+
return h(
|
|
228
|
+
"div",
|
|
229
|
+
{ className: "caf-bars" },
|
|
230
|
+
items.map((it, i) =>
|
|
231
|
+
h(
|
|
232
|
+
"div",
|
|
233
|
+
{ key: it.label ?? i, className: "caf-bar-row" },
|
|
234
|
+
h("span", { className: "caf-bar-label" }, it.label),
|
|
235
|
+
h(
|
|
236
|
+
"div",
|
|
237
|
+
{ className: "caf-bar-track" },
|
|
238
|
+
h("div", {
|
|
239
|
+
className: "caf-bar-fill",
|
|
240
|
+
style: { width: `${Math.max(2, ((Number(it.value) || 0) / max) * 100)}%`, background: colors[i] },
|
|
241
|
+
})
|
|
242
|
+
),
|
|
243
|
+
h("span", { className: "caf-bar-value" }, formatValue(it.value, c.format))
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function lineChart(items, c) {
|
|
250
|
+
const values = items.map((it) => Number(it.value) || 0);
|
|
251
|
+
const min = Math.min(...values);
|
|
252
|
+
const max = Math.max(...values);
|
|
253
|
+
const span = max - min || 1;
|
|
254
|
+
const W = 100;
|
|
255
|
+
const H = 36;
|
|
256
|
+
const pts = values.map((v, i) => [
|
|
257
|
+
values.length === 1 ? W / 2 : (i / (values.length - 1)) * W,
|
|
258
|
+
H - 3 - ((v - min) / span) * (H - 6),
|
|
259
|
+
]);
|
|
260
|
+
const last = pts[pts.length - 1];
|
|
261
|
+
return h(
|
|
262
|
+
"div",
|
|
263
|
+
{ className: "caf-line" },
|
|
264
|
+
h(
|
|
265
|
+
"svg",
|
|
266
|
+
{ viewBox: `0 0 ${W} ${H}`, className: "caf-line-svg", preserveAspectRatio: "none", "aria-hidden": "true" },
|
|
267
|
+
[0.25, 0.5, 0.75].map((f) =>
|
|
268
|
+
h("line", { key: f, x1: 0, x2: W, y1: H * f, y2: H * f, className: "caf-line-grid" })
|
|
269
|
+
),
|
|
270
|
+
h("polyline", { points: pts.map((p) => p.join(",")).join(" "), className: "caf-line-path" }),
|
|
271
|
+
h("circle", { cx: last[0], cy: last[1], r: 1.6, className: "caf-line-dot" })
|
|
272
|
+
),
|
|
273
|
+
h(
|
|
274
|
+
"div",
|
|
275
|
+
{ className: "caf-line-meta" },
|
|
276
|
+
h("span", { className: "caf-hint" }, String(items[0].label ?? "")),
|
|
277
|
+
h("span", { className: "caf-line-last" }, formatValue(values[values.length - 1], c.format)),
|
|
278
|
+
h("span", { className: "caf-hint" }, String(items[items.length - 1].label ?? ""))
|
|
279
|
+
)
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function donutChart(items, c, colors) {
|
|
284
|
+
const total = items.reduce((s, it) => s + (Number(it.value) || 0), 0) || 1;
|
|
285
|
+
const R = 15.9155; // circumference ≈ 100, so dash lengths are percentages
|
|
286
|
+
let offset = 25; // start at 12 o'clock
|
|
287
|
+
const segments = items.map((it, i) => {
|
|
288
|
+
const pct = ((Number(it.value) || 0) / total) * 100;
|
|
289
|
+
const seg = h("circle", {
|
|
290
|
+
key: it.label ?? i,
|
|
291
|
+
cx: 21, cy: 21, r: R,
|
|
292
|
+
className: "caf-donut-seg",
|
|
293
|
+
stroke: colors[i],
|
|
294
|
+
strokeDasharray: `${pct} ${100 - pct}`,
|
|
295
|
+
strokeDashoffset: offset,
|
|
296
|
+
});
|
|
297
|
+
offset -= pct;
|
|
298
|
+
return seg;
|
|
299
|
+
});
|
|
300
|
+
return h(
|
|
301
|
+
"div",
|
|
302
|
+
{ className: "caf-donut" },
|
|
303
|
+
h("svg", { viewBox: "0 0 42 42", className: "caf-donut-svg", "aria-hidden": "true" }, segments),
|
|
304
|
+
h(
|
|
305
|
+
"div",
|
|
306
|
+
{ className: "caf-donut-legend" },
|
|
307
|
+
items.map((it, i) =>
|
|
308
|
+
h(
|
|
309
|
+
"div",
|
|
310
|
+
{ key: it.label ?? i, className: "caf-legend-row" },
|
|
311
|
+
h("span", { className: "caf-legend-swatch", style: { background: colors[i] } }),
|
|
312
|
+
h("span", { className: "caf-legend-label" }, it.label),
|
|
313
|
+
h("span", { className: "caf-legend-value" }, formatValue(it.value, c.format))
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The interval is framework-owned and cleaned up on unmount — a leaked
|
|
321
|
+
// setInterval is one of the classic half-built artifact bugs. The countdown
|
|
322
|
+
// itself is deliberately ephemeral (local state); what the author declares is
|
|
323
|
+
// what happens WHEN it finishes: onDone runs through update(), so its effects
|
|
324
|
+
// persist like any other change.
|
|
325
|
+
export function TimerBlock({ spec, ctx }) {
|
|
326
|
+
const t = spec.timer || {};
|
|
327
|
+
const total = Math.max(1, Math.round((t.minutes || 0) * 60 + (t.seconds || 0)) || 300);
|
|
328
|
+
const [left, setLeft] = useState(total);
|
|
329
|
+
const [running, setRunning] = useState(false);
|
|
330
|
+
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (!running) return;
|
|
333
|
+
const id = setInterval(() => setLeft((prev) => Math.max(0, prev - 1)), 1000);
|
|
334
|
+
return () => clearInterval(id);
|
|
335
|
+
}, [running]);
|
|
336
|
+
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (left === 0 && running) {
|
|
339
|
+
setRunning(false);
|
|
340
|
+
if (typeof t.onDone === "function") ctx.update((d) => t.onDone(d));
|
|
341
|
+
}
|
|
342
|
+
}, [left, running]);
|
|
343
|
+
|
|
344
|
+
const mm = String(Math.floor(left / 60)).padStart(2, "0");
|
|
345
|
+
const ss = String(left % 60).padStart(2, "0");
|
|
346
|
+
const done = left === 0;
|
|
347
|
+
|
|
348
|
+
return h(
|
|
349
|
+
"div",
|
|
350
|
+
{ className: "caf-block caf-timer" },
|
|
351
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
352
|
+
t.label ? h("span", { className: "caf-hint" }, t.label) : null,
|
|
353
|
+
h("div", { className: done ? "caf-timer-time caf-timer-done" : "caf-timer-time" }, done ? (t.doneText || "Done!") : `${mm}:${ss}`),
|
|
354
|
+
h("div", { className: "caf-bar-track" }, h("div", { className: "caf-bar-fill caf-timer-fill", style: { width: `${(left / total) * 100}%` } })),
|
|
355
|
+
h(
|
|
356
|
+
"div",
|
|
357
|
+
{ className: "caf-timer-controls" },
|
|
358
|
+
h(
|
|
359
|
+
"button",
|
|
360
|
+
{ type: "button", className: "caf-btn caf-btn-primary", disabled: done, onClick: () => setRunning((r) => !r) },
|
|
361
|
+
running ? "Pause" : "Start"
|
|
362
|
+
),
|
|
363
|
+
h("button", { type: "button", className: "caf-btn", onClick: () => { setRunning(false); setLeft(total); } }, "Reset")
|
|
364
|
+
)
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
196
368
|
// The escape hatch. Receives the same ctx every built-in block gets, so a
|
|
197
369
|
// custom block keeps persistence, theming, navigation and the error boundary.
|
|
198
370
|
export function CustomBlock({ spec, ctx }) {
|
|
@@ -208,6 +380,8 @@ export const BLOCKS = {
|
|
|
208
380
|
output: OutputBlock,
|
|
209
381
|
text: TextBlock,
|
|
210
382
|
list: ListBlock,
|
|
383
|
+
chart: ChartBlock,
|
|
384
|
+
timer: TimerBlock,
|
|
211
385
|
custom: CustomBlock,
|
|
212
386
|
};
|
|
213
387
|
|
package/src/steps.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// steps.js — the wizard layout: quizzes, assessments, staged calculators,
|
|
2
|
+
// long forms, onboarding.
|
|
3
|
+
//
|
|
4
|
+
// The author declares the sequence; next/back/progress/result are
|
|
5
|
+
// framework-owned. Three rules keep it un-breakable:
|
|
6
|
+
//
|
|
7
|
+
// one data pool step fields write into the same flat `data` every other
|
|
8
|
+
// block uses, so a result step is just a function of data —
|
|
9
|
+
// no "collect answers" plumbing to half-build.
|
|
10
|
+
// gated next Next is disabled while any field on the CURRENT step is
|
|
11
|
+
// invalid; earlier steps are already valid by construction,
|
|
12
|
+
// later ones aren't the user's problem yet.
|
|
13
|
+
// resumable the current step lives in view state, so a reload lands
|
|
14
|
+
// on the step the user was on, holding their answers.
|
|
15
|
+
|
|
16
|
+
import { createElement as h } from "react";
|
|
17
|
+
import { Field, validateField, formatValue } from "./blocks.js";
|
|
18
|
+
|
|
19
|
+
function stepErrors(step, data) {
|
|
20
|
+
return (step.fields || []).some((f) => validateField(f, data[f.key]) !== null);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ResultScreen({ step, ctx }) {
|
|
24
|
+
const items = step.result(ctx.data) || [];
|
|
25
|
+
return h(
|
|
26
|
+
"div",
|
|
27
|
+
{ className: "caf-block caf-output" },
|
|
28
|
+
step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
|
|
29
|
+
h(
|
|
30
|
+
"div",
|
|
31
|
+
{ className: "caf-output-grid" },
|
|
32
|
+
items.map((item, i) =>
|
|
33
|
+
h(
|
|
34
|
+
"div",
|
|
35
|
+
{ key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
36
|
+
h("span", { className: "caf-stat-label" }, item.label),
|
|
37
|
+
h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
38
|
+
item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
),
|
|
42
|
+
h(
|
|
43
|
+
"div",
|
|
44
|
+
{ className: "caf-step-nav" },
|
|
45
|
+
h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(0) }, "Start over")
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function StepsLayout({ spec, ctx }) {
|
|
51
|
+
const steps = spec.steps;
|
|
52
|
+
const i = Math.min(Math.max(ctx.view.step || 0, 0), steps.length - 1);
|
|
53
|
+
const step = steps[i];
|
|
54
|
+
const isResult = typeof step.result === "function";
|
|
55
|
+
const isLast = i === steps.length - 1;
|
|
56
|
+
|
|
57
|
+
return h(
|
|
58
|
+
"div",
|
|
59
|
+
{ className: "caf-panel caf-panel-single" },
|
|
60
|
+
h(
|
|
61
|
+
"div",
|
|
62
|
+
{ className: "caf-steps-progress" },
|
|
63
|
+
h("span", { className: "caf-hint" }, isResult ? "Result" : `Step ${i + 1} of ${steps.length}`),
|
|
64
|
+
h(
|
|
65
|
+
"div",
|
|
66
|
+
{ className: "caf-bar-track" },
|
|
67
|
+
h("div", { className: "caf-bar-fill caf-steps-fill", style: { width: `${((i + 1) / steps.length) * 100}%` } })
|
|
68
|
+
)
|
|
69
|
+
),
|
|
70
|
+
isResult
|
|
71
|
+
? h(ResultScreen, { step, ctx })
|
|
72
|
+
: h(
|
|
73
|
+
"div",
|
|
74
|
+
{ className: "caf-block caf-fields" },
|
|
75
|
+
step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
|
|
76
|
+
step.text ? h("p", { className: "caf-step-text" }, typeof step.text === "function" ? step.text(ctx.data) : step.text) : null,
|
|
77
|
+
(step.fields || []).map((field) =>
|
|
78
|
+
h(Field, {
|
|
79
|
+
key: field.key,
|
|
80
|
+
field,
|
|
81
|
+
value: ctx.data[field.key],
|
|
82
|
+
onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
|
|
83
|
+
})
|
|
84
|
+
),
|
|
85
|
+
h(
|
|
86
|
+
"div",
|
|
87
|
+
{ className: "caf-step-nav" },
|
|
88
|
+
i > 0
|
|
89
|
+
? h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(i - 1) }, "‹ Back")
|
|
90
|
+
: h("span"),
|
|
91
|
+
!isLast
|
|
92
|
+
? h(
|
|
93
|
+
"button",
|
|
94
|
+
{
|
|
95
|
+
type: "button",
|
|
96
|
+
className: "caf-btn caf-btn-primary",
|
|
97
|
+
disabled: stepErrors(step, ctx.data),
|
|
98
|
+
onClick: () => ctx.setStep(i + 1),
|
|
99
|
+
},
|
|
100
|
+
"Next ›"
|
|
101
|
+
)
|
|
102
|
+
: null
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
}
|
package/src/theme.js
CHANGED
|
@@ -146,3 +146,12 @@ export function themeCss(seed) {
|
|
|
146
146
|
:root[data-theme="light"] { ${vars(p.light)} }
|
|
147
147
|
`;
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
// Categorical series colors for charts, derived from the seed by golden-angle
|
|
151
|
+
// hue rotation, so any number of segments stays distinguishable. Mid-tone
|
|
152
|
+
// lightness reads on both the light and dark surface.
|
|
153
|
+
export function seriesColors(seed, n) {
|
|
154
|
+
const [h0, s0] = rgbToHsl(hexToRgb(seed || "#3b6ef6"));
|
|
155
|
+
const s = clamp(s0, 45, 70);
|
|
156
|
+
return Array.from({ length: n }, (_, i) => hsl((h0 + i * 137.5) % 360, s, 52));
|
|
157
|
+
}
|