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/README.md CHANGED
@@ -12,7 +12,7 @@ mirrors npm packages automatically.
12
12
  ### As a global script (`<script>` tag)
13
13
 
14
14
  ```html
15
- <script src="https://cdn.jsdelivr.net/npm/claude-artifact-framework@0.4.0/dist/index.global.js"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/claude-artifact-framework@0.5.0/dist/index.global.js"></script>
16
16
  <script>
17
17
  const { storage, createStore, createPersistedStore, createSharedStore } = ArtifactKit;
18
18
  </script>
@@ -27,11 +27,11 @@ mirrors npm packages automatically.
27
27
  createStore,
28
28
  createPersistedStore,
29
29
  createSharedStore,
30
- } from "https://cdn.jsdelivr.net/npm/claude-artifact-framework@0.4.0/dist/index.esm.js";
30
+ } from "https://cdn.jsdelivr.net/npm/claude-artifact-framework@0.5.0/dist/index.esm.js";
31
31
  </script>
32
32
  ```
33
33
 
34
- Pin a version (`@0.4.0`) for reproducible artifacts, or drop the version
34
+ Pin a version (`@0.5.0`) for reproducible artifacts, or drop the version
35
35
  (`claude-artifact-framework/dist/...`) to always get the latest release.
36
36
 
37
37
  ### Inside a React artifact
@@ -175,6 +175,85 @@ blocks: [
175
175
  `text` blocks also accept a function of data — `{ text: (d) => "Hola " + d.nombre }` —
176
176
  with the same contract as `output`.
177
177
 
178
+ ### The `chat` layout — apps that talk to Claude
179
+
180
+ Tutors, advisors, generators, roleplay. Every piece of the network contract is
181
+ copied from code proven inside the real artifact runtime: the artifact fetches
182
+ `https://api.anthropic.com/v1/messages` directly with **no API key** (the
183
+ runtime proxies the call and injects auth), `stream: true` works, and tools
184
+ are prompted (the proxy does not support native function calling).
185
+
186
+ The author declares the system prompt and, optionally, tools that operate on
187
+ the app's data — a chat that can drive the app. The framework owns message
188
+ state, live streaming, the busy state, the tool loop, and errors as visible
189
+ bubbles. The conversation persists and survives reload.
190
+
191
+ ```js
192
+ ArtifactKit.createApp({
193
+ title: "Asistente de gastos",
194
+ theme: { seed: "#0f766e" },
195
+ layout: "chat",
196
+ chat: {
197
+ system: (d) => "Eres un asistente de gastos. Total actual: " + (d.total || 0),
198
+ greeting: "¡Hola! Contame tus gastos.",
199
+ tools: {
200
+ agregar_gasto: {
201
+ description: "Agrega un gasto",
202
+ input: { concepto: "string", monto: "number" },
203
+ run: (input, ctx) => {
204
+ ctx.update((d) => { d.total = (d.total || 0) + Number(input.monto); });
205
+ return { ok: true };
206
+ },
207
+ },
208
+ },
209
+ },
210
+ });
211
+ ```
212
+
213
+ `system` is a string or a function of data. Tool `run(input, ctx)` returns a
214
+ JSON-serializable result that is fed back to the model; an unknown tool name
215
+ is answered in-band so the model corrects itself. Optional: `greeting`,
216
+ `placeholder`, `model` (default `claude-sonnet-4-6`, proven through the
217
+ proxy), `maxTokens`.
218
+
219
+ ### Search, computed fields, and shared data
220
+
221
+ Three record-app multipliers, all declarative:
222
+
223
+ ```js
224
+ ArtifactKit.createApp({
225
+ title: "Inventario del club",
226
+ layout: "records",
227
+ shared: true, // one copy of the data for every viewer
228
+ records: {
229
+ search: true, // search box over all field values
230
+ fields: [
231
+ { key: "nombre", type: "text", required: true },
232
+ { key: "cantidad", type: "number", value: 1, min: 0 },
233
+ { key: "precio", type: "money", value: 0, min: 0 },
234
+ ],
235
+ compute: { // derived per record, never stored
236
+ subtotal: { label: "Subtotal", format: "money", value: (r) => r.cantidad * r.precio },
237
+ },
238
+ summary: (all) => [
239
+ { label: "Valor total", value: all.reduce((s, r) => s + r.subtotal, 0), format: "money", big: true },
240
+ ],
241
+ },
242
+ });
243
+ ```
244
+
245
+ - `search: true` adds a filter box over every field's value; the count shows
246
+ "n of total" while filtering.
247
+ - `compute` entries are functions of the record (or `{ label, format, value }`).
248
+ Computed values appear read-only in the detail screen and are available to
249
+ `title` / `subtitle` / `sort` / `summary` as if they were fields. A compute
250
+ key that collides with a field key throws.
251
+ - `shared: true` (top level) stores the app's data in the shared scope: every
252
+ viewer of the published artifact converges on the same data via polling.
253
+ Conflict handling is last-write-wins; a poll never overwrites local edits
254
+ that haven't been persisted yet. View state (which screen is open) stays
255
+ personal per viewer.
256
+
178
257
  ## `storage`
179
258
 
180
259
  Thin wrapper over `window.storage`, the persistence API Claude injects into a
package/dist/index.esm.js CHANGED
@@ -124,6 +124,7 @@ function required() {
124
124
  var createElement = (...args) => required().createElement(...args);
125
125
  var useState = (...args) => required().useState(...args);
126
126
  var useEffect = (...args) => required().useEffect(...args);
127
+ var useRef = (...args) => required().useRef(...args);
127
128
  var Component = React && React.Component || class ReactMissing {
128
129
  render() {
129
130
  return required();
@@ -154,10 +155,13 @@ function debounce2(fn, ms) {
154
155
  timer = setTimeout(() => fn(...args), ms);
155
156
  };
156
157
  }
157
- function createAppState(defaults, { debounceMs = 400 } = {}) {
158
+ function createAppState(defaults, { debounceMs = 400, shared = false, pollMs = 2500 } = {}) {
159
+ const dataStore = shared ? storage.shared : storage;
158
160
  let data = structuredCloneish(defaults);
159
161
  let view = { ...EMPTY_VIEW };
160
162
  let hydrated = false;
163
+ let writes = 0;
164
+ let persistedWrites = 0;
161
165
  const listeners = /* @__PURE__ */ new Set();
162
166
  let queued = false;
163
167
  function notify() {
@@ -168,11 +172,30 @@ function createAppState(defaults, { debounceMs = 400 } = {}) {
168
172
  for (const fn of listeners) fn();
169
173
  });
170
174
  }
171
- const persistData = debounce2(() => storage.set(DATA_KEY, data), debounceMs);
175
+ const persistData = debounce2(() => {
176
+ const at = writes;
177
+ Promise.resolve(dataStore.set(DATA_KEY, data)).then(() => {
178
+ persistedWrites = Math.max(persistedWrites, at);
179
+ }).catch(() => {
180
+ });
181
+ }, debounceMs);
172
182
  const persistView = debounce2(() => storage.set(VIEW_KEY, view), debounceMs);
183
+ if (shared) {
184
+ setInterval(async () => {
185
+ if (!hydrated || writes > persistedWrites) return;
186
+ try {
187
+ const remote = await dataStore.get(DATA_KEY, void 0);
188
+ if (remote !== void 0 && JSON.stringify(remote) !== JSON.stringify(data)) {
189
+ data = mergeDefaults(defaults, remote);
190
+ notify();
191
+ }
192
+ } catch {
193
+ }
194
+ }, pollMs);
195
+ }
173
196
  const ready = (async () => {
174
197
  const [storedData, storedView] = await Promise.all([
175
- storage.get(DATA_KEY, void 0),
198
+ dataStore.get(DATA_KEY, void 0),
176
199
  storage.get(VIEW_KEY, void 0)
177
200
  ]);
178
201
  if (storedData !== void 0) data = mergeDefaults(defaults, storedData);
@@ -184,6 +207,7 @@ function createAppState(defaults, { debounceMs = 400 } = {}) {
184
207
  const result = fn(data);
185
208
  if (result !== void 0) data = result;
186
209
  data = { ...data };
210
+ writes++;
187
211
  persistData();
188
212
  notify();
189
213
  }
@@ -688,6 +712,21 @@ var BLOCKS = {
688
712
  var BLOCK_NAMES = Object.keys(BLOCKS);
689
713
 
690
714
  // src/records.js
715
+ function computedEntries(spec) {
716
+ return Object.entries(spec.compute || {}).map(([k, v]) => [k, typeof v === "function" ? { value: v } : v]);
717
+ }
718
+ function withComputed(spec, rec) {
719
+ if (!spec.compute) return rec;
720
+ const out = { ...rec };
721
+ for (const [key, c] of computedEntries(spec)) {
722
+ try {
723
+ out[key] = c.value(rec);
724
+ } catch {
725
+ out[key] = void 0;
726
+ }
727
+ }
728
+ return out;
729
+ }
691
730
  function recordDefaults(fields) {
692
731
  const rec = {};
693
732
  for (const f of fields) {
@@ -727,8 +766,16 @@ function Summary({ spec, records }) {
727
766
  }
728
767
  function ListScreen({ spec, ctx }) {
729
768
  const label = spec.label || "item";
730
- let records = ctx.data.records;
769
+ const [query, setQuery] = useState("");
770
+ let records = ctx.data.records.map((r) => withComputed(spec, r));
731
771
  if (spec.sort) records = records.slice().sort(spec.sort);
772
+ const total = records.length;
773
+ const q = query.trim().toLowerCase();
774
+ if (spec.search && q) {
775
+ records = records.filter(
776
+ (r) => spec.fields.some((f) => String(r[f.key] ?? "").toLowerCase().includes(q))
777
+ );
778
+ }
732
779
  function add() {
733
780
  const rec = { id: makeId(), ...recordDefaults(spec.fields) };
734
781
  ctx.update((d) => {
@@ -742,9 +789,21 @@ function ListScreen({ spec, ctx }) {
742
789
  createElement(
743
790
  "div",
744
791
  { className: "caf-toolbar" },
745
- createElement("span", { className: "caf-count" }, `${records.length} ${label}${records.length === 1 ? "" : "s"}`),
792
+ createElement(
793
+ "span",
794
+ { className: "caf-count" },
795
+ spec.search && q ? `${records.length} of ${total}` : `${total} ${label}${total === 1 ? "" : "s"}`
796
+ ),
746
797
  createElement("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `Add ${label}`)
747
798
  ),
799
+ spec.search ? createElement("input", {
800
+ className: "caf-search",
801
+ value: query,
802
+ placeholder: `Search ${label}s\u2026`,
803
+ "aria-label": "Search",
804
+ onChange: (e) => setQuery(e.target.value)
805
+ }) : null,
806
+ spec.search && q && records.length === 0 && total > 0 ? createElement("div", { className: "caf-empty" }, `Nothing matches "${query}".`) : null,
748
807
  records.length === 0 ? createElement("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`) : records.map(
749
808
  (rec) => createElement(
750
809
  "button",
@@ -787,7 +846,20 @@ function DetailScreen({ spec, ctx, record }) {
787
846
  if (rec) rec[field.key] = v;
788
847
  })
789
848
  })
790
- )
849
+ ),
850
+ spec.compute ? createElement(
851
+ "div",
852
+ { className: "caf-output-grid caf-computed" },
853
+ computedEntries(spec).map(([key, c]) => {
854
+ const live = withComputed(spec, record);
855
+ return createElement(
856
+ "div",
857
+ { key, className: "caf-stat" },
858
+ createElement("span", { className: "caf-stat-label" }, c.label || key),
859
+ createElement("span", { className: "caf-stat-value" }, formatValue(live[key], c.format))
860
+ );
861
+ })
862
+ ) : null
791
863
  );
792
864
  }
793
865
  function RecordsLayout({ spec, ctx }) {
@@ -801,7 +873,7 @@ function RecordsLayout({ spec, ctx }) {
801
873
  return createElement(
802
874
  "div",
803
875
  { className: "caf-panel caf-panel-single" },
804
- createElement(Summary, { spec: rspec, records: ctx.data.records }),
876
+ createElement(Summary, { spec: rspec, records: ctx.data.records.map((r) => withComputed(rspec, r)) }),
805
877
  createElement(ListScreen, { spec: rspec, ctx })
806
878
  );
807
879
  }
@@ -889,10 +961,217 @@ function StepsLayout({ spec, ctx }) {
889
961
  );
890
962
  }
891
963
 
964
+ // src/chat.js
965
+ var CHAT_KEYS = ["system", "greeting", "placeholder", "tools", "model", "maxTokens"];
966
+ var DEFAULT_MODEL = "claude-sonnet-4-6";
967
+ var MAX_ROUNDS = 8;
968
+ async function streamCall({ model, maxTokens, system, messages, onText }) {
969
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
970
+ method: "POST",
971
+ headers: { "Content-Type": "application/json" },
972
+ body: JSON.stringify({ model, max_tokens: maxTokens, system, messages, stream: true })
973
+ });
974
+ if (!res.ok || !res.body) {
975
+ let detail = "";
976
+ try {
977
+ const b = await res.json();
978
+ detail = b && b.error && b.error.message || JSON.stringify(b);
979
+ } catch {
980
+ detail = await res.text().catch(() => "");
981
+ }
982
+ throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`);
983
+ }
984
+ const reader = res.body.getReader();
985
+ const decoder = new TextDecoder();
986
+ let full = "";
987
+ let buf = "";
988
+ for (; ; ) {
989
+ const { done, value } = await reader.read();
990
+ if (done) break;
991
+ buf += decoder.decode(value, { stream: true });
992
+ const lines = buf.split("\n");
993
+ buf = lines.pop();
994
+ for (const line of lines) {
995
+ if (!line.startsWith("data: ")) continue;
996
+ try {
997
+ const j = JSON.parse(line.slice(6));
998
+ if (j.type === "content_block_delta" && j.delta && j.delta.text) {
999
+ full += j.delta.text;
1000
+ if (onText) onText(full);
1001
+ }
1002
+ } catch {
1003
+ }
1004
+ }
1005
+ }
1006
+ return full;
1007
+ }
1008
+ function parseToolCalls(text) {
1009
+ const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
1010
+ if (!match) return null;
1011
+ try {
1012
+ const calls = JSON.parse(match[1]);
1013
+ return Array.isArray(calls) && calls.length ? calls : null;
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ }
1018
+ function stripToolCallBlock(text) {
1019
+ return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
1020
+ }
1021
+ function toolProtocol(tools) {
1022
+ const list = Object.entries(tools).map(([name, t]) => `- ${name}: ${t.description}
1023
+ Input: ${JSON.stringify(t.input || {})}`).join("\n");
1024
+ return `
1025
+
1026
+ You have tools available:
1027
+ ${list}
1028
+
1029
+ 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:
1030
+
1031
+ \`\`\`tool_call
1032
+ [{ "name": "tool_name", "input": { "param": "value" } }]
1033
+ \`\`\`
1034
+
1035
+ You may include several calls in one array. NEVER invent a tool's result \u2014 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.`;
1036
+ }
1037
+ function runTools(calls, tools, ctx) {
1038
+ return calls.map((call) => {
1039
+ const tool = tools[call.name];
1040
+ if (!tool) {
1041
+ return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
1042
+ }
1043
+ try {
1044
+ const result = tool.run(call.input || {}, ctx);
1045
+ return { name: call.name, result: result === void 0 ? { ok: true } : result };
1046
+ } catch (e) {
1047
+ return { name: call.name, error: String(e && e.message || e) };
1048
+ }
1049
+ });
1050
+ }
1051
+ function Bubble({ m }) {
1052
+ if (m.role === "tools") {
1053
+ return createElement("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
1054
+ }
1055
+ if (m.role === "error") {
1056
+ return createElement(
1057
+ "div",
1058
+ { className: "caf-msg caf-msg-error" },
1059
+ createElement("strong", null, "The call failed"),
1060
+ createElement("span", null, m.content),
1061
+ createElement("span", { className: "caf-hint" }, "Your message is kept \u2014 send again to retry.")
1062
+ );
1063
+ }
1064
+ return createElement("div", { className: m.role === "user" ? "caf-msg caf-msg-user" : "caf-msg caf-msg-assistant" }, m.content);
1065
+ }
1066
+ function ChatLayout({ spec, ctx }) {
1067
+ const c = spec.chat;
1068
+ const [draft, setDraft] = useState("");
1069
+ const [busy, setBusy] = useState(false);
1070
+ const [live, setLive] = useState(null);
1071
+ const endRef = useRef(null);
1072
+ const chat = ctx.data.chat || { api: [], log: [] };
1073
+ const log = chat.log || [];
1074
+ useEffect(() => {
1075
+ if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
1076
+ }, [log.length, live]);
1077
+ async function send() {
1078
+ const text = draft.trim();
1079
+ if (!text || busy) return;
1080
+ setDraft("");
1081
+ setBusy(true);
1082
+ let api = [...chat.api || [], { role: "user", content: text }];
1083
+ let logNow = [...log, { role: "user", content: text }];
1084
+ const commit = () => ctx.update((d) => {
1085
+ d.chat = { api, log: logNow };
1086
+ });
1087
+ commit();
1088
+ try {
1089
+ const system = (typeof c.system === "function" ? c.system(ctx.data) : c.system || "") + (c.tools ? toolProtocol(c.tools) : "");
1090
+ for (let round = 0; round < MAX_ROUNDS; round++) {
1091
+ const raw = await streamCall({
1092
+ model: c.model || DEFAULT_MODEL,
1093
+ maxTokens: c.maxTokens || 2e3,
1094
+ system,
1095
+ messages: api,
1096
+ onText: (t) => setLive(stripToolCallBlock(t))
1097
+ });
1098
+ api = [...api, { role: "assistant", content: raw }];
1099
+ const shown = stripToolCallBlock(raw);
1100
+ const calls = c.tools ? parseToolCalls(raw) : null;
1101
+ if (!calls) {
1102
+ logNow = [...logNow, { role: "assistant", content: shown || raw }];
1103
+ commit();
1104
+ break;
1105
+ }
1106
+ const results = runTools(calls, c.tools, ctx);
1107
+ if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
1108
+ logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
1109
+ api = [
1110
+ ...api,
1111
+ {
1112
+ role: "user",
1113
+ content: `[TOOL_RESULT]
1114
+ ${JSON.stringify(results)}
1115
+ [/TOOL_RESULT]
1116
+
1117
+ Continue with this information. If you have everything you need, answer in natural language without further tool_call.`
1118
+ }
1119
+ ];
1120
+ commit();
1121
+ setLive(null);
1122
+ }
1123
+ } catch (e) {
1124
+ logNow = [...logNow, { role: "error", content: String(e && e.message || e) }];
1125
+ commit();
1126
+ } finally {
1127
+ setLive(null);
1128
+ setBusy(false);
1129
+ }
1130
+ }
1131
+ return createElement(
1132
+ "div",
1133
+ { className: "caf-panel caf-panel-single" },
1134
+ createElement(
1135
+ "div",
1136
+ { className: "caf-block caf-chat-log" },
1137
+ c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant" }, c.greeting) : null,
1138
+ log.map((m, i) => createElement(Bubble, { key: i, m })),
1139
+ live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live" }, live || "\u2026") : null,
1140
+ busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
1141
+ createElement("div", { ref: endRef })
1142
+ ),
1143
+ createElement(
1144
+ "form",
1145
+ {
1146
+ className: "caf-chat-input",
1147
+ onSubmit: (e) => {
1148
+ e.preventDefault();
1149
+ send();
1150
+ }
1151
+ },
1152
+ createElement("input", {
1153
+ value: draft,
1154
+ placeholder: c.placeholder || "Message\u2026",
1155
+ disabled: busy,
1156
+ onChange: (e) => setDraft(e.target.value),
1157
+ "aria-label": "Message"
1158
+ }),
1159
+ createElement("button", { type: "submit", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim() }, "Send")
1160
+ )
1161
+ );
1162
+ }
1163
+
892
1164
  // src/app.js
893
- var LAYOUTS = ["panel", "records", "steps"];
1165
+ var LAYOUTS = ["panel", "records", "steps", "chat"];
894
1166
  var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
1167
+ var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared"];
895
1168
  function validate(spec) {
1169
+ const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
1170
+ if (unknownTop.length) {
1171
+ throw new Error(
1172
+ `claude-artifact-framework: unknown top-level keys (${unknownTop.join(", ")}). Valid keys: ${TOP_KEYS.join(", ")}.`
1173
+ );
1174
+ }
896
1175
  const layout = spec.layout || "panel";
897
1176
  if (!LAYOUTS.includes(layout)) {
898
1177
  throw new Error(
@@ -931,6 +1210,28 @@ function validate(spec) {
931
1210
  });
932
1211
  return layout;
933
1212
  }
1213
+ if (layout === "chat") {
1214
+ const c = spec.chat;
1215
+ if (!c || typeof c.system !== "string" && typeof c.system !== "function") {
1216
+ throw new Error(
1217
+ 'claude-artifact-framework: layout "chat" needs `chat: { system: "..." }` (a string or a function of data).'
1218
+ );
1219
+ }
1220
+ const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
1221
+ if (unknown.length) {
1222
+ throw new Error(
1223
+ `claude-artifact-framework: chat has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
1224
+ );
1225
+ }
1226
+ for (const [name, tool] of Object.entries(c.tools || {})) {
1227
+ if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
1228
+ throw new Error(
1229
+ `claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
1230
+ );
1231
+ }
1232
+ }
1233
+ return layout;
1234
+ }
934
1235
  if (layout === "records") {
935
1236
  const r = spec.records;
936
1237
  if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
@@ -938,6 +1239,13 @@ function validate(spec) {
938
1239
  'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` \u2014 the same field declarations the fields block uses.'
939
1240
  );
940
1241
  }
1242
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
1243
+ const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
1244
+ if (unknownR.length) {
1245
+ throw new Error(
1246
+ `claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
1247
+ );
1248
+ }
941
1249
  const seen = /* @__PURE__ */ new Set();
942
1250
  for (const f of r.fields) {
943
1251
  if (!f || !f.key) throw new Error("claude-artifact-framework: every records field needs a `key`.");
@@ -945,6 +1253,18 @@ function validate(spec) {
945
1253
  if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
946
1254
  seen.add(f.key);
947
1255
  }
1256
+ for (const [key, c] of Object.entries(r.compute || {})) {
1257
+ if (seen.has(key) || key === "id") {
1258
+ throw new Error(
1259
+ `claude-artifact-framework: compute key "${key}" collides with a field key \u2014 computed values are derived, never stored.`
1260
+ );
1261
+ }
1262
+ if (typeof c !== "function" && typeof (c && c.value) !== "function") {
1263
+ throw new Error(
1264
+ `claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
1265
+ );
1266
+ }
1267
+ }
948
1268
  return layout;
949
1269
  }
950
1270
  const blocks = spec.blocks || [];
@@ -977,6 +1297,9 @@ function defaultsFrom(spec) {
977
1297
  if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
978
1298
  data.records = [];
979
1299
  }
1300
+ if ((spec.layout || "panel") === "chat" && !data.chat) {
1301
+ data.chat = { api: [], log: [] };
1302
+ }
980
1303
  for (const block of [...spec.blocks || [], ...spec.steps || []]) {
981
1304
  for (const field of block.fields || []) {
982
1305
  if (field && field.key !== void 0 && !(field.key in data)) {
@@ -1030,10 +1353,10 @@ function Panel({ spec, ctx }) {
1030
1353
  )
1031
1354
  );
1032
1355
  }
1033
- var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout, steps: StepsLayout };
1356
+ var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout, steps: StepsLayout, chat: ChatLayout };
1034
1357
  function createApp(spec = {}) {
1035
1358
  const layout = validate(spec);
1036
- const state = createAppState(defaultsFrom(spec));
1359
+ const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
1037
1360
  const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
1038
1361
  return function App() {
1039
1362
  if (!hasReact()) {
@@ -1174,6 +1497,25 @@ var BASE_CSS = `
1174
1497
  .caf-steps-fill { transition: width 0.25s ease; }
1175
1498
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
1176
1499
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
1500
+ .caf-chat-log { min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
1501
+ .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; }
1502
+ .caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
1503
+ .caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
1504
+ .caf-msg-live::after { content: "\u258D"; opacity: 0.6; }
1505
+ .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; }
1506
+ .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); }
1507
+ .caf-msg-error strong { color: var(--caf-danger); font-size: 0.85rem; }
1508
+ .caf-chat-input { display: flex; gap: 0.6rem; margin-top: 0.8rem; }
1509
+ .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); }
1510
+ .caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
1511
+ .caf-search {
1512
+ font: inherit; font-size: 0.9rem; width: 100%;
1513
+ padding: 0.5rem 0.65rem; margin: 0 0 0.3rem;
1514
+ border-radius: 7px; border: 1px solid var(--caf-border);
1515
+ background: var(--caf-sunken); color: var(--caf-text);
1516
+ }
1517
+ .caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
1518
+ .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
1177
1519
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
1178
1520
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
1179
1521
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }