claude-artifact-framework 0.9.1 → 0.11.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 +68 -0
- package/dist/index.esm.js +165 -3
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +19 -9
- package/dist/index.global.js.map +3 -3
- package/package.json +1 -1
- package/src/app.js +191 -3
package/README.md
CHANGED
|
@@ -394,6 +394,74 @@ 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
|
+
|
|
432
|
+
Inside `documents`, blocks work as in `panel` — **including `chat` and
|
|
433
|
+
`file`, which become per-document**:
|
|
434
|
+
|
|
435
|
+
- A `chat` block inside `documents` gives **each document its own
|
|
436
|
+
conversation**, persisted with that document (`data.chat` is scoped). Its
|
|
437
|
+
`system` function receives the document's data, so the copilot talks
|
|
438
|
+
about THIS document. One chat block per document (a second throws).
|
|
439
|
+
- A `file` field inside `documents` scopes `ctx.files[key]` to the active
|
|
440
|
+
document — two documents never collide on the same field key. As
|
|
441
|
+
everywhere, the live `File` is memory-only: metadata survives reload,
|
|
442
|
+
the file is re-picked per document.
|
|
443
|
+
|
|
444
|
+
That combination — one file plus one conversation per tab — is the
|
|
445
|
+
multi-document annotator shape (PDFs, images, CSVs) declared in full:
|
|
446
|
+
|
|
447
|
+
```js
|
|
448
|
+
ArtifactKit.createApp({
|
|
449
|
+
title: "Anotador de archivos",
|
|
450
|
+
layout: "documents",
|
|
451
|
+
documents: {
|
|
452
|
+
label: "archivo",
|
|
453
|
+
title: (d) => (d.archivo && d.archivo.name) || "Sin archivo",
|
|
454
|
+
blocks: [
|
|
455
|
+
{ fields: [{ key: "archivo", label: "Archivo", type: "file" }] },
|
|
456
|
+
{ custom: (ctx) => { /* render ctx.files.archivo */ }, fill: true },
|
|
457
|
+
{ title: "Copiloto", chat: {
|
|
458
|
+
system: (d) => "Ayudás con el archivo " + ((d.archivo && d.archivo.name) || "(ninguno)"),
|
|
459
|
+
}},
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
```
|
|
464
|
+
|
|
397
465
|
### External libraries, uploaded files, full-bleed custom, viewer identity
|
|
398
466
|
|
|
399
467
|
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,31 @@ 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 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
|
+
}
|
|
1535
1561
|
if (layout === "records") {
|
|
1536
1562
|
const r = spec.records;
|
|
1537
1563
|
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
@@ -1609,6 +1635,9 @@ function defaultsFrom(spec) {
|
|
|
1609
1635
|
if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
|
|
1610
1636
|
data.records = [];
|
|
1611
1637
|
}
|
|
1638
|
+
if ((spec.layout || "panel") === "documents" && !Array.isArray(data.docs)) {
|
|
1639
|
+
data.docs = [];
|
|
1640
|
+
}
|
|
1612
1641
|
const allBlocks = flattenBlocks([...spec.blocks || [], ...spec.main || [], ...spec.sidebar || []]);
|
|
1613
1642
|
const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
|
|
1614
1643
|
if (hasChat && !data.chat) {
|
|
@@ -1758,7 +1787,130 @@ function WorkspaceLayout({ spec, ctx }) {
|
|
|
1758
1787
|
spec.sidebar && spec.sidebar.length ? createElement("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
|
|
1759
1788
|
);
|
|
1760
1789
|
}
|
|
1761
|
-
|
|
1790
|
+
function docDefaults(dspec) {
|
|
1791
|
+
const data = {};
|
|
1792
|
+
for (const block of flattenBlocks(dspec.blocks)) {
|
|
1793
|
+
for (const field of block.fields || []) {
|
|
1794
|
+
if (field && field.key !== void 0 && !(field.key in data)) {
|
|
1795
|
+
data[field.key] = field.value !== void 0 ? field.value : field.type === "check" ? false : "";
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return data;
|
|
1800
|
+
}
|
|
1801
|
+
function docId() {
|
|
1802
|
+
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1803
|
+
}
|
|
1804
|
+
var DOC_TAB_KEY = "caf:doc";
|
|
1805
|
+
function docTitle(dspec, data) {
|
|
1806
|
+
if (dspec.title) {
|
|
1807
|
+
try {
|
|
1808
|
+
const t = dspec.title(data);
|
|
1809
|
+
if (t !== void 0 && t !== null && t !== "") return String(t);
|
|
1810
|
+
} catch {
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
for (const block of flattenBlocks(dspec.blocks)) {
|
|
1814
|
+
for (const f of block.fields || []) {
|
|
1815
|
+
if (!f.type || f.type === "text" || f.type === "textarea" || f.type === "select") {
|
|
1816
|
+
const v = data[f.key];
|
|
1817
|
+
if (v !== "" && v !== void 0 && v !== null) return String(v);
|
|
1818
|
+
return "(untitled)";
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return "(untitled)";
|
|
1823
|
+
}
|
|
1824
|
+
function DocumentsLayout({ spec, ctx }) {
|
|
1825
|
+
const dspec = spec.documents;
|
|
1826
|
+
const label = dspec.label || "document";
|
|
1827
|
+
const docs = Array.isArray(ctx.data.docs) ? ctx.data.docs : [];
|
|
1828
|
+
const defaults = docDefaults(dspec);
|
|
1829
|
+
const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : void 0;
|
|
1830
|
+
const active = docs.find((doc) => doc.id === storedId) || docs[0];
|
|
1831
|
+
function add() {
|
|
1832
|
+
const doc = { id: docId(), data: docDefaults(dspec) };
|
|
1833
|
+
ctx.update((d) => {
|
|
1834
|
+
d.docs = [...d.docs || [], doc];
|
|
1835
|
+
});
|
|
1836
|
+
ctx.setTab(DOC_TAB_KEY, doc.id);
|
|
1837
|
+
}
|
|
1838
|
+
function close(id) {
|
|
1839
|
+
const idx = docs.findIndex((doc) => doc.id === id);
|
|
1840
|
+
ctx.update((d) => {
|
|
1841
|
+
d.docs = d.docs.filter((doc) => doc.id !== id);
|
|
1842
|
+
});
|
|
1843
|
+
delete ctx.files["doc:" + id];
|
|
1844
|
+
if (active && active.id === id) {
|
|
1845
|
+
const next = docs[idx + 1] || docs[idx - 1];
|
|
1846
|
+
if (next) ctx.setTab(DOC_TAB_KEY, next.id);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
if (!active) {
|
|
1850
|
+
return createElement(
|
|
1851
|
+
"div",
|
|
1852
|
+
{ className: "caf-panel caf-panel-single" },
|
|
1853
|
+
createElement(
|
|
1854
|
+
"div",
|
|
1855
|
+
{ className: "caf-block caf-empty" },
|
|
1856
|
+
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}`)
|
|
1858
|
+
)
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
const activeData = { ...defaults, ...active.data };
|
|
1862
|
+
const scoped = {
|
|
1863
|
+
...ctx,
|
|
1864
|
+
data: activeData,
|
|
1865
|
+
update: (fn) => ctx.update((d) => {
|
|
1866
|
+
const doc = (d.docs || []).find((x) => x.id === active.id);
|
|
1867
|
+
if (doc) fn(doc.data);
|
|
1868
|
+
}),
|
|
1869
|
+
// Live File objects scoped per document: each doc gets its own sub-map,
|
|
1870
|
+
// so two documents with the same file field never collide. Like the flat
|
|
1871
|
+
// pool, the File itself is memory-only — metadata survives reload, the
|
|
1872
|
+
// file is re-picked per document.
|
|
1873
|
+
files: ctx.files["doc:" + active.id] = ctx.files["doc:" + active.id] || {}
|
|
1874
|
+
};
|
|
1875
|
+
return createElement(
|
|
1876
|
+
"div",
|
|
1877
|
+
{ 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
|
+
)
|
|
1906
|
+
)
|
|
1907
|
+
),
|
|
1908
|
+
createElement("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: add }, "+")
|
|
1909
|
+
),
|
|
1910
|
+
createElement(Panel, { spec: { blocks: dspec.blocks }, ctx: scoped })
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout, documents: DocumentsLayout };
|
|
1762
1914
|
function loadScript(src) {
|
|
1763
1915
|
return new Promise((resolve, reject) => {
|
|
1764
1916
|
const existing = document.querySelector(`script[src="${src}"]`);
|
|
@@ -2047,6 +2199,16 @@ var BASE_CSS = `
|
|
|
2047
2199
|
/* Accent chevron: the one interaction cue that distinguishes a collapsible
|
|
2048
2200
|
header from a static card title without breaking their shared typography. */
|
|
2049
2201
|
.caf-chevron { transition: transform 0.15s ease; color: var(--caf-accent); }
|
|
2202
|
+
.caf-documents { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
|
2203
|
+
.caf-doc-tab { display: inline-flex; align-items: center; }
|
|
2204
|
+
.caf-doc-tab .caf-tab { padding-right: 0.35rem; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
|
|
2205
|
+
.caf-doc-close {
|
|
2206
|
+
font: inherit; font-size: 0.7rem; cursor: pointer; border: none; background: none;
|
|
2207
|
+
color: var(--caf-muted); padding: 0.2rem 0.45rem 0.2rem 0.1rem; line-height: 1;
|
|
2208
|
+
}
|
|
2209
|
+
.caf-doc-close:hover { color: var(--caf-danger); }
|
|
2210
|
+
.caf-doc-add { font-size: 1rem; font-weight: 650; }
|
|
2211
|
+
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
2050
2212
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
2051
2213
|
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
2052
2214
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|