claude-artifact-framework 0.13.1 → 0.14.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/llms.txt ADDED
@@ -0,0 +1,90 @@
1
+ # claude-artifact-framework
2
+
3
+ > Declarative framework for building apps inside Claude Artifacts. Declare what
4
+ > the app IS (fields, records, steps, chat with tools, workspace, documents);
5
+ > the framework owns persistence, hydration, validation, empty/loading/error
6
+ > states, light+dark theming, navigation, and the Claude tool loop.
7
+ > Full documentation: README.md (this file is the compact digest).
8
+
9
+ Load (classic script, global `ArtifactKit`; call `ArtifactKit.useReact(React)` once):
10
+ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
11
+
12
+ ## Registries (any other name throws, naming the valid set)
13
+
14
+ - Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
15
+ steps, chat, main, sidebar, documents, data, shared, libs, machine
16
+ - Layouts: panel (default), records, steps, chat, workspace, documents
17
+ - Block types: fields, output, text, list, chart, timer, actions, chat, tabs,
18
+ records, custom — plus per-block title, when:(d)=>bool, state:"fase",
19
+ collapsible, wide, fill (custom only)
20
+ - Field types: text, textarea, number, money, percent, check, date, select,
21
+ file — attrs: key, label, value, required, min, max, step, placeholder,
22
+ hint, rows, options (array or (d)=>array), accept
23
+ - records spec: label, fields, title, subtitle, sort, summary, empty, search,
24
+ compute, items, actions (+ key when used as a block)
25
+ - documents: label, title, blocks OR types, empty; machine: key, initial,
26
+ states, events (event: from, do, then); chat: system, greeting, placeholder,
27
+ tools, model, maxTokens; steps step: title, text, fields, result
28
+ - Formats: money, percent (0-100), number, date. Output/chart/compute items:
29
+ { label, value, format, big }
30
+
31
+ ## Hard rules
32
+
33
+ 1. Write the spec INDENTED, never minified (measured: minified output drops
34
+ closing braces).
35
+ 2. Theme variables, never hardcoded hex: var(--caf-text), var(--caf-surface),
36
+ var(--caf-accent), var(--caf-border), var(--caf-sunken), var(--caf-muted),
37
+ var(--caf-danger); ctx.colors(n) for series. Hardcoded #fff breaks dark mode.
38
+ 3. Never render a <form> (sandbox lacks allow-forms) — inputs + button onClick.
39
+ 4. Never write big strings (data URLs, file contents) into data — one ~5MB
40
+ storage key holds the whole pool; big things live in React.useState or
41
+ ctx.files.
42
+ 5. Phases (games, turns, processes) belong to `machine` — ctx.send(event,
43
+ payload) is the only door; events are no-ops outside their `from` states;
44
+ `after: {seconds, then, do}` timers are framework-owned.
45
+ 6. Row actions receive (record, data, viewerId); block actions (data,
46
+ viewerId). Stop early if you like, but never skip a middle argument.
47
+ 7. One big:true per screen; the constructive action takes kind:"primary"; a
48
+ counter changed by pressing is an action, not an editable number field.
49
+ 8. Parts of a record are `items` — never JSON in a textarea.
50
+ 9. Don't hand-build empty/loading/error states — the framework owns them.
51
+ 10. CDN libs via libs:[urls]: call the method, not the global (marked.parse);
52
+ pin versions; unsure of the file path → bare package URL
53
+ (https://cdn.jsdelivr.net/npm/<pkg>@<version>).
54
+ 11. Framework record ids are strings — never Number(record.id).
55
+ 12. In custom blocks: return strings/React elements (never DOM elements — use
56
+ ref + React.useEffect for libs that write into an element); hooks are
57
+ allowed; persistent state via ctx.update, ephemeral via React.useState,
58
+ never createStore for UI state.
59
+
60
+ ## Canonical example
61
+
62
+ ArtifactKit.createApp({
63
+ title: "Gastos",
64
+ theme: { seed: "#c2410c" },
65
+ layout: "records",
66
+ shared: false,
67
+ records: {
68
+ label: "gasto",
69
+ fields: [
70
+ { key: "concepto", label: "Concepto", type: "text", required: true },
71
+ { key: "monto", label: "Monto", type: "money", value: 0, min: 0 },
72
+ { key: "categoria", label: "Categoría", type: "select", options: ["comida", "transporte", "otros"] },
73
+ ],
74
+ subtitle: (r) => r.categoria,
75
+ summary: (all) => [
76
+ { label: "Total", value: all.reduce((s, r) => s + (Number(r.monto) || 0), 0), format: "money", big: true },
77
+ ],
78
+ actions: [{ label: "Duplicar", run: (r, d) => { d.records = [...d.records, { ...r, id: undefined }]; } }],
79
+ },
80
+ blocks: [
81
+ { chart: (d) => ({ type: "donut", format: "money", items: porCategoria(d.records) }), title: "Por categoría" },
82
+ ],
83
+ })
84
+
85
+ Chat with tools (workspace copilot): sidebar block
86
+ { title: "Asistente", chat: { system: (d) => "...", tools: { nombre: {
87
+ description: "...", input: { campo: "string" }, run: (input, ctx) => {
88
+ ctx.update((d) => { /* mutate */ }); return { ok: true }; } } } } }
89
+ — the artifact runtime proxies api.anthropic.com with no API key; default
90
+ model claude-sonnet-4-6; conversation persists under data.chat.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.13.1",
3
+ "version": "0.14.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",
@@ -12,16 +12,21 @@
12
12
  },
13
13
  "files": [
14
14
  "dist",
15
- "src"
15
+ "src",
16
+ "llms.txt"
16
17
  ],
17
18
  "scripts": {
18
19
  "build": "node scripts/build.mjs",
19
20
  "build:watch": "node scripts/build.mjs --watch",
21
+ "test": "npm run build && node test/e2e.mjs",
20
22
  "prepublishOnly": "npm run build",
21
- "release": "node scripts/check-docs.mjs && bash scripts/publish.sh"
23
+ "release": "node scripts/check-docs.mjs && npm test && bash scripts/publish.sh"
22
24
  },
23
25
  "devDependencies": {
24
- "esbuild": "^0.25.0"
26
+ "esbuild": "^0.25.0",
27
+ "playwright-core": "^1.45.0",
28
+ "react": "^18.3.0",
29
+ "react-dom": "^18.3.0"
25
30
  },
26
31
  "license": "MIT",
27
32
  "author": "Alejandro Cuartas",
package/src/app.js CHANGED
@@ -18,12 +18,17 @@ 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", "documents"];
21
- const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill"];
21
+ const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill", "state"];
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", "libs", "documents"];
26
+ const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine"];
27
+
28
+ const MACHINE_KEYS = ["key", "initial", "states", "events"];
29
+ const MACHINE_STATE_KEYS = ["after"];
30
+ const MACHINE_AFTER_KEYS = ["seconds", "then", "do"];
31
+ const MACHINE_EVENT_KEYS = ["from", "do", "then"];
27
32
 
28
33
  const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty", "types"];
29
34
  const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
@@ -140,6 +145,87 @@ function checkBlocks(blocks) {
140
145
  });
141
146
  }
142
147
 
148
+ // The measured failure class behind this: hand-rolled phase logic (a memory
149
+ // game fired its comparison logic in the wrong phase and died). The machine
150
+ // makes illegal transitions inexpressible: ctx.send() is the only door, an
151
+ // event is a no-op outside its `from` states, and `after` timers are
152
+ // framework-owned.
153
+ function checkMachine(m) {
154
+ const unknown = Object.keys(m).filter((k) => !MACHINE_KEYS.includes(k));
155
+ if (unknown.length) {
156
+ throw new Error(
157
+ `claude-artifact-framework: machine has unknown keys (${unknown.join(", ")}). Valid keys: ${MACHINE_KEYS.join(", ")}.`
158
+ );
159
+ }
160
+ if (m.key !== undefined && (typeof m.key !== "string" || !m.key)) {
161
+ throw new Error("claude-artifact-framework: machine.key must be a non-empty string (where the current state lives in data).");
162
+ }
163
+ const stateNames = Object.keys(m.states || {});
164
+ if (!stateNames.length) {
165
+ throw new Error("claude-artifact-framework: machine needs `states: { nombre: {...} }` with at least one state.");
166
+ }
167
+ const validState = (name, where) => {
168
+ if (!stateNames.includes(name)) {
169
+ throw new Error(
170
+ `claude-artifact-framework: ${where} names unknown state "${name}". Valid states: ${stateNames.join(", ")}.`
171
+ );
172
+ }
173
+ };
174
+ if (typeof m.initial !== "string") {
175
+ throw new Error("claude-artifact-framework: machine needs `initial: \"...\"` naming the starting state.");
176
+ }
177
+ validState(m.initial, "machine.initial");
178
+ for (const [name, st] of Object.entries(m.states)) {
179
+ const unknownS = Object.keys(st || {}).filter((k) => !MACHINE_STATE_KEYS.includes(k));
180
+ if (unknownS.length) {
181
+ throw new Error(
182
+ `claude-artifact-framework: machine.states.${name} has unknown keys (${unknownS.join(", ")}). Valid keys: ${MACHINE_STATE_KEYS.join(", ")}.`
183
+ );
184
+ }
185
+ if (st && st.after !== undefined) {
186
+ const a = st.after;
187
+ const unknownA = Object.keys(a || {}).filter((k) => !MACHINE_AFTER_KEYS.includes(k));
188
+ if (unknownA.length) {
189
+ throw new Error(
190
+ `claude-artifact-framework: machine.states.${name}.after has unknown keys (${unknownA.join(", ")}). Valid keys: ${MACHINE_AFTER_KEYS.join(", ")}.`
191
+ );
192
+ }
193
+ if (typeof a.seconds !== "number" || a.seconds <= 0) {
194
+ throw new Error(`claude-artifact-framework: machine.states.${name}.after needs \`seconds\` > 0.`);
195
+ }
196
+ if (typeof a.then !== "string") {
197
+ throw new Error(`claude-artifact-framework: machine.states.${name}.after needs \`then: "estado"\`.`);
198
+ }
199
+ validState(a.then, `machine.states.${name}.after.then`);
200
+ if (a.do !== undefined && typeof a.do !== "function") {
201
+ throw new Error(`claude-artifact-framework: machine.states.${name}.after.do must be a function of data.`);
202
+ }
203
+ }
204
+ }
205
+ const eventNames = Object.keys(m.events || {});
206
+ if (!eventNames.length) {
207
+ throw new Error("claude-artifact-framework: machine needs `events: { nombre: {...} }` — ctx.send() is the only way to move it.");
208
+ }
209
+ for (const [name, ev] of Object.entries(m.events)) {
210
+ const unknownE = Object.keys(ev || {}).filter((k) => !MACHINE_EVENT_KEYS.includes(k));
211
+ if (unknownE.length) {
212
+ throw new Error(
213
+ `claude-artifact-framework: machine.events.${name} has unknown keys (${unknownE.join(", ")}). Valid keys: ${MACHINE_EVENT_KEYS.join(", ")}.`
214
+ );
215
+ }
216
+ if (ev.from !== undefined && ev.from !== "*") {
217
+ for (const f of [].concat(ev.from)) validState(f, `machine.events.${name}.from`);
218
+ }
219
+ if (typeof ev.then === "string") validState(ev.then, `machine.events.${name}.then`);
220
+ else if (ev.then !== undefined && typeof ev.then !== "function") {
221
+ throw new Error(`claude-artifact-framework: machine.events.${name}.then must be a state name or a function of data returning one.`);
222
+ }
223
+ if (ev.do !== undefined && typeof ev.do !== "function") {
224
+ throw new Error(`claude-artifact-framework: machine.events.${name}.do must be a function (data, payload).`);
225
+ }
226
+ }
227
+ }
228
+
143
229
  function validate(spec) {
144
230
  const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
145
231
  if (unknownTop.length) {
@@ -147,6 +233,7 @@ function validate(spec) {
147
233
  `claude-artifact-framework: unknown top-level keys (${unknownTop.join(", ")}). Valid keys: ${TOP_KEYS.join(", ")}.`
148
234
  );
149
235
  }
236
+ if (spec.machine !== undefined) checkMachine(spec.machine);
150
237
  if (spec.libs !== undefined) {
151
238
  if (!Array.isArray(spec.libs) || spec.libs.some((u) => typeof u !== "string" || !/^https?:\/\//.test(u))) {
152
239
  throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
@@ -399,6 +486,10 @@ function defaultsFrom(spec) {
399
486
  if ((spec.layout || "panel") === "documents" && !Array.isArray(data.docs)) {
400
487
  data.docs = [];
401
488
  }
489
+ if (spec.machine) {
490
+ const k = spec.machine.key || "fase";
491
+ if (!(k in data)) data[k] = spec.machine.initial;
492
+ }
402
493
  const allBlocks = flattenBlocks([...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || [])]);
403
494
  const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
404
495
  if (hasChat && !data.chat) {
@@ -492,6 +583,8 @@ const ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
492
583
  function Block({ block, ctx }) {
493
584
  // `when` hides a block entirely — the primitive under conditional UIs.
494
585
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
586
+ // `state: "x"` — machine sugar: the block exists only in that phase.
587
+ if (block.state !== undefined && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
495
588
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
496
589
  const Component = ALL_BLOCKS[name];
497
590
  const inner = h(
@@ -829,8 +922,61 @@ function loadScript(src) {
829
922
  });
830
923
  }
831
924
 
925
+ // Every block anywhere in the spec, including inside documents types — for
926
+ // cross-cutting checks like `state:` against the machine's states.
927
+ function everyBlock(spec) {
928
+ const docs = spec.documents
929
+ ? spec.documents.blocks || Object.values(spec.documents.types || {}).flatMap((t) => t.blocks || [])
930
+ : [];
931
+ return flattenBlocks([...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || []), ...docs]);
932
+ }
933
+
934
+ // ctx.send(event, payload) — the machine's only door. Unknown event throws
935
+ // (naming the valid ones); a legal event fired from the wrong phase is a
936
+ // silent no-op (a user mashing buttons is normal, not a bug).
937
+ function runMachineEvent(machine, update, eventName, payload) {
938
+ const ev = machine.events[eventName];
939
+ if (!ev) {
940
+ throw new Error(
941
+ `claude-artifact-framework: unknown machine event "${eventName}". Valid events: ${Object.keys(machine.events).join(", ")}.`
942
+ );
943
+ }
944
+ const key = machine.key || "fase";
945
+ update((d) => {
946
+ const current = d[key];
947
+ const from = ev.from === undefined ? "*" : ev.from;
948
+ if (from !== "*" && ![].concat(from).includes(current)) return;
949
+ if (ev.do) ev.do(d, payload);
950
+ const next = typeof ev.then === "function" ? ev.then(d) : ev.then;
951
+ if (next !== undefined && next !== null) {
952
+ if (!machine.states[next]) {
953
+ throw new Error(
954
+ `claude-artifact-framework: machine event "${eventName}" resolved to unknown state "${next}". Valid states: ${Object.keys(machine.states).join(", ")}.`
955
+ );
956
+ }
957
+ d[key] = next;
958
+ }
959
+ });
960
+ }
961
+
832
962
  export function createApp(spec = {}) {
833
963
  const layout = validate(spec);
964
+ if (spec.machine) {
965
+ const names = Object.keys(spec.machine.states);
966
+ for (const b of everyBlock(spec)) {
967
+ if (b && b.state !== undefined && !names.includes(b.state)) {
968
+ throw new Error(
969
+ `claude-artifact-framework: a block declares state: "${b.state}" but the machine's states are: ${names.join(", ")}.`
970
+ );
971
+ }
972
+ }
973
+ } else {
974
+ for (const b of everyBlock(spec)) {
975
+ if (b && b.state !== undefined) {
976
+ throw new Error('claude-artifact-framework: `state:` on a block needs a `machine` declared at the top level.');
977
+ }
978
+ }
979
+ }
834
980
  const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
835
981
  const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
836
982
  // Live File objects picked by `file` fields — in memory only, never persisted.
@@ -863,6 +1009,27 @@ export function createApp(spec = {}) {
863
1009
  return unsubscribe;
864
1010
  }, []);
865
1011
 
1012
+ // Machine `after` timers are framework-owned: armed on entering a state,
1013
+ // cancelled on leaving it (the dep is the current state), cleaned up on
1014
+ // unmount. A reload mid-state re-arms the full duration. The timeout
1015
+ // re-checks the state before firing — a transition that happened while
1016
+ // it was pending wins.
1017
+ const machineState = spec.machine ? state.getData()[spec.machine.key || "fase"] : null;
1018
+ useEffect(() => {
1019
+ if (!spec.machine || !state.isHydrated()) return;
1020
+ const st = spec.machine.states[machineState];
1021
+ if (!st || !st.after) return;
1022
+ const timer = setTimeout(() => {
1023
+ state.update((d) => {
1024
+ const key = spec.machine.key || "fase";
1025
+ if (d[key] !== machineState) return;
1026
+ if (st.after.do) st.after.do(d);
1027
+ d[key] = st.after.then;
1028
+ });
1029
+ }, st.after.seconds * 1000);
1030
+ return () => clearTimeout(timer);
1031
+ }, [machineState, state.isHydrated()]);
1032
+
866
1033
  const ctx = {
867
1034
  React: getReact(),
868
1035
  data: state.getData(),
@@ -876,6 +1043,12 @@ export function createApp(spec = {}) {
876
1043
  setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
877
1044
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
878
1045
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
1046
+ machine: spec.machine || null,
1047
+ send: spec.machine
1048
+ ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
1049
+ : () => {
1050
+ throw new Error("claude-artifact-framework: ctx.send() needs a `machine` declared at the top level.");
1051
+ },
879
1052
  };
880
1053
 
881
1054
  const Layout = LAYOUT_COMPONENTS[layout];