claude-artifact-framework 0.7.0 → 0.7.2

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.0",
3
+ "version": "0.7.2",
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,13 @@ 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"];
21
- const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
20
+ const LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
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
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"];
26
+ const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar"];
27
27
 
28
28
  // An unknown field type used to fall through silently to a text input — the
29
29
  // exact quiet degradation the framework exists to prevent (a blind-tested
@@ -44,6 +44,27 @@ function checkFields(fields, where) {
44
44
  }
45
45
  }
46
46
 
47
+ function checkChatConfig(c, where) {
48
+ if (!c || (typeof c.system !== "string" && typeof c.system !== "function")) {
49
+ throw new Error(
50
+ `claude-artifact-framework: ${where} needs \`{ system: "..." }\` (a string or a function of data).`
51
+ );
52
+ }
53
+ const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
54
+ if (unknown.length) {
55
+ throw new Error(
56
+ `claude-artifact-framework: ${where} has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
57
+ );
58
+ }
59
+ for (const [name, tool] of Object.entries(c.tools || {})) {
60
+ if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
61
+ throw new Error(
62
+ `claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
63
+ );
64
+ }
65
+ }
66
+ }
67
+
47
68
  const ACTION_KEYS = ["label", "run", "when", "kind"];
48
69
 
49
70
  function checkActions(actions, where) {
@@ -72,7 +93,7 @@ function checkBlocks(blocks) {
72
93
  throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
73
94
  }
74
95
  const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
75
- const known = keys.filter((k) => BLOCK_NAMES.includes(k));
96
+ const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs");
76
97
  if (known.length === 0) {
77
98
  throw new Error(
78
99
  `claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
@@ -89,6 +110,25 @@ function checkBlocks(blocks) {
89
110
  }
90
111
  checkFields(block.fields, `blocks[${i}]`);
91
112
  if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
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
+ }
92
132
  });
93
133
  }
94
134
 
@@ -138,25 +178,28 @@ function validate(spec) {
138
178
  return layout;
139
179
  }
140
180
  if (layout === "chat") {
141
- const c = spec.chat;
142
- if (!c || (typeof c.system !== "string" && typeof c.system !== "function")) {
181
+ checkChatConfig(spec.chat, "chat");
182
+ return layout;
183
+ }
184
+ if (layout === "workspace") {
185
+ if (!Array.isArray(spec.main) || spec.main.length === 0) {
143
186
  throw new Error(
144
- 'claude-artifact-framework: layout "chat" needs `chat: { system: "..." }` (a string or a function of data).'
187
+ 'claude-artifact-framework: layout "workspace" needs `main: [...]` (an array of blocks). `sidebar: [...]` is optional.'
145
188
  );
146
189
  }
147
- const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
148
- if (unknown.length) {
190
+ checkBlocks(spec.main);
191
+ if (spec.sidebar !== undefined) {
192
+ if (!Array.isArray(spec.sidebar)) {
193
+ throw new Error("claude-artifact-framework: workspace `sidebar` must be an array of blocks.");
194
+ }
195
+ checkBlocks(spec.sidebar);
196
+ }
197
+ const chats = flattenBlocks([...spec.main, ...(spec.sidebar || [])]).filter((b) => b && b.chat);
198
+ if (chats.length > 1) {
149
199
  throw new Error(
150
- `claude-artifact-framework: chat has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
200
+ "claude-artifact-framework: only one chat block per app the conversation persists under data.chat and there is exactly one."
151
201
  );
152
202
  }
153
- for (const [name, tool] of Object.entries(c.tools || {})) {
154
- if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
155
- throw new Error(
156
- `claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
157
- );
158
- }
159
- }
160
203
  return layout;
161
204
  }
162
205
  if (layout === "records") {
@@ -222,15 +265,28 @@ function validate(spec) {
222
265
  // Fields declare their own initial value, so the data shape is derived from
223
266
  // the declaration instead of being restated in a separate object. That removes
224
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
+
225
279
  function defaultsFrom(spec) {
226
280
  const data = { ...(spec.data || {}) };
227
281
  if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
228
282
  data.records = [];
229
283
  }
230
- if ((spec.layout || "panel") === "chat" && !data.chat) {
284
+ const allBlocks = flattenBlocks([...(spec.blocks || []), ...(spec.main || []), ...(spec.sidebar || [])]);
285
+ const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
286
+ if (hasChat && !data.chat) {
231
287
  data.chat = { api: [], log: [] };
232
288
  }
233
- for (const block of [...(spec.blocks || []), ...(spec.steps || [])]) {
289
+ for (const block of [...allBlocks, ...(spec.steps || [])]) {
234
290
  for (const field of block.fields || []) {
235
291
  if (field && field.key !== undefined && !(field.key in data)) {
236
292
  data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
@@ -270,10 +326,77 @@ function boundary(React) {
270
326
  return BlockBoundary;
271
327
  }
272
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
+
273
368
  function Block({ block, ctx }) {
274
- const name = Object.keys(block).find((k) => BLOCK_NAMES.includes(k));
275
- const Component = BLOCKS[name];
276
- 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
+ );
277
400
  }
278
401
 
279
402
  function Panel({ spec, ctx }) {
@@ -317,7 +440,20 @@ function RecordsWithBlocks({ spec, ctx }) {
317
440
  );
318
441
  }
319
442
 
320
- const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout };
443
+ // The "content + copilot" shape: a full-width main column and a sidebar.
444
+ // Stacks vertically on narrow screens (sidebar after main).
445
+ function WorkspaceLayout({ spec, ctx }) {
446
+ const render = (blocks) =>
447
+ (blocks || []).map((block, i) => h("section", { key: i, className: "caf-ws-slot" }, h(Block, { block, ctx })));
448
+ return h(
449
+ "div",
450
+ { className: "caf-workspace" },
451
+ h("div", { className: "caf-ws-main" }, render(spec.main)),
452
+ spec.sidebar && spec.sidebar.length ? h("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
453
+ );
454
+ }
455
+
456
+ const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout };
321
457
 
322
458
  export function createApp(spec = {}) {
323
459
  const layout = validate(spec);
@@ -350,6 +486,8 @@ export function createApp(spec = {}) {
350
486
  go: state.go,
351
487
  back: state.back,
352
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 } }),
353
491
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
354
492
  };
355
493
 
@@ -510,6 +648,37 @@ const BASE_CSS = `
510
648
  .caf-md-h { display: block; margin-top: 0.3rem; }
511
649
  .caf-md-li { display: block; padding-left: 1rem; position: relative; }
512
650
  .caf-md-li::before { content: "•"; position: absolute; left: 0.2rem; opacity: 0.6; }
651
+ .caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
652
+ .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
653
+ .caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: 1rem; position: sticky; top: 1rem; }
654
+ .caf-ws-slot { min-width: 0; }
655
+ .caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
656
+ .caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
657
+ @media (max-width: 900px) {
658
+ .caf-workspace { flex-direction: column; align-items: stretch; }
659
+ .caf-ws-side { width: 100%; position: static; }
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); }
513
682
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
514
683
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
515
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);
package/src/blocks.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // Bound lazily rather than snapshotted as `React.createElement`, because this
12
12
  // runs at module load — before the host has had a chance to hand React over.
13
13
  import { createElement as h, useState, useEffect } from "react";
14
+ import { ChatPanel } from "./chat.js";
14
15
 
15
16
  const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
16
17
 
@@ -425,6 +426,7 @@ export const BLOCKS = {
425
426
  chart: ChartBlock,
426
427
  timer: TimerBlock,
427
428
  actions: ActionsBlock,
429
+ chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx }),
428
430
  custom: CustomBlock,
429
431
  };
430
432
 
package/src/chat.js CHANGED
@@ -168,8 +168,11 @@ function Bubble({ m }) {
168
168
  });
169
169
  }
170
170
 
171
- export function ChatLayout({ spec, ctx }) {
172
- const c = spec.chat;
171
+ // The embeddable core: everything the chat layout does, as a single column
172
+ // that can live in a workspace sidebar (or any block slot) as well as fill
173
+ // the whole app. One conversation per app — it persists under data.chat.
174
+ export function ChatPanel({ cfg, ctx }) {
175
+ const c = cfg;
173
176
  const [draft, setDraft] = useState("");
174
177
  const [busy, setBusy] = useState(false);
175
178
  const [live, setLive] = useState(null);
@@ -243,7 +246,7 @@ export function ChatLayout({ spec, ctx }) {
243
246
 
244
247
  return h(
245
248
  "div",
246
- { className: "caf-panel caf-panel-single" },
249
+ { className: "caf-chat-panel" },
247
250
  h(
248
251
  "div",
249
252
  { className: "caf-block caf-chat-log" },
@@ -287,3 +290,7 @@ export function ChatLayout({ spec, ctx }) {
287
290
  )
288
291
  );
289
292
  }
293
+
294
+ export function ChatLayout({ spec, ctx }) {
295
+ return h("div", { className: "caf-panel caf-panel-single" }, h(ChatPanel, { cfg: spec.chat, ctx }));
296
+ }