claude-artifact-framework 0.11.0 → 0.12.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.11.0",
3
+ "version": "0.12.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
@@ -13,7 +13,7 @@ import { createElement as h, useState, useEffect, hasReact, getReact } from "rea
13
13
  import { createAppState } from "./appState.js";
14
14
  import { themeCss, seriesColors } from "./theme.js";
15
15
  import { BLOCKS, BLOCK_NAMES, FIELD_TYPES } from "./blocks.js";
16
- import { RecordsLayout } from "./records.js";
16
+ import { RecordsLayout, RecordsBlock } from "./records.js";
17
17
  import { StepsLayout } from "./steps.js";
18
18
  import { ChatLayout, CHAT_KEYS } from "./chat.js";
19
19
 
@@ -26,6 +26,8 @@ const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "col
26
26
  const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents"];
27
27
 
28
28
  const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty"];
29
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
30
+ const ITEMS_KEYS = ["label", "fields", "empty"];
29
31
 
30
32
  // An unknown field type used to fall through silently to a text input — the
31
33
  // exact quiet degradation the framework exists to prevent (a blind-tested
@@ -95,12 +97,12 @@ function checkBlocks(blocks) {
95
97
  throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
96
98
  }
97
99
  const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
98
- const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs");
100
+ const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs" || k === "records");
99
101
  if (known.length === 0) {
100
102
  throw new Error(
101
103
  `claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
102
104
  keys.join(", ") || "nothing"
103
- }). Valid block types: ${BLOCK_NAMES.join(", ")}.`
105
+ }). Valid block types: ${[...BLOCK_NAMES, "tabs", "records"].join(", ")}.`
104
106
  );
105
107
  }
106
108
  if (known.length > 1) {
@@ -113,6 +115,7 @@ function checkBlocks(blocks) {
113
115
  checkFields(block.fields, `blocks[${i}]`);
114
116
  if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
115
117
  if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
118
+ if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
116
119
  if (known[0] === "tabs") {
117
120
  const tabs = block.tabs;
118
121
  if (!Array.isArray(tabs) || tabs.length === 0) {
@@ -204,12 +207,14 @@ function validate(spec) {
204
207
  }
205
208
  checkBlocks(spec.sidebar);
206
209
  }
207
- const chats = flattenBlocks([...spec.main, ...(spec.sidebar || [])]).filter((b) => b && b.chat);
210
+ const wsFlat = flattenBlocks([...spec.main, ...(spec.sidebar || [])]);
211
+ const chats = wsFlat.filter((b) => b && b.chat);
208
212
  if (chats.length > 1) {
209
213
  throw new Error(
210
214
  "claude-artifact-framework: only one chat block per app — the conversation persists under data.chat and there is exactly one."
211
215
  );
212
216
  }
217
+ checkRecordsBlockKeys(wsFlat);
213
218
  return layout;
214
219
  }
215
220
  if (layout === "documents") {
@@ -232,77 +237,110 @@ function validate(spec) {
232
237
  // Inside documents both chat and file are PER DOCUMENT: the scoped ctx
233
238
  // gives each document its own data.chat and its own ctx.files. One chat
234
239
  // block in the declaration = one conversation per document.
235
- const chats = flattenBlocks(d.blocks).filter((b) => b && b.chat);
240
+ const docFlat = flattenBlocks(d.blocks);
241
+ const chats = docFlat.filter((b) => b && b.chat);
236
242
  if (chats.length > 1) {
237
243
  throw new Error(
238
244
  "claude-artifact-framework: only one chat block per document — each document's conversation persists under its own data.chat and there is exactly one."
239
245
  );
240
246
  }
247
+ checkRecordsBlockKeys(docFlat);
241
248
  return layout;
242
249
  }
243
250
  if (layout === "records") {
244
- const r = spec.records;
245
- if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
251
+ checkRecordsSpec(spec.records, "records", false);
252
+ if (spec.blocks) {
253
+ checkBlocks(spec.blocks);
254
+ if (flattenBlocks(spec.blocks).some((b) => b && b.records)) {
255
+ throw new Error(
256
+ 'claude-artifact-framework: a records BLOCK below a records LAYOUT is ambiguous — the layout already owns the collection. Use panel/workspace/documents to embed records blocks.'
257
+ );
258
+ }
259
+ }
260
+ return layout;
261
+ }
262
+ checkBlocks(spec.blocks || []);
263
+ checkRecordsBlockKeys(flattenBlocks(spec.blocks || []));
264
+ return layout;
265
+ }
266
+
267
+ // The records declaration is validated identically as a layout and as a
268
+ // block; only the block form takes `key` (where the collection lives in the
269
+ // enclosing pool — required for two collections to coexist).
270
+ function checkRecordsSpec(r, where, allowKey) {
271
+ if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
272
+ throw new Error(
273
+ `claude-artifact-framework: ${where} needs \`records: { fields: [...] }\` — the same field declarations the fields block uses.`
274
+ );
275
+ }
276
+ const valid = allowKey ? [...RECORDS_KEYS, "key"] : RECORDS_KEYS;
277
+ const unknownR = Object.keys(r).filter((k) => !valid.includes(k));
278
+ if (unknownR.length) {
279
+ throw new Error(
280
+ `claude-artifact-framework: ${where} has unknown keys (${unknownR.join(", ")}). Valid keys: ${valid.join(", ")}.`
281
+ );
282
+ }
283
+ if (allowKey && r.key !== undefined && (typeof r.key !== "string" || !r.key)) {
284
+ throw new Error(`claude-artifact-framework: ${where}.key must be a non-empty string naming the collection in data.`);
285
+ }
286
+ checkFields(r.fields, where);
287
+ if (r.fields.some((f) => f.type === "file") || (r.items && r.items.fields.some((f) => f.type === "file"))) {
288
+ throw new Error(
289
+ 'claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) — they are not supported inside records or items.'
290
+ );
291
+ }
292
+ const seen = new Set();
293
+ for (const f of r.fields) {
294
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
295
+ if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate ${where} field key "${f.key}".`);
296
+ seen.add(f.key);
297
+ }
298
+ if (r.actions) checkActions(r.actions, `${where}.actions`);
299
+ if (r.items) {
300
+ const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
301
+ if (unknownI.length) {
246
302
  throw new Error(
247
- 'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` the same field declarations the fields block uses.'
303
+ `claude-artifact-framework: ${where}.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
248
304
  );
249
305
  }
250
- const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
251
- const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
252
- if (unknownR.length) {
306
+ if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
307
+ throw new Error(`claude-artifact-framework: ${where}.items needs \`fields: [...]\` — the same field declarations as everywhere else.`);
308
+ }
309
+ checkFields(r.items.fields, `${where}.items`);
310
+ for (const f of r.items.fields) {
311
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
312
+ }
313
+ if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
314
+ throw new Error(`claude-artifact-framework: a ${where} field cannot be named "items" when \`items\` is declared — that key holds the child collection.`);
315
+ }
316
+ }
317
+ for (const [key, c] of Object.entries(r.compute || {})) {
318
+ if (seen.has(key) || key === "id" || (r.items && key === "items")) {
253
319
  throw new Error(
254
- `claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
320
+ `claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
255
321
  );
256
322
  }
257
- checkFields(r.fields, "records");
258
- if (r.fields.some((f) => f.type === "file") || (r.items && r.items.fields.some((f) => f.type === "file"))) {
323
+ if (typeof c !== "function" && typeof (c && c.value) !== "function") {
259
324
  throw new Error(
260
- 'claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) they are not supported inside records or items.'
325
+ `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
261
326
  );
262
327
  }
263
- const seen = new Set();
264
- for (const f of r.fields) {
265
- if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
266
- if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
267
- seen.add(f.key);
268
- }
269
- if (spec.blocks) checkBlocks(spec.blocks);
270
- if (r.actions) checkActions(r.actions, "records.actions");
271
- if (r.items) {
272
- const ITEMS_KEYS = ["label", "fields", "empty"];
273
- const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
274
- if (unknownI.length) {
275
- throw new Error(
276
- `claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
277
- );
278
- }
279
- if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
280
- throw new Error("claude-artifact-framework: records.items needs `fields: [...]` — the same field declarations as everywhere else.");
281
- }
282
- checkFields(r.items.fields, "records.items");
283
- for (const f of r.items.fields) {
284
- if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
285
- }
286
- if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
287
- throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared — that key holds the child collection.');
288
- }
289
- }
290
- for (const [key, c] of Object.entries(r.compute || {})) {
291
- if (seen.has(key) || key === "id" || (r.items && key === "items")) {
292
- throw new Error(
293
- `claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
294
- );
295
- }
296
- if (typeof c !== "function" && typeof (c && c.value) !== "function") {
297
- throw new Error(
298
- `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
299
- );
300
- }
328
+ }
329
+ }
330
+
331
+ // Two records blocks writing the same collection would silently fight.
332
+ function checkRecordsBlockKeys(flatBlocks) {
333
+ const seen = new Set();
334
+ for (const b of flatBlocks) {
335
+ if (!b || !b.records) continue;
336
+ const k = b.records.key || "records";
337
+ if (seen.has(k)) {
338
+ throw new Error(
339
+ `claude-artifact-framework: two records blocks share the collection key "${k}" — give each a distinct \`key\`.`
340
+ );
301
341
  }
302
- return layout;
342
+ seen.add(k);
303
343
  }
304
- checkBlocks(spec.blocks || []);
305
- return layout;
306
344
  }
307
345
 
308
346
  // Fields declare their own initial value, so the data shape is derived from
@@ -339,6 +377,12 @@ function defaultsFrom(spec) {
339
377
  }
340
378
  }
341
379
  }
380
+ for (const b of allBlocks) {
381
+ if (b && b.records) {
382
+ const k = b.records.key || "records";
383
+ if (!Array.isArray(data[k])) data[k] = [];
384
+ }
385
+ }
342
386
  return data;
343
387
  }
344
388
 
@@ -408,8 +452,8 @@ function TabsBlock({ spec, ctx }) {
408
452
  );
409
453
  }
410
454
 
411
- const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
412
- const ALL_NAMES = [...BLOCK_NAMES, "tabs"];
455
+ const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock, records: RecordsBlock };
456
+ const ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
413
457
 
414
458
  function Block({ block, ctx }) {
415
459
  // `when` hides a block entirely — the primitive under conditional UIs.
@@ -516,6 +560,10 @@ function docDefaults(dspec) {
516
560
  data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
517
561
  }
518
562
  }
563
+ if (block.records) {
564
+ const k = block.records.key || "records";
565
+ if (!Array.isArray(data[k])) data[k] = [];
566
+ }
519
567
  }
520
568
  return data;
521
569
  }
@@ -965,6 +1013,7 @@ const BASE_CSS = `
965
1013
  .caf-doc-close:hover { color: var(--caf-danger); }
966
1014
  .caf-doc-add { font-size: 1rem; font-weight: 650; }
967
1015
  .caf-empty .caf-btn { margin-top: 0.6rem; }
1016
+ .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
968
1017
  .caf-chevron-closed { transform: rotate(-90deg); }
969
1018
  .caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
970
1019
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
package/src/records.js CHANGED
@@ -18,7 +18,7 @@
18
18
  // in data — restoring it on reload is correct, and deleting the open record
19
19
  // simply falls back to the list.
20
20
 
21
- import { createElement as h, useState } from "react";
21
+ import { createElement as h, useState, useEffect } from "react";
22
22
  import { Field, formatValue } from "./blocks.js";
23
23
 
24
24
  // Computed fields are derived at render time and never stored, so they can't
@@ -87,6 +87,17 @@ function Summary({ spec, records }) {
87
87
  function ListScreen({ spec, ctx }) {
88
88
  const label = spec.label || "item";
89
89
  const [query, setQuery] = useState("");
90
+ // Records can land in the collection without the framework's Add button —
91
+ // a chat tool, an author action, hand-written `data`. Assigning ids is the
92
+ // framework's job (it's reserved), so complete any record missing one;
93
+ // otherwise its row can never open a detail screen.
94
+ useEffect(() => {
95
+ if ((ctx.data.records || []).some((r) => r && !r.id)) {
96
+ ctx.update((d) => {
97
+ for (const r of d.records || []) if (r && !r.id) r.id = makeId();
98
+ });
99
+ }
100
+ });
90
101
  let records = ctx.data.records.map((r) => withComputed(spec, r));
91
102
  if (spec.sort) records = records.slice().sort(spec.sort);
92
103
  const total = records.length;
@@ -297,6 +308,55 @@ function ItemsEditor({ spec, ctx, record }) {
297
308
  );
298
309
  }
299
310
 
311
+ // The block form of records: the same list/detail/summary machinery embedded
312
+ // wherever panel blocks go (panel, workspace panes, documents, tabs). The
313
+ // collection lives in the enclosing data pool under `key` (default
314
+ // "records"), so inside `documents` each document gets its own collection
315
+ // for free. The open record is view state under a per-collection key —
316
+ // list/detail navigation without touching the app-level screen stack.
317
+ function aliasRecords(d, key) {
318
+ if (key === "records") return d;
319
+ // The internal machinery reads and writes `d.records`; alias it onto the
320
+ // author's collection key while leaving every other property untouched
321
+ // (author actions receive this same object and see the real pool).
322
+ return new Proxy(d, {
323
+ get: (t, p, r) => (p === "records" ? t[key] : Reflect.get(t, p, r)),
324
+ set: (t, p, v) => Reflect.set(t, p === "records" ? key : p, v),
325
+ has: (t, p) => (p === "records" ? key in t : p in t),
326
+ });
327
+ }
328
+
329
+ export function RecordsBlock({ spec, ctx }) {
330
+ const rspec = spec.records;
331
+ const key = rspec.key || "records";
332
+ const arr = Array.isArray(ctx.data[key]) ? ctx.data[key] : [];
333
+ const stateKey = "caf:rec:" + key;
334
+ const openId = ctx.view.tabs ? ctx.view.tabs[stateKey] : undefined;
335
+
336
+ const scoped = {
337
+ ...ctx,
338
+ data: { ...ctx.data, records: arr },
339
+ update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
340
+ go: (_screen, id) => ctx.setTab(stateKey, id),
341
+ back: () => ctx.setTab(stateKey, undefined),
342
+ };
343
+
344
+ // A deleted or stale open record falls back to the list, never a blank pane.
345
+ const record = openId ? arr.find((r) => r.id === openId) : null;
346
+
347
+ return h(
348
+ "div",
349
+ { className: "caf-records-block" },
350
+ spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
351
+ record
352
+ ? h(DetailScreen, { key: "d", spec: rspec, ctx: scoped, record })
353
+ : [
354
+ h(Summary, { key: "s", spec: rspec, records: arr.map((r) => withComputed(rspec, r)) }),
355
+ h(ListScreen, { key: "l", spec: rspec, ctx: scoped }),
356
+ ]
357
+ );
358
+ }
359
+
300
360
  export function RecordsLayout({ spec, ctx }) {
301
361
  const rspec = spec.records;
302
362