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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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
|
@@ -17,13 +17,15 @@ import { RecordsLayout } from "./records.js";
|
|
|
17
17
|
import { StepsLayout } from "./steps.js";
|
|
18
18
|
import { ChatLayout, CHAT_KEYS } from "./chat.js";
|
|
19
19
|
|
|
20
|
-
const LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
|
|
20
|
+
const LAYOUTS = ["panel", "records", "steps", "chat", "workspace", "documents"];
|
|
21
21
|
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill"];
|
|
22
22
|
|
|
23
23
|
// Frontier models still invent identifiers a few percent of the time, so an
|
|
24
24
|
// unknown name has to fail loudly and name the alternatives rather than
|
|
25
25
|
// silently rendering nothing — a blank pane is indistinguishable from a bug.
|
|
26
|
-
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs"];
|
|
26
|
+
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents"];
|
|
27
|
+
|
|
28
|
+
const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty"];
|
|
27
29
|
|
|
28
30
|
// An unknown field type used to fall through silently to a text input — the
|
|
29
31
|
// exact quiet degradation the framework exists to prevent (a blind-tested
|
|
@@ -210,6 +212,34 @@ function validate(spec) {
|
|
|
210
212
|
}
|
|
211
213
|
return layout;
|
|
212
214
|
}
|
|
215
|
+
if (layout === "documents") {
|
|
216
|
+
const d = spec.documents;
|
|
217
|
+
if (!d || !Array.isArray(d.blocks) || d.blocks.length === 0) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }` — the blocks every document repeats, each with its own data.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const unknownD = Object.keys(d).filter((k) => !DOCUMENTS_KEYS.includes(k));
|
|
223
|
+
if (unknownD.length) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`claude-artifact-framework: documents has unknown keys (${unknownD.join(", ")}). Valid keys: ${DOCUMENTS_KEYS.join(", ")}.`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (d.title !== undefined && typeof d.title !== "function") {
|
|
229
|
+
throw new Error("claude-artifact-framework: documents.title must be a function of the document's data returning the tab label.");
|
|
230
|
+
}
|
|
231
|
+
checkBlocks(d.blocks);
|
|
232
|
+
// Inside documents both chat and file are PER DOCUMENT: the scoped ctx
|
|
233
|
+
// gives each document its own data.chat and its own ctx.files. One chat
|
|
234
|
+
// block in the declaration = one conversation per document.
|
|
235
|
+
const chats = flattenBlocks(d.blocks).filter((b) => b && b.chat);
|
|
236
|
+
if (chats.length > 1) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"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
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return layout;
|
|
242
|
+
}
|
|
213
243
|
if (layout === "records") {
|
|
214
244
|
const r = spec.records;
|
|
215
245
|
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
@@ -294,6 +324,9 @@ function defaultsFrom(spec) {
|
|
|
294
324
|
if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
|
|
295
325
|
data.records = [];
|
|
296
326
|
}
|
|
327
|
+
if ((spec.layout || "panel") === "documents" && !Array.isArray(data.docs)) {
|
|
328
|
+
data.docs = [];
|
|
329
|
+
}
|
|
297
330
|
const allBlocks = flattenBlocks([...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || [])]);
|
|
298
331
|
const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
|
|
299
332
|
if (hasChat && !data.chat) {
|
|
@@ -470,7 +503,152 @@ function WorkspaceLayout({ spec, ctx }) {
|
|
|
470
503
|
);
|
|
471
504
|
}
|
|
472
505
|
|
|
473
|
-
|
|
506
|
+
// documents — N parallel instances of the same mini-app, one per top tab.
|
|
507
|
+
// Each document owns a full data pool; the blocks are declared once and see
|
|
508
|
+
// ONLY the active document's data through a scoped ctx. Which document is
|
|
509
|
+
// open lives in view state (personal, survives reload); the documents
|
|
510
|
+
// themselves live in data.docs.
|
|
511
|
+
function docDefaults(dspec) {
|
|
512
|
+
const data = {};
|
|
513
|
+
for (const block of flattenBlocks(dspec.blocks)) {
|
|
514
|
+
for (const field of block.fields || []) {
|
|
515
|
+
if (field && field.key !== undefined && !(field.key in data)) {
|
|
516
|
+
data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return data;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function docId() {
|
|
524
|
+
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const DOC_TAB_KEY = "caf:doc";
|
|
528
|
+
|
|
529
|
+
function docTitle(dspec, data) {
|
|
530
|
+
if (dspec.title) {
|
|
531
|
+
try {
|
|
532
|
+
const t = dspec.title(data);
|
|
533
|
+
if (t !== undefined && t !== null && t !== "") return String(t);
|
|
534
|
+
} catch {
|
|
535
|
+
/* fall through to the default below */
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Default: the first text-ish field — same convention records uses.
|
|
539
|
+
for (const block of flattenBlocks(dspec.blocks)) {
|
|
540
|
+
for (const f of block.fields || []) {
|
|
541
|
+
if (!f.type || f.type === "text" || f.type === "textarea" || f.type === "select") {
|
|
542
|
+
const v = data[f.key];
|
|
543
|
+
if (v !== "" && v !== undefined && v !== null) return String(v);
|
|
544
|
+
return "(untitled)";
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return "(untitled)";
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function DocumentsLayout({ spec, ctx }) {
|
|
552
|
+
const dspec = spec.documents;
|
|
553
|
+
const label = dspec.label || "document";
|
|
554
|
+
const docs = Array.isArray(ctx.data.docs) ? ctx.data.docs : [];
|
|
555
|
+
const defaults = docDefaults(dspec);
|
|
556
|
+
|
|
557
|
+
const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : undefined;
|
|
558
|
+
// A deleted or stale id falls back to the first document, never a blank pane.
|
|
559
|
+
const active = docs.find((doc) => doc.id === storedId) || docs[0];
|
|
560
|
+
|
|
561
|
+
function add() {
|
|
562
|
+
const doc = { id: docId(), data: docDefaults(dspec) };
|
|
563
|
+
ctx.update((d) => {
|
|
564
|
+
d.docs = [...(d.docs || []), doc];
|
|
565
|
+
});
|
|
566
|
+
ctx.setTab(DOC_TAB_KEY, doc.id);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function close(id) {
|
|
570
|
+
const idx = docs.findIndex((doc) => doc.id === id);
|
|
571
|
+
ctx.update((d) => {
|
|
572
|
+
d.docs = d.docs.filter((doc) => doc.id !== id);
|
|
573
|
+
});
|
|
574
|
+
delete ctx.files["doc:" + id];
|
|
575
|
+
if (active && active.id === id) {
|
|
576
|
+
const next = docs[idx + 1] || docs[idx - 1];
|
|
577
|
+
if (next) ctx.setTab(DOC_TAB_KEY, next.id);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!active) {
|
|
582
|
+
return h(
|
|
583
|
+
"div",
|
|
584
|
+
{ className: "caf-panel caf-panel-single" },
|
|
585
|
+
h(
|
|
586
|
+
"div",
|
|
587
|
+
{ className: "caf-block caf-empty" },
|
|
588
|
+
h("p", null, dspec.empty || `No ${label}s yet. Create the first one.`),
|
|
589
|
+
h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `New ${label}`)
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Stored data merges over declared defaults, so a field added in a later
|
|
595
|
+
// edit of the spec falls through to its default on older documents (B3).
|
|
596
|
+
const activeData = { ...defaults, ...active.data };
|
|
597
|
+
const scoped = {
|
|
598
|
+
...ctx,
|
|
599
|
+
data: activeData,
|
|
600
|
+
update: (fn) =>
|
|
601
|
+
ctx.update((d) => {
|
|
602
|
+
const doc = (d.docs || []).find((x) => x.id === active.id);
|
|
603
|
+
if (doc) fn(doc.data);
|
|
604
|
+
}),
|
|
605
|
+
// Live File objects scoped per document: each doc gets its own sub-map,
|
|
606
|
+
// so two documents with the same file field never collide. Like the flat
|
|
607
|
+
// pool, the File itself is memory-only — metadata survives reload, the
|
|
608
|
+
// file is re-picked per document.
|
|
609
|
+
files: (ctx.files["doc:" + active.id] = ctx.files["doc:" + active.id] || {}),
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
return h(
|
|
613
|
+
"div",
|
|
614
|
+
{ className: "caf-documents" },
|
|
615
|
+
h(
|
|
616
|
+
"div",
|
|
617
|
+
{ className: "caf-tabbar", role: "tablist" },
|
|
618
|
+
docs.map((doc) =>
|
|
619
|
+
h(
|
|
620
|
+
"span",
|
|
621
|
+
{ key: doc.id, className: "caf-doc-tab" },
|
|
622
|
+
h(
|
|
623
|
+
"button",
|
|
624
|
+
{
|
|
625
|
+
type: "button",
|
|
626
|
+
role: "tab",
|
|
627
|
+
"aria-selected": doc.id === active.id,
|
|
628
|
+
className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
|
|
629
|
+
onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id),
|
|
630
|
+
},
|
|
631
|
+
docTitle(dspec, { ...defaults, ...doc.data })
|
|
632
|
+
),
|
|
633
|
+
h(
|
|
634
|
+
"button",
|
|
635
|
+
{
|
|
636
|
+
type: "button",
|
|
637
|
+
className: "caf-doc-close",
|
|
638
|
+
"aria-label": `Close ${label}`,
|
|
639
|
+
onClick: () => close(doc.id),
|
|
640
|
+
},
|
|
641
|
+
"✕"
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
),
|
|
645
|
+
h("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: add }, "+")
|
|
646
|
+
),
|
|
647
|
+
h(Panel, { spec: { blocks: dspec.blocks }, ctx: scoped })
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout, documents: DocumentsLayout };
|
|
474
652
|
|
|
475
653
|
// Deduped by src: two apps (or re-mounts) asking for the same lib share one
|
|
476
654
|
// tag, mirroring how the PDF annotator loads pdf.js by hand.
|
|
@@ -777,6 +955,16 @@ const BASE_CSS = `
|
|
|
777
955
|
/* Accent chevron: the one interaction cue that distinguishes a collapsible
|
|
778
956
|
header from a static card title without breaking their shared typography. */
|
|
779
957
|
.caf-chevron { transition: transform 0.15s ease; color: var(--caf-accent); }
|
|
958
|
+
.caf-documents { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
|
959
|
+
.caf-doc-tab { display: inline-flex; align-items: center; }
|
|
960
|
+
.caf-doc-tab .caf-tab { padding-right: 0.35rem; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
|
|
961
|
+
.caf-doc-close {
|
|
962
|
+
font: inherit; font-size: 0.7rem; cursor: pointer; border: none; background: none;
|
|
963
|
+
color: var(--caf-muted); padding: 0.2rem 0.45rem 0.2rem 0.1rem; line-height: 1;
|
|
964
|
+
}
|
|
965
|
+
.caf-doc-close:hover { color: var(--caf-danger); }
|
|
966
|
+
.caf-doc-add { font-size: 1rem; font-weight: 650; }
|
|
967
|
+
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
780
968
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
781
969
|
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
782
970
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|