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/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
+ }