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/README.md CHANGED
@@ -101,6 +101,18 @@ export default function Artifact() {
101
101
  may keep React in a private module registry the bundle can't reach. If React is
102
102
  already on `globalThis`, it is picked up automatically and the call is a no-op.
103
103
 
104
+ > **Write the spec indented, never minified.** Specs nest deep (`types` →
105
+ > `blocks` → `records` → `fields`), and the only syntax errors ever measured
106
+ > in generated specs came from single-line minified output — a dropped or
107
+ > extra closing brace at the tail. Indented, multi-line specs have never
108
+ > produced one. Format like every example in this document.
109
+
110
+ > **Don't hand-build empty, loading, or error states.** Every block that can
111
+ > be empty (`output`, `chart`, `list`, `records`) shows a named empty state
112
+ > by itself; hydration shows a loading screen; a block that throws shows a
113
+ > named error in its own slot. Code like `if (!d.items.length) return "No
114
+ > hay datos"` duplicates what the framework already guarantees.
115
+
104
116
  ### The `records` layout — a full CRUD app from a declaration
105
117
 
106
118
  For "list of things" apps (expenses, habits, notes, todos), declare what a
@@ -266,7 +278,13 @@ ArtifactKit.createApp({
266
278
  viewer of the published artifact converges on the same data via polling.
267
279
  Conflict handling is last-write-wins; a poll never overwrites local edits
268
280
  that haven't been persisted yet. View state (which screen is open) stays
269
- personal per viewer.
281
+ personal per viewer. **Design for last-write-wins**: two viewers writing
282
+ within the same couple of seconds means one write can be lost — prefer
283
+ per-viewer marks in arrays (`votantes: [...viewerIds]`, like the voting
284
+ pattern) over several people editing the same field, and don't build
285
+ anything where a lost write really hurts (money, bookings). Measured in
286
+ practice: appending to your own mark converges; racing on one value
287
+ doesn't.
270
288
 
271
289
  ### `items` — a child collection per record
272
290
 
@@ -296,7 +314,10 @@ records: {
296
314
  ```
297
315
 
298
316
  `items` keys: `label`, `fields`, `empty`. Each child gets a framework
299
- `id`; a parent field can't be named `items`.
317
+ `id`; a parent field can't be named `items`. The smell this replaces:
318
+ **never ask the user to type JSON (or comma-separated rows) into a
319
+ `textarea`** — if a record has parts, declare `items` and each part gets
320
+ real fields, validation, and its own remove button.
300
321
 
301
322
  ### `actions` — one-tap buttons that mutate data
302
323
 
@@ -322,10 +343,17 @@ blocks: [
322
343
  ```
323
344
 
324
345
  Action keys: `label`, `run`, optional `when` (hides the button when false)
325
- and `kind` (`"primary"` / `"danger"`). Row actions receive the record;
326
- block actions receive data. Changes persist like any other update. Give the
327
- constructive action `kind: "primary"` a "Vote"/"Add"/"Done" left as the
328
- neutral default reads weaker than a `"danger"` next to it.
346
+ and `kind` (`"primary"` / `"danger"`). Row actions receive
347
+ `(record, data, viewerId)`, block actions `(data, viewerId)`. Stopping
348
+ early is fine (`run: (r) => ...`), but **never skip a middle argument**:
349
+ `run: (r, viewerId)` reads `data` as your viewer id and breaks silently —
350
+ if you need `viewerId`, name everything before it: `run: (r, d, viewerId)`.
351
+ Changes persist like any other update.
352
+ Give the constructive action `kind: "primary"` — a "Vote"/"Add"/"Done" left
353
+ as the neutral default reads weaker than a `"danger"` next to it. And a
354
+ smell to avoid: **a number that changes by pressing is an `action`, not an
355
+ editable `number` field** — votes, counters, and streaks as editable inputs
356
+ invite typos and lie about intent.
329
357
 
330
358
  Chat bubbles render the assistant's **markdown** (bold, code, lists,
331
359
  headings) automatically — HTML is escaped first, so model output can't
@@ -394,6 +422,63 @@ The active tab and each accordion's open/closed state are framework-owned
394
422
  view state — they survive reload, per viewer. Tabs cannot nest inside tabs;
395
423
  `when` must be a function; both throw otherwise.
396
424
 
425
+ ### The `machine` — declared phases instead of hand-rolled state logic
426
+
427
+ If your app has **phases** — a game (choosing → comparing → won), a turn
428
+ cycle, a multi-stage process — never track them by hand with flags and
429
+ `setTimeout`s: that is the single most measured source of broken apps
430
+ (a memory game died because its compare logic fired in the wrong phase).
431
+ Declare the machine instead:
432
+
433
+ ```js
434
+ ArtifactKit.createApp({
435
+ title: "Memoria",
436
+ machine: {
437
+ key: "fase", // d.fase holds the current state — persisted
438
+ initial: "eligiendo",
439
+ states: {
440
+ eligiendo: {},
441
+ comparando: { // timed transition, framework-owned timer
442
+ after: { seconds: 1, then: "eligiendo", do: (d) => { d.reveladas = []; } },
443
+ },
444
+ ganado: {},
445
+ },
446
+ events: {
447
+ elegir: {
448
+ from: ["eligiendo"], // ONLY legal here — elsewhere it's a no-op
449
+ do: (d, carta) => { d.reveladas = [...d.reveladas, carta]; },
450
+ then: (d) => d.pares === d.total ? "ganado" : d.reveladas.length === 2 ? "comparando" : "eligiendo",
451
+ },
452
+ reiniciar: { from: "*", do: (d) => { d.reveladas = []; d.pares = 0; }, then: "eligiendo" },
453
+ },
454
+ },
455
+ blocks: [
456
+ { custom: (ctx) => /* cards call ctx.send("elegir", i) */ null },
457
+ { text: "¡Ganaste!", state: "ganado" }, // block exists only in that phase
458
+ ],
459
+ })
460
+ ```
461
+
462
+ The contract:
463
+
464
+ - **`ctx.send(event, payload)` is the only door.** An unknown event throws
465
+ naming the valid ones; a legal event sent from the wrong phase is a
466
+ silent no-op — a user mashing buttons is normal, not a bug. Never write
467
+ `d.fase = ...` yourself.
468
+ - `do` mutates data (it receives `(d, payload)`), `then` names the next
469
+ state — a string, or a function of data returning one. Omit `then` to
470
+ stay in the same state.
471
+ - `after: { seconds, then, do }` on a state is a **framework-owned timer**:
472
+ armed on entry, cancelled on leaving the state, cleaned up on unmount.
473
+ No `setTimeout` to forget.
474
+ - The current state lives in `data` under `machine.key` (default `"fase"`),
475
+ so it **persists and resumes** — reloading mid-game continues the phase.
476
+ - Blocks take `state: "nombre"` as sugar for "visible only in that phase";
477
+ `when:` still works for anything more complex.
478
+ - Machine keys: `key`, `initial`, `states`, `events`. Event keys: `from`
479
+ (state, array, or `"*"`), `do`, `then`. One machine per app, app-wide
480
+ (inside `documents` it is NOT per-document).
481
+
397
482
  ### `records` as a block — a CRUD collection inside any screen
398
483
 
399
484
  The full records machinery (list, add-then-edit, detail, search, summary,
@@ -655,6 +740,29 @@ custom: (ctx) => {
655
740
  }
656
741
  ```
657
742
 
743
+ - **Never render a `<form>`.** The published-artifact iframe sandbox lacks
744
+ `allow-forms`: a form that works in preview dies on the phone. Inputs +
745
+ a button with `onClick` (plus Enter via `onKeyDown` if you want it) do
746
+ the same job — that is why every framework block is formless.
747
+ - **Use the theme, not hardcoded colors.** The app ships a light AND dark
748
+ palette; a custom block painted with `#fff` backgrounds or `#dc2626`
749
+ reds looks broken the moment the viewer is in dark mode. Use the CSS
750
+ variables — `var(--caf-text)`, `var(--caf-muted)`, `var(--caf-accent)`,
751
+ `var(--caf-surface)`, `var(--caf-sunken)`, `var(--caf-border)`,
752
+ `var(--caf-danger)` — and `ctx.colors(n)` for chart-like series:
753
+
754
+ ```js
755
+ custom: (ctx) => React.createElement("div", {
756
+ style: { background: "var(--caf-sunken)", color: "var(--caf-text)",
757
+ border: "1px solid var(--caf-border)", borderRadius: "7px", padding: "0.6rem" }
758
+ }, "se ve bien en ambos temas")
759
+ ```
760
+
761
+ - **Never write big strings into `data`.** Storage allows ~5MB per key and
762
+ the whole pool is one key: a data-URL preview of a photo written to
763
+ `d.preview` can kill persistence for the entire app. Big things (data
764
+ URLs, file contents, parse results) live in `React.useState` or come from
765
+ `ctx.files`; `data` holds what is small and serializable.
658
766
  - Custom re-renders whenever `data` or `view` change; it receives the full
659
767
  `ctx` (`data`, `update`, `view`, `files`, `viewerId`, `colors`, `go`,
660
768
  `back`) and lives inside the block error boundary — a bug in your custom
@@ -665,17 +773,18 @@ custom: (ctx) => {
665
773
  Everything an app can declare, in one place.
666
774
 
667
775
  **Top-level keys**: `title`, `subtitle`, `theme: { seed }`, `layout`, `blocks`,
668
- `records`, `steps`, `chat`, `main`, `sidebar` (workspace panes), `data`,
669
- `shared`. Anything else throws.
776
+ `records`, `steps`, `chat`, `main`, `sidebar` (workspace panes), `documents`,
777
+ `data`, `shared`, `libs`. Anything else throws.
670
778
 
671
779
  **Layouts** (`layout:`): `panel` (default — grid of blocks), `records` (CRUD
672
780
  list), `steps` (wizard), `chat` (conversation with Claude), `workspace`
673
- (main column + sidebar). Each requires its matching key (`blocks` /
674
- `records` / `steps` / `chat` / `main`+`sidebar`).
781
+ (main column + sidebar), `documents` (parallel documents in top tabs).
782
+ Each requires its matching key (`blocks` / `records` / `steps` / `chat` /
783
+ `main`+`sidebar` / `documents`).
675
784
 
676
- **Block types** (entries of `blocks`, `main`, or `sidebar`): `fields`,
677
- `output`, `text`, `list`, `chart`, `timer`, `actions`, `chat`, `tabs`,
678
- `custom`. Any block also accepts `when: (d) => bool` (conditional
785
+ **Block types** (entries of `blocks`, `main`, `sidebar`, or a document's
786
+ `blocks`): `fields`, `output`, `text`, `list`, `chart`, `timer`, `actions`,
787
+ `chat`, `tabs`, `records`, `custom`. Any block also accepts `when: (d) => bool` (conditional
679
788
  visibility) and `collapsible: true | "collapsed"` (accordion; needs `title`).
680
789
  On every block `title` is the heading string; `list` additionally accepts a
681
790
  function as `title`, used as the per-row title (rows fall back to
package/dist/index.esm.js CHANGED
@@ -1394,8 +1394,12 @@ function StepsLayout({ spec, ctx }) {
1394
1394
 
1395
1395
  // src/app.js
1396
1396
  var LAYOUTS = ["panel", "records", "steps", "chat", "workspace", "documents"];
1397
- var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill"];
1398
- var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents"];
1397
+ var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill", "state"];
1398
+ var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine"];
1399
+ var MACHINE_KEYS = ["key", "initial", "states", "events"];
1400
+ var MACHINE_STATE_KEYS = ["after"];
1401
+ var MACHINE_AFTER_KEYS = ["seconds", "then", "do"];
1402
+ var MACHINE_EVENT_KEYS = ["from", "do", "then"];
1399
1403
  var DOCUMENTS_KEYS = ["label", "title", "blocks", "empty", "types"];
1400
1404
  var RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
1401
1405
  var ITEMS_KEYS = ["label", "fields", "empty"];
@@ -1500,6 +1504,81 @@ function checkBlocks(blocks) {
1500
1504
  }
1501
1505
  });
1502
1506
  }
1507
+ function checkMachine(m) {
1508
+ const unknown = Object.keys(m).filter((k) => !MACHINE_KEYS.includes(k));
1509
+ if (unknown.length) {
1510
+ throw new Error(
1511
+ `claude-artifact-framework: machine has unknown keys (${unknown.join(", ")}). Valid keys: ${MACHINE_KEYS.join(", ")}.`
1512
+ );
1513
+ }
1514
+ if (m.key !== void 0 && (typeof m.key !== "string" || !m.key)) {
1515
+ throw new Error("claude-artifact-framework: machine.key must be a non-empty string (where the current state lives in data).");
1516
+ }
1517
+ const stateNames = Object.keys(m.states || {});
1518
+ if (!stateNames.length) {
1519
+ throw new Error("claude-artifact-framework: machine needs `states: { nombre: {...} }` with at least one state.");
1520
+ }
1521
+ const validState = (name, where) => {
1522
+ if (!stateNames.includes(name)) {
1523
+ throw new Error(
1524
+ `claude-artifact-framework: ${where} names unknown state "${name}". Valid states: ${stateNames.join(", ")}.`
1525
+ );
1526
+ }
1527
+ };
1528
+ if (typeof m.initial !== "string") {
1529
+ throw new Error('claude-artifact-framework: machine needs `initial: "..."` naming the starting state.');
1530
+ }
1531
+ validState(m.initial, "machine.initial");
1532
+ for (const [name, st] of Object.entries(m.states)) {
1533
+ const unknownS = Object.keys(st || {}).filter((k) => !MACHINE_STATE_KEYS.includes(k));
1534
+ if (unknownS.length) {
1535
+ throw new Error(
1536
+ `claude-artifact-framework: machine.states.${name} has unknown keys (${unknownS.join(", ")}). Valid keys: ${MACHINE_STATE_KEYS.join(", ")}.`
1537
+ );
1538
+ }
1539
+ if (st && st.after !== void 0) {
1540
+ const a = st.after;
1541
+ const unknownA = Object.keys(a || {}).filter((k) => !MACHINE_AFTER_KEYS.includes(k));
1542
+ if (unknownA.length) {
1543
+ throw new Error(
1544
+ `claude-artifact-framework: machine.states.${name}.after has unknown keys (${unknownA.join(", ")}). Valid keys: ${MACHINE_AFTER_KEYS.join(", ")}.`
1545
+ );
1546
+ }
1547
+ if (typeof a.seconds !== "number" || a.seconds <= 0) {
1548
+ throw new Error(`claude-artifact-framework: machine.states.${name}.after needs \`seconds\` > 0.`);
1549
+ }
1550
+ if (typeof a.then !== "string") {
1551
+ throw new Error(`claude-artifact-framework: machine.states.${name}.after needs \`then: "estado"\`.`);
1552
+ }
1553
+ validState(a.then, `machine.states.${name}.after.then`);
1554
+ if (a.do !== void 0 && typeof a.do !== "function") {
1555
+ throw new Error(`claude-artifact-framework: machine.states.${name}.after.do must be a function of data.`);
1556
+ }
1557
+ }
1558
+ }
1559
+ const eventNames = Object.keys(m.events || {});
1560
+ if (!eventNames.length) {
1561
+ throw new Error("claude-artifact-framework: machine needs `events: { nombre: {...} }` \u2014 ctx.send() is the only way to move it.");
1562
+ }
1563
+ for (const [name, ev] of Object.entries(m.events)) {
1564
+ const unknownE = Object.keys(ev || {}).filter((k) => !MACHINE_EVENT_KEYS.includes(k));
1565
+ if (unknownE.length) {
1566
+ throw new Error(
1567
+ `claude-artifact-framework: machine.events.${name} has unknown keys (${unknownE.join(", ")}). Valid keys: ${MACHINE_EVENT_KEYS.join(", ")}.`
1568
+ );
1569
+ }
1570
+ if (ev.from !== void 0 && ev.from !== "*") {
1571
+ for (const f of [].concat(ev.from)) validState(f, `machine.events.${name}.from`);
1572
+ }
1573
+ if (typeof ev.then === "string") validState(ev.then, `machine.events.${name}.then`);
1574
+ else if (ev.then !== void 0 && typeof ev.then !== "function") {
1575
+ throw new Error(`claude-artifact-framework: machine.events.${name}.then must be a state name or a function of data returning one.`);
1576
+ }
1577
+ if (ev.do !== void 0 && typeof ev.do !== "function") {
1578
+ throw new Error(`claude-artifact-framework: machine.events.${name}.do must be a function (data, payload).`);
1579
+ }
1580
+ }
1581
+ }
1503
1582
  function validate(spec) {
1504
1583
  const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
1505
1584
  if (unknownTop.length) {
@@ -1507,6 +1586,7 @@ function validate(spec) {
1507
1586
  `claude-artifact-framework: unknown top-level keys (${unknownTop.join(", ")}). Valid keys: ${TOP_KEYS.join(", ")}.`
1508
1587
  );
1509
1588
  }
1589
+ if (spec.machine !== void 0) checkMachine(spec.machine);
1510
1590
  if (spec.libs !== void 0) {
1511
1591
  if (!Array.isArray(spec.libs) || spec.libs.some((u) => typeof u !== "string" || !/^https?:\/\//.test(u))) {
1512
1592
  throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
@@ -1743,6 +1823,10 @@ function defaultsFrom(spec) {
1743
1823
  if ((spec.layout || "panel") === "documents" && !Array.isArray(data.docs)) {
1744
1824
  data.docs = [];
1745
1825
  }
1826
+ if (spec.machine) {
1827
+ const k = spec.machine.key || "fase";
1828
+ if (!(k in data)) data[k] = spec.machine.initial;
1829
+ }
1746
1830
  const allBlocks = flattenBlocks([...spec.blocks || [], ...spec.main || [], ...spec.sidebar || []]);
1747
1831
  const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
1748
1832
  if (hasChat && !data.chat) {
@@ -1826,6 +1910,7 @@ var ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock, records: RecordsBlock };
1826
1910
  var ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
1827
1911
  function Block({ block, ctx }) {
1828
1912
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
1913
+ if (block.state !== void 0 && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
1829
1914
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
1830
1915
  const Component2 = ALL_BLOCKS[name];
1831
1916
  const inner = createElement(
@@ -2103,8 +2188,52 @@ function loadScript(src) {
2103
2188
  document.head.appendChild(tag);
2104
2189
  });
2105
2190
  }
2191
+ function everyBlock(spec) {
2192
+ const docs = spec.documents ? spec.documents.blocks || Object.values(spec.documents.types || {}).flatMap((t) => t.blocks || []) : [];
2193
+ return flattenBlocks([...spec.blocks || [], ...spec.main || [], ...spec.sidebar || [], ...docs]);
2194
+ }
2195
+ function runMachineEvent(machine, update, eventName, payload) {
2196
+ const ev = machine.events[eventName];
2197
+ if (!ev) {
2198
+ throw new Error(
2199
+ `claude-artifact-framework: unknown machine event "${eventName}". Valid events: ${Object.keys(machine.events).join(", ")}.`
2200
+ );
2201
+ }
2202
+ const key = machine.key || "fase";
2203
+ update((d) => {
2204
+ const current = d[key];
2205
+ const from = ev.from === void 0 ? "*" : ev.from;
2206
+ if (from !== "*" && ![].concat(from).includes(current)) return;
2207
+ if (ev.do) ev.do(d, payload);
2208
+ const next = typeof ev.then === "function" ? ev.then(d) : ev.then;
2209
+ if (next !== void 0 && next !== null) {
2210
+ if (!machine.states[next]) {
2211
+ throw new Error(
2212
+ `claude-artifact-framework: machine event "${eventName}" resolved to unknown state "${next}". Valid states: ${Object.keys(machine.states).join(", ")}.`
2213
+ );
2214
+ }
2215
+ d[key] = next;
2216
+ }
2217
+ });
2218
+ }
2106
2219
  function createApp(spec = {}) {
2107
2220
  const layout = validate(spec);
2221
+ if (spec.machine) {
2222
+ const names = Object.keys(spec.machine.states);
2223
+ for (const b of everyBlock(spec)) {
2224
+ if (b && b.state !== void 0 && !names.includes(b.state)) {
2225
+ throw new Error(
2226
+ `claude-artifact-framework: a block declares state: "${b.state}" but the machine's states are: ${names.join(", ")}.`
2227
+ );
2228
+ }
2229
+ }
2230
+ } else {
2231
+ for (const b of everyBlock(spec)) {
2232
+ if (b && b.state !== void 0) {
2233
+ throw new Error("claude-artifact-framework: `state:` on a block needs a `machine` declared at the top level.");
2234
+ }
2235
+ }
2236
+ }
2108
2237
  const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
2109
2238
  const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
2110
2239
  const files = {};
@@ -2132,6 +2261,21 @@ function createApp(spec = {}) {
2132
2261
  if (state.isHydrated()) force((n) => n + 1);
2133
2262
  return unsubscribe;
2134
2263
  }, []);
2264
+ const machineState = spec.machine ? state.getData()[spec.machine.key || "fase"] : null;
2265
+ useEffect(() => {
2266
+ if (!spec.machine || !state.isHydrated()) return;
2267
+ const st = spec.machine.states[machineState];
2268
+ if (!st || !st.after) return;
2269
+ const timer = setTimeout(() => {
2270
+ state.update((d) => {
2271
+ const key = spec.machine.key || "fase";
2272
+ if (d[key] !== machineState) return;
2273
+ if (st.after.do) st.after.do(d);
2274
+ d[key] = st.after.then;
2275
+ });
2276
+ }, st.after.seconds * 1e3);
2277
+ return () => clearTimeout(timer);
2278
+ }, [machineState, state.isHydrated()]);
2135
2279
  const ctx = {
2136
2280
  React: getReact(),
2137
2281
  data: state.getData(),
@@ -2144,7 +2288,11 @@ function createApp(spec = {}) {
2144
2288
  viewerId: state.getViewerId(),
2145
2289
  setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
2146
2290
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
2147
- colors: (n) => seriesColors(spec.theme && spec.theme.seed, n)
2291
+ colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
2292
+ machine: spec.machine || null,
2293
+ send: spec.machine ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload) : () => {
2294
+ throw new Error("claude-artifact-framework: ctx.send() needs a `machine` declared at the top level.");
2295
+ }
2148
2296
  };
2149
2297
  const Layout = LAYOUT_COMPONENTS[layout];
2150
2298
  return createElement(