claude-artifact-framework 0.7.2 → 0.8.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 +74 -8
- package/dist/index.esm.js +104 -16
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +14 -13
- package/dist/index.global.js.map +3 -3
- package/package.json +2 -2
- package/src/app.js +58 -5
- package/src/appState.js +14 -1
- package/src/blocks.js +44 -6
- package/src/records.js +2 -2
- package/src/steps.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"build": "node scripts/build.mjs",
|
|
19
19
|
"build:watch": "node scripts/build.mjs --watch",
|
|
20
20
|
"prepublishOnly": "npm run build",
|
|
21
|
-
"release": "bash scripts/publish.sh"
|
|
21
|
+
"release": "node scripts/check-docs.mjs && bash scripts/publish.sh"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"esbuild": "^0.25.0"
|
package/src/app.js
CHANGED
|
@@ -18,12 +18,12 @@ import { StepsLayout } from "./steps.js";
|
|
|
18
18
|
import { ChatLayout, CHAT_KEYS } from "./chat.js";
|
|
19
19
|
|
|
20
20
|
const LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
|
|
21
|
-
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible"];
|
|
21
|
+
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill"];
|
|
22
22
|
|
|
23
23
|
// Frontier models still invent identifiers a few percent of the time, so an
|
|
24
24
|
// unknown name has to fail loudly and name the alternatives rather than
|
|
25
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", "main", "sidebar"];
|
|
26
|
+
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs"];
|
|
27
27
|
|
|
28
28
|
// An unknown field type used to fall through silently to a text input — the
|
|
29
29
|
// exact quiet degradation the framework exists to prevent (a blind-tested
|
|
@@ -129,6 +129,9 @@ function checkBlocks(blocks) {
|
|
|
129
129
|
if (block.when !== undefined && typeof block.when !== "function") {
|
|
130
130
|
throw new Error(`claude-artifact-framework: blocks[${i}].when must be a function of data returning true/false.`);
|
|
131
131
|
}
|
|
132
|
+
if (block.fill !== undefined && known[0] !== "custom") {
|
|
133
|
+
throw new Error(`claude-artifact-framework: \`fill\` is only valid on custom blocks — blocks[${i}] is "${known[0]}".`);
|
|
134
|
+
}
|
|
132
135
|
});
|
|
133
136
|
}
|
|
134
137
|
|
|
@@ -139,6 +142,11 @@ function validate(spec) {
|
|
|
139
142
|
`claude-artifact-framework: unknown top-level keys (${unknownTop.join(", ")}). Valid keys: ${TOP_KEYS.join(", ")}.`
|
|
140
143
|
);
|
|
141
144
|
}
|
|
145
|
+
if (spec.libs !== undefined) {
|
|
146
|
+
if (!Array.isArray(spec.libs) || spec.libs.some((u) => typeof u !== "string" || !/^https?:\/\//.test(u))) {
|
|
147
|
+
throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
142
150
|
const layout = spec.layout || "panel";
|
|
143
151
|
if (!LAYOUTS.includes(layout)) {
|
|
144
152
|
throw new Error(
|
|
@@ -217,6 +225,11 @@ function validate(spec) {
|
|
|
217
225
|
);
|
|
218
226
|
}
|
|
219
227
|
checkFields(r.fields, "records");
|
|
228
|
+
if (r.fields.some((f) => f.type === "file") || (r.items && r.items.fields.some((f) => f.type === "file"))) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) — they are not supported inside records or items.'
|
|
231
|
+
);
|
|
232
|
+
}
|
|
220
233
|
const seen = new Set();
|
|
221
234
|
for (const f of r.fields) {
|
|
222
235
|
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
|
|
@@ -455,10 +468,31 @@ function WorkspaceLayout({ spec, ctx }) {
|
|
|
455
468
|
|
|
456
469
|
const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout };
|
|
457
470
|
|
|
471
|
+
// Deduped by src: two apps (or re-mounts) asking for the same lib share one
|
|
472
|
+
// tag, mirroring how the PDF annotator loads pdf.js by hand.
|
|
473
|
+
function loadScript(src) {
|
|
474
|
+
return new Promise((resolve, reject) => {
|
|
475
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
476
|
+
if (existing) {
|
|
477
|
+
if (existing.dataset.cafReady === "1") return resolve();
|
|
478
|
+
existing.addEventListener("load", resolve);
|
|
479
|
+
existing.addEventListener("error", () => reject(new Error(`failed to load ${src}`)));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const tag = document.createElement("script");
|
|
483
|
+
tag.src = src;
|
|
484
|
+
tag.onload = () => { tag.dataset.cafReady = "1"; resolve(); };
|
|
485
|
+
tag.onerror = () => reject(new Error(`failed to load ${src}`));
|
|
486
|
+
document.head.appendChild(tag);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
458
490
|
export function createApp(spec = {}) {
|
|
459
491
|
const layout = validate(spec);
|
|
460
492
|
const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
|
|
461
493
|
const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
|
|
494
|
+
// Live File objects picked by `file` fields — in memory only, never persisted.
|
|
495
|
+
const files = {};
|
|
462
496
|
|
|
463
497
|
return function App() {
|
|
464
498
|
if (!hasReact()) {
|
|
@@ -469,6 +503,15 @@ export function createApp(spec = {}) {
|
|
|
469
503
|
);
|
|
470
504
|
}
|
|
471
505
|
const [, force] = useState(0);
|
|
506
|
+
const [libsState, setLibsState] = useState(spec.libs && spec.libs.length ? "loading" : "ready");
|
|
507
|
+
const [libsError, setLibsError] = useState(null);
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (!spec.libs || !spec.libs.length) return;
|
|
510
|
+
Promise.all(spec.libs.map(loadScript)).then(
|
|
511
|
+
() => setLibsState("ready"),
|
|
512
|
+
(e) => { setLibsError(String((e && e.message) || e)); setLibsState("error"); }
|
|
513
|
+
);
|
|
514
|
+
}, []);
|
|
472
515
|
useEffect(() => {
|
|
473
516
|
const unsubscribe = state.subscribe(() => force((n) => n + 1));
|
|
474
517
|
// Hydration can finish before this effect runs — its notify() then lands
|
|
@@ -486,6 +529,8 @@ export function createApp(spec = {}) {
|
|
|
486
529
|
go: state.go,
|
|
487
530
|
back: state.back,
|
|
488
531
|
setStep: (n) => state.setView({ step: n }),
|
|
532
|
+
files,
|
|
533
|
+
viewerId: state.getViewerId(),
|
|
489
534
|
setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
|
|
490
535
|
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
491
536
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
|
|
@@ -503,9 +548,16 @@ export function createApp(spec = {}) {
|
|
|
503
548
|
spec.title ? h("h1", null, spec.title) : null,
|
|
504
549
|
spec.subtitle ? h("p", null, spec.subtitle) : null
|
|
505
550
|
),
|
|
506
|
-
|
|
507
|
-
? h(
|
|
508
|
-
|
|
551
|
+
libsState === "error"
|
|
552
|
+
? h(
|
|
553
|
+
"div",
|
|
554
|
+
{ className: "caf-block caf-block-error" },
|
|
555
|
+
h("strong", null, "A library failed to load"),
|
|
556
|
+
h("code", null, libsError)
|
|
557
|
+
)
|
|
558
|
+
: !state.isHydrated() || libsState === "loading"
|
|
559
|
+
? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
|
|
560
|
+
: h(Layout, { spec, ctx })
|
|
509
561
|
);
|
|
510
562
|
};
|
|
511
563
|
}
|
|
@@ -679,6 +731,7 @@ const BASE_CSS = `
|
|
|
679
731
|
.caf-collapse-head:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
680
732
|
.caf-chevron { transition: transform 0.15s ease; color: var(--caf-muted); }
|
|
681
733
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
734
|
+
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
682
735
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
|
683
736
|
.caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
|
|
684
737
|
.caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
|
package/src/appState.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { storage } from "./storage.js";
|
|
15
15
|
|
|
16
16
|
const DATA_KEY = "caf:data";
|
|
17
|
+
const VIEWER_KEY = "caf:viewer";
|
|
17
18
|
const VIEW_KEY = "caf:view";
|
|
18
19
|
|
|
19
20
|
const EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null, tabs: {}, collapsed: {} };
|
|
@@ -54,6 +55,7 @@ export function createAppState(defaults, { debounceMs = 400, shared = false, pol
|
|
|
54
55
|
let data = structuredCloneish(defaults);
|
|
55
56
|
let view = { ...EMPTY_VIEW };
|
|
56
57
|
let hydrated = false;
|
|
58
|
+
let viewerId = null;
|
|
57
59
|
// Counts update() calls so a poll never overwrites edits that haven't been
|
|
58
60
|
// persisted yet: remote state only applies when we owe storage nothing.
|
|
59
61
|
let writes = 0;
|
|
@@ -97,10 +99,20 @@ export function createAppState(defaults, { debounceMs = 400, shared = false, pol
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
const ready = (async () => {
|
|
100
|
-
const [storedData, storedView] = await Promise.all([
|
|
102
|
+
const [storedData, storedView, storedViewer] = await Promise.all([
|
|
101
103
|
dataStore.get(DATA_KEY, undefined),
|
|
102
104
|
storage.get(VIEW_KEY, undefined),
|
|
105
|
+
storage.get(VIEWER_KEY, undefined),
|
|
103
106
|
]);
|
|
107
|
+
// A stable anonymous per-viewer id, ALWAYS in the personal scope — in a
|
|
108
|
+
// shared app it lets declarations express "one vote per person" and
|
|
109
|
+
// "added by", without any account system.
|
|
110
|
+
if (typeof storedViewer === "string" && storedViewer) {
|
|
111
|
+
viewerId = storedViewer;
|
|
112
|
+
} else {
|
|
113
|
+
viewerId = "v" + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
|
|
114
|
+
storage.set(VIEWER_KEY, viewerId).catch?.(() => {});
|
|
115
|
+
}
|
|
104
116
|
if (storedData !== undefined) data = mergeDefaults(defaults, storedData);
|
|
105
117
|
if (storedView !== undefined) view = { ...EMPTY_VIEW, ...storedView };
|
|
106
118
|
hydrated = true;
|
|
@@ -126,6 +138,7 @@ export function createAppState(defaults, { debounceMs = 400, shared = false, pol
|
|
|
126
138
|
return {
|
|
127
139
|
ready,
|
|
128
140
|
isHydrated: () => hydrated,
|
|
141
|
+
getViewerId: () => viewerId,
|
|
129
142
|
getData: () => data,
|
|
130
143
|
getView: () => view,
|
|
131
144
|
update,
|
package/src/blocks.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { createElement as h, useState, useEffect } from "react";
|
|
14
14
|
import { ChatPanel } from "./chat.js";
|
|
15
15
|
|
|
16
|
-
const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
|
|
16
|
+
const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select", "file"];
|
|
17
17
|
|
|
18
18
|
export function formatValue(value, format) {
|
|
19
19
|
if (value === null || value === undefined || value === "") return "—";
|
|
@@ -55,6 +55,29 @@ export function Field({ field, value, onChange, data }) {
|
|
|
55
55
|
const id = `caf-f-${field.key}`;
|
|
56
56
|
const numeric = field.type === "number" || field.type === "money" || field.type === "percent";
|
|
57
57
|
|
|
58
|
+
if (field.type === "file") {
|
|
59
|
+
// The File object itself lives at ctx.files[key] (in memory only — the
|
|
60
|
+
// 5MB/key storage limit makes real files unpersistable); `data` gets
|
|
61
|
+
// serializable metadata. After a reload the metadata survives but the
|
|
62
|
+
// file must be picked again.
|
|
63
|
+
return h(
|
|
64
|
+
"div",
|
|
65
|
+
{ className: "caf-field" },
|
|
66
|
+
h("label", { htmlFor: id }, field.label || field.key),
|
|
67
|
+
h("input", {
|
|
68
|
+
id,
|
|
69
|
+
type: "file",
|
|
70
|
+
accept: field.accept,
|
|
71
|
+
onChange: (e) => onChange(e.target.files && e.target.files[0] ? e.target.files[0] : null),
|
|
72
|
+
}),
|
|
73
|
+
value && value.name
|
|
74
|
+
? h("small", { className: "caf-hint" }, `${value.name} (${Math.round((value.size || 0) / 1024)} KB)${value.stale ? " — re-pick after reload" : ""}`)
|
|
75
|
+
: field.hint
|
|
76
|
+
? h("small", { className: "caf-hint" }, field.hint)
|
|
77
|
+
: null
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
58
81
|
if (field.type === "check") {
|
|
59
82
|
return h(
|
|
60
83
|
"label",
|
|
@@ -120,6 +143,18 @@ function toNumber(raw) {
|
|
|
120
143
|
return Number.isFinite(n) ? n : raw;
|
|
121
144
|
}
|
|
122
145
|
|
|
146
|
+
// One write path for every field container: file fields park the live File
|
|
147
|
+
// at ctx.files[key] and store metadata; everything else writes the value.
|
|
148
|
+
export function writeField(ctx, key, field, v, setter) {
|
|
149
|
+
if (field.type === "file") {
|
|
150
|
+
ctx.files[key] = v || null;
|
|
151
|
+
const meta = v ? { name: v.name, size: v.size, type: v.type } : null;
|
|
152
|
+
ctx.update((d) => setter(d, meta));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
ctx.update((d) => setter(d, v));
|
|
156
|
+
}
|
|
157
|
+
|
|
123
158
|
export function FieldsBlock({ spec, ctx }) {
|
|
124
159
|
const fields = spec.fields || [];
|
|
125
160
|
return h(
|
|
@@ -132,7 +167,7 @@ export function FieldsBlock({ spec, ctx }) {
|
|
|
132
167
|
field,
|
|
133
168
|
data: ctx.data,
|
|
134
169
|
value: ctx.data[field.key],
|
|
135
|
-
onChange: (v) => ctx.
|
|
170
|
+
onChange: (v) => writeField(ctx, field.key, field, v, (d, val) => { d[field.key] = val; }),
|
|
136
171
|
})
|
|
137
172
|
)
|
|
138
173
|
);
|
|
@@ -379,7 +414,7 @@ export function TimerBlock({ spec, ctx }) {
|
|
|
379
414
|
// check-ins, +1s) that previously forced an escape to custom code.
|
|
380
415
|
export function ActionsBlock({ spec, ctx }) {
|
|
381
416
|
const actions = spec.actions || [];
|
|
382
|
-
const visible = actions.filter((a) => !a.when || a.when(ctx.data));
|
|
417
|
+
const visible = actions.filter((a) => !a.when || a.when(ctx.data, ctx.viewerId));
|
|
383
418
|
return h(
|
|
384
419
|
"div",
|
|
385
420
|
{ className: "caf-block caf-actions" },
|
|
@@ -399,7 +434,7 @@ export function ActionsBlock({ spec, ctx }) {
|
|
|
399
434
|
: a.kind === "primary"
|
|
400
435
|
? "caf-btn caf-btn-primary"
|
|
401
436
|
: "caf-btn",
|
|
402
|
-
onClick: () => ctx.update((d) => a.run(d)),
|
|
437
|
+
onClick: () => ctx.update((d) => a.run(d, ctx.viewerId)),
|
|
403
438
|
},
|
|
404
439
|
a.label
|
|
405
440
|
)
|
|
@@ -412,10 +447,13 @@ export function ActionsBlock({ spec, ctx }) {
|
|
|
412
447
|
// custom block keeps persistence, theming, navigation and the error boundary.
|
|
413
448
|
export function CustomBlock({ spec, ctx }) {
|
|
414
449
|
const rendered = spec.custom(ctx);
|
|
450
|
+
// `fill: true` frees the block from the card chrome so it can own a large
|
|
451
|
+
// scrollable surface (a viewer, a canvas) — the workspace main-area case.
|
|
452
|
+
const cls = spec.fill ? "caf-block caf-block-fill" : "caf-block";
|
|
415
453
|
if (typeof rendered === "string") {
|
|
416
|
-
return h("div", { className:
|
|
454
|
+
return h("div", { className: cls, dangerouslySetInnerHTML: { __html: rendered } });
|
|
417
455
|
}
|
|
418
|
-
return h("div", { className:
|
|
456
|
+
return h("div", { className: cls }, rendered);
|
|
419
457
|
}
|
|
420
458
|
|
|
421
459
|
export const BLOCKS = {
|
package/src/records.js
CHANGED
|
@@ -152,7 +152,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
152
152
|
"div",
|
|
153
153
|
{ className: "caf-row-actions" },
|
|
154
154
|
spec.actions
|
|
155
|
-
.filter((a) => !a.when || a.when(rec))
|
|
155
|
+
.filter((a) => !a.when || a.when(rec, ctx.viewerId))
|
|
156
156
|
.map((a) =>
|
|
157
157
|
h(
|
|
158
158
|
"button",
|
|
@@ -163,7 +163,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
163
163
|
onClick: () =>
|
|
164
164
|
ctx.update((d) => {
|
|
165
165
|
const live = d.records.find((x) => x.id === rec.id);
|
|
166
|
-
if (live) a.run(live, d);
|
|
166
|
+
if (live) a.run(live, d, ctx.viewerId);
|
|
167
167
|
}),
|
|
168
168
|
},
|
|
169
169
|
a.label
|
package/src/steps.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// on the step the user was on, holding their answers.
|
|
15
15
|
|
|
16
16
|
import { createElement as h } from "react";
|
|
17
|
-
import { Field, validateField, formatValue } from "./blocks.js";
|
|
17
|
+
import { Field, validateField, formatValue, writeField } from "./blocks.js";
|
|
18
18
|
|
|
19
19
|
function stepErrors(step, data) {
|
|
20
20
|
return (step.fields || []).some((f) => validateField(f, data[f.key]) !== null);
|
|
@@ -80,7 +80,7 @@ export function StepsLayout({ spec, ctx }) {
|
|
|
80
80
|
field,
|
|
81
81
|
data: ctx.data,
|
|
82
82
|
value: ctx.data[field.key],
|
|
83
|
-
onChange: (v) => ctx.
|
|
83
|
+
onChange: (v) => writeField(ctx, field.key, field, v, (d, val) => { d[field.key] = val; }),
|
|
84
84
|
})
|
|
85
85
|
),
|
|
86
86
|
h(
|