claude-artifact-framework 0.5.2 → 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/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.5.2/dist/index.global.js"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/claude-artifact-framework@0.6.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.5.2/dist/index.esm.js";
30
+ } from "https://cdn.jsdelivr.net/npm/claude-artifact-framework@0.6.0/dist/index.esm.js";
31
31
  </script>
32
32
  ```
33
33
 
34
- Pin a version (`@0.5.2`) for reproducible artifacts, or drop the version
34
+ Pin a version (`@0.6.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
@@ -254,6 +254,67 @@ ArtifactKit.createApp({
254
254
  that haven't been persisted yet. View state (which screen is open) stays
255
255
  personal per viewer.
256
256
 
257
+ ### `items` — a child collection per record
258
+
259
+ For "a thing with parts": invoices with line items, recipes with
260
+ ingredients, projects with tasks. One level deep by design; children edit
261
+ inline on the parent's detail screen (they're for small rows, not records
262
+ that deserve their own screen). The collection lives at `r.items` and is
263
+ available to `compute` / `title` / `subtitle` / `summary`:
264
+
265
+ ```js
266
+ records: {
267
+ label: "factura",
268
+ fields: [{ key: "cliente", type: "text", required: true }],
269
+ items: {
270
+ label: "ítem",
271
+ fields: [
272
+ { key: "concepto", type: "text" },
273
+ { key: "cantidad", type: "number", value: 1, min: 0 },
274
+ { key: "precio", type: "money", value: 0, min: 0 },
275
+ ],
276
+ },
277
+ compute: {
278
+ total: { label: "Total c/IVA", format: "money",
279
+ value: (r) => (r.items || []).reduce((s, i) => s + i.cantidad * i.precio, 0) * 1.21 },
280
+ },
281
+ }
282
+ ```
283
+
284
+ `items` keys: `label`, `fields`, `empty`. Each child gets a framework
285
+ `id`; a parent field can't be named `items`.
286
+
287
+ ### `actions` — one-tap buttons that mutate data
288
+
289
+ On records rows (mark done, +1, vote — without opening the record):
290
+
291
+ ```js
292
+ records: {
293
+ fields: [{ key: "nombre", type: "text" }, { key: "veces", type: "number", value: 0 }],
294
+ actions: [
295
+ { label: "Hecho hoy", run: (r) => { r.veces = (r.veces || 0) + 1; } },
296
+ { label: "Reset", kind: "danger", when: (r) => r.veces > 0, run: (r) => { r.veces = 0; } },
297
+ ],
298
+ }
299
+ ```
300
+
301
+ And as a block in `panel` (quick counters):
302
+
303
+ ```js
304
+ blocks: [
305
+ { actions: [{ label: "+1 vaso de agua", kind: "primary", run: (d) => { d.agua = (d.agua || 0) + 1; } }] },
306
+ { output: (d) => [{ label: "Agua hoy", value: d.agua || 0, big: true }] },
307
+ ]
308
+ ```
309
+
310
+ Action keys: `label`, `run`, optional `when` (hides the button when false)
311
+ and `kind` (`"primary"` / `"danger"`). Row actions receive the record;
312
+ block actions receive data. Changes persist like any other update.
313
+
314
+ Chat bubbles render the assistant's **markdown** (bold, code, lists,
315
+ headings) automatically — HTML is escaped first, so model output can't
316
+ inject markup.
317
+
257
318
  ## API reference
258
319
 
259
320
  Everything an app can declare, in one place.
@@ -266,7 +327,7 @@ list), `steps` (wizard), `chat` (conversation with Claude). Each requires its
266
327
  matching key (`blocks` / `records` / `steps` / `chat`).
267
328
 
268
329
  **Block types** (entries of `blocks`): `fields`, `output`, `text`, `list`,
269
- `chart`, `timer`, `custom`. One type per entry, plus optional `title`, `empty`,
330
+ `chart`, `timer`, `actions`, `custom`. One type per entry, plus optional `title`, `empty`,
270
331
  `wide`. In the `records` layout, `blocks` render below the list (never on the
271
332
  detail screen); their functions receive the same `data` object as everywhere
272
333
  else — the collection lives at `d.records`.
@@ -278,8 +339,8 @@ else — the collection lives at `d.records`.
278
339
  An unknown `type` throws naming the valid ones.
279
340
 
280
341
  **`records` keys**: `label`, `fields`, `title(r)`, `subtitle(r)`, `sort(a,b)`,
281
- `summary(all)`, `empty`, `search`, `compute`. `id` is framework-assigned and
282
- reserved.
342
+ `summary(all)`, `empty`, `search`, `compute`, `items`, `actions`. `id` is
343
+ framework-assigned and reserved on records and items alike.
283
344
 
284
345
  **`steps` keys** (per step): `title`, `text`, `fields`, `result(d)`. Field
285
346
  keys must be unique across steps.
package/dist/index.esm.js CHANGED
@@ -694,6 +694,31 @@ function TimerBlock({ spec, ctx }) {
694
694
  )
695
695
  );
696
696
  }
697
+ function ActionsBlock({ spec, ctx }) {
698
+ const actions = spec.actions || [];
699
+ const visible = actions.filter((a) => !a.when || a.when(ctx.data));
700
+ return createElement(
701
+ "div",
702
+ { className: "caf-block caf-actions" },
703
+ spec.title ? createElement("h2", { className: "caf-block-title" }, spec.title) : null,
704
+ createElement(
705
+ "div",
706
+ { className: "caf-actions-row" },
707
+ visible.map(
708
+ (a) => createElement(
709
+ "button",
710
+ {
711
+ key: a.label,
712
+ type: "button",
713
+ className: a.kind === "danger" ? "caf-btn caf-btn-danger" : a.kind === "primary" ? "caf-btn caf-btn-primary" : "caf-btn",
714
+ onClick: () => ctx.update((d) => a.run(d))
715
+ },
716
+ a.label
717
+ )
718
+ )
719
+ )
720
+ );
721
+ }
697
722
  function CustomBlock({ spec, ctx }) {
698
723
  const rendered = spec.custom(ctx);
699
724
  if (typeof rendered === "string") {
@@ -708,6 +733,7 @@ var BLOCKS = {
708
733
  list: ListBlock,
709
734
  chart: ChartBlock,
710
735
  timer: TimerBlock,
736
+ actions: ActionsBlock,
711
737
  custom: CustomBlock
712
738
  };
713
739
  var BLOCK_NAMES = Object.keys(BLOCKS);
@@ -779,6 +805,7 @@ function ListScreen({ spec, ctx }) {
779
805
  }
780
806
  function add() {
781
807
  const rec = { id: makeId(), ...recordDefaults(spec.fields) };
808
+ if (spec.items) rec.items = [];
782
809
  ctx.update((d) => {
783
810
  d.records = [...d.records, rec];
784
811
  });
@@ -807,15 +834,37 @@ function ListScreen({ spec, ctx }) {
807
834
  spec.search && q && records.length === 0 && total > 0 ? createElement("div", { className: "caf-empty" }, `Nothing matches "${query}".`) : null,
808
835
  records.length === 0 ? createElement("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`) : records.map(
809
836
  (rec) => createElement(
810
- "button",
811
- {
812
- key: rec.id,
813
- type: "button",
814
- className: "caf-row",
815
- onClick: () => ctx.go("record", rec.id)
816
- },
817
- createElement("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
818
- spec.subtitle ? createElement("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
837
+ "div",
838
+ { key: rec.id, className: "caf-row-wrap" },
839
+ createElement(
840
+ "button",
841
+ {
842
+ type: "button",
843
+ className: "caf-row",
844
+ onClick: () => ctx.go("record", rec.id)
845
+ },
846
+ createElement("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
847
+ spec.subtitle ? createElement("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
848
+ ),
849
+ spec.actions ? createElement(
850
+ "div",
851
+ { className: "caf-row-actions" },
852
+ spec.actions.filter((a) => !a.when || a.when(rec)).map(
853
+ (a) => createElement(
854
+ "button",
855
+ {
856
+ key: a.label,
857
+ type: "button",
858
+ className: a.kind === "danger" ? "caf-btn caf-btn-danger caf-btn-sm" : "caf-btn caf-btn-sm",
859
+ onClick: () => ctx.update((d) => {
860
+ const live = d.records.find((x) => x.id === rec.id);
861
+ if (live) a.run(live, d);
862
+ })
863
+ },
864
+ a.label
865
+ )
866
+ )
867
+ ) : null
819
868
  )
820
869
  )
821
870
  );
@@ -848,6 +897,7 @@ function DetailScreen({ spec, ctx, record }) {
848
897
  })
849
898
  })
850
899
  ),
900
+ spec.items ? createElement(ItemsEditor, { spec, ctx, record }) : null,
851
901
  spec.compute ? createElement(
852
902
  "div",
853
903
  { className: "caf-output-grid caf-computed" },
@@ -863,6 +913,66 @@ function DetailScreen({ spec, ctx, record }) {
863
913
  ) : null
864
914
  );
865
915
  }
916
+ function ItemsEditor({ spec, ctx, record }) {
917
+ const ispec = spec.items;
918
+ const label = ispec.label || "item";
919
+ function mutate(fn) {
920
+ ctx.update((d) => {
921
+ const rec = d.records.find((r) => r.id === record.id);
922
+ if (!rec) return;
923
+ if (!Array.isArray(rec.items)) rec.items = [];
924
+ fn(rec);
925
+ });
926
+ }
927
+ const items = Array.isArray(record.items) ? record.items : [];
928
+ return createElement(
929
+ "div",
930
+ { className: "caf-items" },
931
+ createElement(
932
+ "div",
933
+ { className: "caf-toolbar" },
934
+ createElement("span", { className: "caf-count" }, `${items.length} ${label}${items.length === 1 ? "" : "s"}`),
935
+ createElement(
936
+ "button",
937
+ {
938
+ type: "button",
939
+ className: "caf-btn caf-btn-sm",
940
+ onClick: () => mutate((rec) => rec.items.push({ id: makeId(), ...recordDefaults(ispec.fields) }))
941
+ },
942
+ `Add ${label}`
943
+ )
944
+ ),
945
+ items.length === 0 ? createElement("div", { className: "caf-empty caf-empty-sm" }, ispec.empty || `No ${label}s yet.`) : items.map(
946
+ (item) => createElement(
947
+ "div",
948
+ { key: item.id, className: "caf-item-row" },
949
+ ispec.fields.map(
950
+ (field) => createElement(Field, {
951
+ key: field.key,
952
+ field,
953
+ value: item[field.key],
954
+ onChange: (v) => mutate((rec) => {
955
+ const it = rec.items.find((x) => x.id === item.id);
956
+ if (it) it[field.key] = v;
957
+ })
958
+ })
959
+ ),
960
+ createElement(
961
+ "button",
962
+ {
963
+ type: "button",
964
+ className: "caf-btn caf-btn-danger caf-btn-sm caf-item-remove",
965
+ "aria-label": `Remove ${label}`,
966
+ onClick: () => mutate((rec) => {
967
+ rec.items = rec.items.filter((x) => x.id !== item.id);
968
+ })
969
+ },
970
+ "\u2715"
971
+ )
972
+ )
973
+ )
974
+ );
975
+ }
866
976
  function RecordsLayout({ spec, ctx }) {
867
977
  const rspec = spec.records;
868
978
  if (ctx.view.screen === "record") {
@@ -1049,6 +1159,24 @@ function runTools(calls, tools, ctx) {
1049
1159
  }
1050
1160
  });
1051
1161
  }
1162
+ var escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
1163
+ function renderMarkdown(text) {
1164
+ const codeBlocks = [];
1165
+ let t = String(text).replace(/```[a-z]*\n?([\s\S]*?)```/g, (m, code) => {
1166
+ codeBlocks.push(code.replace(/\n$/, ""));
1167
+ return "\0" + (codeBlocks.length - 1) + "\0";
1168
+ });
1169
+ t = escHtml(t);
1170
+ t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
1171
+ t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1172
+ t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
1173
+ t = t.replace(/^#{1,3} (.+)$/gm, '<strong class="caf-md-h">$1</strong>');
1174
+ t = t.replace(/^[-•*] (.+)$/gm, '<span class="caf-md-li">$1</span>');
1175
+ t = t.replace(/^\d+\. (.+)$/gm, '<span class="caf-md-li caf-md-num">$1</span>');
1176
+ t = t.replace(/\n/g, "<br>");
1177
+ t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => '<pre class="caf-code">' + escHtml(codeBlocks[Number(i)]) + "</pre>");
1178
+ return t;
1179
+ }
1052
1180
  function Bubble({ m }) {
1053
1181
  if (m.role === "tools") {
1054
1182
  return createElement("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
@@ -1062,7 +1190,11 @@ function Bubble({ m }) {
1062
1190
  createElement("span", { className: "caf-hint" }, "Your message is kept \u2014 send again to retry.")
1063
1191
  );
1064
1192
  }
1065
- return createElement("div", { className: m.role === "user" ? "caf-msg caf-msg-user" : "caf-msg caf-msg-assistant" }, m.content);
1193
+ if (m.role === "user") return createElement("div", { className: "caf-msg caf-msg-user" }, m.content);
1194
+ return createElement("div", {
1195
+ className: "caf-msg caf-msg-assistant",
1196
+ dangerouslySetInnerHTML: { __html: renderMarkdown(m.content) }
1197
+ });
1066
1198
  }
1067
1199
  function ChatLayout({ spec, ctx }) {
1068
1200
  const c = spec.chat;
@@ -1135,9 +1267,9 @@ Continue with this information. If you have everything you need, answer in natur
1135
1267
  createElement(
1136
1268
  "div",
1137
1269
  { className: "caf-block caf-chat-log" },
1138
- c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant" }, c.greeting) : null,
1270
+ c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } }) : null,
1139
1271
  log.map((m, i) => createElement(Bubble, { key: i, m })),
1140
- live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live" }, live || "\u2026") : null,
1272
+ live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "\u2026") } }) : null,
1141
1273
  busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
1142
1274
  createElement("div", { ref: endRef })
1143
1275
  ),
@@ -1186,6 +1318,23 @@ function checkFields(fields, where) {
1186
1318
  }
1187
1319
  }
1188
1320
  }
1321
+ var ACTION_KEYS = ["label", "run", "when", "kind"];
1322
+ function checkActions(actions, where) {
1323
+ if (!Array.isArray(actions)) {
1324
+ throw new Error(`claude-artifact-framework: ${where} must be an array of { label, run }.`);
1325
+ }
1326
+ actions.forEach((a, i) => {
1327
+ if (!a || typeof a.label !== "string" || typeof a.run !== "function") {
1328
+ throw new Error(`claude-artifact-framework: ${where}[${i}] needs a \`label\` string and a \`run\` function.`);
1329
+ }
1330
+ const unknown = Object.keys(a).filter((k) => !ACTION_KEYS.includes(k));
1331
+ if (unknown.length) {
1332
+ throw new Error(
1333
+ `claude-artifact-framework: ${where}[${i}] has unknown keys (${unknown.join(", ")}). Valid keys: ${ACTION_KEYS.join(", ")}.`
1334
+ );
1335
+ }
1336
+ });
1337
+ }
1189
1338
  function checkBlocks(blocks) {
1190
1339
  if (!Array.isArray(blocks)) {
1191
1340
  throw new Error("claude-artifact-framework: `blocks` must be an array.");
@@ -1209,6 +1358,7 @@ function checkBlocks(blocks) {
1209
1358
  );
1210
1359
  }
1211
1360
  checkFields(block.fields, `blocks[${i}]`);
1361
+ if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
1212
1362
  });
1213
1363
  }
1214
1364
  function validate(spec) {
@@ -1285,7 +1435,7 @@ function validate(spec) {
1285
1435
  'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` \u2014 the same field declarations the fields block uses.'
1286
1436
  );
1287
1437
  }
1288
- const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute"];
1438
+ const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
1289
1439
  const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
1290
1440
  if (unknownR.length) {
1291
1441
  throw new Error(
@@ -1300,8 +1450,28 @@ function validate(spec) {
1300
1450
  seen.add(f.key);
1301
1451
  }
1302
1452
  if (spec.blocks) checkBlocks(spec.blocks);
1453
+ if (r.actions) checkActions(r.actions, "records.actions");
1454
+ if (r.items) {
1455
+ const ITEMS_KEYS = ["label", "fields", "empty"];
1456
+ const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
1457
+ if (unknownI.length) {
1458
+ throw new Error(
1459
+ `claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
1460
+ );
1461
+ }
1462
+ if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
1463
+ throw new Error("claude-artifact-framework: records.items needs `fields: [...]` \u2014 the same field declarations as everywhere else.");
1464
+ }
1465
+ checkFields(r.items.fields, "records.items");
1466
+ for (const f of r.items.fields) {
1467
+ if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items \u2014 the framework assigns it.');
1468
+ }
1469
+ if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
1470
+ throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared \u2014 that key holds the child collection.');
1471
+ }
1472
+ }
1303
1473
  for (const [key, c] of Object.entries(r.compute || {})) {
1304
- if (seen.has(key) || key === "id") {
1474
+ if (seen.has(key) || key === "id" || r.items && key === "items") {
1305
1475
  throw new Error(
1306
1476
  `claude-artifact-framework: compute key "${key}" collides with a field key \u2014 computed values are derived, never stored.`
1307
1477
  );
@@ -1561,6 +1731,22 @@ var BASE_CSS = `
1561
1731
  .caf-search:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
1562
1732
  .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
1563
1733
  .caf-records-blocks { margin-top: 1rem; }
1734
+ .caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
1735
+ .caf-row-wrap .caf-row { flex: 1; min-width: 0; }
1736
+ .caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
1737
+ .caf-btn-sm { font-size: 0.78rem; padding: 0.3rem 0.6rem; border-radius: 6px; }
1738
+ .caf-items { display: flex; flex-direction: column; gap: 0.5rem; border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
1739
+ .caf-item-row { display: flex; align-items: flex-end; gap: 0.5rem; }
1740
+ .caf-item-row .caf-field { flex: 1; min-width: 0; }
1741
+ .caf-item-row .caf-field label { font-size: 0.68rem; }
1742
+ .caf-item-remove { flex: none; margin-bottom: 0.15rem; }
1743
+ .caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
1744
+ .caf-actions-row { display: flex; flex-wrap: wrap; gap: 0.6rem; }
1745
+ .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; }
1746
+ .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; }
1747
+ .caf-md-h { display: block; margin-top: 0.3rem; }
1748
+ .caf-md-li { display: block; padding-left: 1rem; position: relative; }
1749
+ .caf-md-li::before { content: "\u2022"; position: absolute; left: 0.2rem; opacity: 0.6; }
1564
1750
  .caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
1565
1751
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
1566
1752
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }