@jarenjs/studio 0.83.3 → 0.85.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.
Files changed (90) hide show
  1. package/README.md +21 -6
  2. package/contracts/data.contract.json +243 -0
  3. package/dist/types/component/data/actions.d.ts +234 -0
  4. package/dist/types/component/data/index.d.ts +9 -0
  5. package/dist/types/component/data/mount.d.ts +87 -0
  6. package/dist/types/component/data/project-widget.d.ts +27 -0
  7. package/dist/types/component/data/viewmodel.d.ts +85 -0
  8. package/dist/types/component/data/views.d.ts +148 -0
  9. package/dist/types/component/document.d.ts +82 -0
  10. package/dist/types/component/flow/actions.d.ts +708 -0
  11. package/dist/types/component/flow/index.d.ts +7 -0
  12. package/dist/types/component/flow/mount.d.ts +66 -0
  13. package/dist/types/component/flow/project-widget.d.ts +8 -0
  14. package/dist/types/component/flow/runtime.d.ts +57 -0
  15. package/dist/types/component/flow/views.d.ts +242 -0
  16. package/dist/types/component/host.d.ts +2 -2
  17. package/dist/types/component/index.d.ts +21 -15
  18. package/dist/types/component/mount.d.ts +51 -0
  19. package/dist/types/component/project-actions.d.ts +302 -0
  20. package/dist/types/component/project-controller.d.ts +81 -0
  21. package/dist/types/component/project-state.d.ts +1 -0
  22. package/dist/types/component/project.d.ts +327 -0
  23. package/dist/types/component/shared/host-widget.d.ts +24 -0
  24. package/dist/types/component/shared/memo.d.ts +12 -0
  25. package/dist/types/component/shared/nodes.d.ts +92 -0
  26. package/dist/types/component/shared/schema-options.d.ts +28 -0
  27. package/dist/types/component/shared/studio-kit.d.ts +78 -0
  28. package/dist/types/component/shared/ui.d.ts +136 -0
  29. package/dist/types/component/view.d.ts +6 -6
  30. package/dist/types/data/boot-stages.d.ts +97 -0
  31. package/dist/types/data/browser-worker.d.ts +22 -0
  32. package/dist/types/data/contract.d.ts +9 -0
  33. package/dist/types/data/editor.d.ts +81 -0
  34. package/dist/types/data/handlers.d.ts +130 -0
  35. package/dist/types/data/host.d.ts +8 -0
  36. package/dist/types/data/project-worker.d.ts +8 -0
  37. package/dist/types/data/runtime.d.ts +46 -0
  38. package/dist/types/data/state.d.ts +33 -0
  39. package/dist/types/data/storage.d.ts +26 -0
  40. package/dist/types/data/transport.d.ts +69 -0
  41. package/dist/types/flow-document.d.ts +19 -0
  42. package/dist/types/flow-editor.d.ts +88 -0
  43. package/docs/EDITORS.md +158 -0
  44. package/docs/PROJECT-FORMAT.md +1 -11
  45. package/package.json +32 -15
  46. package/src/component/data/actions.js +138 -0
  47. package/src/component/data/index.js +10 -0
  48. package/src/component/data/mount.js +63 -0
  49. package/src/component/data/project-widget.js +164 -0
  50. package/src/component/data/viewmodel.js +185 -0
  51. package/src/component/data/views.js +241 -0
  52. package/src/component/document.js +344 -0
  53. package/src/component/flow/actions.js +331 -0
  54. package/src/component/flow/index.js +8 -0
  55. package/src/component/flow/mount.js +53 -0
  56. package/src/component/flow/project-widget.js +44 -0
  57. package/src/component/flow/runtime.js +481 -0
  58. package/src/component/flow/views.js +196 -0
  59. package/src/component/host.js +2 -2
  60. package/src/component/index.js +19 -9
  61. package/src/component/mount.js +43 -0
  62. package/src/component/project-actions.js +189 -0
  63. package/src/component/project-controller.js +248 -0
  64. package/src/component/project-state.js +30 -0
  65. package/src/component/project.js +308 -0
  66. package/src/component/shared/host-widget.js +35 -0
  67. package/src/component/shared/memo.js +28 -0
  68. package/src/component/shared/nodes.js +94 -0
  69. package/src/component/shared/schema-options.js +30 -0
  70. package/src/component/shared/studio-kit.js +59 -0
  71. package/src/component/shared/ui.js +134 -0
  72. package/src/data/boot-stages.js +202 -0
  73. package/src/data/browser-worker.js +247 -0
  74. package/src/data/contract.js +7 -0
  75. package/src/data/editor.js +95 -0
  76. package/src/data/handlers.js +349 -0
  77. package/src/data/host.js +8 -0
  78. package/src/data/project-worker.js +26 -0
  79. package/src/data/runtime.js +462 -0
  80. package/src/data/state.js +46 -0
  81. package/src/data/storage.js +61 -0
  82. package/src/data/transport.js +215 -0
  83. package/src/flow-document.js +24 -0
  84. package/src/flow-editor.js +98 -0
  85. package/styles/data.css +53 -0
  86. package/styles/editor.css +150 -0
  87. package/styles/flow.css +99 -0
  88. package/styles/studio.css +1 -0
  89. package/dist/types/author.d.ts +0 -27
  90. package/src/author.js +0 -55
@@ -0,0 +1,308 @@
1
+ //@ts-check
2
+ /** Complete project-stage behavior over injected execution and preview services. */
3
+ import { resolveProjectFile, projectFileContext } from '../index.js';
4
+ import { contentKey, semanticKey } from '@jarenjs/core/object';
5
+ import { createSplitterWidget } from '@jarenjs/app';
6
+ import { createStudioComponent } from './index.js';
7
+
8
+ import { compileFsm, compileDag } from '@jarenjs/flow';
9
+ import { compileContract } from '@jarenjs/contract';
10
+ import { toOpenApi } from '@jarenjs/contract/project';
11
+
12
+ import { errorMessage, cards, error, code } from './shared/nodes.js';
13
+ import { Float64 } from '@jarenjs/core/math';
14
+ const formatMsUnscaled = value => `${Float64.roundToPrecision(value, 3)} ms`;
15
+
16
+
17
+ /** @param {{ operators?: any, loadDocument: Function, runQuery: Function, runJslt: Function, runValidation: Function }} options */
18
+ export function createProjectHost(options) {
19
+ const { operators: operatorRegistry, loadDocument: loadStudioDocument, runQuery, runJslt, runValidation } = options;
20
+ /** The component, with the site's math/finance/stats packs mounted. */
21
+ const projectComponent = createStudioComponent({ operators: operatorRegistry });
22
+
23
+ /** A normalized project view of the live slice (for the engine calls). */
24
+ function projectOf(slice) {
25
+ return {
26
+ project: slice.project ?? '0.1',
27
+ files: slice.files ?? [],
28
+ active: slice.active ?? (slice.files?.[0]?.name ?? null),
29
+ layout: slice.layout,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * The live slice as a persistable `jaren-project` document — what a save
35
+ * or a share token carries (the IDE-only fields — mount, results, dirty —
36
+ * stay behind).
37
+ * @param {any} slice - the `state.project` slice
38
+ */
39
+ function projectSnapshot(slice) {
40
+ return { ...projectOf(slice), name: slice.name ?? 'Untitled project' };
41
+ }
42
+
43
+ /**
44
+ * The project's designated `app` file — the one the Studio contracts
45
+ * (external document operations, the app download) operate on: the
46
+ * active file when it is an app, else the first app file, else null.
47
+ * @param {any} slice - the `state.project` slice
48
+ * @returns {{ name: string, kind: string, text: string } | null}
49
+ */
50
+ function projectAppFile(slice) {
51
+ const files = slice.files ?? [];
52
+ const active = files.find((f) => f.name === slice.active);
53
+ if (active !== undefined && active.kind === 'app') return active;
54
+ return files.find((f) => f.kind === 'app') ?? null;
55
+ }
56
+
57
+ /**
58
+ * The edit loop's pure step: fold the live slice into the last-good
59
+ * `{ mount, revision }` for the active `app` file. A valid structural
60
+ * change bumps the revision (the widget reboots); a valid state-only
61
+ * change keeps the revision but swaps the document (the widget
62
+ * hot-updates); an invalid edit — or a non-app active file — keeps the
63
+ * previous mount untouched (the last good frame stays on the stage).
64
+ * @param {any} slice - the `state.project` slice
65
+ * @returns {{ mount: any, revision: number }}
66
+ */
67
+ function commitProject(slice) {
68
+ const mount = slice.mount ?? null;
69
+ const revision = slice.revision ?? 0;
70
+ const project = projectOf(slice);
71
+ let active = project.active;
72
+ let activeFile = project.files.find((f) => f.name === active);
73
+ if (!activeFile) return { mount, revision };
74
+ // Editing an imported fragment keeps its owning app visible and live.
75
+ if (!['app', 'fsm', 'dag', 'model'].includes(activeFile.kind) && !activeFile.model) {
76
+ const owners = project.files.filter((f) => f.kind === 'app' && f.imports
77
+ && Object.values(f.imports).includes(active));
78
+ if (owners.length !== 1) return { mount, revision };
79
+ activeFile = owners[0]; active = activeFile.name;
80
+ }
81
+ let resolved;
82
+ try { resolved = resolveProjectFile(project, active); }
83
+ catch { return { mount, revision }; }
84
+ const assembledFile = { ...activeFile, text: JSON.stringify(resolved.doc) };
85
+ const verdicts = projectComponent.describe(project).files;
86
+ if (!verdicts.find((f) => f.name === active)?.valid) return { mount, revision };
87
+ let input = null;
88
+ let context;
89
+ try {
90
+ context = projectFileContext(project, activeFile);
91
+ if (context.model && !verdicts.find((f) => f.name === context.model.name)?.valid) return { mount, revision };
92
+ const usesInput = ['fsm', 'dag'].includes(activeFile.kind)
93
+ || (activeFile.kind === 'model' && activeFile.input !== undefined);
94
+ if (context.input && usesInput)
95
+ input = JSON.parse(context.input.text);
96
+ }
97
+ catch { return { mount, revision }; }
98
+ const isStore = activeFile.kind === 'model' || activeFile.model !== undefined;
99
+ const doc = resolved.doc;
100
+ const prevProject = mount && mount.name === active
101
+ ? { files: [{ name: mount.name, kind: mount.kind ?? 'app', text: JSON.stringify(mount.doc) }] }
102
+ : { files: [] };
103
+ const nextProject = { files: [{ name: active, kind: activeFile.kind, text: assembledFile.text }] };
104
+ const policy = projectComponent.hostPolicy(prevProject, nextProject)[active] ?? 'reboot';
105
+ if (policy === 'skip' && mount && mount.name === active
106
+ && (!isStore || mount.files === project.files) && semanticKey(mount.input ?? null) === semanticKey(input))
107
+ return { mount, revision };
108
+ const nextRevision = policy === 'reboot' ? revision + 1 : revision;
109
+ return { mount: { name: active, kind: activeFile.kind, doc, input,
110
+ sourceFiles: resolved.sourceFiles, ...(isStore ? { files: project.files, store: true } : {}),
111
+ revision: nextRevision }, revision: nextRevision };
112
+ }
113
+
114
+ /**
115
+ * Run a transform file (`jslt` / `query`): pair it with the project's
116
+ * input (the first `data` file, else a `state` file) and hand it to the
117
+ * shared transform runners — so registered operators ($npv, $sqrt, …)
118
+ * work here exactly as in play. Returns `{ nodes }` (render nodes for
119
+ * the `ui` mode), or null for a kind that is not a runnable transform.
120
+ * The runners catch their own errors into error nodes, so this never
121
+ * throws.
122
+ * @param {any} slice - the `state.project` slice
123
+ * @param {string} name - the file to run
124
+ * @param {{ model?: (project: any, name: string) => Promise<any> }} [options]
125
+ * @returns {{ nodes: any[] } | Promise<{ nodes: any[] }> | null}
126
+ */
127
+ function runProjectFile(slice, name, options = {}) {
128
+ const project = projectOf(slice);
129
+ const file = project.files.find((f) => f.name === name);
130
+ if (file === undefined) return null;
131
+ if (file.kind === 'model' || file.model !== undefined) {
132
+ if (!options.model) return null;
133
+ return options.model(project, name).then((result) => ({ nodes: [
134
+ code('Result', JSON.stringify(result.result, null, 2)), code('Query plan', JSON.stringify(result.plan, null, 2)),
135
+ ] }), (err) => ({ nodes: [error(err, 'Model run')] }));
136
+ }
137
+ let input;
138
+ try { ({ input } = projectFileContext(project, file)); }
139
+ catch (err) { return { nodes: [error(err, 'Project reference')] }; }
140
+ const dataText = input ? input.text : 'null';
141
+ if (file.kind === 'fsm' || file.kind === 'dag') {
142
+ try {
143
+ const doc = resolveProjectFile(project, name).doc;
144
+ const data = JSON.parse(dataText);
145
+ if (file.kind === 'dag') return compileDag(doc).run(data).then(
146
+ (output) => ({ nodes: [code('DAG output', JSON.stringify(output, null, 2) ?? '(empty sequence)')] }),
147
+ (err) => ({ nodes: [error(err, 'DAG run')] }));
148
+ const machine = compileFsm(doc);
149
+ let state = machine.initial;
150
+ const trace = [];
151
+ for (const event of data?.events ?? []) {
152
+ const result = machine.step(state, typeof event === 'string' ? event : event.event,
153
+ { context: data?.context ?? null, payload: typeof event === 'string' ? null : event.payload });
154
+ trace.push(result); state = result.state;
155
+ }
156
+ return { nodes: [code('Machine run', JSON.stringify({ state, events: state === null ? [] : machine.events(state), trace }, null, 2))] };
157
+ }
158
+ catch (err) { return { nodes: [error(err, 'Flow run')] }; }
159
+ }
160
+ if (file.kind === 'query') return { nodes: runQuery({ query: file.text, data: dataText, externals: '' }) };
161
+ if (file.kind === 'jslt') return { nodes: runJslt({ stylesheet: file.text, data: dataText }) };
162
+ if (file.kind === 'schema') return { nodes: validateNodes(file.text, dataText) };
163
+ if (file.kind === 'contract') return { nodes: contractNodes(file.text) };
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * A `contract` file's stage: compile it and show the projections a
169
+ * consumer reads — `describe()` (resolved bindings + policy) and the
170
+ * OpenAPI 3.1 document. A compile refusal renders as the coded error
171
+ * node with its `JC00xx` and docPath (the same diagnosis the file rail
172
+ * shows), never a throw.
173
+ * @param {string} text - the contract file's text
174
+ */
175
+ function contractNodes(text) {
176
+ let doc;
177
+ try { doc = JSON.parse(text); }
178
+ catch (err) { return [error({ message: errorMessage(err) }, 'Invalid JSON')]; }
179
+ let contract;
180
+ try { contract = compileContract(doc); }
181
+ catch (err) { return [error(/** @type {any} */ (err), 'Contract refusal')]; }
182
+ const description = contract.describe();
183
+ // lenient: a keyword the OpenAPI dialect cannot carry is dropped and
184
+ // COUNTED (the card says so) instead of refusing the whole pane — the
185
+ // strict mode is the CLI's job, the stage's job is to show the file
186
+ const openapi = toOpenApi(contract, {
187
+ lenient: true,
188
+ info: {
189
+ title: typeof doc.id === 'string' ? doc.id : 'contract',
190
+ version: typeof doc.version === 'string' ? doc.version : '0',
191
+ },
192
+ });
193
+ return [
194
+ cards([
195
+ { title: 'Operations', value: String(description.operations.length) },
196
+ ...(openapi.dropped.length > 0 ? [{ title: 'OpenAPI dropped', value: String(openapi.dropped.length) }] : []),
197
+ ]),
198
+ code('describe()', JSON.stringify(description, null, 2)),
199
+ code('OpenAPI 3.1', JSON.stringify(openapi.document, null, 2)),
200
+ ];
201
+ }
202
+
203
+ /** Validate a data file against a schema file and render the report on
204
+ * the stage: a valid/invalid summary plus each error as its own coded
205
+ * node. */
206
+ function validateNodes(schemaText, dataText) {
207
+ let data;
208
+ try { data = JSON.parse(dataText); }
209
+ catch (err) { return [error({ message: `data: ${errorMessage(err)}` }, 'Invalid JSON')]; }
210
+ const r = runValidation(schemaText, data);
211
+ if (r.schemaError !== null) return [error({ message: r.schemaError }, 'Schema error')];
212
+ const nodes = [cards([
213
+ { title: r.valid ? 'Valid' : 'Invalid', value: r.valid ? '✓' : `${r.errors.length} error${r.errors.length === 1 ? '' : 's'}` },
214
+ { title: 'Draft', value: r.draft },
215
+ { title: 'Compile', value: formatMsUnscaled(r.compileMs) },
216
+ { title: 'Validate', value: formatMsUnscaled(r.validateMs) },
217
+ ])];
218
+ for (const e of r.errors) {
219
+ nodes.push(error({
220
+ message: e.message,
221
+ dataPath: e.instancePath === '' ? '(root)' : e.instancePath,
222
+ code: e.keyword !== '' ? e.keyword : undefined,
223
+ }, 'Validation error'));
224
+ }
225
+ return nodes;
226
+ }
227
+
228
+ /**
229
+ * The live stage host widget (`studio-stage`): boot the active app file's
230
+ * assembled document as an isolated nested app, then apply the
231
+ * reboot-vs-hot-update policy on every commit.
232
+ * @param {{ schedule?: (flush: () => void) => void }} [env]
233
+ */
234
+ function createProjectStageWidget(env = {}) {
235
+ const stateKeyOf = (doc) => (doc && typeof doc === 'object' ? contentKey(doc.state ?? null) : null);
236
+ // the document minus its state — the same datum classifyChange keys on.
237
+ // The widget re-derives it defensively: two commits can race in at the
238
+ // SAME revision with different documents (a project/open interleaving
239
+ // with a route-arrival commit), and hot-dispatching one document's
240
+ // state into another document's running view renders garbage.
241
+ const shapeKeyOf = (doc) => (doc && typeof doc === 'object' ? contentKey({ ...doc, state: null }) : null);
242
+
243
+ const boot = (handle, mount) => {
244
+ handle.emit({ action: 'project/stage-error', with: null });
245
+ const result = loadStudioDocument(mount.doc, {
246
+ node: handle.host,
247
+ document: handle.host.ownerDocument,
248
+ schedule: env.schedule,
249
+ onError: (err) => handle.emit({ action: 'project/stage-error', with: errorMessage(err) }),
250
+ });
251
+ handle.app = result.ok ? result.app : null;
252
+ handle.revision = mount.revision;
253
+ handle.stateKey = stateKeyOf(mount.doc);
254
+ handle.shapeKey = shapeKeyOf(mount.doc);
255
+ if (!result.ok) handle.emit({ action: 'project/stage-error', with: result.message });
256
+ };
257
+ const destroy = (handle) => {
258
+ try { handle.app?.destroy(); }
259
+ catch (err) { handle.emit({ action: 'project/stage-error', with: errorMessage(err) }); }
260
+ handle.app = null;
261
+ };
262
+
263
+ return {
264
+ mount(host, props, emit) {
265
+ const handle = { host, emit, app: null, revision: null, stateKey: null, shapeKey: null };
266
+ boot(handle, props);
267
+ return handle;
268
+ },
269
+ update(handle, props, prevProps) {
270
+ if (props === prevProps) return;
271
+ // a structural change bumped the revision — or a raced-in commit
272
+ // swapped the document's shape at the same revision → destroy + reboot
273
+ if (props.revision !== handle.revision || shapeKeyOf(props.doc) !== handle.shapeKey) {
274
+ destroy(handle);
275
+ boot(handle, props);
276
+ return;
277
+ }
278
+ // same revision, same shape, new document → a state-only edit →
279
+ // hot-dispatch it into the RUNNING app (no reboot: scroll, focus and
280
+ // uncontrolled inputs survive the diff re-render)
281
+ const key = stateKeyOf(props.doc);
282
+ if (handle.app !== null && key !== handle.stateKey) {
283
+ handle.app.setState(props.doc.state);
284
+ handle.stateKey = key;
285
+ }
286
+ },
287
+ unmount(handle) {
288
+ destroy(handle);
289
+ },
290
+ };
291
+ }
292
+
293
+ /**
294
+ * The Studio's drag splitter (`studio-splitter`): the shared
295
+ * `createSplitterWidget` bound to the studio grid — a handle over the
296
+ * editor|stage boundary that drives `--js-ratio` live during a drag and
297
+ * commits `project/layout-ratio` on pointer-UP (keyboard-resizable as an
298
+ * ARIA separator). The reusable widget lives in `@jarenjs/app`; play binds
299
+ * the same factory to its own grid.
300
+ */
301
+ function createProjectSplitterWidget() {
302
+ return createSplitterWidget({
303
+ grid: '.jstudio', rail: '.js-rail', cssVar: '--js-ratio', action: 'project/layout-ratio',
304
+ });
305
+ }
306
+
307
+ return { projectComponent, projectSnapshot, projectAppFile, commitProject, runProjectFile, createProjectStageWidget, createProjectSplitterWidget };
308
+ }
@@ -0,0 +1,35 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The shared host-widget lifecycle (the third studio forced the
4
+ * extraction): mount boots a nested app into the host node, update
5
+ * reboots ONLY when the document or its revision changed, unmount
6
+ * destroys. The `mount`/`update`/`unmount` trio was byte-identical
7
+ * between the Studio and Flow boundaries; the `destroy` halves
8
+ * differed DELIBERATELY (Studio reports teardown failures, Flow
9
+ * swallows them), so the factory takes both halves as parameters and
10
+ * reconciles nothing by averaging — one factory, three callers, each
11
+ * keeping its own policy.
12
+ */
13
+
14
+ /**
15
+ * @param {{ boot: (handle: any, props: any) => void,
16
+ * destroy: (handle: any) => void }} lifecycle
17
+ * @returns {{ mount: Function, update: Function, unmount: Function }}
18
+ */
19
+ export function createHostWidget({ boot, destroy }) {
20
+ return {
21
+ mount(host, props, emit) {
22
+ const handle = { host, emit, app: null };
23
+ boot(handle, props);
24
+ return handle;
25
+ },
26
+ update(handle, props, prevProps) {
27
+ if (props.doc === prevProps.doc && props.revision === prevProps.revision) return;
28
+ destroy(handle);
29
+ boot(handle, props);
30
+ },
31
+ unmount(handle) {
32
+ destroy(handle);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,28 @@
1
+ //@ts-check
2
+ /**
3
+ * Single-entry memoization on argument identity. The viewModel wraps
4
+ * its derivations with this so an unchanged input slice returns the
5
+ * PREVIOUS node by reference — which makes the JSLT memo (and through
6
+ * it the renderer's === fast path) fire for the whole subtree.
7
+ * Only successful calls populate the memo; a throwing call keeps the
8
+ * previous successful entry available.
9
+ * @template {(...args: any[]) => any} F
10
+ * @param {F} fn
11
+ * @returns {F}
12
+ */
13
+ export function memo1(fn) {
14
+ /** @type {any[] | null} */
15
+ let lastArgs = null;
16
+ /** @type {any} */
17
+ let lastResult;
18
+ return /** @type {F} */ ((...args) => {
19
+ if (lastArgs !== null && lastArgs.length === args.length
20
+ && lastArgs.every((value, i) => value === args[i])) {
21
+ return lastResult;
22
+ }
23
+ const result = fn(...args);
24
+ lastArgs = args;
25
+ lastResult = result;
26
+ return lastResult;
27
+ });
28
+ }
@@ -0,0 +1,94 @@
1
+ //@ts-check
2
+ /**
3
+ * Builders for the kind-tagged render-node vocabulary. Boundaries emit
4
+ * these plain-JSON nodes; the 'ui' view mode renders each kind with one
5
+ * generic rule. This is the site's component library — as data.
6
+ */
7
+
8
+ /** A paragraph of prose. */
9
+ export const p = (text) => ({ kind: 'p', text });
10
+
11
+ /** A row of stat cards. */
12
+ export const cards = (items) => ({
13
+ kind: 'cards',
14
+ items: items.map((i) => ({ kind: 'card', ...i, note: i.note ?? null })),
15
+ });
16
+
17
+ /** A titled table; rows are `{ cells: string[], strong?: boolean }`. */
18
+ export const table = (title, head, rows, note) => ({
19
+ kind: 'table', title, head, note: note ?? null,
20
+ rows: rows.map((r) => ({ kind: 'row', strong: false, ...r })),
21
+ });
22
+
23
+ /** An informational block with an optional link button. */
24
+ export const callout = (title, text, href, link) =>
25
+ ({ kind: 'callout', title, text, href: href ?? null, link: link ?? null });
26
+
27
+ /** A code block; `badge` renders next to the title. */
28
+ export const code = (title, text, badge) =>
29
+ ({ kind: 'code', title: title ?? null, text, badge: badge ?? null });
30
+
31
+ /**
32
+ * One error → one display string. Coded errors compose their own
33
+ * message (`code: reason at path` per the @jarenjs/core contract), so
34
+ * this never prefixes anything — prefixing again is how the same suite
35
+ * used to show one error two different ways on two pages.
36
+ * @param {unknown} err
37
+ * @returns {string}
38
+ */
39
+ export const errorMessage = (err) =>
40
+ (typeof (/** @type {any} */ (err))?.message === 'string'
41
+ ? /** @type {any} */ (err).message
42
+ : String(err));
43
+
44
+ /** An error block in the engine-error shape (code/docPath/dataPath/position). */
45
+ export function error(err, fallbackTitle) {
46
+ const parts = [];
47
+ // A coded error (it carries `reason`) already renders its code and
48
+ // its location inside `message` — the detail line repeats only what
49
+ // the message does NOT contain.
50
+ const composed = typeof err.reason === 'string';
51
+ if (!composed) {
52
+ if (err.code) parts.push(`code: ${err.code}`);
53
+ if (err.docPath !== undefined && err.docPath !== null) {
54
+ parts.push(`docPath: ${err.docPath === '' ? '"" (document root)' : err.docPath}`);
55
+ }
56
+ if (err.dataPath !== undefined && err.dataPath !== null) {
57
+ parts.push(`dataPath: ${err.dataPath === '' ? '"" (document root)' : err.dataPath}`);
58
+ }
59
+ }
60
+ if (typeof err.position === 'number') parts.push(`position: ${err.position}`);
61
+ if (typeof err.line === 'number') parts.push(`line: ${err.line}`);
62
+ if (typeof err.column === 'number') parts.push(`column: ${err.column}`);
63
+ if (err.hint) parts.push(`hint: ${err.hint}`);
64
+ return {
65
+ kind: 'error',
66
+ title: err.code ?? fallbackTitle ?? err.name ?? 'Error',
67
+ message: errorMessage(err),
68
+ detail: parts.length > 0 ? parts.join(' · ') : null,
69
+ };
70
+ }
71
+
72
+ /** A collapsible section of further nodes. */
73
+ export const details = (summary, items) => ({ kind: 'details', summary, items });
74
+
75
+ /** An SVG chart card: `vnode` is @jarenjs/charts' projection (charts
76
+ * carry their own internal title; `title` adds a card heading only when
77
+ * the SVG has none). */
78
+ export const chart = (title, vnode, note) =>
79
+ ({ kind: 'chart', title: title ?? null, vnode, note: note ?? null });
80
+
81
+ /**
82
+ * A rendered Markdown article: `vnode` is the @jarenjs/md component's
83
+ * projection, spliced in verbatim exactly as a chart's SVG is. The
84
+ * site's own sections are kind-nodes; a section a PACKAGE owns arrives
85
+ * as markdown, and this is the one node that carries it.
86
+ */
87
+ export const article = (vnode) => ({ kind: 'article', vnode });
88
+
89
+ /** A live search input bound to the given action. */
90
+ export const search = (action, value, placeholder) =>
91
+ ({ kind: 'search', action, value, placeholder });
92
+
93
+ /** A "show more" button bound to the given action. */
94
+ export const more = (action, label) => ({ kind: 'more', action, label });
@@ -0,0 +1,30 @@
1
+ //@ts-check
2
+ /**
3
+ * The validator options every surface uses when it compiles a schema
4
+ * the component ships — the engine grammars, the chart definition, the
5
+ * app meta-schema.
6
+ *
7
+ * `unknownFormats: 'error'` is the point of the file. `@jarenjs/validate`
8
+ * defaults to `'ignore'`, because the specification requires that an
9
+ * unknown `format` be treated as an annotation rather than an assertion
10
+ * failure, and a library has to be able to compile a stranger's schema.
11
+ * The component schemas are not a stranger's: a `format` in one of them names a
12
+ * check we intend to happen, so a name with no registered compiler is a
13
+ * missing `addFormats` call, and the alternative to failing is a keyword
14
+ * that silently validates everything. That is how the published grammars
15
+ * came to declare `format: "json-path"` for a long time without anything
16
+ * ever checking one.
17
+ *
18
+ * The line is deliberate and worth keeping: a schema the USER supplied —
19
+ * the validator playground's input, a project file in the studio — keeps
20
+ * the library default, because refusing to compile a stranger's valid
21
+ * schema over a format we happen not to implement would be our problem
22
+ * presented as theirs.
23
+ */
24
+
25
+ /** Options for compiling a schema this project ships. */
26
+ export const OUR_SCHEMA_OPTIONS = Object.freeze({
27
+ skipErrors: false,
28
+ collectErrors: true,
29
+ unknownFormats: /** @type {'error'} */ ('error'),
30
+ });
@@ -0,0 +1,59 @@
1
+ //@ts-check
2
+ /** Shared editor controls and accessible phone pane switching. */
3
+
4
+ /**
5
+ * The editor textarea; live buffers publish input before unrelated renders.
6
+ * @param {{ value: string, action: string, rows?: number,
7
+ * readonly?: boolean, live?: boolean }} options
8
+ */
9
+ export function editorTextarea(options) {
10
+ return ['textarea', {
11
+ class: 'editor',
12
+ rows: options.rows ?? 22,
13
+ spellcheck: 'false',
14
+ value: options.value,
15
+ ...(options.readonly === true ? { readonly: '' } : {}),
16
+ on: { change: options.action, ...(options.live ? { input: options.action } : {}) },
17
+ }];
18
+ }
19
+
20
+ /**
21
+ * The error line.
22
+ * @param {any} content - a query expression or literal text
23
+ */
24
+ export function errorLine(content) {
25
+ return ['p', { class: 'error-line' }, content];
26
+ }
27
+
28
+ /**
29
+ * The phone pane switcher — a segmented bar that shows ONE pane at a
30
+ * time below the breakpoint, the pattern `@jarenjs/play` established.
31
+ *
32
+ * The protocol is three parts, and every surface implements all three:
33
+ * the pane container carries `data-pane` (the live pane id), this bar
34
+ * carries the studio's own `-panebar` class plus the shared `seg`
35
+ * control classes, and the studio's stylesheet hides the unselected
36
+ * panes inside its `@media (max-width: 1024px)` block. Switching is a
37
+ * SINGLE attribute write on the container — the panes stay mounted, so
38
+ * a hidden editor keeps its caret, its scroll and its undo stack, and
39
+ * nothing re-renders but the bar's two changed buttons.
40
+ *
41
+ * The segments are toggle buttons in a group, not a `tablist`: a pane
42
+ * is a grid area, not a `tabpanel`, so `aria-pressed` states the truth
43
+ * that `aria-selected` would overclaim.
44
+ *
45
+ * @param {{ pane: string, action: string, class: string,
46
+ * panes: [string, string][] }} options - `pane` is the query
47
+ * expression holding the live pane id, `panes` the `[id, label]`
48
+ * pairs in bar order
49
+ */
50
+ export function paneSwitcher(options) {
51
+ return ['div', { class: `${options.class} seg`, role: 'group', 'aria-label': 'pane' },
52
+ ...options.panes.map(([id, label]) => ['button', {
53
+ type: 'button',
54
+ class: { $if: [{ $eq: [options.pane, id] }, 'seg-btn active', 'seg-btn'] },
55
+ 'aria-pressed': { $if: [{ $eq: [options.pane, id] }, 'true', 'false'] },
56
+ on: { click: { action: options.action, with: id } },
57
+ }, label]),
58
+ ];
59
+ }