claude-artifact-framework 0.9.0 → 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 +43 -1
- package/dist/index.esm.js +180 -9
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +35 -18
- package/dist/index.global.js.map +3 -3
- package/package.json +1 -1
- package/src/app.js +198 -7
- package/src/blocks.js +1 -1
- package/src/chat.js +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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,36 @@ 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
|
+
const flat = flattenBlocks(d.blocks);
|
|
233
|
+
if (flat.some((b) => b && b.chat)) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
'claude-artifact-framework: a chat block inside "documents" is not supported — the conversation store is app-wide, not per document.'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (flat.some((b) => (b.fields || []).some((f) => f.type === "file"))) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
'claude-artifact-framework: `file` fields live in the flat data pool — they are not supported inside documents (the live File cannot be scoped per document).'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return layout;
|
|
244
|
+
}
|
|
213
245
|
if (layout === "records") {
|
|
214
246
|
const r = spec.records;
|
|
215
247
|
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
@@ -294,6 +326,9 @@ function defaultsFrom(spec) {
|
|
|
294
326
|
if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
|
|
295
327
|
data.records = [];
|
|
296
328
|
}
|
|
329
|
+
if ((spec.layout || "panel") === "documents" && !Array.isArray(data.docs)) {
|
|
330
|
+
data.docs = [];
|
|
331
|
+
}
|
|
297
332
|
const allBlocks = flattenBlocks([...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || [])]);
|
|
298
333
|
const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
|
|
299
334
|
if (hasChat && !data.chat) {
|
|
@@ -470,7 +505,146 @@ function WorkspaceLayout({ spec, ctx }) {
|
|
|
470
505
|
);
|
|
471
506
|
}
|
|
472
507
|
|
|
473
|
-
|
|
508
|
+
// documents — N parallel instances of the same mini-app, one per top tab.
|
|
509
|
+
// Each document owns a full data pool; the blocks are declared once and see
|
|
510
|
+
// ONLY the active document's data through a scoped ctx. Which document is
|
|
511
|
+
// open lives in view state (personal, survives reload); the documents
|
|
512
|
+
// themselves live in data.docs.
|
|
513
|
+
function docDefaults(dspec) {
|
|
514
|
+
const data = {};
|
|
515
|
+
for (const block of flattenBlocks(dspec.blocks)) {
|
|
516
|
+
for (const field of block.fields || []) {
|
|
517
|
+
if (field && field.key !== undefined && !(field.key in data)) {
|
|
518
|
+
data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return data;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function docId() {
|
|
526
|
+
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const DOC_TAB_KEY = "caf:doc";
|
|
530
|
+
|
|
531
|
+
function docTitle(dspec, data) {
|
|
532
|
+
if (dspec.title) {
|
|
533
|
+
try {
|
|
534
|
+
const t = dspec.title(data);
|
|
535
|
+
if (t !== undefined && t !== null && t !== "") return String(t);
|
|
536
|
+
} catch {
|
|
537
|
+
/* fall through to the default below */
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// Default: the first text-ish field — same convention records uses.
|
|
541
|
+
for (const block of flattenBlocks(dspec.blocks)) {
|
|
542
|
+
for (const f of block.fields || []) {
|
|
543
|
+
if (!f.type || f.type === "text" || f.type === "textarea" || f.type === "select") {
|
|
544
|
+
const v = data[f.key];
|
|
545
|
+
if (v !== "" && v !== undefined && v !== null) return String(v);
|
|
546
|
+
return "(untitled)";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return "(untitled)";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function DocumentsLayout({ spec, ctx }) {
|
|
554
|
+
const dspec = spec.documents;
|
|
555
|
+
const label = dspec.label || "document";
|
|
556
|
+
const docs = Array.isArray(ctx.data.docs) ? ctx.data.docs : [];
|
|
557
|
+
const defaults = docDefaults(dspec);
|
|
558
|
+
|
|
559
|
+
const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : undefined;
|
|
560
|
+
// A deleted or stale id falls back to the first document, never a blank pane.
|
|
561
|
+
const active = docs.find((doc) => doc.id === storedId) || docs[0];
|
|
562
|
+
|
|
563
|
+
function add() {
|
|
564
|
+
const doc = { id: docId(), data: docDefaults(dspec) };
|
|
565
|
+
ctx.update((d) => {
|
|
566
|
+
d.docs = [...(d.docs || []), doc];
|
|
567
|
+
});
|
|
568
|
+
ctx.setTab(DOC_TAB_KEY, doc.id);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function close(id) {
|
|
572
|
+
const idx = docs.findIndex((doc) => doc.id === id);
|
|
573
|
+
ctx.update((d) => {
|
|
574
|
+
d.docs = d.docs.filter((doc) => doc.id !== id);
|
|
575
|
+
});
|
|
576
|
+
if (active && active.id === id) {
|
|
577
|
+
const next = docs[idx + 1] || docs[idx - 1];
|
|
578
|
+
if (next) ctx.setTab(DOC_TAB_KEY, next.id);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (!active) {
|
|
583
|
+
return h(
|
|
584
|
+
"div",
|
|
585
|
+
{ className: "caf-panel caf-panel-single" },
|
|
586
|
+
h(
|
|
587
|
+
"div",
|
|
588
|
+
{ className: "caf-block caf-empty" },
|
|
589
|
+
h("p", null, dspec.empty || `No ${label}s yet. Create the first one.`),
|
|
590
|
+
h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `New ${label}`)
|
|
591
|
+
)
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Stored data merges over declared defaults, so a field added in a later
|
|
596
|
+
// edit of the spec falls through to its default on older documents (B3).
|
|
597
|
+
const activeData = { ...defaults, ...active.data };
|
|
598
|
+
const scoped = {
|
|
599
|
+
...ctx,
|
|
600
|
+
data: activeData,
|
|
601
|
+
update: (fn) =>
|
|
602
|
+
ctx.update((d) => {
|
|
603
|
+
const doc = (d.docs || []).find((x) => x.id === active.id);
|
|
604
|
+
if (doc) fn(doc.data);
|
|
605
|
+
}),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
return h(
|
|
609
|
+
"div",
|
|
610
|
+
{ className: "caf-documents" },
|
|
611
|
+
h(
|
|
612
|
+
"div",
|
|
613
|
+
{ className: "caf-tabbar", role: "tablist" },
|
|
614
|
+
docs.map((doc) =>
|
|
615
|
+
h(
|
|
616
|
+
"span",
|
|
617
|
+
{ key: doc.id, className: "caf-doc-tab" },
|
|
618
|
+
h(
|
|
619
|
+
"button",
|
|
620
|
+
{
|
|
621
|
+
type: "button",
|
|
622
|
+
role: "tab",
|
|
623
|
+
"aria-selected": doc.id === active.id,
|
|
624
|
+
className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
|
|
625
|
+
onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id),
|
|
626
|
+
},
|
|
627
|
+
docTitle(dspec, { ...defaults, ...doc.data })
|
|
628
|
+
),
|
|
629
|
+
h(
|
|
630
|
+
"button",
|
|
631
|
+
{
|
|
632
|
+
type: "button",
|
|
633
|
+
className: "caf-doc-close",
|
|
634
|
+
"aria-label": `Close ${label}`,
|
|
635
|
+
onClick: () => close(doc.id),
|
|
636
|
+
},
|
|
637
|
+
"✕"
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
),
|
|
641
|
+
h("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: add }, "+")
|
|
642
|
+
),
|
|
643
|
+
h(Panel, { spec: { blocks: dspec.blocks }, ctx: scoped })
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout, documents: DocumentsLayout };
|
|
474
648
|
|
|
475
649
|
// Deduped by src: two apps (or re-mounts) asking for the same lib share one
|
|
476
650
|
// tag, mirroring how the PDF annotator loads pdf.js by hand.
|
|
@@ -617,7 +791,9 @@ const BASE_CSS = `
|
|
|
617
791
|
}
|
|
618
792
|
.caf-block-title { margin: 0; font-size: 0.78rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--caf-muted); }
|
|
619
793
|
.caf-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
620
|
-
|
|
794
|
+
/* A full size+weight step below .caf-block-title — the uppercase alone was
|
|
795
|
+
not enough to tell "card title" from "field label" at a glance. */
|
|
796
|
+
.caf-field label { font-size: 0.68rem; font-weight: 500; color: var(--caf-muted); }
|
|
621
797
|
.caf-field input, .caf-field select, .caf-field textarea {
|
|
622
798
|
font: inherit; font-size: 0.95rem; padding: 0.5rem 0.6rem;
|
|
623
799
|
border-radius: var(--caf-control-radius); border: 1px solid var(--caf-border);
|
|
@@ -651,7 +827,9 @@ const BASE_CSS = `
|
|
|
651
827
|
.caf-row-sub { font-size: 0.78rem; color: var(--caf-muted); }
|
|
652
828
|
.caf-panel-single { grid-template-columns: 1fr; }
|
|
653
829
|
.caf-panel-single .caf-slot, .caf-panel-single > * { grid-column: span 1; }
|
|
654
|
-
|
|
830
|
+
/* Bottom padding matches the inter-row rhythm so the toolbar doesn't sit
|
|
831
|
+
closer to the first row than rows sit to each other. */
|
|
832
|
+
.caf-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; padding: 0.3rem 0.3rem 0.75rem; }
|
|
655
833
|
.caf-count { font-size: 0.8rem; color: var(--caf-muted); font-variant-numeric: tabular-nums; }
|
|
656
834
|
.caf-btn {
|
|
657
835
|
font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
|
@@ -676,7 +854,8 @@ const BASE_CSS = `
|
|
|
676
854
|
.caf-line-meta { display: flex; justify-content: space-between; align-items: baseline; gap: 0.6rem; }
|
|
677
855
|
.caf-line-last { font-weight: 650; font-variant-numeric: tabular-nums; color: var(--caf-accent); }
|
|
678
856
|
.caf-donut { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; }
|
|
679
|
-
|
|
857
|
+
/* -3px optical correction: a circle beside flush-left text reads indented. */
|
|
858
|
+
.caf-donut-svg { width: 110px; height: 110px; flex: none; transform: rotate(0.001deg); margin-left: -3px; }
|
|
680
859
|
.caf-donut-seg { fill: none; stroke-width: 6; }
|
|
681
860
|
/* Capped width keeps each value NEXT TO its label in wide cards — the pair
|
|
682
861
|
must read as a pair regardless of container width. */
|
|
@@ -769,7 +948,19 @@ const BASE_CSS = `
|
|
|
769
948
|
.caf-collapse-head { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
|
|
770
949
|
.caf-collapse-head:hover { filter: brightness(0.97); }
|
|
771
950
|
.caf-collapse-head:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
772
|
-
|
|
951
|
+
/* Accent chevron: the one interaction cue that distinguishes a collapsible
|
|
952
|
+
header from a static card title without breaking their shared typography. */
|
|
953
|
+
.caf-chevron { transition: transform 0.15s ease; color: var(--caf-accent); }
|
|
954
|
+
.caf-documents { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 1rem; }
|
|
955
|
+
.caf-doc-tab { display: inline-flex; align-items: center; }
|
|
956
|
+
.caf-doc-tab .caf-tab { padding-right: 0.35rem; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
|
|
957
|
+
.caf-doc-close {
|
|
958
|
+
font: inherit; font-size: 0.7rem; cursor: pointer; border: none; background: none;
|
|
959
|
+
color: var(--caf-muted); padding: 0.2rem 0.45rem 0.2rem 0.1rem; line-height: 1;
|
|
960
|
+
}
|
|
961
|
+
.caf-doc-close:hover { color: var(--caf-danger); }
|
|
962
|
+
.caf-doc-add { font-size: 1rem; font-weight: 650; }
|
|
963
|
+
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
773
964
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
774
965
|
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
775
966
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
package/src/blocks.js
CHANGED
|
@@ -464,7 +464,7 @@ export const BLOCKS = {
|
|
|
464
464
|
chart: ChartBlock,
|
|
465
465
|
timer: TimerBlock,
|
|
466
466
|
actions: ActionsBlock,
|
|
467
|
-
chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx }),
|
|
467
|
+
chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx, title: spec.title }),
|
|
468
468
|
custom: CustomBlock,
|
|
469
469
|
};
|
|
470
470
|
|
package/src/chat.js
CHANGED
|
@@ -171,7 +171,7 @@ function Bubble({ m }) {
|
|
|
171
171
|
// The embeddable core: everything the chat layout does, as a single column
|
|
172
172
|
// that can live in a workspace sidebar (or any block slot) as well as fill
|
|
173
173
|
// the whole app. One conversation per app — it persists under data.chat.
|
|
174
|
-
export function ChatPanel({ cfg, ctx }) {
|
|
174
|
+
export function ChatPanel({ cfg, ctx, title }) {
|
|
175
175
|
const c = cfg;
|
|
176
176
|
const [draft, setDraft] = useState("");
|
|
177
177
|
const [busy, setBusy] = useState(false);
|
|
@@ -250,6 +250,9 @@ export function ChatPanel({ cfg, ctx }) {
|
|
|
250
250
|
h(
|
|
251
251
|
"div",
|
|
252
252
|
{ className: "caf-block caf-chat-log" },
|
|
253
|
+
// Every other block opens with its title label — a chat card that
|
|
254
|
+
// starts straight at a bubble was the one heading-less block on screen.
|
|
255
|
+
title ? h("h2", { className: "caf-block-title" }, title) : null,
|
|
253
256
|
c.greeting
|
|
254
257
|
? h("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } })
|
|
255
258
|
: null,
|