claude-artifact-framework 0.5.2 → 0.7.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.2",
3
+ "version": "0.7.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
@@ -36,7 +36,31 @@ function checkFields(fields, where) {
36
36
  `claude-artifact-framework: field "${f.key}" in ${where} has unknown type "${f.type}". Valid types: ${FIELD_TYPES.join(", ")}.`
37
37
  );
38
38
  }
39
+ if (f.type === "select" && f.options !== undefined && !Array.isArray(f.options) && typeof f.options !== "function") {
40
+ throw new Error(
41
+ `claude-artifact-framework: field "${f.key}" in ${where}: \`options\` must be an array or a function of data returning one.`
42
+ );
43
+ }
44
+ }
45
+ }
46
+
47
+ const ACTION_KEYS = ["label", "run", "when", "kind"];
48
+
49
+ function checkActions(actions, where) {
50
+ if (!Array.isArray(actions)) {
51
+ throw new Error(`claude-artifact-framework: ${where} must be an array of { label, run }.`);
39
52
  }
53
+ actions.forEach((a, i) => {
54
+ if (!a || typeof a.label !== "string" || typeof a.run !== "function") {
55
+ throw new Error(`claude-artifact-framework: ${where}[${i}] needs a \`label\` string and a \`run\` function.`);
56
+ }
57
+ const unknown = Object.keys(a).filter((k) => !ACTION_KEYS.includes(k));
58
+ if (unknown.length) {
59
+ throw new Error(
60
+ `claude-artifact-framework: ${where}[${i}] has unknown keys (${unknown.join(", ")}). Valid keys: ${ACTION_KEYS.join(", ")}.`
61
+ );
62
+ }
63
+ });
40
64
  }
41
65
 
42
66
  function checkBlocks(blocks) {
@@ -64,6 +88,7 @@ function checkBlocks(blocks) {
64
88
  );
65
89
  }
66
90
  checkFields(block.fields, `blocks[${i}]`);
91
+ if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
67
92
  });
68
93
  }
69
94
 
@@ -141,7 +166,7 @@ function validate(spec) {
141
166
  'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
142
167
  );
143
168
  }
144
- const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
169
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
145
170
  const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
146
171
  if (unknownR.length) {
147
172
  throw new Error(
@@ -156,8 +181,28 @@ function validate(spec) {
156
181
  seen.add(f.key);
157
182
  }
158
183
  if (spec.blocks) checkBlocks(spec.blocks);
184
+ if (r.actions) checkActions(r.actions, "records.actions");
185
+ if (r.items) {
186
+ const ITEMS_KEYS = ["label", "fields", "empty"];
187
+ const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
188
+ if (unknownI.length) {
189
+ throw new Error(
190
+ `claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
191
+ );
192
+ }
193
+ if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
194
+ throw new Error("claude-artifact-framework: records.items needs `fields: [...]` — the same field declarations as everywhere else.");
195
+ }
196
+ checkFields(r.items.fields, "records.items");
197
+ for (const f of r.items.fields) {
198
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
199
+ }
200
+ if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
201
+ throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared — that key holds the child collection.');
202
+ }
203
+ }
159
204
  for (const [key, c] of Object.entries(r.compute || {})) {
160
- if (seen.has(key) || key === "id") {
205
+ if (seen.has(key) || key === "id" || (r.items && key === "items")) {
161
206
  throw new Error(
162
207
  `claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
163
208
  );
@@ -449,6 +494,22 @@ const BASE_CSS = `
449
494
  .caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
450
495
  .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
451
496
  .caf-records-blocks { margin-top: 1rem; }
497
+ .caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
498
+ .caf-row-wrap .caf-row { flex: 1; min-width: 0; }
499
+ .caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
500
+ .caf-btn-sm { font-size: 0.78rem; padding: 0.3rem 0.6rem; border-radius: 6px; }
501
+ .caf-items { display: flex; flex-direction: column; gap: 0.5rem; border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
502
+ .caf-item-row { display: flex; align-items: flex-end; gap: 0.5rem; }
503
+ .caf-item-row .caf-field { flex: 1; min-width: 0; }
504
+ .caf-item-row .caf-field label { font-size: 0.68rem; }
505
+ .caf-item-remove { flex: none; margin-bottom: 0.15rem; }
506
+ .caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
507
+ .caf-actions-row { display: flex; flex-wrap: wrap; gap: 0.6rem; }
508
+ .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; }
509
+ .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; }
510
+ .caf-md-h { display: block; margin-top: 0.3rem; }
511
+ .caf-md-li { display: block; padding-left: 1rem; position: relative; }
512
+ .caf-md-li::before { content: "•"; position: absolute; left: 0.2rem; opacity: 0.6; }
452
513
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
453
514
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
454
515
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
package/src/blocks.js CHANGED
@@ -49,7 +49,7 @@ export function validateField(field, value) {
49
49
  return null;
50
50
  }
51
51
 
52
- export function Field({ field, value, onChange }) {
52
+ export function Field({ field, value, onChange, data }) {
53
53
  const error = validateField(field, value);
54
54
  const id = `caf-f-${field.key}`;
55
55
  const numeric = field.type === "number" || field.type === "money" || field.type === "percent";
@@ -79,11 +79,14 @@ export function Field({ field, value, onChange }) {
79
79
  if (field.type === "textarea") {
80
80
  control = h("textarea", { ...common, rows: field.rows || 3 });
81
81
  } else if (field.type === "select") {
82
+ // Options may be declared as a function of the app data, for selects
83
+ // whose choices come from what the user has entered elsewhere.
84
+ const options = typeof field.options === "function" ? field.options(data) || [] : field.options || [];
82
85
  control = h(
83
86
  "select",
84
87
  common,
85
88
  h("option", { value: "" }, field.placeholder || "Select…"),
86
- (field.options || []).map((opt) => {
89
+ options.map((opt) => {
87
90
  const val = typeof opt === "string" ? opt : opt.value;
88
91
  const label = typeof opt === "string" ? opt : opt.label;
89
92
  return h("option", { key: val, value: val }, label);
@@ -126,6 +129,7 @@ export function FieldsBlock({ spec, ctx }) {
126
129
  h(Field, {
127
130
  key: field.key,
128
131
  field,
132
+ data: ctx.data,
129
133
  value: ctx.data[field.key],
130
134
  onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
131
135
  })
@@ -170,13 +174,18 @@ export function TextBlock({ spec, ctx }) {
170
174
 
171
175
  export function ListBlock({ spec, ctx }) {
172
176
  const rows = (typeof spec.list === "function" ? spec.list(ctx.data) : spec.list) || [];
177
+ // `title` follows the same convention as every other block: a string is the
178
+ // block heading. A function is the per-row title. Both are supported —
179
+ // making list the odd one out was an API inconsistency a blind test caught.
180
+ const heading = typeof spec.title === "string" ? spec.title : null;
181
+ const rowTitle = typeof spec.title === "function" ? spec.title : (r) => r.title ?? r.name ?? r.label ?? r.text ?? String(r);
173
182
  if (!rows.length) {
174
183
  return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing here yet.");
175
184
  }
176
- const title = spec.title || ((r) => r.title ?? r.name ?? r.label ?? String(r));
177
185
  return h(
178
186
  "div",
179
187
  { className: "caf-block caf-list" },
188
+ heading ? h("h2", { className: "caf-block-title" }, heading) : null,
180
189
  rows.map((row, i) =>
181
190
  h(
182
191
  "button",
@@ -186,7 +195,7 @@ export function ListBlock({ spec, ctx }) {
186
195
  type: "button",
187
196
  onClick: spec.onSelect ? () => spec.onSelect(row, ctx) : undefined,
188
197
  },
189
- h("span", { className: "caf-row-title" }, title(row)),
198
+ h("span", { className: "caf-row-title" }, rowTitle(row)),
190
199
  spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(row)) : null
191
200
  )
192
201
  )
@@ -365,6 +374,39 @@ export function TimerBlock({ spec, ctx }) {
365
374
  );
366
375
  }
367
376
 
377
+ // Declarative buttons that mutate data — the one-tap interactions (counters,
378
+ // check-ins, +1s) that previously forced an escape to custom code.
379
+ export function ActionsBlock({ spec, ctx }) {
380
+ const actions = spec.actions || [];
381
+ const visible = actions.filter((a) => !a.when || a.when(ctx.data));
382
+ return h(
383
+ "div",
384
+ { className: "caf-block caf-actions" },
385
+ spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
386
+ h(
387
+ "div",
388
+ { className: "caf-actions-row" },
389
+ visible.map((a) =>
390
+ h(
391
+ "button",
392
+ {
393
+ key: a.label,
394
+ type: "button",
395
+ className:
396
+ a.kind === "danger"
397
+ ? "caf-btn caf-btn-danger"
398
+ : a.kind === "primary"
399
+ ? "caf-btn caf-btn-primary"
400
+ : "caf-btn",
401
+ onClick: () => ctx.update((d) => a.run(d)),
402
+ },
403
+ a.label
404
+ )
405
+ )
406
+ )
407
+ );
408
+ }
409
+
368
410
  // The escape hatch. Receives the same ctx every built-in block gets, so a
369
411
  // custom block keeps persistence, theming, navigation and the error boundary.
370
412
  export function CustomBlock({ spec, ctx }) {
@@ -382,6 +424,7 @@ export const BLOCKS = {
382
424
  list: ListBlock,
383
425
  chart: ChartBlock,
384
426
  timer: TimerBlock,
427
+ actions: ActionsBlock,
385
428
  custom: CustomBlock,
386
429
  };
387
430
 
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,9 +247,13 @@ 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
  ),
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
  );
@@ -171,6 +199,7 @@ function DetailScreen({ spec, ctx, record }) {
171
199
  h(Field, {
172
200
  key: field.key,
173
201
  field,
202
+ data: ctx.data,
174
203
  value: record[field.key],
175
204
  onChange: (v) =>
176
205
  ctx.update((d) => {
@@ -179,6 +208,7 @@ function DetailScreen({ spec, ctx, record }) {
179
208
  }),
180
209
  })
181
210
  ),
211
+ spec.items ? h(ItemsEditor, { spec, ctx, record }) : null,
182
212
  spec.compute
183
213
  ? h(
184
214
  "div",
@@ -197,6 +227,76 @@ function DetailScreen({ spec, ctx, record }) {
197
227
  );
198
228
  }
199
229
 
230
+ // The child collection edits INLINE on the parent's detail screen — one
231
+ // navigation level, no child screens. That bounds the feature honestly: items
232
+ // are for small rows (2-4 fields), not for records that deserve their own
233
+ // detail view.
234
+ function ItemsEditor({ spec, ctx, record }) {
235
+ const ispec = spec.items;
236
+ const label = ispec.label || "item";
237
+
238
+ function mutate(fn) {
239
+ ctx.update((d) => {
240
+ const rec = d.records.find((r) => r.id === record.id);
241
+ if (!rec) return;
242
+ if (!Array.isArray(rec.items)) rec.items = [];
243
+ fn(rec);
244
+ });
245
+ }
246
+
247
+ const items = Array.isArray(record.items) ? record.items : [];
248
+
249
+ return h(
250
+ "div",
251
+ { className: "caf-items" },
252
+ h(
253
+ "div",
254
+ { className: "caf-toolbar" },
255
+ h("span", { className: "caf-count" }, `${items.length} ${label}${items.length === 1 ? "" : "s"}`),
256
+ h(
257
+ "button",
258
+ {
259
+ type: "button",
260
+ className: "caf-btn caf-btn-sm",
261
+ onClick: () => mutate((rec) => rec.items.push({ id: makeId(), ...recordDefaults(ispec.fields) })),
262
+ },
263
+ `Add ${label}`
264
+ )
265
+ ),
266
+ items.length === 0
267
+ ? h("div", { className: "caf-empty caf-empty-sm" }, ispec.empty || `No ${label}s yet.`)
268
+ : items.map((item) =>
269
+ h(
270
+ "div",
271
+ { key: item.id, className: "caf-item-row" },
272
+ ispec.fields.map((field) =>
273
+ h(Field, {
274
+ key: field.key,
275
+ field,
276
+ data: ctx.data,
277
+ value: item[field.key],
278
+ onChange: (v) =>
279
+ mutate((rec) => {
280
+ const it = rec.items.find((x) => x.id === item.id);
281
+ if (it) it[field.key] = v;
282
+ }),
283
+ })
284
+ ),
285
+ h(
286
+ "button",
287
+ {
288
+ type: "button",
289
+ className: "caf-btn caf-btn-danger caf-btn-sm caf-item-remove",
290
+ "aria-label": `Remove ${label}`,
291
+ onClick: () => mutate((rec) => { rec.items = rec.items.filter((x) => x.id !== item.id); }),
292
+ },
293
+ "✕"
294
+ )
295
+ )
296
+ )
297
+ );
298
+ }
299
+
200
300
  export function RecordsLayout({ spec, ctx }) {
201
301
  const rspec = spec.records;
202
302
 
package/src/steps.js CHANGED
@@ -78,6 +78,7 @@ export function StepsLayout({ spec, ctx }) {
78
78
  h(Field, {
79
79
  key: field.key,
80
80
  field,
81
+ data: ctx.data,
81
82
  value: ctx.data[field.key],
82
83
  onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
83
84
  })