claude-artifact-framework 0.10.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/README.md CHANGED
@@ -394,6 +394,47 @@ The active tab and each accordion's open/closed state are framework-owned
394
394
  view state — they survive reload, per viewer. Tabs cannot nest inside tabs;
395
395
  `when` must be a function; both throw otherwise.
396
396
 
397
+ ### `records` as a block — a CRUD collection inside any screen
398
+
399
+ The full records machinery (list, add-then-edit, detail, search, summary,
400
+ compute, items, row actions) is also a **block**, embeddable wherever panel
401
+ blocks go — a workspace pane, a document, a tab. Declare it with the same
402
+ `records: {...}` spec plus one extra key: **`key`**, naming where the
403
+ collection lives in the enclosing data pool (default `"records"`; two
404
+ records blocks need distinct keys).
405
+
406
+ ```js
407
+ // A task list per project: records INSIDE documents — each document gets
408
+ // its own independent collection automatically.
409
+ ArtifactKit.createApp({
410
+ title: "Proyectos",
411
+ layout: "documents",
412
+ documents: {
413
+ label: "proyecto",
414
+ title: (d) => d.nombre || "Sin nombre",
415
+ blocks: [
416
+ { fields: [{ key: "nombre", label: "Proyecto", type: "text" }] },
417
+ { records: {
418
+ key: "tareas",
419
+ label: "tarea",
420
+ fields: [
421
+ { key: "titulo", label: "Título", type: "text", required: true },
422
+ { key: "hecha", label: "Hecha", type: "check" },
423
+ ],
424
+ summary: (all) => [{ label: "Pendientes", value: all.filter((t) => !t.hecha).length, big: true }],
425
+ }},
426
+ ],
427
+ },
428
+ })
429
+ ```
430
+
431
+ Inside a `documents` layout the collection is per document; in `panel` or
432
+ `workspace` it lives in the flat pool under `key`, where `output`/`chart`
433
+ blocks and chat tools can read and write it (`d.tareas`). Which record is
434
+ open is framework-owned view state per collection — it survives reload,
435
+ and a deleted open record falls back to the list. A records block below a
436
+ records *layout* throws (the layout already owns the collection).
437
+
397
438
  ### The `documents` layout — parallel documents, one per top tab
398
439
 
399
440
  `tabs` are views over ONE shared data pool. When the ask is "several
@@ -428,9 +469,39 @@ blank pane), the per-document data pools (`d` inside any block, `output`,
428
469
  `custom`, or `when` is THAT document's data; `ctx.update` writes there),
429
470
  the empty state, and persistence — all documents and which one is open
430
471
  survive reload. Documents keys: `label`, `title`, `blocks`, `empty`.
431
- Inside `documents`, blocks work as in `panel`; `chat` blocks and `file`
432
- fields are not supported there (both are app-wide, not per-document) and
433
- throw at creation.
472
+
473
+ Inside `documents`, blocks work as in `panel` **including `chat` and
474
+ `file`, which become per-document**:
475
+
476
+ - A `chat` block inside `documents` gives **each document its own
477
+ conversation**, persisted with that document (`data.chat` is scoped). Its
478
+ `system` function receives the document's data, so the copilot talks
479
+ about THIS document. One chat block per document (a second throws).
480
+ - A `file` field inside `documents` scopes `ctx.files[key]` to the active
481
+ document — two documents never collide on the same field key. As
482
+ everywhere, the live `File` is memory-only: metadata survives reload,
483
+ the file is re-picked per document.
484
+
485
+ That combination — one file plus one conversation per tab — is the
486
+ multi-document annotator shape (PDFs, images, CSVs) declared in full:
487
+
488
+ ```js
489
+ ArtifactKit.createApp({
490
+ title: "Anotador de archivos",
491
+ layout: "documents",
492
+ documents: {
493
+ label: "archivo",
494
+ title: (d) => (d.archivo && d.archivo.name) || "Sin archivo",
495
+ blocks: [
496
+ { fields: [{ key: "archivo", label: "Archivo", type: "file" }] },
497
+ { custom: (ctx) => { /* render ctx.files.archivo */ }, fill: true },
498
+ { title: "Copiloto", chat: {
499
+ system: (d) => "Ayudás con el archivo " + ((d.archivo && d.archivo.name) || "(ninguno)"),
500
+ }},
501
+ ],
502
+ },
503
+ })
504
+ ```
434
505
 
435
506
  ### External libraries, uploaded files, full-bleed custom, viewer identity
436
507
 
package/dist/index.esm.js CHANGED
@@ -1072,6 +1072,13 @@ function Summary({ spec, records }) {
1072
1072
  function ListScreen({ spec, ctx }) {
1073
1073
  const label = spec.label || "item";
1074
1074
  const [query, setQuery] = useState("");
1075
+ useEffect(() => {
1076
+ if ((ctx.data.records || []).some((r) => r && !r.id)) {
1077
+ ctx.update((d) => {
1078
+ for (const r of d.records || []) if (r && !r.id) r.id = makeId();
1079
+ });
1080
+ }
1081
+ });
1075
1082
  let records = ctx.data.records.map((r) => withComputed(spec, r));
1076
1083
  if (spec.sort) records = records.slice().sort(spec.sort);
1077
1084
  const total = records.length;
@@ -1253,6 +1260,38 @@ function ItemsEditor({ spec, ctx, record }) {
1253
1260
  )
1254
1261
  );
1255
1262
  }
1263
+ function aliasRecords(d, key) {
1264
+ if (key === "records") return d;
1265
+ return new Proxy(d, {
1266
+ get: (t, p, r) => p === "records" ? t[key] : Reflect.get(t, p, r),
1267
+ set: (t, p, v) => Reflect.set(t, p === "records" ? key : p, v),
1268
+ has: (t, p) => p === "records" ? key in t : p in t
1269
+ });
1270
+ }
1271
+ function RecordsBlock({ spec, ctx }) {
1272
+ const rspec = spec.records;
1273
+ const key = rspec.key || "records";
1274
+ const arr = Array.isArray(ctx.data[key]) ? ctx.data[key] : [];
1275
+ const stateKey = "caf:rec:" + key;
1276
+ const openId = ctx.view.tabs ? ctx.view.tabs[stateKey] : void 0;
1277
+ const scoped = {
1278
+ ...ctx,
1279
+ data: { ...ctx.data, records: arr },
1280
+ update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
1281
+ go: (_screen, id) => ctx.setTab(stateKey, id),
1282
+ back: () => ctx.setTab(stateKey, void 0)
1283
+ };
1284
+ const record = openId ? arr.find((r) => r.id === openId) : null;
1285
+ return createElement(
1286
+ "div",
1287
+ { className: "caf-records-block" },
1288
+ spec.title ? createElement("h2", { className: "caf-block-title" }, spec.title) : null,
1289
+ record ? createElement(DetailScreen, { key: "d", spec: rspec, ctx: scoped, record }) : [
1290
+ createElement(Summary, { key: "s", spec: rspec, records: arr.map((r) => withComputed(rspec, r)) }),
1291
+ createElement(ListScreen, { key: "l", spec: rspec, ctx: scoped })
1292
+ ]
1293
+ );
1294
+ }
1256
1295
  function RecordsLayout({ spec, ctx }) {
1257
1296
  const rspec = spec.records;
1258
1297
  if (ctx.view.screen === "record") {
@@ -1358,6 +1397,8 @@ var LAYOUTS = ["panel", "records", "steps", "chat", "workspace", "documents"];
1358
1397
  var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill"];
1359
1398
  var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents"];
1360
1399
  var DOCUMENTS_KEYS = ["label", "title", "blocks", "empty"];
1400
+ var RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
1401
+ var ITEMS_KEYS = ["label", "fields", "empty"];
1361
1402
  function checkFields(fields, where) {
1362
1403
  for (const f of fields || []) {
1363
1404
  if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in ${where} needs a \`key\`.`);
@@ -1419,10 +1460,10 @@ function checkBlocks(blocks) {
1419
1460
  throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
1420
1461
  }
1421
1462
  const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
1422
- const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs");
1463
+ const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs" || k === "records");
1423
1464
  if (known.length === 0) {
1424
1465
  throw new Error(
1425
- `claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${keys.join(", ") || "nothing"}). Valid block types: ${BLOCK_NAMES.join(", ")}.`
1466
+ `claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${keys.join(", ") || "nothing"}). Valid block types: ${[...BLOCK_NAMES, "tabs", "records"].join(", ")}.`
1426
1467
  );
1427
1468
  }
1428
1469
  if (known.length > 1) {
@@ -1435,6 +1476,7 @@ function checkBlocks(blocks) {
1435
1476
  checkFields(block.fields, `blocks[${i}]`);
1436
1477
  if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
1437
1478
  if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
1479
+ if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
1438
1480
  if (known[0] === "tabs") {
1439
1481
  const tabs = block.tabs;
1440
1482
  if (!Array.isArray(tabs) || tabs.length === 0) {
@@ -1525,12 +1567,14 @@ function validate(spec) {
1525
1567
  }
1526
1568
  checkBlocks(spec.sidebar);
1527
1569
  }
1528
- const chats = flattenBlocks([...spec.main, ...spec.sidebar || []]).filter((b) => b && b.chat);
1570
+ const wsFlat = flattenBlocks([...spec.main, ...spec.sidebar || []]);
1571
+ const chats = wsFlat.filter((b) => b && b.chat);
1529
1572
  if (chats.length > 1) {
1530
1573
  throw new Error(
1531
1574
  "claude-artifact-framework: only one chat block per app \u2014 the conversation persists under data.chat and there is exactly one."
1532
1575
  );
1533
1576
  }
1577
+ checkRecordsBlockKeys(wsFlat);
1534
1578
  return layout;
1535
1579
  }
1536
1580
  if (layout === "documents") {
@@ -1550,82 +1594,104 @@ function validate(spec) {
1550
1594
  throw new Error("claude-artifact-framework: documents.title must be a function of the document's data returning the tab label.");
1551
1595
  }
1552
1596
  checkBlocks(d.blocks);
1553
- const flat = flattenBlocks(d.blocks);
1554
- if (flat.some((b) => b && b.chat)) {
1555
- throw new Error(
1556
- 'claude-artifact-framework: a chat block inside "documents" is not supported \u2014 the conversation store is app-wide, not per document.'
1557
- );
1558
- }
1559
- if (flat.some((b) => (b.fields || []).some((f) => f.type === "file"))) {
1597
+ const docFlat = flattenBlocks(d.blocks);
1598
+ const chats = docFlat.filter((b) => b && b.chat);
1599
+ if (chats.length > 1) {
1560
1600
  throw new Error(
1561
- "claude-artifact-framework: `file` fields live in the flat data pool \u2014 they are not supported inside documents (the live File cannot be scoped per document)."
1601
+ "claude-artifact-framework: only one chat block per document \u2014 each document's conversation persists under its own data.chat and there is exactly one."
1562
1602
  );
1563
1603
  }
1604
+ checkRecordsBlockKeys(docFlat);
1564
1605
  return layout;
1565
1606
  }
1566
1607
  if (layout === "records") {
1567
- const r = spec.records;
1568
- if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
1608
+ checkRecordsSpec(spec.records, "records", false);
1609
+ if (spec.blocks) {
1610
+ checkBlocks(spec.blocks);
1611
+ if (flattenBlocks(spec.blocks).some((b) => b && b.records)) {
1612
+ throw new Error(
1613
+ "claude-artifact-framework: a records BLOCK below a records LAYOUT is ambiguous \u2014 the layout already owns the collection. Use panel/workspace/documents to embed records blocks."
1614
+ );
1615
+ }
1616
+ }
1617
+ return layout;
1618
+ }
1619
+ checkBlocks(spec.blocks || []);
1620
+ checkRecordsBlockKeys(flattenBlocks(spec.blocks || []));
1621
+ return layout;
1622
+ }
1623
+ function checkRecordsSpec(r, where, allowKey) {
1624
+ if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
1625
+ throw new Error(
1626
+ `claude-artifact-framework: ${where} needs \`records: { fields: [...] }\` \u2014 the same field declarations the fields block uses.`
1627
+ );
1628
+ }
1629
+ const valid = allowKey ? [...RECORDS_KEYS, "key"] : RECORDS_KEYS;
1630
+ const unknownR = Object.keys(r).filter((k) => !valid.includes(k));
1631
+ if (unknownR.length) {
1632
+ throw new Error(
1633
+ `claude-artifact-framework: ${where} has unknown keys (${unknownR.join(", ")}). Valid keys: ${valid.join(", ")}.`
1634
+ );
1635
+ }
1636
+ if (allowKey && r.key !== void 0 && (typeof r.key !== "string" || !r.key)) {
1637
+ throw new Error(`claude-artifact-framework: ${where}.key must be a non-empty string naming the collection in data.`);
1638
+ }
1639
+ checkFields(r.fields, where);
1640
+ if (r.fields.some((f) => f.type === "file") || r.items && r.items.fields.some((f) => f.type === "file")) {
1641
+ throw new Error(
1642
+ "claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) \u2014 they are not supported inside records or items."
1643
+ );
1644
+ }
1645
+ const seen = /* @__PURE__ */ new Set();
1646
+ for (const f of r.fields) {
1647
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records \u2014 the framework assigns it.');
1648
+ if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate ${where} field key "${f.key}".`);
1649
+ seen.add(f.key);
1650
+ }
1651
+ if (r.actions) checkActions(r.actions, `${where}.actions`);
1652
+ if (r.items) {
1653
+ const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
1654
+ if (unknownI.length) {
1569
1655
  throw new Error(
1570
- 'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` \u2014 the same field declarations the fields block uses.'
1656
+ `claude-artifact-framework: ${where}.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
1571
1657
  );
1572
1658
  }
1573
- const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
1574
- const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
1575
- if (unknownR.length) {
1659
+ if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
1660
+ throw new Error(`claude-artifact-framework: ${where}.items needs \`fields: [...]\` \u2014 the same field declarations as everywhere else.`);
1661
+ }
1662
+ checkFields(r.items.fields, `${where}.items`);
1663
+ for (const f of r.items.fields) {
1664
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items \u2014 the framework assigns it.');
1665
+ }
1666
+ if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
1667
+ throw new Error(`claude-artifact-framework: a ${where} field cannot be named "items" when \`items\` is declared \u2014 that key holds the child collection.`);
1668
+ }
1669
+ }
1670
+ for (const [key, c] of Object.entries(r.compute || {})) {
1671
+ if (seen.has(key) || key === "id" || r.items && key === "items") {
1576
1672
  throw new Error(
1577
- `claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
1673
+ `claude-artifact-framework: compute key "${key}" collides with a field key \u2014 computed values are derived, never stored.`
1578
1674
  );
1579
1675
  }
1580
- checkFields(r.fields, "records");
1581
- if (r.fields.some((f) => f.type === "file") || r.items && r.items.fields.some((f) => f.type === "file")) {
1676
+ if (typeof c !== "function" && typeof (c && c.value) !== "function") {
1582
1677
  throw new Error(
1583
- "claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) \u2014 they are not supported inside records or items."
1678
+ `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
1584
1679
  );
1585
1680
  }
1586
- const seen = /* @__PURE__ */ new Set();
1587
- for (const f of r.fields) {
1588
- if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records \u2014 the framework assigns it.');
1589
- if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
1590
- seen.add(f.key);
1591
- }
1592
- if (spec.blocks) checkBlocks(spec.blocks);
1593
- if (r.actions) checkActions(r.actions, "records.actions");
1594
- if (r.items) {
1595
- const ITEMS_KEYS = ["label", "fields", "empty"];
1596
- const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
1597
- if (unknownI.length) {
1598
- throw new Error(
1599
- `claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
1600
- );
1601
- }
1602
- if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
1603
- throw new Error("claude-artifact-framework: records.items needs `fields: [...]` \u2014 the same field declarations as everywhere else.");
1604
- }
1605
- checkFields(r.items.fields, "records.items");
1606
- for (const f of r.items.fields) {
1607
- if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items \u2014 the framework assigns it.');
1608
- }
1609
- if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
1610
- throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared \u2014 that key holds the child collection.');
1611
- }
1612
- }
1613
- for (const [key, c] of Object.entries(r.compute || {})) {
1614
- if (seen.has(key) || key === "id" || r.items && key === "items") {
1615
- throw new Error(
1616
- `claude-artifact-framework: compute key "${key}" collides with a field key \u2014 computed values are derived, never stored.`
1617
- );
1618
- }
1619
- if (typeof c !== "function" && typeof (c && c.value) !== "function") {
1620
- throw new Error(
1621
- `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
1622
- );
1623
- }
1681
+ }
1682
+ }
1683
+ function checkRecordsBlockKeys(flatBlocks) {
1684
+ const seen = /* @__PURE__ */ new Set();
1685
+ for (const b of flatBlocks) {
1686
+ if (!b || !b.records) continue;
1687
+ const k = b.records.key || "records";
1688
+ if (seen.has(k)) {
1689
+ throw new Error(
1690
+ `claude-artifact-framework: two records blocks share the collection key "${k}" \u2014 give each a distinct \`key\`.`
1691
+ );
1624
1692
  }
1625
- return layout;
1693
+ seen.add(k);
1626
1694
  }
1627
- checkBlocks(spec.blocks || []);
1628
- return layout;
1629
1695
  }
1630
1696
  function flattenBlocks(blocks) {
1631
1697
  const out = [];
@@ -1655,6 +1721,12 @@ function defaultsFrom(spec) {
1655
1721
  }
1656
1722
  }
1657
1723
  }
1724
+ for (const b of allBlocks) {
1725
+ if (b && b.records) {
1726
+ const k = b.records.key || "records";
1727
+ if (!Array.isArray(data[k])) data[k] = [];
1728
+ }
1729
+ }
1658
1730
  return data;
1659
1731
  }
1660
1732
  var BlockBoundary = null;
@@ -1716,8 +1788,8 @@ function TabsBlock({ spec, ctx }) {
1716
1788
  )
1717
1789
  );
1718
1790
  }
1719
- var ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
1720
- var ALL_NAMES = [...BLOCK_NAMES, "tabs"];
1791
+ var ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock, records: RecordsBlock };
1792
+ var ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
1721
1793
  function Block({ block, ctx }) {
1722
1794
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
1723
1795
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
@@ -1800,6 +1872,10 @@ function docDefaults(dspec) {
1800
1872
  data[field.key] = field.value !== void 0 ? field.value : field.type === "check" ? false : "";
1801
1873
  }
1802
1874
  }
1875
+ if (block.records) {
1876
+ const k = block.records.key || "records";
1877
+ if (!Array.isArray(data[k])) data[k] = [];
1878
+ }
1803
1879
  }
1804
1880
  return data;
1805
1881
  }
@@ -1845,6 +1921,7 @@ function DocumentsLayout({ spec, ctx }) {
1845
1921
  ctx.update((d) => {
1846
1922
  d.docs = d.docs.filter((doc) => doc.id !== id);
1847
1923
  });
1924
+ delete ctx.files["doc:" + id];
1848
1925
  if (active && active.id === id) {
1849
1926
  const next = docs[idx + 1] || docs[idx - 1];
1850
1927
  if (next) ctx.setTab(DOC_TAB_KEY, next.id);
@@ -1869,7 +1946,12 @@ function DocumentsLayout({ spec, ctx }) {
1869
1946
  update: (fn) => ctx.update((d) => {
1870
1947
  const doc = (d.docs || []).find((x) => x.id === active.id);
1871
1948
  if (doc) fn(doc.data);
1872
- })
1949
+ }),
1950
+ // Live File objects scoped per document: each doc gets its own sub-map,
1951
+ // so two documents with the same file field never collide. Like the flat
1952
+ // pool, the File itself is memory-only — metadata survives reload, the
1953
+ // file is re-picked per document.
1954
+ files: ctx.files["doc:" + active.id] = ctx.files["doc:" + active.id] || {}
1873
1955
  };
1874
1956
  return createElement(
1875
1957
  "div",
@@ -2208,6 +2290,7 @@ var BASE_CSS = `
2208
2290
  .caf-doc-close:hover { color: var(--caf-danger); }
2209
2291
  .caf-doc-add { font-size: 1rem; font-weight: 650; }
2210
2292
  .caf-empty .caf-btn { margin-top: 0.6rem; }
2293
+ .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
2211
2294
  .caf-chevron-closed { transform: rotate(-90deg); }
2212
2295
  .caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
2213
2296
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }