claude-artifact-framework 0.9.1 → 0.10.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,44 @@ 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
+ ### The `documents` layout — parallel documents, one per top tab
398
+
399
+ `tabs` are views over ONE shared data pool. When the ask is "several
400
+ documents, each with its own state" — a multi-note editor, one budget per
401
+ trip, one character sheet per player — that is the `documents` layout:
402
+ declare the blocks once, and every document gets its own private copy of
403
+ their data.
404
+
405
+ ```js
406
+ ArtifactKit.createApp({
407
+ title: "Editor multi-documento",
408
+ layout: "documents",
409
+ documents: {
410
+ label: "documento",
411
+ title: (d) => d.nombre || "Sin título", // tab label, live from the doc's data
412
+ blocks: [
413
+ { fields: [
414
+ { key: "nombre", label: "Nombre", type: "text" },
415
+ { key: "contenido", label: "Contenido", type: "textarea", rows: 14 },
416
+ ]},
417
+ { output: (d) => [{ label: "Palabras", value: (d.contenido || "").split(/\s+/).filter(Boolean).length }] },
418
+ ],
419
+ },
420
+ })
421
+ ```
422
+
423
+ The framework owns the whole document lifecycle: the top tab bar (one tab
424
+ per document, live-labelled, plus a **+** tab that creates and opens a new
425
+ document — no draft to lose, it already exists), the **✕** on each tab to
426
+ close it (closing the open document falls back to a neighbour, never a
427
+ blank pane), the per-document data pools (`d` inside any block, `output`,
428
+ `custom`, or `when` is THAT document's data; `ctx.update` writes there),
429
+ the empty state, and persistence — all documents and which one is open
430
+ 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.
434
+
397
435
  ### External libraries, uploaded files, full-bleed custom, viewer identity
398
436
 
399
437
  Four primitives for the custom-heavy end of the spectrum (document viewers,
package/dist/index.esm.js CHANGED
@@ -1354,9 +1354,10 @@ function StepsLayout({ spec, ctx }) {
1354
1354
  }
1355
1355
 
1356
1356
  // src/app.js
1357
- var LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
1357
+ var LAYOUTS = ["panel", "records", "steps", "chat", "workspace", "documents"];
1358
1358
  var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill"];
1359
- var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs"];
1359
+ 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"];
1360
1361
  function checkFields(fields, where) {
1361
1362
  for (const f of fields || []) {
1362
1363
  if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in ${where} needs a \`key\`.`);
@@ -1532,6 +1533,36 @@ function validate(spec) {
1532
1533
  }
1533
1534
  return layout;
1534
1535
  }
1536
+ if (layout === "documents") {
1537
+ const d = spec.documents;
1538
+ if (!d || !Array.isArray(d.blocks) || d.blocks.length === 0) {
1539
+ throw new Error(
1540
+ 'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }` \u2014 the blocks every document repeats, each with its own data.'
1541
+ );
1542
+ }
1543
+ const unknownD = Object.keys(d).filter((k) => !DOCUMENTS_KEYS.includes(k));
1544
+ if (unknownD.length) {
1545
+ throw new Error(
1546
+ `claude-artifact-framework: documents has unknown keys (${unknownD.join(", ")}). Valid keys: ${DOCUMENTS_KEYS.join(", ")}.`
1547
+ );
1548
+ }
1549
+ if (d.title !== void 0 && typeof d.title !== "function") {
1550
+ throw new Error("claude-artifact-framework: documents.title must be a function of the document's data returning the tab label.");
1551
+ }
1552
+ 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"))) {
1560
+ 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)."
1562
+ );
1563
+ }
1564
+ return layout;
1565
+ }
1535
1566
  if (layout === "records") {
1536
1567
  const r = spec.records;
1537
1568
  if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
@@ -1609,6 +1640,9 @@ function defaultsFrom(spec) {
1609
1640
  if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
1610
1641
  data.records = [];
1611
1642
  }
1643
+ if ((spec.layout || "panel") === "documents" && !Array.isArray(data.docs)) {
1644
+ data.docs = [];
1645
+ }
1612
1646
  const allBlocks = flattenBlocks([...spec.blocks || [], ...spec.main || [], ...spec.sidebar || []]);
1613
1647
  const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
1614
1648
  if (hasChat && !data.chat) {
@@ -1758,7 +1792,124 @@ function WorkspaceLayout({ spec, ctx }) {
1758
1792
  spec.sidebar && spec.sidebar.length ? createElement("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
1759
1793
  );
1760
1794
  }
1761
- var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout };
1795
+ function docDefaults(dspec) {
1796
+ const data = {};
1797
+ for (const block of flattenBlocks(dspec.blocks)) {
1798
+ for (const field of block.fields || []) {
1799
+ if (field && field.key !== void 0 && !(field.key in data)) {
1800
+ data[field.key] = field.value !== void 0 ? field.value : field.type === "check" ? false : "";
1801
+ }
1802
+ }
1803
+ }
1804
+ return data;
1805
+ }
1806
+ function docId() {
1807
+ return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
1808
+ }
1809
+ var DOC_TAB_KEY = "caf:doc";
1810
+ function docTitle(dspec, data) {
1811
+ if (dspec.title) {
1812
+ try {
1813
+ const t = dspec.title(data);
1814
+ if (t !== void 0 && t !== null && t !== "") return String(t);
1815
+ } catch {
1816
+ }
1817
+ }
1818
+ for (const block of flattenBlocks(dspec.blocks)) {
1819
+ for (const f of block.fields || []) {
1820
+ if (!f.type || f.type === "text" || f.type === "textarea" || f.type === "select") {
1821
+ const v = data[f.key];
1822
+ if (v !== "" && v !== void 0 && v !== null) return String(v);
1823
+ return "(untitled)";
1824
+ }
1825
+ }
1826
+ }
1827
+ return "(untitled)";
1828
+ }
1829
+ function DocumentsLayout({ spec, ctx }) {
1830
+ const dspec = spec.documents;
1831
+ const label = dspec.label || "document";
1832
+ const docs = Array.isArray(ctx.data.docs) ? ctx.data.docs : [];
1833
+ const defaults = docDefaults(dspec);
1834
+ const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : void 0;
1835
+ const active = docs.find((doc) => doc.id === storedId) || docs[0];
1836
+ function add() {
1837
+ const doc = { id: docId(), data: docDefaults(dspec) };
1838
+ ctx.update((d) => {
1839
+ d.docs = [...d.docs || [], doc];
1840
+ });
1841
+ ctx.setTab(DOC_TAB_KEY, doc.id);
1842
+ }
1843
+ function close(id) {
1844
+ const idx = docs.findIndex((doc) => doc.id === id);
1845
+ ctx.update((d) => {
1846
+ d.docs = d.docs.filter((doc) => doc.id !== id);
1847
+ });
1848
+ if (active && active.id === id) {
1849
+ const next = docs[idx + 1] || docs[idx - 1];
1850
+ if (next) ctx.setTab(DOC_TAB_KEY, next.id);
1851
+ }
1852
+ }
1853
+ if (!active) {
1854
+ return createElement(
1855
+ "div",
1856
+ { className: "caf-panel caf-panel-single" },
1857
+ createElement(
1858
+ "div",
1859
+ { className: "caf-block caf-empty" },
1860
+ createElement("p", null, dspec.empty || `No ${label}s yet. Create the first one.`),
1861
+ createElement("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `New ${label}`)
1862
+ )
1863
+ );
1864
+ }
1865
+ const activeData = { ...defaults, ...active.data };
1866
+ const scoped = {
1867
+ ...ctx,
1868
+ data: activeData,
1869
+ update: (fn) => ctx.update((d) => {
1870
+ const doc = (d.docs || []).find((x) => x.id === active.id);
1871
+ if (doc) fn(doc.data);
1872
+ })
1873
+ };
1874
+ return createElement(
1875
+ "div",
1876
+ { className: "caf-documents" },
1877
+ createElement(
1878
+ "div",
1879
+ { className: "caf-tabbar", role: "tablist" },
1880
+ docs.map(
1881
+ (doc) => createElement(
1882
+ "span",
1883
+ { key: doc.id, className: "caf-doc-tab" },
1884
+ createElement(
1885
+ "button",
1886
+ {
1887
+ type: "button",
1888
+ role: "tab",
1889
+ "aria-selected": doc.id === active.id,
1890
+ className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
1891
+ onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id)
1892
+ },
1893
+ docTitle(dspec, { ...defaults, ...doc.data })
1894
+ ),
1895
+ createElement(
1896
+ "button",
1897
+ {
1898
+ type: "button",
1899
+ className: "caf-doc-close",
1900
+ "aria-label": `Close ${label}`,
1901
+ onClick: () => close(doc.id)
1902
+ },
1903
+ "\u2715"
1904
+ )
1905
+ )
1906
+ ),
1907
+ createElement("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: add }, "+")
1908
+ ),
1909
+ createElement(Panel, { spec: { blocks: dspec.blocks }, ctx: scoped })
1910
+ );
1911
+ }
1912
+ var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout, documents: DocumentsLayout };
1762
1913
  function loadScript(src) {
1763
1914
  return new Promise((resolve, reject) => {
1764
1915
  const existing = document.querySelector(`script[src="${src}"]`);
@@ -2047,6 +2198,16 @@ var BASE_CSS = `
2047
2198
  /* Accent chevron: the one interaction cue that distinguishes a collapsible
2048
2199
  header from a static card title without breaking their shared typography. */
2049
2200
  .caf-chevron { transition: transform 0.15s ease; color: var(--caf-accent); }
2201
+ .caf-documents { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
2202
+ .caf-doc-tab { display: inline-flex; align-items: center; }
2203
+ .caf-doc-tab .caf-tab { padding-right: 0.35rem; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
2204
+ .caf-doc-close {
2205
+ font: inherit; font-size: 0.7rem; cursor: pointer; border: none; background: none;
2206
+ color: var(--caf-muted); padding: 0.2rem 0.45rem 0.2rem 0.1rem; line-height: 1;
2207
+ }
2208
+ .caf-doc-close:hover { color: var(--caf-danger); }
2209
+ .caf-doc-add { font-size: 1rem; font-weight: 650; }
2210
+ .caf-empty .caf-btn { margin-top: 0.6rem; }
2050
2211
  .caf-chevron-closed { transform: rotate(-90deg); }
2051
2212
  .caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
2052
2213
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }