claude-artifact-framework 0.2.1 → 0.3.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 +92 -3
- package/dist/index.esm.js +776 -1
- package/dist/index.esm.js.map +4 -4
- package/dist/index.global.js +89 -1
- package/dist/index.global.js.map +4 -4
- package/package.json +1 -1
- package/src/app.js +271 -0
- package/src/appState.js +127 -0
- package/src/blocks.js +215 -0
- package/src/index.js +3 -0
- package/src/react-runtime.js +80 -0
- package/src/records.js +158 -0
- package/src/theme.js +148 -0
package/src/app.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// app.js — createApp: the whole framework's entry point.
|
|
2
|
+
//
|
|
3
|
+
// Returns a React component, so an artifact is `export default createApp({…})`
|
|
4
|
+
// with no mounting ceremony.
|
|
5
|
+
//
|
|
6
|
+
// Lifecycle, and deliberately only four stages:
|
|
7
|
+
// hydrate read data + view from storage, merge over declared defaults
|
|
8
|
+
// mount first render already holding real data, so there's no flash
|
|
9
|
+
// run update() -> batched notify -> render, persistence debounced apart
|
|
10
|
+
// error a boundary per block: one broken block shows its error, the rest live
|
|
11
|
+
|
|
12
|
+
import { createElement as h, useState, useEffect, hasReact, getReact } from "react";
|
|
13
|
+
import { createAppState } from "./appState.js";
|
|
14
|
+
import { themeCss } from "./theme.js";
|
|
15
|
+
import { BLOCKS, BLOCK_NAMES } from "./blocks.js";
|
|
16
|
+
import { RecordsLayout } from "./records.js";
|
|
17
|
+
|
|
18
|
+
const LAYOUTS = ["panel", "records"];
|
|
19
|
+
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
|
|
20
|
+
|
|
21
|
+
// Frontier models still invent identifiers a few percent of the time, so an
|
|
22
|
+
// unknown name has to fail loudly and name the alternatives rather than
|
|
23
|
+
// silently rendering nothing — a blank pane is indistinguishable from a bug.
|
|
24
|
+
function validate(spec) {
|
|
25
|
+
const layout = spec.layout || "panel";
|
|
26
|
+
if (!LAYOUTS.includes(layout)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`claude-artifact-framework: unknown layout "${layout}". Valid layouts: ${LAYOUTS.join(", ")}.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (layout === "records") {
|
|
32
|
+
const r = spec.records;
|
|
33
|
+
if (!r || !Array.isArray(r.fields) || r.fields.length === 0) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'claude-artifact-framework: layout "records" needs `records: { fields: [...] }` — the same field declarations the fields block uses.'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const f of r.fields) {
|
|
40
|
+
if (!f || !f.key) throw new Error("claude-artifact-framework: every records field needs a `key`.");
|
|
41
|
+
if (f.key === "id") throw new Error('claude-artifact-framework: "id" is reserved on records — the framework assigns it.');
|
|
42
|
+
if (seen.has(f.key)) throw new Error(`claude-artifact-framework: duplicate records field key "${f.key}".`);
|
|
43
|
+
seen.add(f.key);
|
|
44
|
+
}
|
|
45
|
+
return layout;
|
|
46
|
+
}
|
|
47
|
+
const blocks = spec.blocks || [];
|
|
48
|
+
if (!Array.isArray(blocks)) {
|
|
49
|
+
throw new Error("claude-artifact-framework: `blocks` must be an array.");
|
|
50
|
+
}
|
|
51
|
+
blocks.forEach((block, i) => {
|
|
52
|
+
if (!block || typeof block !== "object") {
|
|
53
|
+
throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
|
|
54
|
+
}
|
|
55
|
+
const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
|
|
56
|
+
const known = keys.filter((k) => BLOCK_NAMES.includes(k));
|
|
57
|
+
if (known.length === 0) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${
|
|
60
|
+
keys.join(", ") || "nothing"
|
|
61
|
+
}). Valid block types: ${BLOCK_NAMES.join(", ")}.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (known.length > 1) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`claude-artifact-framework: blocks[${i}] declares more than one block type (${known.join(
|
|
67
|
+
", "
|
|
68
|
+
)}). Split them into separate entries of \`blocks\`.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return layout;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Fields declare their own initial value, so the data shape is derived from
|
|
76
|
+
// the declaration instead of being restated in a separate object. That removes
|
|
77
|
+
// the key as an invention point: it is written exactly once.
|
|
78
|
+
function defaultsFrom(spec) {
|
|
79
|
+
const data = { ...(spec.data || {}) };
|
|
80
|
+
if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
|
|
81
|
+
data.records = [];
|
|
82
|
+
}
|
|
83
|
+
for (const block of spec.blocks || []) {
|
|
84
|
+
for (const field of block.fields || []) {
|
|
85
|
+
if (field && field.key !== undefined && !(field.key in data)) {
|
|
86
|
+
data[field.key] = field.value !== undefined ? field.value : field.type === "check" ? false : "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Built on first use, not at module scope: `extends React.Component` needs a
|
|
94
|
+
// real React, and at load time the host may not have handed one over yet.
|
|
95
|
+
let BlockBoundary = null;
|
|
96
|
+
|
|
97
|
+
function boundary(React) {
|
|
98
|
+
if (!BlockBoundary) {
|
|
99
|
+
BlockBoundary = class extends React.Component {
|
|
100
|
+
constructor(props) {
|
|
101
|
+
super(props);
|
|
102
|
+
this.state = { error: null };
|
|
103
|
+
}
|
|
104
|
+
static getDerivedStateFromError(error) {
|
|
105
|
+
return { error };
|
|
106
|
+
}
|
|
107
|
+
render() {
|
|
108
|
+
if (this.state.error) {
|
|
109
|
+
return h(
|
|
110
|
+
"div",
|
|
111
|
+
{ className: "caf-block caf-block-error" },
|
|
112
|
+
h("strong", null, "This section failed to render"),
|
|
113
|
+
h("code", null, String(this.state.error.message || this.state.error))
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return this.props.children;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return BlockBoundary;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Block({ block, ctx }) {
|
|
124
|
+
const name = Object.keys(block).find((k) => BLOCK_NAMES.includes(k));
|
|
125
|
+
const Component = BLOCKS[name];
|
|
126
|
+
return h(boundary(ctx.React), null, h(Component, { spec: block, ctx }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function Panel({ spec, ctx }) {
|
|
130
|
+
return h(
|
|
131
|
+
"div",
|
|
132
|
+
{ className: "caf-panel" },
|
|
133
|
+
(spec.blocks || []).map((block, i) =>
|
|
134
|
+
h(
|
|
135
|
+
"section",
|
|
136
|
+
{ key: i, className: block.wide ? "caf-slot caf-slot-wide" : "caf-slot" },
|
|
137
|
+
h(Block, { block, ctx })
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const LAYOUT_COMPONENTS = { panel: Panel, records: RecordsLayout };
|
|
144
|
+
|
|
145
|
+
export function createApp(spec = {}) {
|
|
146
|
+
const layout = validate(spec);
|
|
147
|
+
const state = createAppState(defaultsFrom(spec));
|
|
148
|
+
const css = themeCss(spec.theme && spec.theme.seed) + BASE_CSS;
|
|
149
|
+
|
|
150
|
+
return function App() {
|
|
151
|
+
if (!hasReact()) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"claude-artifact-framework: React not found. Hand it over once, before rendering:\n\n" +
|
|
154
|
+
' import React from "react";\n' +
|
|
155
|
+
" ArtifactKit.useReact(React);\n"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const [, force] = useState(0);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const unsubscribe = state.subscribe(() => force((n) => n + 1));
|
|
161
|
+
// Hydration can finish before this effect runs — its notify() then lands
|
|
162
|
+
// on zero listeners and nothing ever re-renders. Re-check on subscribe,
|
|
163
|
+
// so a mount that raced hydration still leaves the loading screen.
|
|
164
|
+
if (state.isHydrated()) force((n) => n + 1);
|
|
165
|
+
return unsubscribe;
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
const ctx = {
|
|
169
|
+
React: getReact(),
|
|
170
|
+
data: state.getData(),
|
|
171
|
+
view: state.getView(),
|
|
172
|
+
update: state.update,
|
|
173
|
+
go: state.go,
|
|
174
|
+
back: state.back,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const Layout = LAYOUT_COMPONENTS[layout];
|
|
178
|
+
|
|
179
|
+
return h(
|
|
180
|
+
"div",
|
|
181
|
+
{ className: "caf-root" },
|
|
182
|
+
h("style", { dangerouslySetInnerHTML: { __html: css } }),
|
|
183
|
+
h(
|
|
184
|
+
"header",
|
|
185
|
+
{ className: "caf-header" },
|
|
186
|
+
spec.title ? h("h1", null, spec.title) : null,
|
|
187
|
+
spec.subtitle ? h("p", null, spec.subtitle) : null
|
|
188
|
+
),
|
|
189
|
+
state.isHydrated()
|
|
190
|
+
? h(Layout, { spec, ctx })
|
|
191
|
+
: h("div", { className: "caf-block caf-loading" }, "Loading…")
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const BASE_CSS = `
|
|
197
|
+
.caf-root {
|
|
198
|
+
--caf-radius: 10px;
|
|
199
|
+
box-sizing: border-box;
|
|
200
|
+
min-height: 100vh;
|
|
201
|
+
padding: 2rem 1.25rem 4rem;
|
|
202
|
+
background: var(--caf-bg);
|
|
203
|
+
color: var(--caf-text);
|
|
204
|
+
font-family: -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
|
205
|
+
-webkit-font-smoothing: antialiased;
|
|
206
|
+
}
|
|
207
|
+
.caf-root *, .caf-root *::before, .caf-root *::after { box-sizing: inherit; }
|
|
208
|
+
.caf-header { max-width: 760px; margin: 0 auto 1.5rem; }
|
|
209
|
+
.caf-header h1 { margin: 0; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.01em; text-wrap: balance; }
|
|
210
|
+
.caf-header p { margin: 0.4rem 0 0; color: var(--caf-muted); font-size: 0.95rem; line-height: 1.5; }
|
|
211
|
+
.caf-panel { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
212
|
+
.caf-slot { grid-column: span 2; min-width: 0; }
|
|
213
|
+
@media (min-width: 720px) { .caf-slot { grid-column: span 1; } .caf-slot-wide { grid-column: span 2; } }
|
|
214
|
+
.caf-block {
|
|
215
|
+
background: var(--caf-surface);
|
|
216
|
+
border: 1px solid var(--caf-border);
|
|
217
|
+
border-radius: var(--caf-radius);
|
|
218
|
+
padding: 1.1rem;
|
|
219
|
+
display: flex;
|
|
220
|
+
flex-direction: column;
|
|
221
|
+
gap: 0.8rem;
|
|
222
|
+
height: 100%;
|
|
223
|
+
}
|
|
224
|
+
.caf-block-title { margin: 0; font-size: 0.95rem; font-weight: 650; }
|
|
225
|
+
.caf-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
226
|
+
.caf-field label { font-size: 0.78rem; font-weight: 600; color: var(--caf-muted); letter-spacing: 0.02em; }
|
|
227
|
+
.caf-field input, .caf-field select, .caf-field textarea {
|
|
228
|
+
font: inherit; font-size: 0.95rem; padding: 0.5rem 0.6rem;
|
|
229
|
+
border-radius: 7px; border: 1px solid var(--caf-border);
|
|
230
|
+
background: var(--caf-sunken); color: var(--caf-text); width: 100%;
|
|
231
|
+
}
|
|
232
|
+
.caf-field input:focus-visible, .caf-field select:focus-visible, .caf-field textarea:focus-visible,
|
|
233
|
+
.caf-row:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
234
|
+
.caf-field input[aria-invalid="true"] { border-color: var(--caf-danger); }
|
|
235
|
+
.caf-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
|
|
236
|
+
.caf-hint { color: var(--caf-muted); font-size: 0.75rem; }
|
|
237
|
+
.caf-error { color: var(--caf-danger); font-size: 0.75rem; font-weight: 600; }
|
|
238
|
+
.caf-output-grid { display: flex; flex-direction: column; gap: 0.7rem; }
|
|
239
|
+
.caf-stat { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
|
240
|
+
.caf-stat-label { color: var(--caf-muted); font-size: 0.82rem; }
|
|
241
|
+
.caf-stat-value { font-size: 1rem; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
242
|
+
.caf-stat-big { flex-direction: column; align-items: flex-start; gap: 0.15rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--caf-border); }
|
|
243
|
+
.caf-stat-big .caf-stat-value { font-size: 1.9rem; font-weight: 680; letter-spacing: -0.02em; color: var(--caf-accent); }
|
|
244
|
+
.caf-list { padding: 0.4rem; gap: 0; }
|
|
245
|
+
.caf-row {
|
|
246
|
+
font: inherit; text-align: left; cursor: pointer;
|
|
247
|
+
display: flex; flex-direction: column; gap: 0.15rem;
|
|
248
|
+
padding: 0.65rem 0.7rem; border: none; border-radius: 7px;
|
|
249
|
+
background: transparent; color: inherit; width: 100%;
|
|
250
|
+
}
|
|
251
|
+
.caf-row:hover { background: var(--caf-sunken); }
|
|
252
|
+
.caf-row-title { font-size: 0.92rem; font-weight: 550; }
|
|
253
|
+
.caf-row-sub { font-size: 0.78rem; color: var(--caf-muted); }
|
|
254
|
+
.caf-panel-single { grid-template-columns: 1fr; }
|
|
255
|
+
.caf-panel-single .caf-slot, .caf-panel-single > * { grid-column: span 1; }
|
|
256
|
+
.caf-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; padding: 0.3rem 0.3rem 0.5rem; }
|
|
257
|
+
.caf-count { font-size: 0.8rem; color: var(--caf-muted); font-variant-numeric: tabular-nums; }
|
|
258
|
+
.caf-btn {
|
|
259
|
+
font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
|
260
|
+
padding: 0.45rem 0.8rem; border-radius: 7px;
|
|
261
|
+
border: 1px solid var(--caf-border); background: var(--caf-sunken); color: var(--caf-text);
|
|
262
|
+
}
|
|
263
|
+
.caf-btn:hover { filter: brightness(0.97); }
|
|
264
|
+
.caf-btn:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
265
|
+
.caf-btn-primary { background: var(--caf-accent); border-color: var(--caf-accent); color: var(--caf-accentText); }
|
|
266
|
+
.caf-btn-danger { background: transparent; border-color: var(--caf-danger); color: var(--caf-danger); }
|
|
267
|
+
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
|
268
|
+
.caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
|
|
269
|
+
.caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
|
|
270
|
+
.caf-block-error code { font-size: 0.78rem; color: var(--caf-muted); word-break: break-word; }
|
|
271
|
+
`;
|
package/src/appState.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// appState.js — the store behind createApp.
|
|
2
|
+
//
|
|
3
|
+
// Two states, and the split is the point:
|
|
4
|
+
// data domain data only. Declared by the author, persisted whole in one write.
|
|
5
|
+
// view navigation stack, selection, current step, form drafts, pending flags.
|
|
6
|
+
// Owned by the framework, persisted under its own key, never declared
|
|
7
|
+
// and never seen by the author.
|
|
8
|
+
//
|
|
9
|
+
// That split is why `data` needs no zones or transient markers: nothing that
|
|
10
|
+
// would be wrong to restore (an open modal, a half-typed draft) ever lives in
|
|
11
|
+
// it. It also removes every storage key as an invention point — the author
|
|
12
|
+
// never names one.
|
|
13
|
+
|
|
14
|
+
import { storage } from "./storage.js";
|
|
15
|
+
|
|
16
|
+
const DATA_KEY = "caf:data";
|
|
17
|
+
const VIEW_KEY = "caf:view";
|
|
18
|
+
|
|
19
|
+
const EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null };
|
|
20
|
+
|
|
21
|
+
function isPlainObject(v) {
|
|
22
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Stored values win, but only where they exist. A field the author added
|
|
26
|
+
// after data was already saved simply falls through to its declared default,
|
|
27
|
+
// which is what makes additive edits need no migration at all.
|
|
28
|
+
function mergeDefaults(defaults, stored) {
|
|
29
|
+
if (!isPlainObject(defaults) || !isPlainObject(stored)) {
|
|
30
|
+
return stored === undefined ? defaults : stored;
|
|
31
|
+
}
|
|
32
|
+
const out = { ...defaults };
|
|
33
|
+
for (const key of Object.keys(stored)) {
|
|
34
|
+
out[key] = isPlainObject(defaults[key]) ? mergeDefaults(defaults[key], stored[key]) : stored[key];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function debounce(fn, ms) {
|
|
40
|
+
let timer;
|
|
41
|
+
return (...args) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createAppState(defaults, { debounceMs = 400 } = {}) {
|
|
48
|
+
let data = structuredCloneish(defaults);
|
|
49
|
+
let view = { ...EMPTY_VIEW };
|
|
50
|
+
let hydrated = false;
|
|
51
|
+
const listeners = new Set();
|
|
52
|
+
|
|
53
|
+
// Notification is batched to a microtask: three mutations in a row produce
|
|
54
|
+
// one render, not three. Persistence is separately debounced, because
|
|
55
|
+
// writing to window.storage is async and must not run at typing speed.
|
|
56
|
+
let queued = false;
|
|
57
|
+
function notify() {
|
|
58
|
+
if (queued) return;
|
|
59
|
+
queued = true;
|
|
60
|
+
Promise.resolve().then(() => {
|
|
61
|
+
queued = false;
|
|
62
|
+
for (const fn of listeners) fn();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const persistData = debounce(() => storage.set(DATA_KEY, data), debounceMs);
|
|
67
|
+
const persistView = debounce(() => storage.set(VIEW_KEY, view), debounceMs);
|
|
68
|
+
|
|
69
|
+
const ready = (async () => {
|
|
70
|
+
const [storedData, storedView] = await Promise.all([
|
|
71
|
+
storage.get(DATA_KEY, undefined),
|
|
72
|
+
storage.get(VIEW_KEY, undefined),
|
|
73
|
+
]);
|
|
74
|
+
if (storedData !== undefined) data = mergeDefaults(defaults, storedData);
|
|
75
|
+
if (storedView !== undefined) view = { ...EMPTY_VIEW, ...storedView };
|
|
76
|
+
hydrated = true;
|
|
77
|
+
notify();
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
function update(fn) {
|
|
81
|
+
const result = fn(data);
|
|
82
|
+
// Handlers may either mutate the draft or return a replacement.
|
|
83
|
+
if (result !== undefined) data = result;
|
|
84
|
+
data = { ...data };
|
|
85
|
+
persistData();
|
|
86
|
+
notify();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function setView(patch) {
|
|
90
|
+
view = { ...view, ...patch };
|
|
91
|
+
persistView();
|
|
92
|
+
notify();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
ready,
|
|
97
|
+
isHydrated: () => hydrated,
|
|
98
|
+
getData: () => data,
|
|
99
|
+
getView: () => view,
|
|
100
|
+
update,
|
|
101
|
+
setView,
|
|
102
|
+
go(screen, arg) {
|
|
103
|
+
setView({ stack: [...view.stack, { screen: view.screen, arg: view.arg }], screen, arg });
|
|
104
|
+
},
|
|
105
|
+
back() {
|
|
106
|
+
const stack = view.stack.slice();
|
|
107
|
+
const previous = stack.pop() || { screen: null, arg: null };
|
|
108
|
+
setView({ stack, screen: previous.screen, arg: previous.arg });
|
|
109
|
+
},
|
|
110
|
+
subscribe(fn) {
|
|
111
|
+
listeners.add(fn);
|
|
112
|
+
return () => listeners.delete(fn);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Defaults come from a literal in the declaration; a shallow structural copy
|
|
118
|
+
// is enough and avoids depending on structuredClone being present.
|
|
119
|
+
function structuredCloneish(value) {
|
|
120
|
+
if (Array.isArray(value)) return value.map(structuredCloneish);
|
|
121
|
+
if (isPlainObject(value)) {
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const k of Object.keys(value)) out[k] = structuredCloneish(value[k]);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
package/src/blocks.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// blocks.js — the content blocks that fill a layout's slots.
|
|
2
|
+
//
|
|
3
|
+
// Every block is a plain React component taking (spec, ctx). Custom code from
|
|
4
|
+
// the escape hatch implements exactly the same contract, which is why ejecting
|
|
5
|
+
// one block never requires ejecting the app.
|
|
6
|
+
//
|
|
7
|
+
// Loading / empty / error are part of the contract rather than edge cases the
|
|
8
|
+
// author opts into: a block that has nothing to show says so by default, so an
|
|
9
|
+
// app can't ship a blank pane while it waits.
|
|
10
|
+
|
|
11
|
+
// Bound lazily rather than snapshotted as `React.createElement`, because this
|
|
12
|
+
// runs at module load — before the host has had a chance to hand React over.
|
|
13
|
+
import { createElement as h } from "react";
|
|
14
|
+
|
|
15
|
+
const FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
|
|
16
|
+
|
|
17
|
+
export function formatValue(value, format) {
|
|
18
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
19
|
+
const n = Number(value);
|
|
20
|
+
switch (format) {
|
|
21
|
+
case "money":
|
|
22
|
+
return Number.isFinite(n)
|
|
23
|
+
? n.toLocaleString(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 })
|
|
24
|
+
: String(value);
|
|
25
|
+
case "percent":
|
|
26
|
+
return Number.isFinite(n) ? `${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}%` : String(value);
|
|
27
|
+
case "number":
|
|
28
|
+
return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(value);
|
|
29
|
+
case "date":
|
|
30
|
+
return value instanceof Date ? value.toLocaleDateString() : String(value);
|
|
31
|
+
default:
|
|
32
|
+
return String(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Validation is derived from the field declaration, so the author gets it
|
|
37
|
+
// without writing any: declaring a type and a range IS the validation.
|
|
38
|
+
export function validateField(field, value) {
|
|
39
|
+
if (field.required && (value === "" || value === null || value === undefined)) {
|
|
40
|
+
return "Required";
|
|
41
|
+
}
|
|
42
|
+
if (value === "" || value === null || value === undefined) return null;
|
|
43
|
+
if (field.type === "number" || field.type === "money" || field.type === "percent") {
|
|
44
|
+
const n = Number(value);
|
|
45
|
+
if (!Number.isFinite(n)) return "Must be a number";
|
|
46
|
+
if (field.min !== undefined && n < field.min) return `Must be at least ${field.min}`;
|
|
47
|
+
if (field.max !== undefined && n > field.max) return `Must be at most ${field.max}`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Field({ field, value, onChange }) {
|
|
53
|
+
const error = validateField(field, value);
|
|
54
|
+
const id = `caf-f-${field.key}`;
|
|
55
|
+
const numeric = field.type === "number" || field.type === "money" || field.type === "percent";
|
|
56
|
+
|
|
57
|
+
if (field.type === "check") {
|
|
58
|
+
return h(
|
|
59
|
+
"label",
|
|
60
|
+
{ className: "caf-check" },
|
|
61
|
+
h("input", {
|
|
62
|
+
id,
|
|
63
|
+
type: "checkbox",
|
|
64
|
+
checked: Boolean(value),
|
|
65
|
+
onChange: (e) => onChange(e.target.checked),
|
|
66
|
+
}),
|
|
67
|
+
h("span", null, field.label || field.key)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const common = {
|
|
72
|
+
id,
|
|
73
|
+
value: value === null || value === undefined ? "" : value,
|
|
74
|
+
"aria-invalid": error ? "true" : undefined,
|
|
75
|
+
onChange: (e) => onChange(numeric ? toNumber(e.target.value) : e.target.value),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let control;
|
|
79
|
+
if (field.type === "textarea") {
|
|
80
|
+
control = h("textarea", { ...common, rows: field.rows || 3 });
|
|
81
|
+
} else if (field.type === "select") {
|
|
82
|
+
control = h(
|
|
83
|
+
"select",
|
|
84
|
+
common,
|
|
85
|
+
h("option", { value: "" }, field.placeholder || "Select…"),
|
|
86
|
+
(field.options || []).map((opt) => {
|
|
87
|
+
const val = typeof opt === "string" ? opt : opt.value;
|
|
88
|
+
const label = typeof opt === "string" ? opt : opt.label;
|
|
89
|
+
return h("option", { key: val, value: val }, label);
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
control = h("input", {
|
|
94
|
+
...common,
|
|
95
|
+
type: field.type === "date" ? "date" : numeric ? "number" : "text",
|
|
96
|
+
min: field.min,
|
|
97
|
+
max: field.max,
|
|
98
|
+
step: field.step || (field.type === "money" || field.type === "percent" ? "any" : undefined),
|
|
99
|
+
placeholder: field.placeholder,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return h(
|
|
104
|
+
"div",
|
|
105
|
+
{ className: "caf-field" },
|
|
106
|
+
h("label", { htmlFor: id }, field.label || field.key),
|
|
107
|
+
control,
|
|
108
|
+
field.hint && !error ? h("small", { className: "caf-hint" }, field.hint) : null,
|
|
109
|
+
error ? h("small", { className: "caf-error" }, error) : null
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toNumber(raw) {
|
|
114
|
+
if (raw === "") return "";
|
|
115
|
+
const n = Number(raw);
|
|
116
|
+
return Number.isFinite(n) ? n : raw;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function FieldsBlock({ spec, ctx }) {
|
|
120
|
+
const fields = spec.fields || [];
|
|
121
|
+
return h(
|
|
122
|
+
"div",
|
|
123
|
+
{ className: "caf-block caf-fields" },
|
|
124
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
125
|
+
fields.map((field) =>
|
|
126
|
+
h(Field, {
|
|
127
|
+
key: field.key,
|
|
128
|
+
field,
|
|
129
|
+
value: ctx.data[field.key],
|
|
130
|
+
onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function OutputBlock({ spec, ctx }) {
|
|
137
|
+
const items = typeof spec.output === "function" ? spec.output(ctx.data) : spec.output || [];
|
|
138
|
+
if (!items.length) {
|
|
139
|
+
return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
|
|
140
|
+
}
|
|
141
|
+
return h(
|
|
142
|
+
"div",
|
|
143
|
+
{ className: "caf-block caf-output" },
|
|
144
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
145
|
+
h(
|
|
146
|
+
"div",
|
|
147
|
+
{ className: "caf-output-grid" },
|
|
148
|
+
items.map((item, i) =>
|
|
149
|
+
h(
|
|
150
|
+
"div",
|
|
151
|
+
{ key: item.label || i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
152
|
+
h("span", { className: "caf-stat-label" }, item.label),
|
|
153
|
+
h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
154
|
+
item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function TextBlock({ spec }) {
|
|
162
|
+
const body = typeof spec.text === "function" ? spec.text() : spec.text;
|
|
163
|
+
return h(
|
|
164
|
+
"div",
|
|
165
|
+
{ className: "caf-block caf-text" },
|
|
166
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
167
|
+
h("p", null, body)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function ListBlock({ spec, ctx }) {
|
|
172
|
+
const rows = (typeof spec.list === "function" ? spec.list(ctx.data) : spec.list) || [];
|
|
173
|
+
if (!rows.length) {
|
|
174
|
+
return h("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing here yet.");
|
|
175
|
+
}
|
|
176
|
+
const title = spec.title || ((r) => r.title ?? r.name ?? r.label ?? String(r));
|
|
177
|
+
return h(
|
|
178
|
+
"div",
|
|
179
|
+
{ className: "caf-block caf-list" },
|
|
180
|
+
rows.map((row, i) =>
|
|
181
|
+
h(
|
|
182
|
+
"button",
|
|
183
|
+
{
|
|
184
|
+
key: row.id ?? i,
|
|
185
|
+
className: "caf-row",
|
|
186
|
+
type: "button",
|
|
187
|
+
onClick: spec.onSelect ? () => spec.onSelect(row, ctx) : undefined,
|
|
188
|
+
},
|
|
189
|
+
h("span", { className: "caf-row-title" }, title(row)),
|
|
190
|
+
spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(row)) : null
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The escape hatch. Receives the same ctx every built-in block gets, so a
|
|
197
|
+
// custom block keeps persistence, theming, navigation and the error boundary.
|
|
198
|
+
export function CustomBlock({ spec, ctx }) {
|
|
199
|
+
const rendered = spec.custom(ctx);
|
|
200
|
+
if (typeof rendered === "string") {
|
|
201
|
+
return h("div", { className: "caf-block", dangerouslySetInnerHTML: { __html: rendered } });
|
|
202
|
+
}
|
|
203
|
+
return h("div", { className: "caf-block" }, rendered);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const BLOCKS = {
|
|
207
|
+
fields: FieldsBlock,
|
|
208
|
+
output: OutputBlock,
|
|
209
|
+
text: TextBlock,
|
|
210
|
+
list: ListBlock,
|
|
211
|
+
custom: CustomBlock,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const BLOCK_NAMES = Object.keys(BLOCKS);
|
|
215
|
+
export { FIELD_TYPES };
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
export { storage } from "./storage.js";
|
|
6
6
|
export { createStore, createPersistedStore, createSharedStore } from "./state.js";
|
|
7
|
+
export { createApp } from "./app.js";
|
|
8
|
+
export { palette, themeCss, contrast } from "./theme.js";
|
|
9
|
+
export { useReact, hasReact } from "./react-runtime.js";
|