claude-artifact-framework 0.4.0 → 0.5.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/src/app.js CHANGED
@@ -15,14 +15,23 @@ import { themeCss, seriesColors } from "./theme.js";
15
15
  import { BLOCKS, BLOCK_NAMES } from "./blocks.js";
16
16
  import { RecordsLayout } from "./records.js";
17
17
  import { StepsLayout } from "./steps.js";
18
+ import { ChatLayout, CHAT_KEYS } from "./chat.js";
18
19
 
19
- const LAYOUTS = ["panel", "records", "steps"];
20
+ const LAYOUTS = ["panel", "records", "steps", "chat"];
20
21
  const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
21
22
 
22
23
  // Frontier models still invent identifiers a few percent of the time, so an
23
24
  // unknown name has to fail loudly and name the alternatives rather than
24
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"];
27
+
25
28
  function validate(spec) {
29
+ const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
30
+ if (unknownTop.length) {
31
+ throw new Error(
32
+ `claude-artifact-framework: unknown top-level keys (${unknownTop.join(", ")}). Valid keys: ${TOP_KEYS.join(", ")}.`
33
+ );
34
+ }
26
35
  const layout = spec.layout || "panel";
27
36
  if (!LAYOUTS.includes(layout)) {
28
37
  throw new Error(
@@ -61,6 +70,28 @@ function validate(spec) {
61
70
  });
62
71
  return layout;
63
72
  }
73
+ if (layout === "chat") {
74
+ const c = spec.chat;
75
+ if (!c || (typeof c.system !== "string" && typeof c.system !== "function")) {
76
+ throw new Error(
77
+ 'claude-artifact-framework: layout "chat" needs `chat: { system: "..." }` (a string or a function of data).'
78
+ );
79
+ }
80
+ const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
81
+ if (unknown.length) {
82
+ throw new Error(
83
+ `claude-artifact-framework: chat has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
84
+ );
85
+ }
86
+ for (const [name, tool] of Object.entries(c.tools || {})) {
87
+ if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
88
+ throw new Error(
89
+ `claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
90
+ );
91
+ }
92
+ }
93
+ return layout;
94
+ }
64
95
  if (layout === "records") {
65
96
  const r = spec.records;
66
97
  if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
@@ -68,6 +99,13 @@ function validate(spec) {
68
99
  'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
69
100
  );
70
101
  }
102
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
103
+ const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
104
+ if (unknownR.length) {
105
+ throw new Error(
106
+ `claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
107
+ );
108
+ }
71
109
  const seen = new Set();
72
110
  for (const f of r.fields) {
73
111
  if (!f || !f.key) throw new Error("claude-artifact-framework: every records field needs a `key`.");
@@ -75,6 +113,18 @@ function validate(spec) {
75
113
  if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
76
114
  seen.add(f.key);
77
115
  }
116
+ for (const [key, c] of Object.entries(r.compute || {})) {
117
+ if (seen.has(key) || key === "id") {
118
+ throw new Error(
119
+ `claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
120
+ );
121
+ }
122
+ if (typeof c !== "function" && typeof (c && c.value) !== "function") {
123
+ throw new Error(
124
+ `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
125
+ );
126
+ }
127
+ }
78
128
  return layout;
79
129
  }
80
130
  const blocks = spec.blocks || [];
@@ -113,6 +163,9 @@ function defaultsFrom(spec) {
113
163
  if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
114
164
  data.records = [];
115
165
  }
166
+ if ((spec.layout || "panel") === "chat" && !data.chat) {
167
+ data.chat = { api: [], log: [] };
168
+ }
116
169
  for (const block of [...(spec.blocks || []), ...(spec.steps || [])]) {
117
170
  for (const field of block.fields || []) {
118
171
  if (field && field.key !== undefined && !(field.key in data)) {
@@ -173,11 +226,11 @@ function Panel({ spec, ctx }) {
173
226
  );
174
227
  }
175
228
 
176
- const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout, steps: StepsLayout };
229
+ const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout, steps: StepsLayout, chat: ChatLayout };
177
230
 
178
231
  export function createApp(spec = {}) {
179
232
  const layout = validate(spec);
180
- const state = createAppState(defaultsFrom(spec));
233
+ const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
181
234
  const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
182
235
 
183
236
  return function App() {
@@ -330,6 +383,25 @@ const BASE_CSS = `
330
383
  .caf-steps-fill { transition: width 0.25s ease; }
331
384
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
332
385
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
386
+ .caf-chat-log { min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
387
+ .caf-msg { max-width: 85%; padding: 0.55rem 0.75rem; border-radius: 12px; font-size: 0.92rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
388
+ .caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
389
+ .caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
390
+ .caf-msg-live::after { content: "▍"; opacity: 0.6; }
391
+ .caf-msg-tools { align-self: flex-start; font-size: 0.74rem; color: var(--caf-muted); padding: 0.15rem 0.55rem; border: 1px solid var(--caf-border); border-radius: 999px; }
392
+ .caf-msg-error { align-self: stretch; max-width: none; display: flex; flex-direction: column; gap: 0.2rem; background: transparent; border: 1px solid var(--caf-danger); }
393
+ .caf-msg-error strong { color: var(--caf-danger); font-size: 0.85rem; }
394
+ .caf-chat-input { display: flex; gap: 0.6rem; margin-top: 0.8rem; }
395
+ .caf-chat-input input { flex: 1; font: inherit; font-size: 0.95rem; padding: 0.55rem 0.7rem; border-radius: 8px; border: 1px solid var(--caf-border); background: var(--caf-surface); color: var(--caf-text); }
396
+ .caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
397
+ .caf-search {
398
+ font: inherit; font-size: 0.9rem; width: 100%;
399
+ padding: 0.5rem 0.65rem; margin: 0 0 0.3rem;
400
+ border-radius: 7px; border: 1px solid var(--caf-border);
401
+ background: var(--caf-sunken); color: var(--caf-text);
402
+ }
403
+ .caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
404
+ .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
333
405
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
334
406
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
335
407
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
package/src/appState.js CHANGED
@@ -44,10 +44,20 @@ function debounce(fn, ms) {
44
44
  };
45
45
  }
46
46
 
47
- export function createAppState(defaults, { debounceMs = 400 } = {}) {
47
+ // With `shared: true` the DATA pool lives in the shared storage scope, so
48
+ // every viewer of a published artifact converges on the same data (polled,
49
+ // last-write-wins). View state — navigation, selection, the open record —
50
+ // stays personal ALWAYS: two people looking at the same list should not
51
+ // fight over which screen is open.
52
+ export function createAppState(defaults, { debounceMs = 400, shared = false, pollMs = 2500 } = {}) {
53
+ const dataStore = shared ? storage.shared : storage;
48
54
  let data = structuredCloneish(defaults);
49
55
  let view = { ...EMPTY_VIEW };
50
56
  let hydrated = false;
57
+ // Counts update() calls so a poll never overwrites edits that haven't been
58
+ // persisted yet: remote state only applies when we owe storage nothing.
59
+ let writes = 0;
60
+ let persistedWrites = 0;
51
61
  const listeners = new Set();
52
62
 
53
63
  // Notification is batched to a microtask: three mutations in a row produce
@@ -63,12 +73,32 @@ export function createAppState(defaults, { debounceMs = 400 } = {}) {
63
73
  });
64
74
  }
65
75
 
66
- const persistData = debounce(() => storage.set(DATA_KEY, data), debounceMs);
76
+ const persistData = debounce(() => {
77
+ const at = writes;
78
+ Promise.resolve(dataStore.set(DATA_KEY, data)).then(() => {
79
+ persistedWrites = Math.max(persistedWrites, at);
80
+ }).catch(() => {});
81
+ }, debounceMs);
67
82
  const persistView = debounce(() => storage.set(VIEW_KEY, view), debounceMs);
68
83
 
84
+ if (shared) {
85
+ setInterval(async () => {
86
+ if (!hydrated || writes > persistedWrites) return; // local edits in flight win
87
+ try {
88
+ const remote = await dataStore.get(DATA_KEY, undefined);
89
+ if (remote !== undefined && JSON.stringify(remote) !== JSON.stringify(data)) {
90
+ data = mergeDefaults(defaults, remote);
91
+ notify();
92
+ }
93
+ } catch {
94
+ // a failed poll just means we try again next tick
95
+ }
96
+ }, pollMs);
97
+ }
98
+
69
99
  const ready = (async () => {
70
100
  const [storedData, storedView] = await Promise.all([
71
- storage.get(DATA_KEY, undefined),
101
+ dataStore.get(DATA_KEY, undefined),
72
102
  storage.get(VIEW_KEY, undefined),
73
103
  ]);
74
104
  if (storedData !== undefined) data = mergeDefaults(defaults, storedData);
@@ -82,6 +112,7 @@ export function createAppState(defaults, { debounceMs = 400 } = {}) {
82
112
  // Handlers may either mutate the draft or return a replacement.
83
113
  if (result !== undefined) data = result;
84
114
  data = { ...data };
115
+ writes++;
85
116
  persistData();
86
117
  notify();
87
118
  }
package/src/chat.js ADDED
@@ -0,0 +1,248 @@
1
+ // chat.js — the conversation layout: tutors, advisors, generators, roleplay.
2
+ //
3
+ // Every piece of the network contract here is copied from code proven inside
4
+ // the real artifact runtime (the PDF annotator), not designed on paper:
5
+ //
6
+ // endpoint fetch("https://api.anthropic.com/v1/messages") with NO api
7
+ // key — the artifact runtime proxies the call and injects auth.
8
+ // streaming stream: true works; SSE parsed via content_block_delta.
9
+ // tools the proxy does NOT support native function calling. Tools are
10
+ // prompted: the model replies with a ```tool_call fenced block
11
+ // holding a JSON array; results go back as a user message
12
+ // tagged [TOOL_RESULT]; loop capped at 8 rounds.
13
+ //
14
+ // The author declares the system prompt and optionally tools that operate on
15
+ // the app's data through ctx.update — a chat that can drive the app. The
16
+ // framework owns everything that normally ships half-built: message state,
17
+ // live streaming, the busy state, the tool loop, and errors as visible
18
+ // bubbles instead of silent console noise.
19
+ //
20
+ // State split, same philosophy as data/view: `api` holds the exact messages
21
+ // the API needs (including raw tool_call text and [TOOL_RESULT] payloads);
22
+ // `log` holds what a human should see (stripped text, tool pills, errors).
23
+ // Both persist under data.chat, so a conversation survives reload. The
24
+ // in-flight streaming bubble is deliberately ephemeral local state.
25
+
26
+ import { createElement as h, useState, useEffect, useRef } from "react";
27
+
28
+ export const CHAT_KEYS = ["system", "greeting", "placeholder", "tools", "model", "maxTokens"];
29
+
30
+ // Proven default through the artifact proxy.
31
+ const DEFAULT_MODEL = "claude-sonnet-4-6";
32
+ const MAX_ROUNDS = 8;
33
+
34
+ async function streamCall({ model, maxTokens, system, messages, onText }) {
35
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ model, max_tokens: maxTokens, system, messages, stream: true }),
39
+ });
40
+ if (!res.ok || !res.body) {
41
+ let detail = "";
42
+ try {
43
+ const b = await res.json();
44
+ detail = (b && b.error && b.error.message) || JSON.stringify(b);
45
+ } catch {
46
+ detail = await res.text().catch(() => "");
47
+ }
48
+ throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`);
49
+ }
50
+ const reader = res.body.getReader();
51
+ const decoder = new TextDecoder();
52
+ let full = "";
53
+ let buf = ""; // reassembles SSE events split across network chunks
54
+ for (;;) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+ buf += decoder.decode(value, { stream: true });
58
+ const lines = buf.split("\n");
59
+ buf = lines.pop();
60
+ for (const line of lines) {
61
+ if (!line.startsWith("data: ")) continue;
62
+ try {
63
+ const j = JSON.parse(line.slice(6));
64
+ if (j.type === "content_block_delta" && j.delta && j.delta.text) {
65
+ full += j.delta.text;
66
+ if (onText) onText(full);
67
+ }
68
+ } catch {
69
+ // non-JSON lines (pings, [DONE]) are expected — skip
70
+ }
71
+ }
72
+ }
73
+ return full;
74
+ }
75
+
76
+ export function parseToolCalls(text) {
77
+ const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
78
+ if (!match) return null;
79
+ try {
80
+ const calls = JSON.parse(match[1]);
81
+ return Array.isArray(calls) && calls.length ? calls : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ export function stripToolCallBlock(text) {
88
+ return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
89
+ }
90
+
91
+ // The instructions the model needs to use prompted tools. Appended to the
92
+ // author's system prompt only when tools are declared.
93
+ function toolProtocol(tools) {
94
+ const list = Object.entries(tools)
95
+ .map(([name, t]) => `- ${name}: ${t.description}\n Input: ${JSON.stringify(t.input || {})}`)
96
+ .join("\n");
97
+ return `
98
+
99
+ You have tools available:
100
+ ${list}
101
+
102
+ This environment does not support native function calling. To use a tool, reply ONLY with a \`\`\`tool_call code block containing a JSON array, with no other text in that turn. Example:
103
+
104
+ \`\`\`tool_call
105
+ [{ "name": "tool_name", "input": { "param": "value" } }]
106
+ \`\`\`
107
+
108
+ You may include several calls in one array. NEVER invent a tool's result — emit the pure tool_call block and wait for the real result, which arrives in the next message tagged [TOOL_RESULT]. When you have everything you need, answer in natural language with no tool_call block.`;
109
+ }
110
+
111
+ function runTools(calls, tools, ctx) {
112
+ return calls.map((call) => {
113
+ const tool = tools[call.name];
114
+ if (!tool) {
115
+ // Fed back to the model, loud and in-band: it can correct itself on the
116
+ // next round instead of hallucinating a result.
117
+ return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
118
+ }
119
+ try {
120
+ const result = tool.run(call.input || {}, ctx);
121
+ return { name: call.name, result: result === undefined ? { ok: true } : result };
122
+ } catch (e) {
123
+ return { name: call.name, error: String((e && e.message) || e) };
124
+ }
125
+ });
126
+ }
127
+
128
+ function Bubble({ m }) {
129
+ if (m.role === "tools") {
130
+ return h("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
131
+ }
132
+ if (m.role === "error") {
133
+ return h(
134
+ "div",
135
+ { className: "caf-msg caf-msg-error" },
136
+ h("strong", null, "The call failed"),
137
+ h("span", null, m.content),
138
+ h("span", { className: "caf-hint" }, "Your message is kept — send again to retry.")
139
+ );
140
+ }
141
+ return h("div", { className: m.role === "user" ? "caf-msg caf-msg-user" : "caf-msg caf-msg-assistant" }, m.content);
142
+ }
143
+
144
+ export function ChatLayout({ spec, ctx }) {
145
+ const c = spec.chat;
146
+ const [draft, setDraft] = useState("");
147
+ const [busy, setBusy] = useState(false);
148
+ const [live, setLive] = useState(null);
149
+ const endRef = useRef(null);
150
+
151
+ const chat = ctx.data.chat || { api: [], log: [] };
152
+ const log = chat.log || [];
153
+
154
+ useEffect(() => {
155
+ if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
156
+ }, [log.length, live]);
157
+
158
+ async function send() {
159
+ const text = draft.trim();
160
+ if (!text || busy) return;
161
+ setDraft("");
162
+ setBusy(true);
163
+
164
+ // api/log are threaded locally through the loop; ctx.data would be stale
165
+ // inside this closure after each update().
166
+ let api = [...(chat.api || []), { role: "user", content: text }];
167
+ let logNow = [...log, { role: "user", content: text }];
168
+ const commit = () => ctx.update((d) => { d.chat = { api, log: logNow }; });
169
+ commit();
170
+
171
+ try {
172
+ const system =
173
+ (typeof c.system === "function" ? c.system(ctx.data) : c.system || "") +
174
+ (c.tools ? toolProtocol(c.tools) : "");
175
+
176
+ for (let round = 0; round < MAX_ROUNDS; round++) {
177
+ const raw = await streamCall({
178
+ model: c.model || DEFAULT_MODEL,
179
+ maxTokens: c.maxTokens || 2000,
180
+ system,
181
+ messages: api,
182
+ onText: (t) => setLive(stripToolCallBlock(t)),
183
+ });
184
+
185
+ api = [...api, { role: "assistant", content: raw }];
186
+ const shown = stripToolCallBlock(raw);
187
+ const calls = c.tools ? parseToolCalls(raw) : null;
188
+
189
+ if (!calls) {
190
+ logNow = [...logNow, { role: "assistant", content: shown || raw }];
191
+ commit();
192
+ break;
193
+ }
194
+
195
+ const results = runTools(calls, c.tools, ctx);
196
+ if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
197
+ logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
198
+ api = [
199
+ ...api,
200
+ {
201
+ role: "user",
202
+ content: `[TOOL_RESULT]\n${JSON.stringify(results)}\n[/TOOL_RESULT]\n\nContinue with this information. If you have everything you need, answer in natural language without further tool_call.`,
203
+ },
204
+ ];
205
+ commit();
206
+ setLive(null);
207
+ }
208
+ } catch (e) {
209
+ logNow = [...logNow, { role: "error", content: String((e && e.message) || e) }];
210
+ commit();
211
+ } finally {
212
+ setLive(null);
213
+ setBusy(false);
214
+ }
215
+ }
216
+
217
+ return h(
218
+ "div",
219
+ { className: "caf-panel caf-panel-single" },
220
+ h(
221
+ "div",
222
+ { className: "caf-block caf-chat-log" },
223
+ c.greeting ? h("div", { className: "caf-msg caf-msg-assistant" }, c.greeting) : null,
224
+ log.map((m, i) => h(Bubble, { key: i, m })),
225
+ live !== null ? h("div", { className: "caf-msg caf-msg-assistant caf-msg-live" }, live || "…") : null,
226
+ busy && live === null ? h("div", { className: "caf-msg-tools" }, "thinking…") : null,
227
+ h("div", { ref: endRef })
228
+ ),
229
+ h(
230
+ "form",
231
+ {
232
+ className: "caf-chat-input",
233
+ onSubmit: (e) => {
234
+ e.preventDefault();
235
+ send();
236
+ },
237
+ },
238
+ h("input", {
239
+ value: draft,
240
+ placeholder: c.placeholder || "Message…",
241
+ disabled: busy,
242
+ onChange: (e) => setDraft(e.target.value),
243
+ "aria-label": "Message",
244
+ }),
245
+ h("button", { type: "submit", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim() }, "Send")
246
+ )
247
+ );
248
+ }
package/src/records.js CHANGED
@@ -18,9 +18,29 @@
18
18
  // in data — restoring it on reload is correct, and deleting the open record
19
19
  // simply falls back to the list.
20
20
 
21
- import { createElement as h } from "react";
21
+ import { createElement as h, useState } from "react";
22
22
  import { Field, formatValue } from "./blocks.js";
23
23
 
24
+ // Computed fields are derived at render time and never stored, so they can't
25
+ // go stale. Accepts `{ subtotal: (r) => ... }` or
26
+ // `{ subtotal: { label, format, value: (r) => ... } }`.
27
+ function computedEntries(spec) {
28
+ return Object.entries(spec.compute || {}).map(([k, v]) => [k, typeof v === "function" ? { value: v } : v]);
29
+ }
30
+
31
+ export function withComputed(spec, rec) {
32
+ if (!spec.compute) return rec;
33
+ const out = { ...rec };
34
+ for (const [key, c] of computedEntries(spec)) {
35
+ try {
36
+ out[key] = c.value(rec);
37
+ } catch {
38
+ out[key] = undefined;
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
24
44
  export function recordDefaults(fields) {
25
45
  const rec = {};
26
46
  for (const f of fields) {
@@ -66,8 +86,16 @@ function Summary({ spec, records }) {
66
86
 
67
87
  function ListScreen({ spec, ctx }) {
68
88
  const label = spec.label || "item";
69
- let records = ctx.data.records;
89
+ const [query, setQuery] = useState("");
90
+ let records = ctx.data.records.map((r) => withComputed(spec, r));
70
91
  if (spec.sort) records = records.slice().sort(spec.sort);
92
+ const total = records.length;
93
+ const q = query.trim().toLowerCase();
94
+ if (spec.search && q) {
95
+ records = records.filter((r) =>
96
+ spec.fields.some((f) => String(r[f.key] ?? "").toLowerCase().includes(q))
97
+ );
98
+ }
71
99
 
72
100
  function add() {
73
101
  const rec = { id: makeId(), ...recordDefaults(spec.fields) };
@@ -83,9 +111,25 @@ function ListScreen({ spec, ctx }) {
83
111
  h(
84
112
  "div",
85
113
  { className: "caf-toolbar" },
86
- h("span", { className: "caf-count" }, `${records.length} ${label}${records.length === 1 ? "" : "s"}`),
114
+ h(
115
+ "span",
116
+ { className: "caf-count" },
117
+ spec.search && q ? `${records.length} of ${total}` : `${total} ${label}${total === 1 ? "" : "s"}`
118
+ ),
87
119
  h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `Add ${label}`)
88
120
  ),
121
+ spec.search
122
+ ? h("input", {
123
+ className: "caf-search",
124
+ value: query,
125
+ placeholder: `Search ${label}s…`,
126
+ "aria-label": "Search",
127
+ onChange: (e) => setQuery(e.target.value),
128
+ })
129
+ : null,
130
+ spec.search && q && records.length === 0 && total > 0
131
+ ? h("div", { className: "caf-empty" }, `Nothing matches "${query}".`)
132
+ : null,
89
133
  records.length === 0
90
134
  ? h("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`)
91
135
  : records.map((rec) =>
@@ -134,7 +178,22 @@ function DetailScreen({ spec, ctx, record }) {
134
178
  if (rec) rec[field.key] = v;
135
179
  }),
136
180
  })
137
- )
181
+ ),
182
+ spec.compute
183
+ ? h(
184
+ "div",
185
+ { className: "caf-output-grid caf-computed" },
186
+ computedEntries(spec).map(([key, c]) => {
187
+ const live = withComputed(spec, record);
188
+ return h(
189
+ "div",
190
+ { key, className: "caf-stat" },
191
+ h("span", { className: "caf-stat-label" }, c.label || key),
192
+ h("span", { className: "caf-stat-value" }, formatValue(live[key], c.format))
193
+ );
194
+ })
195
+ )
196
+ : null
138
197
  );
139
198
  }
140
199
 
@@ -152,7 +211,7 @@ export function RecordsLayout({ spec, ctx }) {
152
211
  return h(
153
212
  "div",
154
213
  { className: "caf-panel caf-panel-single" },
155
- h(Summary, { spec: rspec, records: ctx.data.records }),
214
+ h(Summary, { spec: rspec, records: ctx.data.records.map((r) => withComputed(rspec, r)) }),
156
215
  h(ListScreen, { spec: rspec, ctx })
157
216
  );
158
217
  }