claude-artifact-framework 0.7.1 → 0.7.3

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.7.1",
3
+ "version": "0.7.3",
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
@@ -18,7 +18,7 @@ import { StepsLayout } from "./steps.js";
18
18
  import { ChatLayout, CHAT_KEYS } from "./chat.js";
19
19
 
20
20
  const LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
21
- const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
21
+ const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible"];
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
@@ -93,7 +93,7 @@ function checkBlocks(blocks) {
93
93
  throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
94
94
  }
95
95
  const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
96
- const known = keys.filter((k) => BLOCK_NAMES.includes(k));
96
+ const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs");
97
97
  if (known.length === 0) {
98
98
  throw new Error(
99
99
  `claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
@@ -111,6 +111,24 @@ function checkBlocks(blocks) {
111
111
  checkFields(block.fields, `blocks[${i}]`);
112
112
  if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
113
113
  if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
114
+ if (known[0] === "tabs") {
115
+ const tabs = block.tabs;
116
+ if (!Array.isArray(tabs) || tabs.length === 0) {
117
+ throw new Error(`claude-artifact-framework: blocks[${i}].tabs needs an array of { label, blocks: [...] }.`);
118
+ }
119
+ tabs.forEach((t, j) => {
120
+ if (!t || typeof t.label !== "string" || !Array.isArray(t.blocks) || t.blocks.length === 0) {
121
+ throw new Error(`claude-artifact-framework: tabs[${j}] needs a \`label\` string and a non-empty \`blocks\` array.`);
122
+ }
123
+ if (t.blocks.some((b) => b && b.tabs)) {
124
+ throw new Error("claude-artifact-framework: tabs cannot nest inside tabs.");
125
+ }
126
+ checkBlocks(t.blocks);
127
+ });
128
+ }
129
+ if (block.when !== undefined && typeof block.when !== "function") {
130
+ throw new Error(`claude-artifact-framework: blocks[${i}].when must be a function of data returning true/false.`);
131
+ }
114
132
  });
115
133
  }
116
134
 
@@ -176,7 +194,7 @@ function validate(spec) {
176
194
  }
177
195
  checkBlocks(spec.sidebar);
178
196
  }
179
- const chats = [...spec.main, ...(spec.sidebar || [])].filter((b) => b && b.chat);
197
+ const chats = flattenBlocks([...spec.main, ...(spec.sidebar || [])]).filter((b) => b && b.chat);
180
198
  if (chats.length > 1) {
181
199
  throw new Error(
182
200
  "claude-artifact-framework: only one chat block per app — the conversation persists under data.chat and there is exactly one."
@@ -247,12 +265,23 @@ function validate(spec) {
247
265
  // Fields declare their own initial value, so the data shape is derived from
248
266
  // the declaration instead of being restated in a separate object. That removes
249
267
  // the key as an invention point: it is written exactly once.
268
+ // Tabs hold blocks of their own, so every scan over "all blocks" (field
269
+ // defaults, chat detection) has to see through them.
270
+ function flattenBlocks(blocks) {
271
+ const out = [];
272
+ for (const b of blocks || []) {
273
+ out.push(b);
274
+ if (b && Array.isArray(b.tabs)) for (const t of b.tabs) out.push(...flattenBlocks(t.blocks));
275
+ }
276
+ return out;
277
+ }
278
+
250
279
  function defaultsFrom(spec) {
251
280
  const data = { ...(spec.data || {}) };
252
281
  if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
253
282
  data.records = [];
254
283
  }
255
- const allBlocks = [...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || [])];
284
+ const allBlocks = flattenBlocks([...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || [])]);
256
285
  const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
257
286
  if (hasChat && !data.chat) {
258
287
  data.chat = { api: [], log: [] };
@@ -297,10 +326,77 @@ function boundary(React) {
297
326
  return BlockBoundary;
298
327
  }
299
328
 
329
+ // Tabs are defined here (not blocks.js) because they render other blocks —
330
+ // keeping the recursion in one file avoids an import cycle.
331
+ function TabsBlock({ spec, ctx }) {
332
+ const tabs = spec.tabs;
333
+ const key = tabs.map((t) => t.label).join("|");
334
+ const stored = ctx.view.tabs ? ctx.view.tabs[key] : undefined;
335
+ const active = Math.min(stored === undefined ? 0 : stored, tabs.length - 1);
336
+ return h(
337
+ "div",
338
+ { className: "caf-tabs" },
339
+ h(
340
+ "div",
341
+ { className: "caf-tabbar", role: "tablist" },
342
+ tabs.map((t, i) =>
343
+ h(
344
+ "button",
345
+ {
346
+ key: t.label,
347
+ type: "button",
348
+ role: "tab",
349
+ "aria-selected": i === active,
350
+ className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
351
+ onClick: () => ctx.setTab(key, i),
352
+ },
353
+ t.label
354
+ )
355
+ )
356
+ ),
357
+ h(
358
+ "div",
359
+ { className: "caf-tabs-body" },
360
+ tabs[active].blocks.map((b, i) => h(Block, { key: active + ":" + i, block: b, ctx }))
361
+ )
362
+ );
363
+ }
364
+
365
+ const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
366
+ const ALL_NAMES = [...BLOCK_NAMES, "tabs"];
367
+
300
368
  function Block({ block, ctx }) {
301
- const name = Object.keys(block).find((k) => BLOCK_NAMES.includes(k));
302
- const Component = BLOCKS[name];
303
- return h(boundary(ctx.React), null, h(Component, { spec: block, ctx }));
369
+ // `when` hides a block entirely — the primitive under conditional UIs.
370
+ if (typeof block.when === "function" && !block.when(ctx.data)) return null;
371
+ const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
372
+ const Component = ALL_BLOCKS[name];
373
+ const inner = h(
374
+ boundary(ctx.React),
375
+ null,
376
+ h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx })
377
+ );
378
+ if (!block.collapsible) return inner;
379
+ // Collapsed state is framework-owned view state, keyed by the block's title
380
+ // (its stable, human-visible identity), so it survives reload.
381
+ const key = typeof block.title === "string" && block.title ? block.title : name;
382
+ const collapsed =
383
+ ctx.view.collapsed && key in ctx.view.collapsed ? ctx.view.collapsed[key] : block.collapsible === "collapsed";
384
+ return h(
385
+ "div",
386
+ { className: "caf-collapse" },
387
+ h(
388
+ "button",
389
+ {
390
+ type: "button",
391
+ className: "caf-collapse-head",
392
+ "aria-expanded": !collapsed,
393
+ onClick: () => ctx.toggleCollapsed(key, !collapsed),
394
+ },
395
+ h("span", null, block.title || name),
396
+ h("span", { className: collapsed ? "caf-chevron caf-chevron-closed" : "caf-chevron" }, "▾")
397
+ ),
398
+ collapsed ? null : h("div", { className: "caf-collapse-body" }, inner)
399
+ );
304
400
  }
305
401
 
306
402
  function Panel({ spec, ctx }) {
@@ -390,6 +486,8 @@ export function createApp(spec = {}) {
390
486
  go: state.go,
391
487
  back: state.back,
392
488
  setStep: (n) => state.setView({ step: n }),
489
+ setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
490
+ toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
393
491
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
394
492
  };
395
493
 
@@ -560,6 +658,27 @@ const BASE_CSS = `
560
658
  .caf-workspace { flex-direction: column; align-items: stretch; }
561
659
  .caf-ws-side { width: 100%; position: static; }
562
660
  }
661
+ .caf-tabs { display: flex; flex-direction: column; gap: 0.8rem; min-width: 0; }
662
+ .caf-tabbar { display: flex; gap: 0.2rem; border-bottom: 1px solid var(--caf-border); overflow-x: auto; }
663
+ .caf-tab {
664
+ font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
665
+ padding: 0.45rem 0.8rem; border: none; background: none; color: var(--caf-muted);
666
+ border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap;
667
+ }
668
+ .caf-tab:hover { color: var(--caf-text); }
669
+ .caf-tab:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: -2px; }
670
+ .caf-tab-active { color: var(--caf-accent); border-bottom-color: var(--caf-accent); }
671
+ .caf-tabs-body { display: flex; flex-direction: column; gap: 0.8rem; }
672
+ .caf-collapse { display: flex; flex-direction: column; gap: 0.5rem; min-width: 0; }
673
+ .caf-collapse-head {
674
+ font: inherit; font-size: 0.95rem; font-weight: 650; cursor: pointer;
675
+ display: flex; align-items: center; justify-content: space-between; gap: 0.6rem;
676
+ padding: 0.7rem 1rem; border: 1px solid var(--caf-border); border-radius: var(--caf-radius);
677
+ background: var(--caf-surface); color: var(--caf-text); width: 100%; text-align: left;
678
+ }
679
+ .caf-collapse-head:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
680
+ .caf-chevron { transition: transform 0.15s ease; color: var(--caf-muted); }
681
+ .caf-chevron-closed { transform: rotate(-90deg); }
563
682
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
564
683
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
565
684
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
package/src/appState.js CHANGED
@@ -16,7 +16,7 @@ import { storage } from "./storage.js";
16
16
  const DATA_KEY = "caf:data";
17
17
  const VIEW_KEY = "caf:view";
18
18
 
19
- const EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null };
19
+ const EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null, tabs: {}, collapsed: {} };
20
20
 
21
21
  function isPlainObject(v) {
22
22
  return v !== null && typeof v === "object" && !Array.isArray(v);