claude-artifact-framework 0.11.0 → 0.13.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 +85 -1
- package/dist/index.esm.js +271 -103
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +15 -13
- package/dist/index.global.js.map +3 -3
- package/package.json +1 -1
- package/src/app.js +254 -104
- package/src/records.js +61 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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
|
@@ -13,7 +13,7 @@ import { createElement as h, useState, useEffect, hasReact, getReact } from "rea
|
|
|
13
13
|
import { createAppState } from "./appState.js";
|
|
14
14
|
import { themeCss, seriesColors } from "./theme.js";
|
|
15
15
|
import { BLOCKS, BLOCK_NAMES, FIELD_TYPES } from "./blocks.js";
|
|
16
|
-
import { RecordsLayout } from "./records.js";
|
|
16
|
+
import { RecordsLayout, RecordsBlock } from "./records.js";
|
|
17
17
|
import { StepsLayout } from "./steps.js";
|
|
18
18
|
import { ChatLayout, CHAT_KEYS } from "./chat.js";
|
|
19
19
|
|
|
@@ -25,7 +25,9 @@ const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "col
|
|
|
25
25
|
// silently rendering nothing — a blank pane is indistinguishable from a bug.
|
|
26
26
|
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents"];
|
|
27
27
|
|
|
28
|
-
const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty"];
|
|
28
|
+
const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty", "types"];
|
|
29
|
+
const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
|
|
30
|
+
const ITEMS_KEYS = ["label", "fields", "empty"];
|
|
29
31
|
|
|
30
32
|
// An unknown field type used to fall through silently to a text input — the
|
|
31
33
|
// exact quiet degradation the framework exists to prevent (a blind-tested
|
|
@@ -95,12 +97,12 @@ function checkBlocks(blocks) {
|
|
|
95
97
|
throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
|
|
96
98
|
}
|
|
97
99
|
const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
|
|
98
|
-
const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs");
|
|
100
|
+
const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs" || k === "records");
|
|
99
101
|
if (known.length === 0) {
|
|
100
102
|
throw new Error(
|
|
101
103
|
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
|
|
102
104
|
keys.join(", ") || "nothing"
|
|
103
|
-
}). Valid block types: ${BLOCK_NAMES.join(", ")}.`
|
|
105
|
+
}). Valid block types: ${[...BLOCK_NAMES, "tabs", "records"].join(", ")}.`
|
|
104
106
|
);
|
|
105
107
|
}
|
|
106
108
|
if (known.length > 1) {
|
|
@@ -113,6 +115,7 @@ function checkBlocks(blocks) {
|
|
|
113
115
|
checkFields(block.fields, `blocks[${i}]`);
|
|
114
116
|
if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
|
|
115
117
|
if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
|
|
118
|
+
if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
|
|
116
119
|
if (known[0] === "tabs") {
|
|
117
120
|
const tabs = block.tabs;
|
|
118
121
|
if (!Array.isArray(tabs) || tabs.length === 0) {
|
|
@@ -204,19 +207,26 @@ function validate(spec) {
|
|
|
204
207
|
}
|
|
205
208
|
checkBlocks(spec.sidebar);
|
|
206
209
|
}
|
|
207
|
-
const
|
|
210
|
+
const wsFlat = flattenBlocks([...spec.main, ...(spec.sidebar || [])]);
|
|
211
|
+
const chats = wsFlat.filter((b) => b && b.chat);
|
|
208
212
|
if (chats.length > 1) {
|
|
209
213
|
throw new Error(
|
|
210
214
|
"claude-artifact-framework: only one chat block per app — the conversation persists under data.chat and there is exactly one."
|
|
211
215
|
);
|
|
212
216
|
}
|
|
217
|
+
checkRecordsBlockKeys(wsFlat);
|
|
213
218
|
return layout;
|
|
214
219
|
}
|
|
215
220
|
if (layout === "documents") {
|
|
216
221
|
const d = spec.documents;
|
|
217
|
-
if (!d ||
|
|
222
|
+
if (!d || typeof d !== "object" || (!d.blocks && !d.types)) {
|
|
218
223
|
throw new Error(
|
|
219
|
-
'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }`
|
|
224
|
+
'claude-artifact-framework: layout "documents" needs `documents: { blocks: [...] }` (every document repeats the same blocks) or `documents: { types: {...} }` (each document is one of several kinds).'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (d.blocks && d.types) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
"claude-artifact-framework: documents takes `blocks` (uniform documents) OR `types` (typed documents) — not both."
|
|
220
230
|
);
|
|
221
231
|
}
|
|
222
232
|
const unknownD = Object.keys(d).filter((k) => !DOCUMENTS_KEYS.includes(k));
|
|
@@ -228,83 +238,145 @@ function validate(spec) {
|
|
|
228
238
|
if (d.title !== undefined && typeof d.title !== "function") {
|
|
229
239
|
throw new Error("claude-artifact-framework: documents.title must be a function of the document's data returning the tab label.");
|
|
230
240
|
}
|
|
231
|
-
checkBlocks(d.blocks);
|
|
232
241
|
// Inside documents both chat and file are PER DOCUMENT: the scoped ctx
|
|
233
242
|
// gives each document its own data.chat and its own ctx.files. One chat
|
|
234
243
|
// block in the declaration = one conversation per document.
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
return layout;
|
|
242
|
-
}
|
|
243
|
-
if (layout === "records") {
|
|
244
|
-
const r = spec.records;
|
|
245
|
-
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
246
|
-
throw new Error(
|
|
247
|
-
'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
|
|
251
|
-
const unknownR = Object.keys(r).filter((k) => !RECORDS_KEYS.includes(k));
|
|
252
|
-
if (unknownR.length) {
|
|
253
|
-
throw new Error(
|
|
254
|
-
`claude-artifact-framework: records has unknown keys (${unknownR.join(", ")}). Valid keys: ${RECORDS_KEYS.join(", ")}.`
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
checkFields(r.fields, "records");
|
|
258
|
-
if (r.fields.some((f) => f.type === "file") || (r.items && r.items.fields.some((f) => f.type === "file"))) {
|
|
259
|
-
throw new Error(
|
|
260
|
-
'claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) — they are not supported inside records or items.'
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
const seen = new Set();
|
|
264
|
-
for (const f of r.fields) {
|
|
265
|
-
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
|
|
266
|
-
if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
|
|
267
|
-
seen.add(f.key);
|
|
268
|
-
}
|
|
269
|
-
if (spec.blocks) checkBlocks(spec.blocks);
|
|
270
|
-
if (r.actions) checkActions(r.actions, "records.actions");
|
|
271
|
-
if (r.items) {
|
|
272
|
-
const ITEMS_KEYS = ["label", "fields", "empty"];
|
|
273
|
-
const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
|
|
274
|
-
if (unknownI.length) {
|
|
244
|
+
const checkDocBlocks = (blocks, where) => {
|
|
245
|
+
checkBlocks(blocks);
|
|
246
|
+
const flat = flattenBlocks(blocks);
|
|
247
|
+
if (flat.filter((b) => b && b.chat).length > 1) {
|
|
275
248
|
throw new Error(
|
|
276
|
-
`claude-artifact-framework:
|
|
249
|
+
`claude-artifact-framework: only one chat block per document (${where}) — each document's conversation persists under its own data.chat and there is exactly one.`
|
|
277
250
|
);
|
|
278
251
|
}
|
|
279
|
-
|
|
280
|
-
|
|
252
|
+
checkRecordsBlockKeys(flat);
|
|
253
|
+
};
|
|
254
|
+
if (d.blocks) {
|
|
255
|
+
if (!Array.isArray(d.blocks) || d.blocks.length === 0) {
|
|
256
|
+
throw new Error("claude-artifact-framework: documents.blocks must be a non-empty array of blocks.");
|
|
281
257
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
258
|
+
checkDocBlocks(d.blocks, "documents.blocks");
|
|
259
|
+
} else {
|
|
260
|
+
const names = Object.keys(d.types || {});
|
|
261
|
+
if (!names.length) {
|
|
262
|
+
throw new Error("claude-artifact-framework: documents.types needs at least one type, e.g. `types: { nota: { blocks: [...] } }`.");
|
|
285
263
|
}
|
|
286
|
-
|
|
287
|
-
|
|
264
|
+
for (const name of names) {
|
|
265
|
+
const t = d.types[name];
|
|
266
|
+
const TYPE_KEYS = ["label", "title", "blocks", "empty"];
|
|
267
|
+
const unknownT = Object.keys(t || {}).filter((k) => !TYPE_KEYS.includes(k));
|
|
268
|
+
if (unknownT.length) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`claude-artifact-framework: documents.types.${name} has unknown keys (${unknownT.join(", ")}). Valid keys: ${TYPE_KEYS.join(", ")}.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (!t || !Array.isArray(t.blocks) || t.blocks.length === 0) {
|
|
274
|
+
throw new Error(`claude-artifact-framework: documents.types.${name} needs a non-empty \`blocks\` array.`);
|
|
275
|
+
}
|
|
276
|
+
if (t.title !== undefined && typeof t.title !== "function") {
|
|
277
|
+
throw new Error(`claude-artifact-framework: documents.types.${name}.title must be a function of the document's data.`);
|
|
278
|
+
}
|
|
279
|
+
checkDocBlocks(t.blocks, `documents.types.${name}`);
|
|
288
280
|
}
|
|
289
281
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
282
|
+
return layout;
|
|
283
|
+
}
|
|
284
|
+
if (layout === "records") {
|
|
285
|
+
checkRecordsSpec(spec.records, "records", false);
|
|
286
|
+
if (spec.blocks) {
|
|
287
|
+
checkBlocks(spec.blocks);
|
|
288
|
+
if (flattenBlocks(spec.blocks).some((b) => b && b.records)) {
|
|
297
289
|
throw new Error(
|
|
298
|
-
|
|
290
|
+
'claude-artifact-framework: a records BLOCK below a records LAYOUT is ambiguous — the layout already owns the collection. Use panel/workspace/documents to embed records blocks.'
|
|
299
291
|
);
|
|
300
292
|
}
|
|
301
293
|
}
|
|
302
294
|
return layout;
|
|
303
295
|
}
|
|
304
296
|
checkBlocks(spec.blocks || []);
|
|
297
|
+
checkRecordsBlockKeys(flattenBlocks(spec.blocks || []));
|
|
305
298
|
return layout;
|
|
306
299
|
}
|
|
307
300
|
|
|
301
|
+
// The records declaration is validated identically as a layout and as a
|
|
302
|
+
// block; only the block form takes `key` (where the collection lives in the
|
|
303
|
+
// enclosing pool — required for two collections to coexist).
|
|
304
|
+
function checkRecordsSpec(r, where, allowKey) {
|
|
305
|
+
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`claude-artifact-framework: ${where} needs \`records: { fields: [...] }\` — the same field declarations the fields block uses.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const valid = allowKey ? [...RECORDS_KEYS, "key"] : RECORDS_KEYS;
|
|
311
|
+
const unknownR = Object.keys(r).filter((k) => !valid.includes(k));
|
|
312
|
+
if (unknownR.length) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`claude-artifact-framework: ${where} has unknown keys (${unknownR.join(", ")}). Valid keys: ${valid.join(", ")}.`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (allowKey && r.key !== undefined && (typeof r.key !== "string" || !r.key)) {
|
|
318
|
+
throw new Error(`claude-artifact-framework: ${where}.key must be a non-empty string naming the collection in data.`);
|
|
319
|
+
}
|
|
320
|
+
checkFields(r.fields, where);
|
|
321
|
+
if (r.fields.some((f) => f.type === "file") || (r.items && r.items.fields.some((f) => f.type === "file"))) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
'claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) — they are not supported inside records or items.'
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
for (const f of r.fields) {
|
|
328
|
+
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
|
|
329
|
+
if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate ${where} field key "${f.key}".`);
|
|
330
|
+
seen.add(f.key);
|
|
331
|
+
}
|
|
332
|
+
if (r.actions) checkActions(r.actions, `${where}.actions`);
|
|
333
|
+
if (r.items) {
|
|
334
|
+
const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
|
|
335
|
+
if (unknownI.length) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`claude-artifact-framework: ${where}.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
|
|
341
|
+
throw new Error(`claude-artifact-framework: ${where}.items needs \`fields: [...]\` — the same field declarations as everywhere else.`);
|
|
342
|
+
}
|
|
343
|
+
checkFields(r.items.fields, `${where}.items`);
|
|
344
|
+
for (const f of r.items.fields) {
|
|
345
|
+
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
|
|
346
|
+
}
|
|
347
|
+
if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
|
|
348
|
+
throw new Error(`claude-artifact-framework: a ${where} field cannot be named "items" when \`items\` is declared — that key holds the child collection.`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const [key, c] of Object.entries(r.compute || {})) {
|
|
352
|
+
if (seen.has(key) || key === "id" || (r.items && key === "items")) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (typeof c !== "function" && typeof (c && c.value) !== "function") {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Two records blocks writing the same collection would silently fight.
|
|
366
|
+
function checkRecordsBlockKeys(flatBlocks) {
|
|
367
|
+
const seen = new Set();
|
|
368
|
+
for (const b of flatBlocks) {
|
|
369
|
+
if (!b || !b.records) continue;
|
|
370
|
+
const k = b.records.key || "records";
|
|
371
|
+
if (seen.has(k)) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`claude-artifact-framework: two records blocks share the collection key "${k}" — give each a distinct \`key\`.`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
seen.add(k);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
308
380
|
// Fields declare their own initial value, so the data shape is derived from
|
|
309
381
|
// the declaration instead of being restated in a separate object. That removes
|
|
310
382
|
// the key as an invention point: it is written exactly once.
|
|
@@ -339,6 +411,12 @@ function defaultsFrom(spec) {
|
|
|
339
411
|
}
|
|
340
412
|
}
|
|
341
413
|
}
|
|
414
|
+
for (const b of allBlocks) {
|
|
415
|
+
if (b && b.records) {
|
|
416
|
+
const k = b.records.key || "records";
|
|
417
|
+
if (!Array.isArray(data[k])) data[k] = [];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
342
420
|
return data;
|
|
343
421
|
}
|
|
344
422
|
|
|
@@ -408,8 +486,8 @@ function TabsBlock({ spec, ctx }) {
|
|
|
408
486
|
);
|
|
409
487
|
}
|
|
410
488
|
|
|
411
|
-
const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
|
|
412
|
-
const ALL_NAMES = [...BLOCK_NAMES, "tabs"];
|
|
489
|
+
const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock, records: RecordsBlock };
|
|
490
|
+
const ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
|
|
413
491
|
|
|
414
492
|
function Block({ block, ctx }) {
|
|
415
493
|
// `when` hides a block entirely — the primitive under conditional UIs.
|
|
@@ -516,6 +594,10 @@ function docDefaults(dspec) {
|
|
|
516
594
|
data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
|
|
517
595
|
}
|
|
518
596
|
}
|
|
597
|
+
if (block.records) {
|
|
598
|
+
const k = block.records.key || "records";
|
|
599
|
+
if (!Array.isArray(data[k])) data[k] = [];
|
|
600
|
+
}
|
|
519
601
|
}
|
|
520
602
|
return data;
|
|
521
603
|
}
|
|
@@ -526,7 +608,7 @@ function docId() {
|
|
|
526
608
|
|
|
527
609
|
const DOC_TAB_KEY = "caf:doc";
|
|
528
610
|
|
|
529
|
-
function docTitle(dspec, data) {
|
|
611
|
+
function docTitle(dspec, data, untitled) {
|
|
530
612
|
if (dspec.title) {
|
|
531
613
|
try {
|
|
532
614
|
const t = dspec.title(data);
|
|
@@ -535,35 +617,52 @@ function docTitle(dspec, data) {
|
|
|
535
617
|
/* fall through to the default below */
|
|
536
618
|
}
|
|
537
619
|
}
|
|
538
|
-
// Default: the first text-ish field — same convention records uses.
|
|
620
|
+
// Default: the first text-ish field — same convention records uses. Typed
|
|
621
|
+
// documents fall back to their type label instead of "(untitled)".
|
|
539
622
|
for (const block of flattenBlocks(dspec.blocks)) {
|
|
540
623
|
for (const f of block.fields || []) {
|
|
541
624
|
if (!f.type || f.type === "text" || f.type === "textarea" || f.type === "select") {
|
|
542
625
|
const v = data[f.key];
|
|
543
626
|
if (v !== "" && v !== undefined && v !== null) return String(v);
|
|
544
|
-
return "(untitled)";
|
|
627
|
+
return untitled || "(untitled)";
|
|
545
628
|
}
|
|
546
629
|
}
|
|
547
630
|
}
|
|
548
|
-
return "(untitled)";
|
|
631
|
+
return untitled || "(untitled)";
|
|
549
632
|
}
|
|
550
633
|
|
|
551
634
|
function DocumentsLayout({ spec, ctx }) {
|
|
552
635
|
const dspec = spec.documents;
|
|
553
636
|
const label = dspec.label || "document";
|
|
554
637
|
const docs = Array.isArray(ctx.data.docs) ? ctx.data.docs : [];
|
|
555
|
-
|
|
638
|
+
// Typed documents: each doc records which kind it is (`doc.type`, on the
|
|
639
|
+
// envelope — never inside the document's data) and renders ITS type's
|
|
640
|
+
// blocks. Uniform documents are the one-type degenerate case.
|
|
641
|
+
const types = dspec.types || null;
|
|
642
|
+
const typeNames = types ? Object.keys(types) : [];
|
|
643
|
+
const specFor = (doc) => (types ? types[doc.type] : dspec);
|
|
644
|
+
const typeLabel = (name) => (types[name] && types[name].label) || name;
|
|
645
|
+
const [picking, setPicking] = useState(false);
|
|
556
646
|
|
|
557
647
|
const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : undefined;
|
|
558
648
|
// A deleted or stale id falls back to the first document, never a blank pane.
|
|
559
649
|
const active = docs.find((doc) => doc.id === storedId) || docs[0];
|
|
560
650
|
|
|
561
|
-
function add() {
|
|
562
|
-
const
|
|
651
|
+
function add(typeName) {
|
|
652
|
+
const tspec = types ? types[typeName] : dspec;
|
|
653
|
+
const doc = { id: docId(), ...(types ? { type: typeName } : {}), data: docDefaults(tspec) };
|
|
563
654
|
ctx.update((d) => {
|
|
564
655
|
d.docs = [...(d.docs || []), doc];
|
|
565
656
|
});
|
|
566
657
|
ctx.setTab(DOC_TAB_KEY, doc.id);
|
|
658
|
+
setPicking(false);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// One type (or uniform) creates directly; several ask which kind first.
|
|
662
|
+
function onAdd() {
|
|
663
|
+
if (!types) return add();
|
|
664
|
+
if (typeNames.length === 1) return add(typeNames[0]);
|
|
665
|
+
setPicking((p) => !p);
|
|
567
666
|
}
|
|
568
667
|
|
|
569
668
|
function close(id) {
|
|
@@ -578,6 +677,16 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
578
677
|
}
|
|
579
678
|
}
|
|
580
679
|
|
|
680
|
+
const typePicker = types && picking
|
|
681
|
+
? h(
|
|
682
|
+
"div",
|
|
683
|
+
{ className: "caf-doc-typemenu" },
|
|
684
|
+
typeNames.map((name) =>
|
|
685
|
+
h("button", { key: name, type: "button", className: "caf-btn caf-btn-sm", onClick: () => add(name) }, typeLabel(name))
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
: null;
|
|
689
|
+
|
|
581
690
|
if (!active) {
|
|
582
691
|
return h(
|
|
583
692
|
"div",
|
|
@@ -586,11 +695,38 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
586
695
|
"div",
|
|
587
696
|
{ className: "caf-block caf-empty" },
|
|
588
697
|
h("p", null, dspec.empty || `No ${label}s yet. Create the first one.`),
|
|
589
|
-
|
|
698
|
+
types
|
|
699
|
+
? h(
|
|
700
|
+
"div",
|
|
701
|
+
{ className: "caf-doc-typemenu" },
|
|
702
|
+
typeNames.map((name) =>
|
|
703
|
+
h("button", { key: name, type: "button", className: "caf-btn caf-btn-primary", onClick: () => add(name) }, `New ${typeLabel(name)}`)
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
: h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: () => add() }, `New ${label}`)
|
|
590
707
|
)
|
|
591
708
|
);
|
|
592
709
|
}
|
|
593
710
|
|
|
711
|
+
const aspec = specFor(active);
|
|
712
|
+
// A document whose type was removed from the spec: loud, named, contained —
|
|
713
|
+
// the tab (and its ✕) stays usable, the pane explains instead of crashing.
|
|
714
|
+
if (!aspec) {
|
|
715
|
+
return h(
|
|
716
|
+
"div",
|
|
717
|
+
{ className: "caf-documents" },
|
|
718
|
+
h(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
|
|
719
|
+
typePicker,
|
|
720
|
+
h(
|
|
721
|
+
"div",
|
|
722
|
+
{ className: "caf-block caf-block-error" },
|
|
723
|
+
h("strong", null, `This ${label}'s type "${active.type}" no longer exists`),
|
|
724
|
+
h("code", null, `valid types: ${typeNames.join(", ")}`)
|
|
725
|
+
)
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
const defaults = docDefaults(aspec);
|
|
729
|
+
|
|
594
730
|
// Stored data merges over declared defaults, so a field added in a later
|
|
595
731
|
// edit of the spec falls through to its default on older documents (B3).
|
|
596
732
|
const activeData = { ...defaults, ...active.data };
|
|
@@ -612,39 +748,51 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
612
748
|
return h(
|
|
613
749
|
"div",
|
|
614
750
|
{ className: "caf-documents" },
|
|
615
|
-
h(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
751
|
+
h(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
|
|
752
|
+
typePicker,
|
|
753
|
+
h(Panel, { spec: { blocks: aspec.blocks }, ctx: scoped })
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function DocTabbar({ docs, active, dspec, types, close, onAdd, label, ctx }) {
|
|
758
|
+
const tabTitle = (doc) => {
|
|
759
|
+
const tspec = types ? types[doc.type] : dspec;
|
|
760
|
+
if (!tspec) return doc.type; // removed type: the name is the best label left
|
|
761
|
+
const merged = { ...docDefaults(tspec), ...doc.data };
|
|
762
|
+
const fallback = types ? (tspec.label || doc.type) : undefined;
|
|
763
|
+
return docTitle(tspec, merged, fallback);
|
|
764
|
+
};
|
|
765
|
+
return h(
|
|
766
|
+
"div",
|
|
767
|
+
{ className: "caf-tabbar", role: "tablist" },
|
|
768
|
+
docs.map((doc) =>
|
|
769
|
+
h(
|
|
770
|
+
"span",
|
|
771
|
+
{ key: doc.id, className: "caf-doc-tab" },
|
|
619
772
|
h(
|
|
620
|
-
"
|
|
621
|
-
{
|
|
622
|
-
|
|
623
|
-
"
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
"
|
|
635
|
-
{
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
onClick: () => close(doc.id),
|
|
640
|
-
},
|
|
641
|
-
"✕"
|
|
642
|
-
)
|
|
773
|
+
"button",
|
|
774
|
+
{
|
|
775
|
+
type: "button",
|
|
776
|
+
role: "tab",
|
|
777
|
+
"aria-selected": doc.id === active.id,
|
|
778
|
+
className: doc.id === active.id ? "caf-tab caf-tab-active" : "caf-tab",
|
|
779
|
+
onClick: () => ctx.setTab(DOC_TAB_KEY, doc.id),
|
|
780
|
+
},
|
|
781
|
+
tabTitle(doc)
|
|
782
|
+
),
|
|
783
|
+
h(
|
|
784
|
+
"button",
|
|
785
|
+
{
|
|
786
|
+
type: "button",
|
|
787
|
+
className: "caf-doc-close",
|
|
788
|
+
"aria-label": `Close ${label}`,
|
|
789
|
+
onClick: () => close(doc.id),
|
|
790
|
+
},
|
|
791
|
+
"✕"
|
|
643
792
|
)
|
|
644
|
-
)
|
|
645
|
-
h("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: add }, "+")
|
|
793
|
+
)
|
|
646
794
|
),
|
|
647
|
-
h(
|
|
795
|
+
h("button", { type: "button", className: "caf-tab caf-doc-add", "aria-label": `New ${label}`, onClick: onAdd }, "+")
|
|
648
796
|
);
|
|
649
797
|
}
|
|
650
798
|
|
|
@@ -965,6 +1113,8 @@ const BASE_CSS = `
|
|
|
965
1113
|
.caf-doc-close:hover { color: var(--caf-danger); }
|
|
966
1114
|
.caf-doc-add { font-size: 1rem; font-weight: 650; }
|
|
967
1115
|
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
1116
|
+
.caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
1117
|
+
.caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
968
1118
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
969
1119
|
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
970
1120
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
package/src/records.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// in data — restoring it on reload is correct, and deleting the open record
|
|
19
19
|
// simply falls back to the list.
|
|
20
20
|
|
|
21
|
-
import { createElement as h, useState } from "react";
|
|
21
|
+
import { createElement as h, useState, useEffect } from "react";
|
|
22
22
|
import { Field, formatValue } from "./blocks.js";
|
|
23
23
|
|
|
24
24
|
// Computed fields are derived at render time and never stored, so they can't
|
|
@@ -87,6 +87,17 @@ function Summary({ spec, records }) {
|
|
|
87
87
|
function ListScreen({ spec, ctx }) {
|
|
88
88
|
const label = spec.label || "item";
|
|
89
89
|
const [query, setQuery] = useState("");
|
|
90
|
+
// Records can land in the collection without the framework's Add button —
|
|
91
|
+
// a chat tool, an author action, hand-written `data`. Assigning ids is the
|
|
92
|
+
// framework's job (it's reserved), so complete any record missing one;
|
|
93
|
+
// otherwise its row can never open a detail screen.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if ((ctx.data.records || []).some((r) => r && !r.id)) {
|
|
96
|
+
ctx.update((d) => {
|
|
97
|
+
for (const r of d.records || []) if (r && !r.id) r.id = makeId();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
90
101
|
let records = ctx.data.records.map((r) => withComputed(spec, r));
|
|
91
102
|
if (spec.sort) records = records.slice().sort(spec.sort);
|
|
92
103
|
const total = records.length;
|
|
@@ -297,6 +308,55 @@ function ItemsEditor({ spec, ctx, record }) {
|
|
|
297
308
|
);
|
|
298
309
|
}
|
|
299
310
|
|
|
311
|
+
// The block form of records: the same list/detail/summary machinery embedded
|
|
312
|
+
// wherever panel blocks go (panel, workspace panes, documents, tabs). The
|
|
313
|
+
// collection lives in the enclosing data pool under `key` (default
|
|
314
|
+
// "records"), so inside `documents` each document gets its own collection
|
|
315
|
+
// for free. The open record is view state under a per-collection key —
|
|
316
|
+
// list/detail navigation without touching the app-level screen stack.
|
|
317
|
+
function aliasRecords(d, key) {
|
|
318
|
+
if (key === "records") return d;
|
|
319
|
+
// The internal machinery reads and writes `d.records`; alias it onto the
|
|
320
|
+
// author's collection key while leaving every other property untouched
|
|
321
|
+
// (author actions receive this same object and see the real pool).
|
|
322
|
+
return new Proxy(d, {
|
|
323
|
+
get: (t, p, r) => (p === "records" ? t[key] : Reflect.get(t, p, r)),
|
|
324
|
+
set: (t, p, v) => Reflect.set(t, p === "records" ? key : p, v),
|
|
325
|
+
has: (t, p) => (p === "records" ? key in t : p in t),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function RecordsBlock({ spec, ctx }) {
|
|
330
|
+
const rspec = spec.records;
|
|
331
|
+
const key = rspec.key || "records";
|
|
332
|
+
const arr = Array.isArray(ctx.data[key]) ? ctx.data[key] : [];
|
|
333
|
+
const stateKey = "caf:rec:" + key;
|
|
334
|
+
const openId = ctx.view.tabs ? ctx.view.tabs[stateKey] : undefined;
|
|
335
|
+
|
|
336
|
+
const scoped = {
|
|
337
|
+
...ctx,
|
|
338
|
+
data: { ...ctx.data, records: arr },
|
|
339
|
+
update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
|
|
340
|
+
go: (_screen, id) => ctx.setTab(stateKey, id),
|
|
341
|
+
back: () => ctx.setTab(stateKey, undefined),
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// A deleted or stale open record falls back to the list, never a blank pane.
|
|
345
|
+
const record = openId ? arr.find((r) => r.id === openId) : null;
|
|
346
|
+
|
|
347
|
+
return h(
|
|
348
|
+
"div",
|
|
349
|
+
{ className: "caf-records-block" },
|
|
350
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
351
|
+
record
|
|
352
|
+
? h(DetailScreen, { key: "d", spec: rspec, ctx: scoped, record })
|
|
353
|
+
: [
|
|
354
|
+
h(Summary, { key: "s", spec: rspec, records: arr.map((r) => withComputed(rspec, r)) }),
|
|
355
|
+
h(ListScreen, { key: "l", spec: rspec, ctx: scoped }),
|
|
356
|
+
]
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
300
360
|
export function RecordsLayout({ spec, ctx }) {
|
|
301
361
|
const rspec = spec.records;
|
|
302
362
|
|