claude-artifact-framework 0.2.1 → 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 +144 -3
- package/dist/index.esm.js +1086 -1
- package/dist/index.esm.js.map +4 -4
- package/dist/index.global.js +120 -1
- package/dist/index.global.js.map +4 -4
- package/package.json +1 -1
- package/src/app.js +337 -0
- package/src/appState.js +127 -0
- package/src/blocks.js +389 -0
- package/src/index.js +3 -0
- package/src/react-runtime.js +80 -0
- package/src/records.js +158 -0
- package/src/steps.js +106 -0
- package/src/theme.js +157 -0
package/src/appState.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// appState.js — the store behind createApp.
|
|
2
|
+
//
|
|
3
|
+
// Two states, and the split is the point:
|
|
4
|
+
// data domain data only. Declared by the author, persisted whole in one write.
|
|
5
|
+
// view navigation stack, selection, current step, form drafts, pending flags.
|
|
6
|
+
// Owned by the framework, persisted under its own key, never declared
|
|
7
|
+
// and never seen by the author.
|
|
8
|
+
//
|
|
9
|
+
// That split is why `data` needs no zones or transient markers: nothing that
|
|
10
|
+
// would be wrong to restore (an open modal, a half-typed draft) ever lives in
|
|
11
|
+
// it. It also removes every storage key as an invention point — the author
|
|
12
|
+
// never names one.
|
|
13
|
+
|
|
14
|
+
import { storage } from "./storage.js";
|
|
15
|
+
|
|
16
|
+
const DATA_KEY = "caf:data";
|
|
17
|
+
const VIEW_KEY = "caf:view";
|
|
18
|
+
|
|
19
|
+
const EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null };
|
|
20
|
+
|
|
21
|
+
function isPlainObject(v) {
|
|
22
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Stored values win, but only where they exist. A field the author added
|
|
26
|
+
// after data was already saved simply falls through to its declared default,
|
|
27
|
+
// which is what makes additive edits need no migration at all.
|
|
28
|
+
function mergeDefaults(defaults, stored) {
|
|
29
|
+
if (!isPlainObject(defaults) || !isPlainObject(stored)) {
|
|
30
|
+
return stored === undefined ? defaults : stored;
|
|
31
|
+
}
|
|
32
|
+
const out = { ...defaults };
|
|
33
|
+
for (const key of Object.keys(stored)) {
|
|
34
|
+
out[key] = isPlainObject(defaults[key]) ? mergeDefaults(defaults[key], stored[key]) : stored[key];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function debounce(fn, ms) {
|
|
40
|
+
let timer;
|
|
41
|
+
return (...args) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createAppState(defaults, { debounceMs = 400 } = {}) {
|
|
48
|
+
let data = structuredCloneish(defaults);
|
|
49
|
+
let view = { ...EMPTY_VIEW };
|
|
50
|
+
let hydrated = false;
|
|
51
|
+
const listeners = new Set();
|
|
52
|
+
|
|
53
|
+
// Notification is batched to a microtask: three mutations in a row produce
|
|
54
|
+
// one render, not three. Persistence is separately debounced, because
|
|
55
|
+
// writing to window.storage is async and must not run at typing speed.
|
|
56
|
+
let queued = false;
|
|
57
|
+
function notify() {
|
|
58
|
+
if (queued) return;
|
|
59
|
+
queued = true;
|
|
60
|
+
Promise.resolve().then(() => {
|
|
61
|
+
queued = false;
|
|
62
|
+
for (const fn of listeners) fn();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const persistData = debounce(() => storage.set(DATA_KEY, data), debounceMs);
|
|
67
|
+
const persistView = debounce(() => storage.set(VIEW_KEY, view), debounceMs);
|
|
68
|
+
|
|
69
|
+
const ready = (async () => {
|
|
70
|
+
const [storedData, storedView] = await Promise.all([
|
|
71
|
+
storage.get(DATA_KEY, undefined),
|
|
72
|
+
storage.get(VIEW_KEY, undefined),
|
|
73
|
+
]);
|
|
74
|
+
if (storedData !== undefined) data = mergeDefaults(defaults, storedData);
|
|
75
|
+
if (storedView !== undefined) view = { ...EMPTY_VIEW, ...storedView };
|
|
76
|
+
hydrated = true;
|
|
77
|
+
notify();
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
function update(fn) {
|
|
81
|
+
const result = fn(data);
|
|
82
|
+
// Handlers may either mutate the draft or return a replacement.
|
|
83
|
+
if (result !== undefined) data = result;
|
|
84
|
+
data = { ...data };
|
|
85
|
+
persistData();
|
|
86
|
+
notify();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function setView(patch) {
|
|
90
|
+
view = { ...view, ...patch };
|
|
91
|
+
persistView();
|
|
92
|
+
notify();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
ready,
|
|
97
|
+
isHydrated: () => hydrated,
|
|
98
|
+
getData: () => data,
|
|
99
|
+
getView: () => view,
|
|
100
|
+
update,
|
|
101
|
+
setView,
|
|
102
|
+
go(screen, arg) {
|
|
103
|
+
setView({ stack: [...view.stack, { screen: view.screen, arg: view.arg }], screen, arg });
|
|
104
|
+
},
|
|
105
|
+
back() {
|
|
106
|
+
const stack = view.stack.slice();
|
|
107
|
+
const previous = stack.pop() || { screen: null, arg: null };
|
|
108
|
+
setView({ stack, screen: previous.screen, arg: previous.arg });
|
|
109
|
+
},
|
|
110
|
+
subscribe(fn) {
|
|
111
|
+
listeners.add(fn);
|
|
112
|
+
return () => listeners.delete(fn);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Defaults come from a literal in the declaration; a shallow structural copy
|
|
118
|
+
// is enough and avoids depending on structuredClone being present.
|
|
119
|
+
function structuredCloneish(value) {
|
|
120
|
+
if (Array.isArray(value)) return value.map(structuredCloneish);
|
|
121
|
+
if (isPlainObject(value)) {
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const k of Object.keys(value)) out[k] = structuredCloneish(value[k]);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
package/src/blocks.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// blocks.js — the content blocks that fill a layout's slots.
|
|
2
|
+
//
|
|
3
|
+
// Every block is a plain React component taking (spec, ctx). Custom code from
|
|
4
|
+
// the escape hatch implements exactly the same contract, which is why ejecting
|
|
5
|
+
// one block never requires ejecting the app.
|
|
6
|
+
//
|
|
7
|
+
// Loading / empty / error are part of the contract rather than edge cases the
|
|
8
|
+
// author opts into: a block that has nothing to show says so by default, so an
|
|
9
|
+
// app can't ship a blank pane while it waits.
|
|
10
|
+
|
|
11
|
+
// Bound lazily rather than snapshotted as `React.createElement`, because this
|
|
12
|
+
// runs at module load — before the host has had a chance to hand React over.
|
|
13
|
+
import { createElement as h, useState, useEffect } from "react";
|
|
14
|
+
|
|
15
|
+
const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
|
|
16
|
+
|
|
17
|
+
export function formatValue(value, format) {
|
|
18
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
19
|
+
const n = Number(value);
|
|
20
|
+
switch (format) {
|
|
21
|
+
case "money":
|
|
22
|
+
return Number.isFinite(n)
|
|
23
|
+
? n.toLocaleString(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 })
|
|
24
|
+
: String(value);
|
|
25
|
+
case "percent":
|
|
26
|
+
return Number.isFinite(n) ? `${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}%` : String(value);
|
|
27
|
+
case "number":
|
|
28
|
+
return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(value);
|
|
29
|
+
case "date":
|
|
30
|
+
return value instanceof Date ? value.toLocaleDateString() : String(value);
|
|
31
|
+
default:
|
|
32
|
+
return String(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Validation is derived from the field declaration, so the author gets it
|
|
37
|
+
// without writing any: declaring a type and a range IS the validation.
|
|
38
|
+
export function validateField(field, value) {
|
|
39
|
+
if (field.required && (value === "" || value === null || value === undefined)) {
|
|
40
|
+
return "Required";
|
|
41
|
+
}
|
|
42
|
+
if (value === "" || value === null || value === undefined) return null;
|
|
43
|
+
if (field.type === "number" || field.type === "money" || field.type === "percent") {
|
|
44
|
+
const n = Number(value);
|
|
45
|
+
if (!Number.isFinite(n)) return "Must be a number";
|
|
46
|
+
if (field.min !== undefined && n < field.min) return `Must be at least ${field.min}`;
|
|
47
|
+
if (field.max !== undefined && n > field.max) return `Must be at most ${field.max}`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Field({ field, value, onChange }) {
|
|
53
|
+
const error = validateField(field, value);
|
|
54
|
+
const id = `caf-f-${field.key}`;
|
|
55
|
+
const numeric = field.type === "number" || field.type === "money" || field.type === "percent";
|
|
56
|
+
|
|
57
|
+
if (field.type === "check") {
|
|
58
|
+
return h(
|
|
59
|
+
"label",
|
|
60
|
+
{ className: "caf-check" },
|
|
61
|
+
h("input", {
|
|
62
|
+
id,
|
|
63
|
+
type: "checkbox",
|
|
64
|
+
checked: Boolean(value),
|
|
65
|
+
onChange: (e) => onChange(e.target.checked),
|
|
66
|
+
}),
|
|
67
|
+
h("span", null, field.label || field.key)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const common = {
|
|
72
|
+
id,
|
|
73
|
+
value: value === null || value === undefined ? "" : value,
|
|
74
|
+
"aria-invalid": error ? "true" : undefined,
|
|
75
|
+
onChange: (e) => onChange(numeric ? toNumber(e.target.value) : e.target.value),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let control;
|
|
79
|
+
if (field.type === "textarea") {
|
|
80
|
+
control = h("textarea", { ...common, rows: field.rows || 3 });
|
|
81
|
+
} else if (field.type === "select") {
|
|
82
|
+
control = h(
|
|
83
|
+
"select",
|
|
84
|
+
common,
|
|
85
|
+
h("option", { value: "" }, field.placeholder || "Select…"),
|
|
86
|
+
(field.options || []).map((opt) => {
|
|
87
|
+
const val = typeof opt === "string" ? opt : opt.value;
|
|
88
|
+
const label = typeof opt === "string" ? opt : opt.label;
|
|
89
|
+
return h("option", { key: val, value: val }, label);
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
control = h("input", {
|
|
94
|
+
...common,
|
|
95
|
+
type: field.type === "date" ? "date" : numeric ? "number" : "text",
|
|
96
|
+
min: field.min,
|
|
97
|
+
max: field.max,
|
|
98
|
+
step: field.step || (field.type === "money" || field.type === "percent" ? "any" : undefined),
|
|
99
|
+
placeholder: field.placeholder,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return h(
|
|
104
|
+
"div",
|
|
105
|
+
{ className: "caf-field" },
|
|
106
|
+
h("label", { htmlFor: id }, field.label || field.key),
|
|
107
|
+
control,
|
|
108
|
+
field.hint && !error ? h("small", { className: "caf-hint" }, field.hint) : null,
|
|
109
|
+
error ? h("small", { className: "caf-error" }, error) : null
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toNumber(raw) {
|
|
114
|
+
if (raw === "") return "";
|
|
115
|
+
const n = Number(raw);
|
|
116
|
+
return Number.isFinite(n) ? n : raw;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function FieldsBlock({ spec, ctx }) {
|
|
120
|
+
const fields = spec.fields || [];
|
|
121
|
+
return h(
|
|
122
|
+
"div",
|
|
123
|
+
{ className: "caf-block caf-fields" },
|
|
124
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
125
|
+
fields.map((field) =>
|
|
126
|
+
h(Field, {
|
|
127
|
+
key: field.key,
|
|
128
|
+
field,
|
|
129
|
+
value: ctx.data[field.key],
|
|
130
|
+
onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function OutputBlock({ spec, ctx }) {
|
|
137
|
+
const items = typeof spec.output === "function" ? spec.output(ctx.data) : spec.output || [];
|
|
138
|
+
if (!items.length) {
|
|
139
|
+
return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
|
|
140
|
+
}
|
|
141
|
+
return h(
|
|
142
|
+
"div",
|
|
143
|
+
{ className: "caf-block caf-output" },
|
|
144
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
145
|
+
h(
|
|
146
|
+
"div",
|
|
147
|
+
{ className: "caf-output-grid" },
|
|
148
|
+
items.map((item, i) =>
|
|
149
|
+
h(
|
|
150
|
+
"div",
|
|
151
|
+
{ key: item.label || i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
152
|
+
h("span", { className: "caf-stat-label" }, item.label),
|
|
153
|
+
h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
154
|
+
item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function TextBlock({ spec, ctx }) {
|
|
162
|
+
const body = typeof spec.text === "function" ? spec.text(ctx.data) : spec.text;
|
|
163
|
+
return h(
|
|
164
|
+
"div",
|
|
165
|
+
{ className: "caf-block caf-text" },
|
|
166
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
167
|
+
h("p", null, body)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function ListBlock({ spec, ctx }) {
|
|
172
|
+
const rows = (typeof spec.list === "function" ? spec.list(ctx.data) : spec.list) || [];
|
|
173
|
+
if (!rows.length) {
|
|
174
|
+
return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing here yet.");
|
|
175
|
+
}
|
|
176
|
+
const title = spec.title || ((r) => r.title ?? r.name ?? r.label ?? String(r));
|
|
177
|
+
return h(
|
|
178
|
+
"div",
|
|
179
|
+
{ className: "caf-block caf-list" },
|
|
180
|
+
rows.map((row, i) =>
|
|
181
|
+
h(
|
|
182
|
+
"button",
|
|
183
|
+
{
|
|
184
|
+
key: row.id ?? i,
|
|
185
|
+
className: "caf-row",
|
|
186
|
+
type: "button",
|
|
187
|
+
onClick: spec.onSelect ? () => spec.onSelect(row, ctx) : undefined,
|
|
188
|
+
},
|
|
189
|
+
h("span", { className: "caf-row-title" }, title(row)),
|
|
190
|
+
spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(row)) : null
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
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
|
+
|
|
368
|
+
// The escape hatch. Receives the same ctx every built-in block gets, so a
|
|
369
|
+
// custom block keeps persistence, theming, navigation and the error boundary.
|
|
370
|
+
export function CustomBlock({ spec, ctx }) {
|
|
371
|
+
const rendered = spec.custom(ctx);
|
|
372
|
+
if (typeof rendered === "string") {
|
|
373
|
+
return h("div", { className: "caf-block", dangerouslySetInnerHTML: { __html: rendered } });
|
|
374
|
+
}
|
|
375
|
+
return h("div", { className: "caf-block" }, rendered);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const BLOCKS = {
|
|
379
|
+
fields: FieldsBlock,
|
|
380
|
+
output: OutputBlock,
|
|
381
|
+
text: TextBlock,
|
|
382
|
+
list: ListBlock,
|
|
383
|
+
chart: ChartBlock,
|
|
384
|
+
timer: TimerBlock,
|
|
385
|
+
custom: CustomBlock,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
export const BLOCK_NAMES = Object.keys(BLOCKS);
|
|
389
|
+
export { FIELD_TYPES };
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
export { storage } from "./storage.js";
|
|
6
6
|
export { createStore, createPersistedStore, createSharedStore } from "./state.js";
|
|
7
|
+
export { createApp } from "./app.js";
|
|
8
|
+
export { palette, themeCss, contrast } from "./theme.js";
|
|
9
|
+
export { useReact, hasReact } from "./react-runtime.js";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// react-runtime.js — how the framework gets hold of React.
|
|
2
|
+
//
|
|
3
|
+
// It must use the SAME React the host already loaded: a second copy on the
|
|
4
|
+
// page makes every hook throw. But it must not depend on it with a static
|
|
5
|
+
// `import React from "react"` either — a specifier the host can't resolve
|
|
6
|
+
// kills the whole module at load time, before anything is left running to
|
|
7
|
+
// report why.
|
|
8
|
+
//
|
|
9
|
+
// So React is resolved at runtime, from whichever of these is available:
|
|
10
|
+
//
|
|
11
|
+
// 1. globalThis.React — present when the host exposes it
|
|
12
|
+
// 2. useReact(React) — the explicit hand-off
|
|
13
|
+
//
|
|
14
|
+
// The hand-off is one line and always available, because an artifact already
|
|
15
|
+
// imports React for its own code:
|
|
16
|
+
//
|
|
17
|
+
// import React from "react";
|
|
18
|
+
// ArtifactKit.useReact(React);
|
|
19
|
+
//
|
|
20
|
+
// Deliberately no dynamic import("react") fallback: it would need top-level
|
|
21
|
+
// await, which the IIFE build can't have, and it only pays off in a runtime
|
|
22
|
+
// with a native import map — which is not the way artifacts load code.
|
|
23
|
+
|
|
24
|
+
let React = typeof globalThis !== "undefined" ? globalThis.React : undefined;
|
|
25
|
+
|
|
26
|
+
export function useReact(instance) {
|
|
27
|
+
React = instance;
|
|
28
|
+
return instance;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasReact() {
|
|
32
|
+
return Boolean(React);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The live instance, for the few places that need React itself rather than one
|
|
36
|
+
// of its functions — `class extends React.Component`, most of all.
|
|
37
|
+
export function getReact() {
|
|
38
|
+
return required();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function required() {
|
|
42
|
+
if (!React) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"claude-artifact-framework: React not found. Hand it over once, before createApp:\n\n" +
|
|
45
|
+
' import React from "react";\n' +
|
|
46
|
+
" ArtifactKit.useReact(React);\n"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return React;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Bound lazily, because modules import these at load time — which is before
|
|
53
|
+
// useReact() can possibly have run.
|
|
54
|
+
export const createElement = (...args) => required().createElement(...args);
|
|
55
|
+
export const useState = (...args) => required().useState(...args);
|
|
56
|
+
export const useEffect = (...args) => required().useEffect(...args);
|
|
57
|
+
export const useMemo = (...args) => required().useMemo(...args);
|
|
58
|
+
export const useRef = (...args) => required().useRef(...args);
|
|
59
|
+
|
|
60
|
+
// A class component is read as a value at module scope, so it can't be
|
|
61
|
+
// deferred behind a call. This stand-in is only ever constructed if React was
|
|
62
|
+
// still missing at render time, and it reports the same error then.
|
|
63
|
+
export const Component =
|
|
64
|
+
(React && React.Component) ||
|
|
65
|
+
class ReactMissing {
|
|
66
|
+
render() {
|
|
67
|
+
return required();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The default export mirrors `import React from "react"`, so the source files
|
|
72
|
+
// can keep using React.createElement / React.useState unchanged.
|
|
73
|
+
export default {
|
|
74
|
+
get createElement() { return required().createElement; },
|
|
75
|
+
get Component() { return required().Component; },
|
|
76
|
+
get useState() { return required().useState; },
|
|
77
|
+
get useEffect() { return required().useEffect; },
|
|
78
|
+
get useMemo() { return required().useMemo; },
|
|
79
|
+
get useRef() { return required().useRef; },
|
|
80
|
+
};
|