claude-artifact-framework 0.5.1 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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
@@ -39,6 +39,25 @@ function checkFields(fields, where) {
39
39
  }
40
40
  }
41
41
 
42
+ const ACTION_KEYS = ["label", "run", "when", "kind"];
43
+
44
+ function checkActions(actions, where) {
45
+ if (!Array.isArray(actions)) {
46
+ throw new Error(`claude-artifact-framework: ${where} must be an array of { label, run }.`);
47
+ }
48
+ actions.forEach((a, i) => {
49
+ if (!a || typeof a.label !== "string" || typeof a.run !== "function") {
50
+ throw new Error(`claude-artifact-framework: ${where}[${i}] needs a \`label\` string and a \`run\` function.`);
51
+ }
52
+ const unknown = Object.keys(a).filter((k) => !ACTION_KEYS.includes(k));
53
+ if (unknown.length) {
54
+ throw new Error(
55
+ `claude-artifact-framework: ${where}[${i}] has unknown keys (${unknown.join(", ")}). Valid keys: ${ACTION_KEYS.join(", ")}.`
56
+ );
57
+ }
58
+ });
59
+ }
60
+
42
61
  function checkBlocks(blocks) {
43
62
  if (!Array.isArray(blocks)) {
44
63
  throw new Error("claude-artifact-framework: `blocks` must be an array.");
@@ -64,6 +83,7 @@ function checkBlocks(blocks) {
64
83
  );
65
84
  }
66
85
  checkFields(block.fields, `blocks[${i}]`);
86
+ if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
67
87
  });
68
88
  }
69
89
 
@@ -141,7 +161,7 @@ function validate(spec) {
141
161
  'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
142
162
  );
143
163
  }
144
- const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
164
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
145
165
  const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
146
166
  if (unknownR.length) {
147
167
  throw new Error(
@@ -156,8 +176,28 @@ function validate(spec) {
156
176
  seen.add(f.key);
157
177
  }
158
178
  if (spec.blocks) checkBlocks(spec.blocks);
179
+ if (r.actions) checkActions(r.actions, "records.actions");
180
+ if (r.items) {
181
+ const ITEMS_KEYS = ["label", "fields", "empty"];
182
+ const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
183
+ if (unknownI.length) {
184
+ throw new Error(
185
+ `claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
186
+ );
187
+ }
188
+ if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
189
+ throw new Error("claude-artifact-framework: records.items needs `fields: [...]` — the same field declarations as everywhere else.");
190
+ }
191
+ checkFields(r.items.fields, "records.items");
192
+ for (const f of r.items.fields) {
193
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
194
+ }
195
+ if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
196
+ throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared — that key holds the child collection.');
197
+ }
198
+ }
159
199
  for (const [key, c] of Object.entries(r.compute || {})) {
160
- if (seen.has(key) || key === "id") {
200
+ if (seen.has(key) || key === "id" || (r.items && key === "items")) {
161
201
  throw new Error(
162
202
  `claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
163
203
  );
@@ -449,6 +489,22 @@ const BASE_CSS = `
449
489
  .caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
450
490
  .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
451
491
  .caf-records-blocks { margin-top: 1rem; }
492
+ .caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
493
+ .caf-row-wrap .caf-row { flex: 1; min-width: 0; }
494
+ .caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
495
+ .caf-btn-sm { font-size: 0.78rem; padding: 0.3rem 0.6rem; border-radius: 6px; }
496
+ .caf-items { display: flex; flex-direction: column; gap: 0.5rem; border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
497
+ .caf-item-row { display: flex; align-items: flex-end; gap: 0.5rem; }
498
+ .caf-item-row .caf-field { flex: 1; min-width: 0; }
499
+ .caf-item-row .caf-field label { font-size: 0.68rem; }
500
+ .caf-item-remove { flex: none; margin-bottom: 0.15rem; }
501
+ .caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
502
+ .caf-actions-row { display: flex; flex-wrap: wrap; gap: 0.6rem; }
503
+ .caf-code { background: var(--caf-sunken); border-radius: 6px; padding: 0.55rem 0.7rem; font-size: 0.8rem; overflow-x: auto; margin: 0.3rem 0; font-family: ui-monospace, monospace; }
504
+ .caf-msg code { background: color-mix(in srgb, currentColor 12%, transparent); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; font-family: ui-monospace, monospace; }
505
+ .caf-md-h { display: block; margin-top: 0.3rem; }
506
+ .caf-md-li { display: block; padding-left: 1rem; position: relative; }
507
+ .caf-md-li::before { content: "•"; position: absolute; left: 0.2rem; opacity: 0.6; }
452
508
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
453
509
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
454
510
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
package/src/blocks.js CHANGED
@@ -365,6 +365,39 @@ export function TimerBlock({ spec, ctx }) {
365
365
  );
366
366
  }
367
367
 
368
+ // Declarative buttons that mutate data — the one-tap interactions (counters,
369
+ // check-ins, +1s) that previously forced an escape to custom code.
370
+ export function ActionsBlock({ spec, ctx }) {
371
+ const actions = spec.actions || [];
372
+ const visible = actions.filter((a) => !a.when || a.when(ctx.data));
373
+ return h(
374
+ "div",
375
+ { className: "caf-block caf-actions" },
376
+ spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
377
+ h(
378
+ "div",
379
+ { className: "caf-actions-row" },
380
+ visible.map((a) =>
381
+ h(
382
+ "button",
383
+ {
384
+ key: a.label,
385
+ type: "button",
386
+ className:
387
+ a.kind === "danger"
388
+ ? "caf-btn caf-btn-danger"
389
+ : a.kind === "primary"
390
+ ? "caf-btn caf-btn-primary"
391
+ : "caf-btn",
392
+ onClick: () => ctx.update((d) => a.run(d)),
393
+ },
394
+ a.label
395
+ )
396
+ )
397
+ )
398
+ );
399
+ }
400
+
368
401
  // The escape hatch. Receives the same ctx every built-in block gets, so a
369
402
  // custom block keeps persistence, theming, navigation and the error boundary.
370
403
  export function CustomBlock({ spec, ctx }) {
@@ -382,6 +415,7 @@ export const BLOCKS = {
382
415
  list: ListBlock,
383
416
  chart: ChartBlock,
384
417
  timer: TimerBlock,
418
+ actions: ActionsBlock,
385
419
  custom: CustomBlock,
386
420
  };
387
421
 
package/src/chat.js CHANGED
@@ -125,6 +125,29 @@ function runTools(calls, tools, ctx) {
125
125
  });
126
126
  }
127
127
 
128
+ // Claude answers in markdown; showing raw asterisks makes every chat app
129
+ // look broken. Minimal renderer: HTML is escaped FIRST, then a small set of
130
+ // markdown forms is layered on, so model output can never inject markup.
131
+ const escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
132
+
133
+ export function renderMarkdown(text) {
134
+ const codeBlocks = [];
135
+ let t = String(text).replace(/```[a-z]*\n?([\s\S]*?)```/g, (m, code) => {
136
+ codeBlocks.push(code.replace(/\n$/, ""));
137
+ return "\u0000" + (codeBlocks.length - 1) + "\u0000";
138
+ });
139
+ t = escHtml(t);
140
+ t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
141
+ t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
142
+ t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
143
+ t = t.replace(/^#{1,3} (.+)$/gm, '<strong class="caf-md-h">$1</strong>');
144
+ t = t.replace(/^[-•*] (.+)$/gm, '<span class="caf-md-li">$1</span>');
145
+ t = t.replace(/^\d+\. (.+)$/gm, '<span class="caf-md-li caf-md-num">$1</span>');
146
+ t = t.replace(/\n/g, "<br>");
147
+ t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => '<pre class="caf-code">' + escHtml(codeBlocks[Number(i)]) + "</pre>");
148
+ return t;
149
+ }
150
+
128
151
  function Bubble({ m }) {
129
152
  if (m.role === "tools") {
130
153
  return h("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
@@ -138,7 +161,11 @@ function Bubble({ m }) {
138
161
  h("span", { className: "caf-hint" }, "Your message is kept — send again to retry.")
139
162
  );
140
163
  }
141
- return h("div", { className: m.role === "user" ? "caf-msg caf-msg-user" : "caf-msg caf-msg-assistant" }, m.content);
164
+ if (m.role === "user") return h("div", { className: "caf-msg caf-msg-user" }, m.content);
165
+ return h("div", {
166
+ className: "caf-msg caf-msg-assistant",
167
+ dangerouslySetInnerHTML: { __html: renderMarkdown(m.content) },
168
+ });
142
169
  }
143
170
 
144
171
  export function ChatLayout({ spec, ctx }) {
@@ -220,29 +247,43 @@ export function ChatLayout({ spec, ctx }) {
220
247
  h(
221
248
  "div",
222
249
  { className: "caf-block caf-chat-log" },
223
- c.greeting ? h("div", { className: "caf-msg caf-msg-assistant" }, c.greeting) : null,
250
+ c.greeting
251
+ ? h("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } })
252
+ : null,
224
253
  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,
254
+ live !== null
255
+ ? h("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "…") } })
256
+ : null,
226
257
  busy && live === null ? h("div", { className: "caf-msg-tools" }, "thinking…") : null,
227
258
  h("div", { ref: endRef })
228
259
  ),
260
+ // Deliberately NOT a <form>: the artifact iframe's sandbox lacks
261
+ // allow-forms, so native form submission is blocked there — and on mobile
262
+ // the keyboard's send key triggers exactly that implicit submission. A
263
+ // plain div with onClick + Enter via onKeyDown never touches the native
264
+ // form machinery, so it can't be sandboxed away.
229
265
  h(
230
- "form",
231
- {
232
- className: "caf-chat-input",
233
- onSubmit: (e) => {
234
- e.preventDefault();
235
- send();
236
- },
237
- },
266
+ "div",
267
+ { className: "caf-chat-input" },
238
268
  h("input", {
239
269
  value: draft,
240
270
  placeholder: c.placeholder || "Message…",
241
271
  disabled: busy,
272
+ enterKeyHint: "send",
242
273
  onChange: (e) => setDraft(e.target.value),
274
+ onKeyDown: (e) => {
275
+ if (e.key === "Enter") {
276
+ e.preventDefault();
277
+ send();
278
+ }
279
+ },
243
280
  "aria-label": "Message",
244
281
  }),
245
- h("button", { type: "submit", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim() }, "Send")
282
+ h(
283
+ "button",
284
+ { type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
285
+ "Send"
286
+ )
246
287
  )
247
288
  );
248
289
  }
package/src/records.js CHANGED
@@ -99,6 +99,7 @@ function ListScreen({ spec, ctx }) {
99
99
 
100
100
  function add() {
101
101
  const rec = { id: makeId(), ...recordDefaults(spec.fields) };
102
+ if (spec.items) rec.items = [];
102
103
  ctx.update((d) => {
103
104
  d.records = [...d.records, rec];
104
105
  });
@@ -134,15 +135,42 @@ function ListScreen({ spec, ctx }) {
134
135
  ? h("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`)
135
136
  : records.map((rec) =>
136
137
  h(
137
- "button",
138
- {
139
- key: rec.id,
140
- type: "button",
141
- className: "caf-row",
142
- onClick: () => ctx.go("record", rec.id),
143
- },
144
- h("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
145
- spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
138
+ "div",
139
+ { key: rec.id, className: "caf-row-wrap" },
140
+ h(
141
+ "button",
142
+ {
143
+ type: "button",
144
+ className: "caf-row",
145
+ onClick: () => ctx.go("record", rec.id),
146
+ },
147
+ h("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
148
+ spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
149
+ ),
150
+ spec.actions
151
+ ? h(
152
+ "div",
153
+ { className: "caf-row-actions" },
154
+ spec.actions
155
+ .filter((a) => !a.when || a.when(rec))
156
+ .map((a) =>
157
+ h(
158
+ "button",
159
+ {
160
+ key: a.label,
161
+ type: "button",
162
+ className: a.kind === "danger" ? "caf-btn caf-btn-danger caf-btn-sm" : "caf-btn caf-btn-sm",
163
+ onClick: () =>
164
+ ctx.update((d) => {
165
+ const live = d.records.find((x) => x.id === rec.id);
166
+ if (live) a.run(live, d);
167
+ }),
168
+ },
169
+ a.label
170
+ )
171
+ )
172
+ )
173
+ : null
146
174
  )
147
175
  )
148
176
  );
@@ -179,6 +207,7 @@ function DetailScreen({ spec, ctx, record }) {
179
207
  }),
180
208
  })
181
209
  ),
210
+ spec.items ? h(ItemsEditor, { spec, ctx, record }) : null,
182
211
  spec.compute
183
212
  ? h(
184
213
  "div",
@@ -197,6 +226,75 @@ function DetailScreen({ spec, ctx, record }) {
197
226
  );
198
227
  }
199
228
 
229
+ // The child collection edits INLINE on the parent's detail screen — one
230
+ // navigation level, no child screens. That bounds the feature honestly: items
231
+ // are for small rows (2-4 fields), not for records that deserve their own
232
+ // detail view.
233
+ function ItemsEditor({ spec, ctx, record }) {
234
+ const ispec = spec.items;
235
+ const label = ispec.label || "item";
236
+
237
+ function mutate(fn) {
238
+ ctx.update((d) => {
239
+ const rec = d.records.find((r) => r.id === record.id);
240
+ if (!rec) return;
241
+ if (!Array.isArray(rec.items)) rec.items = [];
242
+ fn(rec);
243
+ });
244
+ }
245
+
246
+ const items = Array.isArray(record.items) ? record.items : [];
247
+
248
+ return h(
249
+ "div",
250
+ { className: "caf-items" },
251
+ h(
252
+ "div",
253
+ { className: "caf-toolbar" },
254
+ h("span", { className: "caf-count" }, `${items.length} ${label}${items.length === 1 ? "" : "s"}`),
255
+ h(
256
+ "button",
257
+ {
258
+ type: "button",
259
+ className: "caf-btn caf-btn-sm",
260
+ onClick: () => mutate((rec) => rec.items.push({ id: makeId(), ...recordDefaults(ispec.fields) })),
261
+ },
262
+ `Add ${label}`
263
+ )
264
+ ),
265
+ items.length === 0
266
+ ? h("div", { className: "caf-empty caf-empty-sm" }, ispec.empty || `No ${label}s yet.`)
267
+ : items.map((item) =>
268
+ h(
269
+ "div",
270
+ { key: item.id, className: "caf-item-row" },
271
+ ispec.fields.map((field) =>
272
+ h(Field, {
273
+ key: field.key,
274
+ field,
275
+ value: item[field.key],
276
+ onChange: (v) =>
277
+ mutate((rec) => {
278
+ const it = rec.items.find((x) => x.id === item.id);
279
+ if (it) it[field.key] = v;
280
+ }),
281
+ })
282
+ ),
283
+ h(
284
+ "button",
285
+ {
286
+ type: "button",
287
+ className: "caf-btn caf-btn-danger caf-btn-sm caf-item-remove",
288
+ "aria-label": `Remove ${label}`,
289
+ onClick: () => mutate((rec) => { rec.items = rec.items.filter((x) => x.id !== item.id); }),
290
+ },
291
+ "✕"
292
+ )
293
+ )
294
+ )
295
+ );
296
+ }
297
+
200
298
  export function RecordsLayout({ spec, ctx }) {
201
299
  const rspec = spec.records;
202
300