claude-artifact-framework 0.3.0 → 0.5.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/app.js CHANGED
@@ -11,23 +11,87 @@
11
11
 
12
12
  import { createElement as h, useState, useEffect, hasReact, getReact } from "react";
13
13
  import { createAppState } from "./appState.js";
14
- import { themeCss } from "./theme.js";
14
+ import { themeCss, seriesColors } from "./theme.js";
15
15
  import { BLOCKS, BLOCK_NAMES } from "./blocks.js";
16
16
  import { RecordsLayout } from "./records.js";
17
+ import { StepsLayout } from "./steps.js";
18
+ import { ChatLayout, CHAT_KEYS } from "./chat.js";
17
19
 
18
- const LAYOUTS = ["panel", "records"];
20
+ const LAYOUTS = ["panel", "records", "steps", "chat"];
19
21
  const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
20
22
 
21
23
  // Frontier models still invent identifiers a few percent of the time, so an
22
24
  // unknown name has to fail loudly and name the alternatives rather than
23
25
  // silently rendering nothing — a blank pane is indistinguishable from a bug.
26
+ const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared"];
27
+
24
28
  function validate(spec) {
29
+ const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
30
+ if (unknownTop.length) {
31
+ throw new Error(
32
+ `claude-artifact-framework: unknown top-level keys (${unknownTop.join(", ")}). Valid keys: ${TOP_KEYS.join(", ")}.`
33
+ );
34
+ }
25
35
  const layout = spec.layout || "panel";
26
36
  if (!LAYOUTS.includes(layout)) {
27
37
  throw new Error(
28
38
  `claude-artifact-framework: unknown layout "${layout}". Valid layouts: ${LAYOUTS.join(", ")}.`
29
39
  );
30
40
  }
41
+ if (layout === "steps") {
42
+ const steps = spec.steps;
43
+ if (!Array.isArray(steps) || steps.length === 0) {
44
+ throw new Error('claude-artifact-framework: layout "steps" needs `steps: [...]` with at least one step.');
45
+ }
46
+ const STEP_KEYS = ["title", "text", "fields", "result"];
47
+ const seen = new Set();
48
+ steps.forEach((st, i) => {
49
+ if (!st || typeof st !== "object") {
50
+ throw new Error(`claude-artifact-framework: steps[${i}] must be an object.`);
51
+ }
52
+ const unknown = Object.keys(st).filter((k) => !STEP_KEYS.includes(k));
53
+ if (unknown.length) {
54
+ throw new Error(
55
+ `claude-artifact-framework: steps[${i}] has unknown keys (${unknown.join(", ")}). Valid keys: ${STEP_KEYS.join(", ")}.`
56
+ );
57
+ }
58
+ if (!st.fields && !st.text && !st.result) {
59
+ throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
60
+ }
61
+ for (const f of st.fields || []) {
62
+ if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in steps[${i}] needs a \`key\`.`);
63
+ if (seen.has(f.key)) {
64
+ throw new Error(
65
+ `claude-artifact-framework: field key "${f.key}" appears in more than one step — keys share one data pool and must be unique.`
66
+ );
67
+ }
68
+ seen.add(f.key);
69
+ }
70
+ });
71
+ return layout;
72
+ }
73
+ if (layout === "chat") {
74
+ const c = spec.chat;
75
+ if (!c || (typeof c.system !== "string" && typeof c.system !== "function")) {
76
+ throw new Error(
77
+ 'claude-artifact-framework: layout "chat" needs `chat: { system: "..." }` (a string or a function of data).'
78
+ );
79
+ }
80
+ const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
81
+ if (unknown.length) {
82
+ throw new Error(
83
+ `claude-artifact-framework: chat has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
84
+ );
85
+ }
86
+ for (const [name, tool] of Object.entries(c.tools || {})) {
87
+ if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
88
+ throw new Error(
89
+ `claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
90
+ );
91
+ }
92
+ }
93
+ return layout;
94
+ }
31
95
  if (layout === "records") {
32
96
  const r = spec.records;
33
97
  if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
@@ -35,6 +99,13 @@ function validate(spec) {
35
99
  'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
36
100
  );
37
101
  }
102
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
103
+ const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
104
+ if (unknownR.length) {
105
+ throw new Error(
106
+ `claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
107
+ );
108
+ }
38
109
  const seen = new Set();
39
110
  for (const f of r.fields) {
40
111
  if (!f || !f.key) throw new Error("claude-artifact-framework: every records field needs a `key`.");
@@ -42,6 +113,18 @@ function validate(spec) {
42
113
  if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
43
114
  seen.add(f.key);
44
115
  }
116
+ for (const [key, c] of Object.entries(r.compute || {})) {
117
+ if (seen.has(key) || key === "id") {
118
+ throw new Error(
119
+ `claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
120
+ );
121
+ }
122
+ if (typeof c !== "function" && typeof (c && c.value) !== "function") {
123
+ throw new Error(
124
+ `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
125
+ );
126
+ }
127
+ }
45
128
  return layout;
46
129
  }
47
130
  const blocks = spec.blocks || [];
@@ -80,7 +163,10 @@ function defaultsFrom(spec) {
80
163
  if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
81
164
  data.records = [];
82
165
  }
83
- for (const block of spec.blocks || []) {
166
+ if ((spec.layout || "panel") === "chat" && !data.chat) {
167
+ data.chat = { api: [], log: [] };
168
+ }
169
+ for (const block of [...(spec.blocks || []), ...(spec.steps || [])]) {
84
170
  for (const field of block.fields || []) {
85
171
  if (field && field.key !== undefined && !(field.key in data)) {
86
172
  data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
@@ -140,11 +226,11 @@ function Panel({ spec, ctx }) {
140
226
  );
141
227
  }
142
228
 
143
- const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout };
229
+ const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout, steps: StepsLayout, chat: ChatLayout };
144
230
 
145
231
  export function createApp(spec = {}) {
146
232
  const layout = validate(spec);
147
- const state = createAppState(defaultsFrom(spec));
233
+ const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
148
234
  const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
149
235
 
150
236
  return function App() {
@@ -172,6 +258,8 @@ export function createApp(spec = {}) {
172
258
  update: state.update,
173
259
  go: state.go,
174
260
  back: state.back,
261
+ setStep: (n) => state.setView({ step: n }),
262
+ colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
175
263
  };
176
264
 
177
265
  const Layout = LAYOUT_COMPONENTS[layout];
@@ -264,6 +352,56 @@ const BASE_CSS = `
264
352
  .caf-btn:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
265
353
  .caf-btn-primary { background: var(--caf-accent); border-color: var(--caf-accent); color: var(--caf-accentText); }
266
354
  .caf-btn-danger { background: transparent; border-color: var(--caf-danger); color: var(--caf-danger); }
355
+ .caf-bars { display: flex; flex-direction: column; gap: 0.55rem; }
356
+ .caf-bar-row { display: grid; grid-template-columns: minmax(60px, 30%) 1fr auto; align-items: center; gap: 0.6rem; }
357
+ .caf-bar-label { font-size: 0.82rem; color: var(--caf-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
358
+ .caf-bar-track { height: 8px; border-radius: 4px; background: var(--caf-sunken); overflow: hidden; }
359
+ .caf-bar-fill { height: 100%; border-radius: 4px; background: var(--caf-accent); }
360
+ .caf-bar-value { font-size: 0.82rem; font-weight: 600; font-variant-numeric: tabular-nums; }
361
+ .caf-line-svg { width: 100%; height: 110px; display: block; }
362
+ .caf-line-grid { stroke: var(--caf-border); stroke-width: 0.3; }
363
+ .caf-line-path { fill: none; stroke: var(--caf-accent); stroke-width: 1.1; stroke-linejoin: round; stroke-linecap: round; }
364
+ .caf-line-dot { fill: var(--caf-accent); }
365
+ .caf-line-meta { display: flex; justify-content: space-between; align-items: baseline; gap: 0.6rem; }
366
+ .caf-line-last { font-weight: 650; font-variant-numeric: tabular-nums; color: var(--caf-accent); }
367
+ .caf-donut { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; }
368
+ .caf-donut-svg { width: 110px; height: 110px; flex: none; transform: rotate(0.001deg); }
369
+ .caf-donut-seg { fill: none; stroke-width: 6; }
370
+ .caf-donut-legend { display: flex; flex-direction: column; gap: 0.4rem; min-width: 0; flex: 1; }
371
+ .caf-legend-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.84rem; }
372
+ .caf-legend-swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
373
+ .caf-legend-label { color: var(--caf-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
374
+ .caf-legend-value { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; }
375
+ .caf-timer { align-items: center; text-align: center; }
376
+ .caf-timer .caf-bar-track { width: 100%; }
377
+ .caf-timer-time { font-size: 2.6rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
378
+ .caf-timer-done { color: var(--caf-accent); }
379
+ .caf-timer-fill { transition: width 0.9s linear; }
380
+ .caf-timer-controls { display: flex; gap: 0.6rem; }
381
+ .caf-btn:disabled { opacity: 0.45; cursor: not-allowed; }
382
+ .caf-steps-progress { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.2rem; }
383
+ .caf-steps-fill { transition: width 0.25s ease; }
384
+ .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
385
+ .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
386
+ .caf-chat-log { min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
387
+ .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; }
388
+ .caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
389
+ .caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
390
+ .caf-msg-live::after { content: "▍"; opacity: 0.6; }
391
+ .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; }
392
+ .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); }
393
+ .caf-msg-error strong { color: var(--caf-danger); font-size: 0.85rem; }
394
+ .caf-chat-input { display: flex; gap: 0.6rem; margin-top: 0.8rem; }
395
+ .caf-chat-input input { flex: 1; font: inherit; font-size: 0.95rem; padding: 0.55rem 0.7rem; border-radius: 8px; border: 1px solid var(--caf-border); background: var(--caf-surface); color: var(--caf-text); }
396
+ .caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
397
+ .caf-search {
398
+ font: inherit; font-size: 0.9rem; width: 100%;
399
+ padding: 0.5rem 0.65rem; margin: 0 0 0.3rem;
400
+ border-radius: 7px; border: 1px solid var(--caf-border);
401
+ background: var(--caf-sunken); color: var(--caf-text);
402
+ }
403
+ .caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
404
+ .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
267
405
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
268
406
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
269
407
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
package/src/appState.js CHANGED
@@ -44,10 +44,20 @@ function debounce(fn, ms) {
44
44
  };
45
45
  }
46
46
 
47
- export function createAppState(defaults, { debounceMs = 400 } = {}) {
47
+ // With `shared: true` the DATA pool lives in the shared storage scope, so
48
+ // every viewer of a published artifact converges on the same data (polled,
49
+ // last-write-wins). View state — navigation, selection, the open record —
50
+ // stays personal ALWAYS: two people looking at the same list should not
51
+ // fight over which screen is open.
52
+ export function createAppState(defaults, { debounceMs = 400, shared = false, pollMs = 2500 } = {}) {
53
+ const dataStore = shared ? storage.shared : storage;
48
54
  let data = structuredCloneish(defaults);
49
55
  let view = { ...EMPTY_VIEW };
50
56
  let hydrated = false;
57
+ // Counts update() calls so a poll never overwrites edits that haven't been
58
+ // persisted yet: remote state only applies when we owe storage nothing.
59
+ let writes = 0;
60
+ let persistedWrites = 0;
51
61
  const listeners = new Set();
52
62
 
53
63
  // Notification is batched to a microtask: three mutations in a row produce
@@ -63,12 +73,32 @@ export function createAppState(defaults, { debounceMs = 400 } = {}) {
63
73
  });
64
74
  }
65
75
 
66
- const persistData = debounce(() => storage.set(DATA_KEY, data), debounceMs);
76
+ const persistData = debounce(() => {
77
+ const at = writes;
78
+ Promise.resolve(dataStore.set(DATA_KEY, data)).then(() => {
79
+ persistedWrites = Math.max(persistedWrites, at);
80
+ }).catch(() => {});
81
+ }, debounceMs);
67
82
  const persistView = debounce(() => storage.set(VIEW_KEY, view), debounceMs);
68
83
 
84
+ if (shared) {
85
+ setInterval(async () => {
86
+ if (!hydrated || writes > persistedWrites) return; // local edits in flight win
87
+ try {
88
+ const remote = await dataStore.get(DATA_KEY, undefined);
89
+ if (remote !== undefined && JSON.stringify(remote) !== JSON.stringify(data)) {
90
+ data = mergeDefaults(defaults, remote);
91
+ notify();
92
+ }
93
+ } catch {
94
+ // a failed poll just means we try again next tick
95
+ }
96
+ }, pollMs);
97
+ }
98
+
69
99
  const ready = (async () => {
70
100
  const [storedData, storedView] = await Promise.all([
71
- storage.get(DATA_KEY, undefined),
101
+ dataStore.get(DATA_KEY, undefined),
72
102
  storage.get(VIEW_KEY, undefined),
73
103
  ]);
74
104
  if (storedData !== undefined) data = mergeDefaults(defaults, storedData);
@@ -82,6 +112,7 @@ export function createAppState(defaults, { debounceMs = 400 } = {}) {
82
112
  // Handlers may either mutate the draft or return a replacement.
83
113
  if (result !== undefined) data = result;
84
114
  data = { ...data };
115
+ writes++;
85
116
  persistData();
86
117
  notify();
87
118
  }
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