claude-artifact-framework 0.11.0 → 0.13.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
@@ -427,7 +468,50 @@ close it (closing the open document falls back to a neighbour, never a
427
468
  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
- survive reload. Documents keys: `label`, `title`, `blocks`, `empty`.
471
+ survive reload. Documents keys: `label`, `title`, `blocks`, `empty`,
472
+ `types`.
473
+
474
+ **Typed documents** — when tabs are not all the same kind of thing, declare
475
+ `types` instead of `blocks` (one or the other, not both). Each type has its
476
+ own `label`, optional `title` function, and its own `blocks`; the **+** tab
477
+ asks which kind to create when there is more than one. Each document
478
+ remembers its type forever; an untitled document shows its type label in
479
+ the tab.
480
+
481
+ ```js
482
+ ArtifactKit.createApp({
483
+ title: "Cuaderno",
484
+ layout: "documents",
485
+ documents: {
486
+ label: "página",
487
+ types: {
488
+ nota: {
489
+ label: "Nota",
490
+ title: (d) => d.titulo || "",
491
+ blocks: [{ fields: [
492
+ { key: "titulo", label: "Título", type: "text" },
493
+ { key: "texto", label: "Texto", type: "textarea", rows: 12 },
494
+ ]}],
495
+ },
496
+ compras: {
497
+ label: "Lista de compras",
498
+ blocks: [
499
+ { records: { key: "items", label: "producto", fields: [
500
+ { key: "producto", label: "Producto", type: "text", required: true },
501
+ { key: "comprado", label: "Comprado", type: "check" },
502
+ ]}},
503
+ ],
504
+ },
505
+ },
506
+ },
507
+ })
508
+ ```
509
+
510
+ When the variation is smaller than "a different kind of document" — the
511
+ same document showing or hiding sections by its own state — prefer one
512
+ `blocks` set with `when:` conditions (`when: (d) => d.modo === "avanzado"`)
513
+ over `types`: one schema with conditional blocks keeps every document
514
+ migratable and searchable as the same shape.
431
515
 
432
516
  Inside `documents`, blocks work as in `panel` — **including `chat` and
433
517
  `file`, which become per-document**:
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") {
@@ -1357,7 +1396,9 @@ function StepsLayout({ spec, ctx }) {
1357
1396
  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
- var DOCUMENTS_KEYS = ["label", "title", "blocks", "empty"];
1399
+ var DOCUMENTS_KEYS = ["label", "title", "blocks", "empty", "types"];
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,19 +1567,26 @@ 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") {
1537
1581
  const d = spec.documents;
1538
- if (!d || !Array.isArray(d.blocks) || d.blocks.length === 0) {
1582
+ if (!d || typeof d !== "object" || !d.blocks && !d.types) {
1539
1583
  throw new Error(
1540
- 'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }` \u2014 the blocks every document repeats, each with its own data.'
1584
+ 'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }` (every document repeats the same blocks) or `documents: { types: {...} }` (each document is one of several kinds).'
1585
+ );
1586
+ }
1587
+ if (d.blocks && d.types) {
1588
+ throw new Error(
1589
+ "claude-artifact-framework: documents takes `blocks` (uniform documents) OR `types` (typed documents) \u2014 not both."
1541
1590
  );
1542
1591
  }
1543
1592
  const unknownD = Object.keys(d).filter((k) => !DOCUMENTS_KEYS.includes(k));
@@ -1549,79 +1598,135 @@ function validate(spec) {
1549
1598
  if (d.title !== void 0 && typeof d.title !== "function") {
1550
1599
  throw new Error("claude-artifact-framework: documents.title must be a function of the document's data returning the tab label.");
1551
1600
  }
1552
- checkBlocks(d.blocks);
1553
- const chats = flattenBlocks(d.blocks).filter((b) => b && b.chat);
1554
- if (chats.length > 1) {
1555
- throw new Error(
1556
- "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."
1557
- );
1558
- }
1559
- return layout;
1560
- }
1561
- if (layout === "records") {
1562
- const r = spec.records;
1563
- if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
1564
- throw new Error(
1565
- 'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` \u2014 the same field declarations the fields block uses.'
1566
- );
1567
- }
1568
- const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
1569
- const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
1570
- if (unknownR.length) {
1571
- throw new Error(
1572
- `claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
1573
- );
1574
- }
1575
- checkFields(r.fields, "records");
1576
- if (r.fields.some((f) => f.type === "file") || r.items && r.items.fields.some((f) => f.type === "file")) {
1577
- throw new Error(
1578
- "claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) \u2014 they are not supported inside records or items."
1579
- );
1580
- }
1581
- const seen = /* @__PURE__ */ new Set();
1582
- for (const f of r.fields) {
1583
- if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records \u2014 the framework assigns it.');
1584
- if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
1585
- seen.add(f.key);
1586
- }
1587
- if (spec.blocks) checkBlocks(spec.blocks);
1588
- if (r.actions) checkActions(r.actions, "records.actions");
1589
- if (r.items) {
1590
- const ITEMS_KEYS = ["label", "fields", "empty"];
1591
- const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
1592
- if (unknownI.length) {
1601
+ const checkDocBlocks = (blocks, where) => {
1602
+ checkBlocks(blocks);
1603
+ const flat = flattenBlocks(blocks);
1604
+ if (flat.filter((b) => b && b.chat).length > 1) {
1593
1605
  throw new Error(
1594
- `claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
1606
+ `claude-artifact-framework: only one chat block per document (${where}) \u2014 each document's conversation persists under its own data.chat and there is exactly one.`
1595
1607
  );
1596
1608
  }
1597
- if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
1598
- throw new Error("claude-artifact-framework: records.items needs `fields: [...]` \u2014 the same field declarations as everywhere else.");
1609
+ checkRecordsBlockKeys(flat);
1610
+ };
1611
+ if (d.blocks) {
1612
+ if (!Array.isArray(d.blocks) || d.blocks.length === 0) {
1613
+ throw new Error("claude-artifact-framework: documents.blocks must be a non-empty array of blocks.");
1599
1614
  }
1600
- checkFields(r.items.fields, "records.items");
1601
- for (const f of r.items.fields) {
1602
- if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items \u2014 the framework assigns it.');
1615
+ checkDocBlocks(d.blocks, "documents.blocks");
1616
+ } else {
1617
+ const names = Object.keys(d.types || {});
1618
+ if (!names.length) {
1619
+ throw new Error("claude-artifact-framework: documents.types needs at least one type, e.g. `types: { nota: { blocks: [...] } }`.");
1603
1620
  }
1604
- if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
1605
- throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared \u2014 that key holds the child collection.');
1621
+ for (const name of names) {
1622
+ const t = d.types[name];
1623
+ const TYPE_KEYS = ["label", "title", "blocks", "empty"];
1624
+ const unknownT = Object.keys(t || {}).filter((k) => !TYPE_KEYS.includes(k));
1625
+ if (unknownT.length) {
1626
+ throw new Error(
1627
+ `claude-artifact-framework: documents.types.${name} has unknown keys (${unknownT.join(", ")}). Valid keys: ${TYPE_KEYS.join(", ")}.`
1628
+ );
1629
+ }
1630
+ if (!t || !Array.isArray(t.blocks) || t.blocks.length === 0) {
1631
+ throw new Error(`claude-artifact-framework: documents.types.${name} needs a non-empty \`blocks\` array.`);
1632
+ }
1633
+ if (t.title !== void 0 && typeof t.title !== "function") {
1634
+ throw new Error(`claude-artifact-framework: documents.types.${name}.title must be a function of the document's data.`);
1635
+ }
1636
+ checkDocBlocks(t.blocks, `documents.types.${name}`);
1606
1637
  }
1607
1638
  }
1608
- for (const [key, c] of Object.entries(r.compute || {})) {
1609
- if (seen.has(key) || key === "id" || r.items && key === "items") {
1610
- throw new Error(
1611
- `claude-artifact-framework: compute key "${key}" collides with a field key \u2014 computed values are derived, never stored.`
1612
- );
1613
- }
1614
- if (typeof c !== "function" && typeof (c && c.value) !== "function") {
1639
+ return layout;
1640
+ }
1641
+ if (layout === "records") {
1642
+ checkRecordsSpec(spec.records, "records", false);
1643
+ if (spec.blocks) {
1644
+ checkBlocks(spec.blocks);
1645
+ if (flattenBlocks(spec.blocks).some((b) => b && b.records)) {
1615
1646
  throw new Error(
1616
- `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
1647
+ "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."
1617
1648
  );
1618
1649
  }
1619
1650
  }
1620
1651
  return layout;
1621
1652
  }
1622
1653
  checkBlocks(spec.blocks || []);
1654
+ checkRecordsBlockKeys(flattenBlocks(spec.blocks || []));
1623
1655
  return layout;
1624
1656
  }
1657
+ function checkRecordsSpec(r, where, allowKey) {
1658
+ if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
1659
+ throw new Error(
1660
+ `claude-artifact-framework: ${where} needs \`records: { fields: [...] }\` \u2014 the same field declarations the fields block uses.`
1661
+ );
1662
+ }
1663
+ const valid = allowKey ? [...RECORDS_KEYS, "key"] : RECORDS_KEYS;
1664
+ const unknownR = Object.keys(r).filter((k) => !valid.includes(k));
1665
+ if (unknownR.length) {
1666
+ throw new Error(
1667
+ `claude-artifact-framework: ${where} has unknown keys (${unknownR.join(", ")}). Valid keys: ${valid.join(", ")}.`
1668
+ );
1669
+ }
1670
+ if (allowKey && r.key !== void 0 && (typeof r.key !== "string" || !r.key)) {
1671
+ throw new Error(`claude-artifact-framework: ${where}.key must be a non-empty string naming the collection in data.`);
1672
+ }
1673
+ checkFields(r.fields, where);
1674
+ if (r.fields.some((f) => f.type === "file") || r.items && r.items.fields.some((f) => f.type === "file")) {
1675
+ throw new Error(
1676
+ "claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) \u2014 they are not supported inside records or items."
1677
+ );
1678
+ }
1679
+ const seen = /* @__PURE__ */ new Set();
1680
+ for (const f of r.fields) {
1681
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records \u2014 the framework assigns it.');
1682
+ if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate ${where} field key "${f.key}".`);
1683
+ seen.add(f.key);
1684
+ }
1685
+ if (r.actions) checkActions(r.actions, `${where}.actions`);
1686
+ if (r.items) {
1687
+ const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
1688
+ if (unknownI.length) {
1689
+ throw new Error(
1690
+ `claude-artifact-framework: ${where}.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
1691
+ );
1692
+ }
1693
+ if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
1694
+ throw new Error(`claude-artifact-framework: ${where}.items needs \`fields: [...]\` \u2014 the same field declarations as everywhere else.`);
1695
+ }
1696
+ checkFields(r.items.fields, `${where}.items`);
1697
+ for (const f of r.items.fields) {
1698
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items \u2014 the framework assigns it.');
1699
+ }
1700
+ if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
1701
+ throw new Error(`claude-artifact-framework: a ${where} field cannot be named "items" when \`items\` is declared \u2014 that key holds the child collection.`);
1702
+ }
1703
+ }
1704
+ for (const [key, c] of Object.entries(r.compute || {})) {
1705
+ if (seen.has(key) || key === "id" || r.items && key === "items") {
1706
+ throw new Error(
1707
+ `claude-artifact-framework: compute key "${key}" collides with a field key \u2014 computed values are derived, never stored.`
1708
+ );
1709
+ }
1710
+ if (typeof c !== "function" && typeof (c && c.value) !== "function") {
1711
+ throw new Error(
1712
+ `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
1713
+ );
1714
+ }
1715
+ }
1716
+ }
1717
+ function checkRecordsBlockKeys(flatBlocks) {
1718
+ const seen = /* @__PURE__ */ new Set();
1719
+ for (const b of flatBlocks) {
1720
+ if (!b || !b.records) continue;
1721
+ const k = b.records.key || "records";
1722
+ if (seen.has(k)) {
1723
+ throw new Error(
1724
+ `claude-artifact-framework: two records blocks share the collection key "${k}" \u2014 give each a distinct \`key\`.`
1725
+ );
1726
+ }
1727
+ seen.add(k);
1728
+ }
1729
+ }
1625
1730
  function flattenBlocks(blocks) {
1626
1731
  const out = [];
1627
1732
  for (const b of blocks || []) {
@@ -1650,6 +1755,12 @@ function defaultsFrom(spec) {
1650
1755
  }
1651
1756
  }
1652
1757
  }
1758
+ for (const b of allBlocks) {
1759
+ if (b && b.records) {
1760
+ const k = b.records.key || "records";
1761
+ if (!Array.isArray(data[k])) data[k] = [];
1762
+ }
1763
+ }
1653
1764
  return data;
1654
1765
  }
1655
1766
  var BlockBoundary = null;
@@ -1711,8 +1822,8 @@ function TabsBlock({ spec, ctx }) {
1711
1822
  )
1712
1823
  );
1713
1824
  }
1714
- var ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
1715
- var ALL_NAMES = [...BLOCK_NAMES, "tabs"];
1825
+ var ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock, records: RecordsBlock };
1826
+ var ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
1716
1827
  function Block({ block, ctx }) {
1717
1828
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
1718
1829
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
@@ -1795,6 +1906,10 @@ function docDefaults(dspec) {
1795
1906
  data[field.key] = field.value !== void 0 ? field.value : field.type === "check" ? false : "";
1796
1907
  }
1797
1908
  }
1909
+ if (block.records) {
1910
+ const k = block.records.key || "records";
1911
+ if (!Array.isArray(data[k])) data[k] = [];
1912
+ }
1798
1913
  }
1799
1914
  return data;
1800
1915
  }
@@ -1802,7 +1917,7 @@ function docId() {
1802
1917
  return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1803
1918
  }
1804
1919
  var DOC_TAB_KEY = "caf:doc";
1805
- function docTitle(dspec, data) {
1920
+ function docTitle(dspec, data, untitled) {
1806
1921
  if (dspec.title) {
1807
1922
  try {
1808
1923
  const t = dspec.title(data);
@@ -1815,25 +1930,36 @@ function docTitle(dspec, data) {
1815
1930
  if (!f.type || f.type === "text" || f.type === "textarea" || f.type === "select") {
1816
1931
  const v = data[f.key];
1817
1932
  if (v !== "" && v !== void 0 && v !== null) return String(v);
1818
- return "(untitled)";
1933
+ return untitled || "(untitled)";
1819
1934
  }
1820
1935
  }
1821
1936
  }
1822
- return "(untitled)";
1937
+ return untitled || "(untitled)";
1823
1938
  }
1824
1939
  function DocumentsLayout({ spec, ctx }) {
1825
1940
  const dspec = spec.documents;
1826
1941
  const label = dspec.label || "document";
1827
1942
  const docs = Array.isArray(ctx.data.docs) ? ctx.data.docs : [];
1828
- const defaults = docDefaults(dspec);
1943
+ const types = dspec.types || null;
1944
+ const typeNames = types ? Object.keys(types) : [];
1945
+ const specFor = (doc) => types ? types[doc.type] : dspec;
1946
+ const typeLabel = (name) => types[name] && types[name].label || name;
1947
+ const [picking, setPicking] = useState(false);
1829
1948
  const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : void 0;
1830
1949
  const active = docs.find((doc) => doc.id === storedId) || docs[0];
1831
- function add() {
1832
- const doc = { id: docId(), data: docDefaults(dspec) };
1950
+ function add(typeName) {
1951
+ const tspec = types ? types[typeName] : dspec;
1952
+ const doc = { id: docId(), ...types ? { type: typeName } : {}, data: docDefaults(tspec) };
1833
1953
  ctx.update((d) => {
1834
1954
  d.docs = [...d.docs || [], doc];
1835
1955
  });
1836
1956
  ctx.setTab(DOC_TAB_KEY, doc.id);
1957
+ setPicking(false);
1958
+ }
1959
+ function onAdd() {
1960
+ if (!types) return add();
1961
+ if (typeNames.length === 1) return add(typeNames[0]);
1962
+ setPicking((p) => !p);
1837
1963
  }
1838
1964
  function close(id) {
1839
1965
  const idx = docs.findIndex((doc) => doc.id === id);
@@ -1846,6 +1972,13 @@ function DocumentsLayout({ spec, ctx }) {
1846
1972
  if (next) ctx.setTab(DOC_TAB_KEY, next.id);
1847
1973
  }
1848
1974
  }
1975
+ const typePicker = types && picking ? createElement(
1976
+ "div",
1977
+ { className: "caf-doc-typemenu" },
1978
+ typeNames.map(
1979
+ (name) => createElement("button", { key: name, type: "button", className: "caf-btn caf-btn-sm", onClick: () => add(name) }, typeLabel(name))
1980
+ )
1981
+ ) : null;
1849
1982
  if (!active) {
1850
1983
  return createElement(
1851
1984
  "div",
@@ -1854,10 +1987,32 @@ function DocumentsLayout({ spec, ctx }) {
1854
1987
  "div",
1855
1988
  { className: "caf-block caf-empty" },
1856
1989
  createElement("p", null, dspec.empty || `No ${label}s yet. Create the first one.`),
1857
- createElement("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `New ${label}`)
1990
+ types ? createElement(
1991
+ "div",
1992
+ { className: "caf-doc-typemenu" },
1993
+ typeNames.map(
1994
+ (name) => createElement("button", { key: name, type: "button", className: "caf-btn caf-btn-primary", onClick: () => add(name) }, `New ${typeLabel(name)}`)
1995
+ )
1996
+ ) : createElement("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: () => add() }, `New ${label}`)
1858
1997
  )
1859
1998
  );
1860
1999
  }
2000
+ const aspec = specFor(active);
2001
+ if (!aspec) {
2002
+ return createElement(
2003
+ "div",
2004
+ { className: "caf-documents" },
2005
+ createElement(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
2006
+ typePicker,
2007
+ createElement(
2008
+ "div",
2009
+ { className: "caf-block caf-block-error" },
2010
+ createElement("strong", null, `This ${label}'s type "${active.type}" no longer exists`),
2011
+ createElement("code", null, `valid types: ${typeNames.join(", ")}`)
2012
+ )
2013
+ );
2014
+ }
2015
+ const defaults = docDefaults(aspec);
1861
2016
  const activeData = { ...defaults, ...active.data };
1862
2017
  const scoped = {
1863
2018
  ...ctx,
@@ -1875,39 +2030,50 @@ function DocumentsLayout({ spec, ctx }) {
1875
2030
  return createElement(
1876
2031
  "div",
1877
2032
  { className: "caf-documents" },
1878
- createElement(
1879
- "div",
1880
- { className: "caf-tabbar", role: "tablist" },
1881
- docs.map(
1882
- (doc) => createElement(
1883
- "span",
1884
- { key: doc.id, className: "caf-doc-tab" },
1885
- createElement(
1886
- "button",
1887
- {
1888
- type: "button",
1889
- role: "tab",
1890
- "aria-selected": doc.id === active.id,
1891
- className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
1892
- onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id)
1893
- },
1894
- docTitle(dspec, { ...defaults, ...doc.data })
1895
- ),
1896
- createElement(
1897
- "button",
1898
- {
1899
- type: "button",
1900
- className: "caf-doc-close",
1901
- "aria-label": `Close ${label}`,
1902
- onClick: () => close(doc.id)
1903
- },
1904
- "\u2715"
1905
- )
2033
+ createElement(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
2034
+ typePicker,
2035
+ createElement(Panel, { spec: { blocks: aspec.blocks }, ctx: scoped })
2036
+ );
2037
+ }
2038
+ function DocTabbar({ docs, active, dspec, types, close, onAdd, label, ctx }) {
2039
+ const tabTitle = (doc) => {
2040
+ const tspec = types ? types[doc.type] : dspec;
2041
+ if (!tspec) return doc.type;
2042
+ const merged = { ...docDefaults(tspec), ...doc.data };
2043
+ const fallback = types ? tspec.label || doc.type : void 0;
2044
+ return docTitle(tspec, merged, fallback);
2045
+ };
2046
+ return createElement(
2047
+ "div",
2048
+ { className: "caf-tabbar", role: "tablist" },
2049
+ docs.map(
2050
+ (doc) => createElement(
2051
+ "span",
2052
+ { key: doc.id, className: "caf-doc-tab" },
2053
+ createElement(
2054
+ "button",
2055
+ {
2056
+ type: "button",
2057
+ role: "tab",
2058
+ "aria-selected": doc.id === active.id,
2059
+ className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
2060
+ onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id)
2061
+ },
2062
+ tabTitle(doc)
2063
+ ),
2064
+ createElement(
2065
+ "button",
2066
+ {
2067
+ type: "button",
2068
+ className: "caf-doc-close",
2069
+ "aria-label": `Close ${label}`,
2070
+ onClick: () => close(doc.id)
2071
+ },
2072
+ "\u2715"
1906
2073
  )
1907
- ),
1908
- createElement("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: add }, "+")
2074
+ )
1909
2075
  ),
1910
- createElement(Panel, { spec: { blocks: dspec.blocks }, ctx: scoped })
2076
+ createElement("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: onAdd }, "+")
1911
2077
  );
1912
2078
  }
1913
2079
  var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout, documents: DocumentsLayout };
@@ -2209,6 +2375,8 @@ var BASE_CSS = `
2209
2375
  .caf-doc-close:hover { color: var(--caf-danger); }
2210
2376
  .caf-doc-add { font-size: 1rem; font-weight: 650; }
2211
2377
  .caf-empty .caf-btn { margin-top: 0.6rem; }
2378
+ .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
2379
+ .caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
2212
2380
  .caf-chevron-closed { transform: rotate(-90deg); }
2213
2381
  .caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
2214
2382
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }