claude-artifact-framework 0.7.3 → 0.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
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
- state.isHydrated()
507
- ? h(Layout, { spec, ctx })
508
- : h("div", { className: "caf-block caf-loading" }, "Loading…")
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.update((d) => { d[field.key] = v; }),
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: "caf-block", dangerouslySetInnerHTML: { __html: rendered } });
454
+ return h("div", { className: cls, dangerouslySetInnerHTML: { __html: rendered } });
417
455
  }
418
- return h("div", { className: "caf-block" }, rendered);
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.update((d) => { d[field.key] = v; }),
83
+ onChange: (v) => writeField(ctx, field.key, field, v, (d, val) => { d[field.key] = val; }),
84
84
  })
85
85
  ),
86
86
  h(