claude-artifact-framework 0.12.0 → 0.13.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
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
@@ -25,7 +25,7 @@ const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "col
25
25
  // silently rendering nothing — a blank pane is indistinguishable from a bug.
26
26
  const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents"];
27
27
 
28
- const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty"];
28
+ const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty", "types"];
29
29
  const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
30
30
  const ITEMS_KEYS = ["label", "fields", "empty"];
31
31
 
@@ -219,9 +219,14 @@ function validate(spec) {
219
219
  }
220
220
  if (layout === "documents") {
221
221
  const d = spec.documents;
222
- if (!d || !Array.isArray(d.blocks) || d.blocks.length === 0) {
222
+ if (!d || typeof d !== "object" || (!d.blocks && !d.types)) {
223
223
  throw new Error(
224
- 'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }` the blocks every document repeats, each with its own data.'
224
+ 'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }` (every document repeats the same blocks) or `documents: { types: {...} }` (each document is one of several kinds).'
225
+ );
226
+ }
227
+ if (d.blocks && d.types) {
228
+ throw new Error(
229
+ "claude-artifact-framework: documents takes `blocks` (uniform documents) OR `types` (typed documents) — not both."
225
230
  );
226
231
  }
227
232
  const unknownD = Object.keys(d).filter((k) => !DOCUMENTS_KEYS.includes(k));
@@ -233,18 +238,47 @@ function validate(spec) {
233
238
  if (d.title !== undefined && typeof d.title !== "function") {
234
239
  throw new Error("claude-artifact-framework: documents.title must be a function of the document's data returning the tab label.");
235
240
  }
236
- checkBlocks(d.blocks);
237
241
  // Inside documents both chat and file are PER DOCUMENT: the scoped ctx
238
242
  // gives each document its own data.chat and its own ctx.files. One chat
239
243
  // block in the declaration = one conversation per document.
240
- const docFlat = flattenBlocks(d.blocks);
241
- const chats = docFlat.filter((b) => b && b.chat);
242
- if (chats.length > 1) {
243
- throw new Error(
244
- "claude-artifact-framework: only one chat block per document — each document's conversation persists under its own data.chat and there is exactly one."
245
- );
244
+ const checkDocBlocks = (blocks, where) => {
245
+ checkBlocks(blocks);
246
+ const flat = flattenBlocks(blocks);
247
+ if (flat.filter((b) => b && b.chat).length > 1) {
248
+ throw new Error(
249
+ `claude-artifact-framework: only one chat block per document (${where}) — each document's conversation persists under its own data.chat and there is exactly one.`
250
+ );
251
+ }
252
+ checkRecordsBlockKeys(flat);
253
+ };
254
+ if (d.blocks) {
255
+ if (!Array.isArray(d.blocks) || d.blocks.length === 0) {
256
+ throw new Error("claude-artifact-framework: documents.blocks must be a non-empty array of blocks.");
257
+ }
258
+ checkDocBlocks(d.blocks, "documents.blocks");
259
+ } else {
260
+ const names = Object.keys(d.types || {});
261
+ if (!names.length) {
262
+ throw new Error("claude-artifact-framework: documents.types needs at least one type, e.g. `types: { nota: { blocks: [...] } }`.");
263
+ }
264
+ for (const name of names) {
265
+ const t = d.types[name];
266
+ const TYPE_KEYS = ["label", "title", "blocks", "empty"];
267
+ const unknownT = Object.keys(t || {}).filter((k) => !TYPE_KEYS.includes(k));
268
+ if (unknownT.length) {
269
+ throw new Error(
270
+ `claude-artifact-framework: documents.types.${name} has unknown keys (${unknownT.join(", ")}). Valid keys: ${TYPE_KEYS.join(", ")}.`
271
+ );
272
+ }
273
+ if (!t || !Array.isArray(t.blocks) || t.blocks.length === 0) {
274
+ throw new Error(`claude-artifact-framework: documents.types.${name} needs a non-empty \`blocks\` array.`);
275
+ }
276
+ if (t.title !== undefined && typeof t.title !== "function") {
277
+ throw new Error(`claude-artifact-framework: documents.types.${name}.title must be a function of the document's data.`);
278
+ }
279
+ checkDocBlocks(t.blocks, `documents.types.${name}`);
280
+ }
246
281
  }
247
- checkRecordsBlockKeys(docFlat);
248
282
  return layout;
249
283
  }
250
284
  if (layout === "records") {
@@ -574,7 +608,7 @@ function docId() {
574
608
 
575
609
  const DOC_TAB_KEY = "caf:doc";
576
610
 
577
- function docTitle(dspec, data) {
611
+ function docTitle(dspec, data, untitled) {
578
612
  if (dspec.title) {
579
613
  try {
580
614
  const t = dspec.title(data);
@@ -583,35 +617,52 @@ function docTitle(dspec, data) {
583
617
  /* fall through to the default below */
584
618
  }
585
619
  }
586
- // Default: the first text-ish field — same convention records uses.
620
+ // Default: the first text-ish field — same convention records uses. Typed
621
+ // documents fall back to their type label instead of "(untitled)".
587
622
  for (const block of flattenBlocks(dspec.blocks)) {
588
623
  for (const f of block.fields || []) {
589
624
  if (!f.type || f.type === "text" || f.type === "textarea" || f.type === "select") {
590
625
  const v = data[f.key];
591
626
  if (v !== "" && v !== undefined && v !== null) return String(v);
592
- return "(untitled)";
627
+ return untitled || "(untitled)";
593
628
  }
594
629
  }
595
630
  }
596
- return "(untitled)";
631
+ return untitled || "(untitled)";
597
632
  }
598
633
 
599
634
  function DocumentsLayout({ spec, ctx }) {
600
635
  const dspec = spec.documents;
601
636
  const label = dspec.label || "document";
602
637
  const docs = Array.isArray(ctx.data.docs) ? ctx.data.docs : [];
603
- const defaults = docDefaults(dspec);
638
+ // Typed documents: each doc records which kind it is (`doc.type`, on the
639
+ // envelope — never inside the document's data) and renders ITS type's
640
+ // blocks. Uniform documents are the one-type degenerate case.
641
+ const types = dspec.types || null;
642
+ const typeNames = types ? Object.keys(types) : [];
643
+ const specFor = (doc) => (types ? types[doc.type] : dspec);
644
+ const typeLabel = (name) => (types[name] && types[name].label) || name;
645
+ const [picking, setPicking] = useState(false);
604
646
 
605
647
  const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : undefined;
606
648
  // A deleted or stale id falls back to the first document, never a blank pane.
607
649
  const active = docs.find((doc) => doc.id === storedId) || docs[0];
608
650
 
609
- function add() {
610
- const doc = { id: docId(), data: docDefaults(dspec) };
651
+ function add(typeName) {
652
+ const tspec = types ? types[typeName] : dspec;
653
+ const doc = { id: docId(), ...(types ? { type: typeName } : {}), data: docDefaults(tspec) };
611
654
  ctx.update((d) => {
612
655
  d.docs = [...(d.docs || []), doc];
613
656
  });
614
657
  ctx.setTab(DOC_TAB_KEY, doc.id);
658
+ setPicking(false);
659
+ }
660
+
661
+ // One type (or uniform) creates directly; several ask which kind first.
662
+ function onAdd() {
663
+ if (!types) return add();
664
+ if (typeNames.length === 1) return add(typeNames[0]);
665
+ setPicking((p) => !p);
615
666
  }
616
667
 
617
668
  function close(id) {
@@ -626,6 +677,16 @@ function DocumentsLayout({ spec, ctx }) {
626
677
  }
627
678
  }
628
679
 
680
+ const typePicker = types && picking
681
+ ? h(
682
+ "div",
683
+ { className: "caf-doc-typemenu" },
684
+ typeNames.map((name) =>
685
+ h("button", { key: name, type: "button", className: "caf-btn caf-btn-sm", onClick: () => add(name) }, typeLabel(name))
686
+ )
687
+ )
688
+ : null;
689
+
629
690
  if (!active) {
630
691
  return h(
631
692
  "div",
@@ -634,10 +695,37 @@ function DocumentsLayout({ spec, ctx }) {
634
695
  "div",
635
696
  { className: "caf-block caf-empty" },
636
697
  h("p", null, dspec.empty || `No ${label}s yet. Create the first one.`),
637
- h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `New ${label}`)
698
+ types
699
+ ? h(
700
+ "div",
701
+ { className: "caf-doc-typemenu" },
702
+ typeNames.map((name) =>
703
+ h("button", { key: name, type: "button", className: "caf-btn caf-btn-primary", onClick: () => add(name) }, `New ${typeLabel(name)}`)
704
+ )
705
+ )
706
+ : h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: () => add() }, `New ${label}`)
707
+ )
708
+ );
709
+ }
710
+
711
+ const aspec = specFor(active);
712
+ // A document whose type was removed from the spec: loud, named, contained —
713
+ // the tab (and its ✕) stays usable, the pane explains instead of crashing.
714
+ if (!aspec) {
715
+ return h(
716
+ "div",
717
+ { className: "caf-documents" },
718
+ h(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
719
+ typePicker,
720
+ h(
721
+ "div",
722
+ { className: "caf-block caf-block-error" },
723
+ h("strong", null, `This ${label}'s type "${active.type}" no longer exists`),
724
+ h("code", null, `valid types: ${typeNames.join(", ")}`)
638
725
  )
639
726
  );
640
727
  }
728
+ const defaults = docDefaults(aspec);
641
729
 
642
730
  // Stored data merges over declared defaults, so a field added in a later
643
731
  // edit of the spec falls through to its default on older documents (B3).
@@ -660,39 +748,51 @@ function DocumentsLayout({ spec, ctx }) {
660
748
  return h(
661
749
  "div",
662
750
  { className: "caf-documents" },
663
- h(
664
- "div",
665
- { className: "caf-tabbar", role: "tablist" },
666
- docs.map((doc) =>
751
+ h(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
752
+ typePicker,
753
+ h(Panel, { spec: { blocks: aspec.blocks }, ctx: scoped })
754
+ );
755
+ }
756
+
757
+ function DocTabbar({ docs, active, dspec, types, close, onAdd, label, ctx }) {
758
+ const tabTitle = (doc) => {
759
+ const tspec = types ? types[doc.type] : dspec;
760
+ if (!tspec) return doc.type; // removed type: the name is the best label left
761
+ const merged = { ...docDefaults(tspec), ...doc.data };
762
+ const fallback = types ? (tspec.label || doc.type) : undefined;
763
+ return docTitle(tspec, merged, fallback);
764
+ };
765
+ return h(
766
+ "div",
767
+ { className: "caf-tabbar", role: "tablist" },
768
+ docs.map((doc) =>
769
+ h(
770
+ "span",
771
+ { key: doc.id, className: "caf-doc-tab" },
667
772
  h(
668
- "span",
669
- { key: doc.id, className: "caf-doc-tab" },
670
- h(
671
- "button",
672
- {
673
- type: "button",
674
- role: "tab",
675
- "aria-selected": doc.id === active.id,
676
- className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
677
- onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id),
678
- },
679
- docTitle(dspec, { ...defaults, ...doc.data })
680
- ),
681
- h(
682
- "button",
683
- {
684
- type: "button",
685
- className: "caf-doc-close",
686
- "aria-label": `Close ${label}`,
687
- onClick: () => close(doc.id),
688
- },
689
- "✕"
690
- )
773
+ "button",
774
+ {
775
+ type: "button",
776
+ role: "tab",
777
+ "aria-selected": doc.id === active.id,
778
+ className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
779
+ onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id),
780
+ },
781
+ tabTitle(doc)
782
+ ),
783
+ h(
784
+ "button",
785
+ {
786
+ type: "button",
787
+ className: "caf-doc-close",
788
+ "aria-label": `Close ${label}`,
789
+ onClick: () => close(doc.id),
790
+ },
791
+ ""
691
792
  )
692
- ),
693
- h("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: add }, "+")
793
+ )
694
794
  ),
695
- h(Panel, { spec: { blocks: dspec.blocks }, ctx: scoped })
795
+ h("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: onAdd }, "+")
696
796
  );
697
797
  }
698
798
 
@@ -1014,6 +1114,7 @@ const BASE_CSS = `
1014
1114
  .caf-doc-add { font-size: 1rem; font-weight: 650; }
1015
1115
  .caf-empty .caf-btn { margin-top: 0.6rem; }
1016
1116
  .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
1117
+ .caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
1017
1118
  .caf-chevron-closed { transform: rotate(-90deg); }
1018
1119
  .caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
1019
1120
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }