claude-artifact-framework 0.10.0 → 0.12.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 +74 -3
- package/dist/index.esm.js +148 -65
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +14 -13
- package/dist/index.global.js.map +3 -3
- package/package.json +1 -1
- package/src/app.js +118 -65
- 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.12.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
|
|
|
@@ -26,6 +26,8 @@ const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "col
|
|
|
26
26
|
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents"];
|
|
27
27
|
|
|
28
28
|
const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty"];
|
|
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,12 +207,14 @@ 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") {
|
|
@@ -229,82 +234,113 @@ function validate(spec) {
|
|
|
229
234
|
throw new Error("claude-artifact-framework: documents.title must be a function of the document's data returning the tab label.");
|
|
230
235
|
}
|
|
231
236
|
checkBlocks(d.blocks);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (flat.some((b) => (b.fields || []).some((f) => f.type === "file"))) {
|
|
237
|
+
// Inside documents both chat and file are PER DOCUMENT: the scoped ctx
|
|
238
|
+
// gives each document its own data.chat and its own ctx.files. One chat
|
|
239
|
+
// block in the declaration = one conversation per document.
|
|
240
|
+
const docFlat = flattenBlocks(d.blocks);
|
|
241
|
+
const chats = docFlat.filter((b) => b && b.chat);
|
|
242
|
+
if (chats.length > 1) {
|
|
239
243
|
throw new Error(
|
|
240
|
-
|
|
244
|
+
"claude-artifact-framework: only one chat block per document — each document's conversation persists under its own data.chat and there is exactly one."
|
|
241
245
|
);
|
|
242
246
|
}
|
|
247
|
+
checkRecordsBlockKeys(docFlat);
|
|
243
248
|
return layout;
|
|
244
249
|
}
|
|
245
250
|
if (layout === "records") {
|
|
246
|
-
|
|
247
|
-
if (
|
|
251
|
+
checkRecordsSpec(spec.records, "records", false);
|
|
252
|
+
if (spec.blocks) {
|
|
253
|
+
checkBlocks(spec.blocks);
|
|
254
|
+
if (flattenBlocks(spec.blocks).some((b) => b && b.records)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
'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.'
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return layout;
|
|
261
|
+
}
|
|
262
|
+
checkBlocks(spec.blocks || []);
|
|
263
|
+
checkRecordsBlockKeys(flattenBlocks(spec.blocks || []));
|
|
264
|
+
return layout;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The records declaration is validated identically as a layout and as a
|
|
268
|
+
// block; only the block form takes `key` (where the collection lives in the
|
|
269
|
+
// enclosing pool — required for two collections to coexist).
|
|
270
|
+
function checkRecordsSpec(r, where, allowKey) {
|
|
271
|
+
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`claude-artifact-framework: ${where} needs \`records: { fields: [...] }\` — the same field declarations the fields block uses.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const valid = allowKey ? [...RECORDS_KEYS, "key"] : RECORDS_KEYS;
|
|
277
|
+
const unknownR = Object.keys(r).filter((k) => !valid.includes(k));
|
|
278
|
+
if (unknownR.length) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`claude-artifact-framework: ${where} has unknown keys (${unknownR.join(", ")}). Valid keys: ${valid.join(", ")}.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (allowKey && r.key !== undefined && (typeof r.key !== "string" || !r.key)) {
|
|
284
|
+
throw new Error(`claude-artifact-framework: ${where}.key must be a non-empty string naming the collection in data.`);
|
|
285
|
+
}
|
|
286
|
+
checkFields(r.fields, where);
|
|
287
|
+
if (r.fields.some((f) => f.type === "file") || (r.items && r.items.fields.some((f) => f.type === "file"))) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
'claude-artifact-framework: `file` fields live in the flat data pool (panel blocks or steps) — they are not supported inside records or items.'
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
for (const f of r.fields) {
|
|
294
|
+
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
|
|
295
|
+
if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate ${where} field key "${f.key}".`);
|
|
296
|
+
seen.add(f.key);
|
|
297
|
+
}
|
|
298
|
+
if (r.actions) checkActions(r.actions, `${where}.actions`);
|
|
299
|
+
if (r.items) {
|
|
300
|
+
const unknownI = Object.keys(r.items).filter((k) => !ITEMS_KEYS.includes(k));
|
|
301
|
+
if (unknownI.length) {
|
|
248
302
|
throw new Error(
|
|
249
|
-
|
|
303
|
+
`claude-artifact-framework: ${where}.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
|
|
250
304
|
);
|
|
251
305
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
306
|
+
if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
|
|
307
|
+
throw new Error(`claude-artifact-framework: ${where}.items needs \`fields: [...]\` — the same field declarations as everywhere else.`);
|
|
308
|
+
}
|
|
309
|
+
checkFields(r.items.fields, `${where}.items`);
|
|
310
|
+
for (const f of r.items.fields) {
|
|
311
|
+
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
|
|
312
|
+
}
|
|
313
|
+
if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
|
|
314
|
+
throw new Error(`claude-artifact-framework: a ${where} field cannot be named "items" when \`items\` is declared — that key holds the child collection.`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
for (const [key, c] of Object.entries(r.compute || {})) {
|
|
318
|
+
if (seen.has(key) || key === "id" || (r.items && key === "items")) {
|
|
255
319
|
throw new Error(
|
|
256
|
-
`claude-artifact-framework:
|
|
320
|
+
`claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
|
|
257
321
|
);
|
|
258
322
|
}
|
|
259
|
-
|
|
260
|
-
if (r.fields.some((f) => f.type === "file") || (r.items && r.items.fields.some((f) => f.type === "file"))) {
|
|
323
|
+
if (typeof c !== "function" && typeof (c && c.value) !== "function") {
|
|
261
324
|
throw new Error(
|
|
262
|
-
|
|
325
|
+
`claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
|
|
263
326
|
);
|
|
264
327
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
`claude-artifact-framework: records.items has unknown keys (${unknownI.join(", ")}). Valid keys: ${ITEMS_KEYS.join(", ")}.`
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
if (!Array.isArray(r.items.fields) || r.items.fields.length === 0) {
|
|
282
|
-
throw new Error("claude-artifact-framework: records.items needs `fields: [...]` — the same field declarations as everywhere else.");
|
|
283
|
-
}
|
|
284
|
-
checkFields(r.items.fields, "records.items");
|
|
285
|
-
for (const f of r.items.fields) {
|
|
286
|
-
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on items — the framework assigns it.');
|
|
287
|
-
}
|
|
288
|
-
if (seen.has("items") || r.fields.some((f) => f.key === "items")) {
|
|
289
|
-
throw new Error('claude-artifact-framework: a records field cannot be named "items" when `items` is declared — that key holds the child collection.');
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
for (const [key, c] of Object.entries(r.compute || {})) {
|
|
293
|
-
if (seen.has(key) || key === "id" || (r.items && key === "items")) {
|
|
294
|
-
throw new Error(
|
|
295
|
-
`claude-artifact-framework: compute key "${key}" collides with a field key — computed values are derived, never stored.`
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
if (typeof c !== "function" && typeof (c && c.value) !== "function") {
|
|
299
|
-
throw new Error(
|
|
300
|
-
`claude-artifact-framework: compute "${key}" must be a function of the record, or { label, format, value: (r) => ... }.`
|
|
301
|
-
);
|
|
302
|
-
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Two records blocks writing the same collection would silently fight.
|
|
332
|
+
function checkRecordsBlockKeys(flatBlocks) {
|
|
333
|
+
const seen = new Set();
|
|
334
|
+
for (const b of flatBlocks) {
|
|
335
|
+
if (!b || !b.records) continue;
|
|
336
|
+
const k = b.records.key || "records";
|
|
337
|
+
if (seen.has(k)) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`claude-artifact-framework: two records blocks share the collection key "${k}" — give each a distinct \`key\`.`
|
|
340
|
+
);
|
|
303
341
|
}
|
|
304
|
-
|
|
342
|
+
seen.add(k);
|
|
305
343
|
}
|
|
306
|
-
checkBlocks(spec.blocks || []);
|
|
307
|
-
return layout;
|
|
308
344
|
}
|
|
309
345
|
|
|
310
346
|
// Fields declare their own initial value, so the data shape is derived from
|
|
@@ -341,6 +377,12 @@ function defaultsFrom(spec) {
|
|
|
341
377
|
}
|
|
342
378
|
}
|
|
343
379
|
}
|
|
380
|
+
for (const b of allBlocks) {
|
|
381
|
+
if (b && b.records) {
|
|
382
|
+
const k = b.records.key || "records";
|
|
383
|
+
if (!Array.isArray(data[k])) data[k] = [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
344
386
|
return data;
|
|
345
387
|
}
|
|
346
388
|
|
|
@@ -410,8 +452,8 @@ function TabsBlock({ spec, ctx }) {
|
|
|
410
452
|
);
|
|
411
453
|
}
|
|
412
454
|
|
|
413
|
-
const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
|
|
414
|
-
const ALL_NAMES = [...BLOCK_NAMES, "tabs"];
|
|
455
|
+
const ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock, records: RecordsBlock };
|
|
456
|
+
const ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
|
|
415
457
|
|
|
416
458
|
function Block({ block, ctx }) {
|
|
417
459
|
// `when` hides a block entirely — the primitive under conditional UIs.
|
|
@@ -518,6 +560,10 @@ function docDefaults(dspec) {
|
|
|
518
560
|
data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
|
|
519
561
|
}
|
|
520
562
|
}
|
|
563
|
+
if (block.records) {
|
|
564
|
+
const k = block.records.key || "records";
|
|
565
|
+
if (!Array.isArray(data[k])) data[k] = [];
|
|
566
|
+
}
|
|
521
567
|
}
|
|
522
568
|
return data;
|
|
523
569
|
}
|
|
@@ -573,6 +619,7 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
573
619
|
ctx.update((d) => {
|
|
574
620
|
d.docs = d.docs.filter((doc) => doc.id !== id);
|
|
575
621
|
});
|
|
622
|
+
delete ctx.files["doc:" + id];
|
|
576
623
|
if (active && active.id === id) {
|
|
577
624
|
const next = docs[idx + 1] || docs[idx - 1];
|
|
578
625
|
if (next) ctx.setTab(DOC_TAB_KEY, next.id);
|
|
@@ -603,6 +650,11 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
603
650
|
const doc = (d.docs || []).find((x) => x.id === active.id);
|
|
604
651
|
if (doc) fn(doc.data);
|
|
605
652
|
}),
|
|
653
|
+
// Live File objects scoped per document: each doc gets its own sub-map,
|
|
654
|
+
// so two documents with the same file field never collide. Like the flat
|
|
655
|
+
// pool, the File itself is memory-only — metadata survives reload, the
|
|
656
|
+
// file is re-picked per document.
|
|
657
|
+
files: (ctx.files["doc:" + active.id] = ctx.files["doc:" + active.id] || {}),
|
|
606
658
|
};
|
|
607
659
|
|
|
608
660
|
return h(
|
|
@@ -961,6 +1013,7 @@ const BASE_CSS = `
|
|
|
961
1013
|
.caf-doc-close:hover { color: var(--caf-danger); }
|
|
962
1014
|
.caf-doc-add { font-size: 1rem; font-weight: 650; }
|
|
963
1015
|
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
1016
|
+
.caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
964
1017
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
965
1018
|
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
966
1019
|
.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
|
|