claude-artifact-framework 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
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];