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/README.md +67 -6
- package/dist/index.esm.js +219 -23
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +25 -9
- package/dist/index.global.js.map +3 -3
- package/package.json +1 -1
- package/src/app.js +58 -2
- package/src/blocks.js +34 -0
- package/src/chat.js +53 -12
- package/src/records.js +107 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
-
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
"
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|