@jarenjs/studio 0.83.3 → 0.84.3

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,241 @@
1
+ //@ts-check
2
+ /**
3
+ * The data studio — mode 'data', dispatched with `$.ui.data` (derived
4
+ * by boundaries/data.js — the boundary-exports convention). The THIRD
5
+ * studio, built on the extracted kit: both editors are the kit's
6
+ * `editorTextarea`, errors are its `errorLine`. Left: the model
7
+ * document and the store's honest status (topology, VFS, durability).
8
+ * Middle: the query document with results and `explain()` — the
9
+ * pushdown made visible. Right: a live query maintaining as rows are
10
+ * inserted, and the worked migration with its shadow verification.
11
+ * Fourth: the spatial round trip — CSV to a map through the stylesheet,
12
+ * the meta-schema, a throwaway store with derived spatial indexes, a
13
+ * linq `$within` and its `explain()`, in this tab.
14
+ *
15
+ * Below the breakpoint those four become one pane at a time behind the
16
+ * shared segmented switcher (Store · Query · Live · Round trip),
17
+ * defaulting to the query — the pane a reader of this page came for.
18
+ */
19
+ import { editorTextarea, errorLine, paneSwitcher } from '../shared/studio-kit.js';
20
+
21
+ const ownerDisabled = { $or: [{ $ne: ['$.topology', 'owner'] }, { $ne: ['$.status', 'ready'] }] };
22
+ const ownerReason = { $if: [{ $ne: ['$.topology', 'owner'] },
23
+ ['p', { class: 'muted data-owner-reason' }, 'Only the owning tab can recreate or migrate the store.']] };
24
+
25
+ const statusCard =
26
+ ['div', { class: 'card pg-card data-status' },
27
+ ['h3', {}, 'Store'],
28
+ ['p', { class: 'data-badges' },
29
+ ['span', { class: 'badge' }, 'topology: ', ['strong', { class: 'data-topology' }, '$.topology']],
30
+ ['span', { class: 'badge' }, 'vfs: ', ['strong', { class: 'data-vfs' }, '$.vfs']],
31
+ ['span', { class: 'badge' }, 'capture: ', ['strong', {}, '$.capture']],
32
+ ['span', { class: 'badge' }, 'sqlite ', '$.version'],
33
+ ['span', { class: 'badge' }, 'operators: ', ['strong', { class: 'data-operators' }, '$.operatorSummary']],
34
+ ],
35
+ ['p', { class: 'muted data-durability' }, '$.durability'],
36
+ { $if: ['$.oracleAvailable', ['details', { class: 'details-card data-oracle' },
37
+ ['summary', {}, 'Spatial agreement \u2014 the same plans as Node'],
38
+ ['p', { class: 'muted' },
39
+ 'This store runs the same spatial plans as Node. The committed spatial corpus \u2014 the entries the JavaScript engine recorded its answers for \u2014 runs here through SQLite compiled to wasm, one throwaway in-memory store per entry, with and without the derived spatial indexes, and every answer is compared with the recorded one. The same corpus holds the Node driver to the engine in the test suite; this tab is the third executor, and the test suite drives this button in Chromium, Firefox and WebKit. It proves execution, not durability: where OPFS is absent the store is in-memory, which is a property of the host, not of the suite.'],
40
+ ['button', { class: 'btn small data-oracle-run', type: 'button', on: { click: 'data/oracle-run' } },
41
+ 'Run the spatial corpus'],
42
+ { $if: ['$.oracle',
43
+ ['div', { class: 'data-oracle-report', 'data-status': '$.oracle.status' },
44
+ ['p', { class: 'data-oracle-summary' }, '$.oracleSummary'],
45
+ { $if: ['$.oracleDisagreed',
46
+ ['pre', { class: 'code-block clamp data-oracle-disagreements' },
47
+ ['code', {}, '$.oracleDisagreements']]] },
48
+ { $if: ['$.oracleDone',
49
+ ['details', { class: 'details-card' },
50
+ ['summary', {}, 'Every answer, as JSON'],
51
+ ['pre', { class: 'code-block clamp data-oracle-results' },
52
+ ['code', {}, '$.oracleResultsJson']]]] },
53
+ ]] },
54
+ ]] },
55
+ ['details', { class: 'details-card' },
56
+ ['summary', {}, 'Registered operators (host opt-in)'],
57
+ ['p', { class: 'muted' }, 'This studio mounts the math / finance / stats packs, so queries here may use $sqrt, $npv, $mean, $stddev, $percentile and more. The pushable-scalar subset (math) runs inside SQLite as deterministic UDFs; finance and stats fold a series in the query residual — explain() shows which.'],
58
+ ['pre', { class: 'code-block clamp' }, ['code', {}, '$.operatorList']],
59
+ ],
60
+ { $if: ['$.refusal',
61
+ ['p', { class: 'muted data-refusal' },
62
+ ['code', {}, '$.refusal.code'], ' — ', '$.refusal.message']] },
63
+ ['details', { class: 'details-card', open: true },
64
+ ['summary', {}, 'Model (jaren-model JSON)'],
65
+ editorTextarea({ value: '$.modelText', action: 'data/model-text', rows: 14, live: true }),
66
+ ['button', { class: 'btn small', type: 'button', disabled: ownerDisabled, on: { click: 'data/open' } },
67
+ 'Recreate store from model'],
68
+ ownerReason,
69
+ ['p', { class: 'muted' },
70
+ 'Recreating unlinks the database the owning tab holds, so only that tab can do it: a client tab is refused, with the store\u2019s own coded message.'],
71
+ ],
72
+ ['details', { class: 'details-card', open: true },
73
+ ['summary', {}, 'Documents'],
74
+ ['p', { class: 'muted data-row-summary' }, '$.rowSummary'],
75
+ ['ul', { class: 'data-rows' }, [{ $apply: '$.rowList[*]' }]],
76
+ ],
77
+ ];
78
+
79
+ /** One stored document: what it holds, and the delete the live pane
80
+ * shows arriving as an RFC 6902 remove. A row whose key pointer found
81
+ * nothing carries no control — there would be nothing to address. */
82
+ const documentRow = {
83
+ match: '$.ui.data.rowList[*]', mode: 'data',
84
+ body: ['li', { class: 'data-row' },
85
+ ['code', {}, '$.text'],
86
+ { $if: [{ $exists: '$.key' },
87
+ ['button', {
88
+ type: 'button', class: 'btn small data-row-delete', title: 'Delete this document',
89
+ on: { click: { action: 'data/delete', with: '$.key' } },
90
+ }, '\u00d7']] },
91
+ ],
92
+ };
93
+
94
+ const queryCard =
95
+ ['div', { class: 'card pg-card data-query' },
96
+ ['h3', {}, 'Query'],
97
+ editorTextarea({ value: '$.queryText', action: 'data/query-text', rows: 8, live: true }),
98
+ ['p', {},
99
+ ['button', {
100
+ class: 'btn small primary', type: 'button',
101
+ disabled: { $ne: ['$.status', 'ready'] }, on: { click: 'data/run' },
102
+ },
103
+ 'Run + explain'],
104
+ // every keystroke is published, then Enter or blur commits: this
105
+ // page re-renders on each live-query event, and a controlled input
106
+ // whose buffer is not in state is reset by that render mid-typing
107
+ ['input', {
108
+ class: 'data-insert-title', type: 'text',
109
+ value: '$.insertDraft', placeholder: '$.insertPlaceholder',
110
+ disabled: { $ne: ['$.status', 'ready'] },
111
+ on: { input: 'data/insert-draft', change: 'data/insert' },
112
+ }]],
113
+ { $if: ['$.explain',
114
+ ['details', { class: 'details-card', open: true },
115
+ ['summary', {}, 'explain() — the pushdown, visible'],
116
+ ['pre', { class: 'code-block data-explain-sql' }, ['code', {}, '$.explain.sql']],
117
+ ['p', { class: 'muted' }, 'params: ', ['code', {}, '$.explain.params']],
118
+ ['p', { class: 'muted' }, 'indexes: ', ['code', { class: 'data-explain-indexes' }, '$.explain.indexes']],
119
+ ['p', { class: 'muted' }, 'residual: ', ['code', {}, '$.explain.residual']],
120
+ ]] },
121
+ ['details', { class: 'details-card', open: true },
122
+ ['summary', {}, 'Results'],
123
+ ['pre', { class: 'code-block clamp data-results' }, ['code', {}, '$.resultsJson']],
124
+ ],
125
+ ['details', { class: 'details-card' },
126
+ ['summary', {}, 'Try a registered operator'],
127
+ ['p', { class: 'muted' }, 'The sqrt operator is a pushable scalar — paste this, Run, and watch explain() show a jaren_p_ UDF in the SQL (SQLite does the filtering). A finance or stats operator, such as mean over a series, runs in the residual instead, and explain() names it.'],
128
+ ['pre', { class: 'code-block' }, ['code', {}, '$.operatorSample']],
129
+ ],
130
+ ];
131
+
132
+ const liveCard =
133
+ ['div', { class: 'card pg-card data-live' },
134
+ ['h3', {}, 'Live query'],
135
+ { $if: ['$.liveNote', ['p', { class: 'muted data-live-note' }, '$.liveNote']] },
136
+ ['p', { class: 'muted' },
137
+ 'A subscribe operation over the contract stream binding: the snapshot, then RFC 6902 patches as writes commit — one diff format end to end.'],
138
+ ['p', { class: 'data-live-count' }, '$.liveSummary'],
139
+ ['p', { class: 'muted data-live-regs' }, 'live registrations on the store: ', ['strong', {}, '$.liveRegs']],
140
+ ['pre', { class: 'code-block clamp data-live-rows' }, ['code', {}, '$.liveJson']],
141
+ ['h3', {}, 'Migration'],
142
+ ['p', { class: 'muted' },
143
+ 'Plan a model change, verify it on a SHADOW database, apply it — in the browser.'],
144
+ { $if: ['$.migrationAvailable', ['div', {},
145
+ ['button', { class: 'btn small', type: 'button', disabled: ownerDisabled, on: { click: 'data/migrate' } },
146
+ 'Add a title index (plan → shadow → apply)'],
147
+ ownerReason,
148
+ ]] },
149
+ { $if: ['$.migration',
150
+ ['div', { class: 'data-migration' },
151
+ ['p', {}, 'planned steps:'],
152
+ ['pre', { class: 'code-block clamp data-migration-steps' },
153
+ ['code', {}, '$.migrationSteps']],
154
+ ['p', { class: 'data-migration-applied' }, '$.migrationSummary'],
155
+ ]] },
156
+ ];
157
+
158
+ /**
159
+ * The round trip, where a reader can watch it: every stage is a
160
+ * document the page prints, the store stage is explained beside its
161
+ * answer, and the answer is drawn. The map is what the `geojson`
162
+ * format's preview hint asks a host for; this host has a renderer.
163
+ */
164
+ const tripCard =
165
+ ['div', { class: 'card pg-card data-trip' },
166
+ ['h3', {}, 'Round trip'],
167
+ ['p', { class: 'muted' },
168
+ 'CSV in, a map out, in this tab. The rows become a FeatureCollection through a JSLT stylesheet, the collection is judged by the GeoJSON meta-schema, stored in a throwaway collection with derived spatial indexes, queried with a $within written through @jarenjs/linq, and explain() shows the two-stage plan \u2014 the bounding box the index seeks and the exact refinement the engine keeps \u2014 before the result is drawn.'],
169
+ editorTextarea({ value: '$.tripCsv', action: 'data/trip-csv', rows: 7, live: true }),
170
+ ['p', {},
171
+ ['button', { class: 'btn small primary data-trip-run', type: 'button', on: { click: 'data/trip-run' } },
172
+ 'Run the round trip']],
173
+ { $if: ['$.trip',
174
+ ['div', { class: 'data-trip-report', 'data-status': '$.tripStatus' },
175
+ ['p', { class: 'data-trip-summary' }, '$.tripSummary'],
176
+ { $if: ['$.tripDone',
177
+ ['div', {},
178
+ ['details', { class: 'details-card', open: true },
179
+ ['summary', {}, 'explain() \u2014 the two-stage spatial plan'],
180
+ ['pre', { class: 'code-block data-trip-sql' }, ['code', {}, '$.tripExplainSql']],
181
+ ['p', { class: 'muted' }, 'pre-filters: ', ['code', { class: 'data-trip-prefilters' }, '$.tripPrefilters']],
182
+ ['p', { class: 'muted' }, 'scan narrative: ', ['code', { class: 'data-trip-narrative' }, '$.tripNarrative']],
183
+ ['p', { class: 'muted' }, 'indexes: ', ['code', { class: 'data-trip-indexes' }, '$.tripIndexes']],
184
+ ['p', { class: 'muted' }, 'residual: ', ['code', { class: 'data-trip-residual' }, '$.tripResidual']],
185
+ ],
186
+ { $if: ['$.tripMap', ['div', { class: 'chart-card data-trip-map' }, '$.tripMap']] },
187
+ ['details', { class: 'details-card' },
188
+ ['summary', {}, 'The result \u2014 the features inside the region, as stored'],
189
+ ['pre', { class: 'code-block clamp data-trip-results' }, ['code', {}, '$.tripResultsJson']]],
190
+ ]] },
191
+ ]] },
192
+ ['details', { class: 'details-card' },
193
+ ['summary', {}, 'The stylesheet \u2014 CSV rows to a FeatureCollection, no code'],
194
+ ['pre', { class: 'code-block clamp' }, ['code', {}, '$.tripStylesheet']]],
195
+ ['details', { class: 'details-card' },
196
+ ['summary', {}, 'The model \u2014 derived spatial indexes over the geometry'],
197
+ ['pre', { class: 'code-block clamp' }, ['code', {}, '$.tripModel']]],
198
+ ['details', { class: 'details-card' },
199
+ ['summary', {}, 'The query \u2014 written through @jarenjs/linq'],
200
+ ['pre', { class: 'code-block' }, ['code', {}, '$.tripChain']],
201
+ ['p', { class: 'muted' }, 'emits this document, with the region bound as an external:'],
202
+ ['pre', { class: 'code-block data-trip-query' }, ['code', {}, '$.tripQuery']],
203
+ ['pre', { class: 'code-block clamp' }, ['code', {}, '$.tripRegion']]],
204
+ ];
205
+
206
+ const dataPaneSwitcher = paneSwitcher({
207
+ class: 'data-panebar', pane: '$.mobilePane', action: 'data/pane',
208
+ panes: [['store', 'Store'], ['query', 'Query'], ['live', 'Live'], ['trip', 'Round trip']],
209
+ });
210
+ dataPaneSwitcher[dataPaneSwitcher.length - 1] = { $if: ['$.tripAvailable', dataPaneSwitcher.at(-1)] };
211
+
212
+ export const DATA_RULES = [
213
+ documentRow,
214
+ {
215
+ match: '$.ui.data', mode: 'data',
216
+ // `container` for the gutters the other studios have: without it this
217
+ // page ran edge-to-edge, into the notch, at every width
218
+ body: ['section', { class: 'jaren-data-editor' },
219
+ { $if: [{ $eq: ['$.status', 'boot'] },
220
+ ['p', { class: 'muted data-booting' }, 'Loading the SQLite wasm build…']] },
221
+ // the boot's terminal failure: the stage that failed is what a
222
+ // reader can report, and the retry starts the protocol over
223
+ { $if: [{ $eq: ['$.status', 'error'] },
224
+ ['div', { class: 'card pg-card data-boot-error', role: 'alert' },
225
+ ['p', {},
226
+ ['strong', {}, 'The store did not boot.'], ' Stage ',
227
+ ['code', { class: 'data-boot-stage' }, '$.boot.stage'], ' failed: ',
228
+ ['span', { class: 'data-boot-message' }, '$.boot.message']],
229
+ ['button', { class: 'btn small data-boot-retry', type: 'button', on: { click: 'data/retry' } },
230
+ 'Retry the boot']]] },
231
+ { $if: ['$.plainError', errorLine('$.plainError')] },
232
+ ['div', { class: 'data-grid', 'data-pane': '$.mobilePane' },
233
+ dataPaneSwitcher,
234
+ statusCard,
235
+ queryCard,
236
+ liveCard,
237
+ { $if: ['$.tripAvailable', tripCard] },
238
+ ],
239
+ ],
240
+ },
241
+ ];
@@ -0,0 +1,344 @@
1
+ //@ts-check
2
+ /** Validate, audit and mount isolated app documents with explicit render services. */
3
+ import { JarenValidator } from '@jarenjs/validate';
4
+ import { jsonFormats } from '@jarenjs/formats';
5
+ import { OUR_SCHEMA_OPTIONS } from './shared/schema-options.js';
6
+ import { createTypeTestCompiler } from '@jarenjs/validate/query';
7
+ import { createApp, createFormView, formEventFields } from '@jarenjs/app';
8
+ import { createDomRenderer } from '@jarenjs/view';
9
+ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
10
+ import { buildFormModel, buildFormViewModel } from '@jarenjs/forms';
11
+ import { compileChart } from '@jarenjs/charts';
12
+ import { errorMessage } from './shared/nodes.js';
13
+ import appSchema from '@jarenjs/app/schemas/jaren-app.schema.json' with { type: 'json' };
14
+ import querySchema from '@jarenjs/json/schemas/jaren-query.schema.json' with { type: 'json' };
15
+ import jsltSchema from '@jarenjs/json/schemas/jaren-jslt.schema.json' with { type: 'json' };
16
+
17
+
18
+
19
+ /** @param {{ markdown: (source: string) => any, diagram: (source: string) => any, templates?: any[] }} options */
20
+ export function createStudioDocumentHost(options) {
21
+ const { markdown, diagram, templates: STUDIO_TEMPLATES = [] } = options;
22
+ /** Schema errors kept per report: enough to repair, bounded for state. */
23
+ const MAX_ERRORS = 20;
24
+
25
+ // the meta-schema composes the published query and JSLT grammars by
26
+ // reference — register those artifacts alongside it (APP-FORMAT §2)
27
+ const validateApp = new JarenValidator(OUR_SCHEMA_OPTIONS)
28
+ .addFormats(jsonFormats)
29
+ .addSchema(querySchema)
30
+ .addSchema(jsltSchema)
31
+ .compile(appSchema);
32
+
33
+ const message = errorMessage;
34
+
35
+ /**
36
+ * Validate a candidate document against the jaren-app meta-schema.
37
+ * @param {any} doc
38
+ * @returns {{ valid: boolean, errors: Array<{ instancePath: string, keyword: string, message: string }>, total: number }}
39
+ */
40
+ function validateAppDocument(doc) {
41
+ const outcome = validateApp(doc);
42
+ const valid = typeof outcome === 'object' && outcome !== null ? outcome.valid : outcome === true;
43
+ if (valid) return { valid: true, errors: [], total: 0 };
44
+ const raw = (typeof outcome === 'object' && outcome !== null ? outcome.errors : null) ?? [];
45
+ return {
46
+ valid: false,
47
+ total: raw.length,
48
+ errors: raw.slice(0, MAX_ERRORS).map((e) => ({
49
+ instancePath: e.instancePath ?? '',
50
+ keyword: e.keyword ?? '',
51
+ message: e.message ?? 'invalid',
52
+ })),
53
+ };
54
+ }
55
+
56
+ //#region the document's render-capability widgets
57
+
58
+ /**
59
+ * A pure props→vnode widget: its own mini renderer into the host, and
60
+ * every `on` binding inside the projection emits back into the hosting
61
+ * app's dispatch (VIEW-FORMAT §7) — the widget renders, the document's
62
+ * actions own the state transitions.
63
+ * @param {(props: any) => any} view
64
+ * @returns {{ mount: (host: HTMLElement, props: any, emit: (binding: any, event: Event) => void) => { render: ReturnType<typeof createDomRenderer> }, update: (handle: { render: ReturnType<typeof createDomRenderer> }, props: any) => void, unmount: (handle: { render: ReturnType<typeof createDomRenderer> }) => void }}
65
+ */
66
+ function vnodeWidget(view) {
67
+ return {
68
+ mount(host, props, emit) {
69
+ const render = createDomRenderer(host, {
70
+ document: host.ownerDocument,
71
+ onEvent: (binding, event) => emit(binding, event),
72
+ });
73
+ render(view(props));
74
+ return { render };
75
+ },
76
+ update(handle, props) {
77
+ handle.render(view(props));
78
+ },
79
+ unmount(handle) {
80
+ handle.render.destroy?.();
81
+ },
82
+ };
83
+ }
84
+
85
+ // the standard forms stylesheet, compiled once: the form widget renders
86
+ // any `buildFormViewModel` tree; its bindings dispatch the standard
87
+ // form actions the document embeds (createFormActions output is JSON)
88
+ const formStylesheet = compileJsltStylesheet({
89
+ $jslt: '0.1',
90
+ rules: [
91
+ { match: '$', body: { $apply: '$.form' } },
92
+ ...createFormView(),
93
+ ],
94
+ }, { memo: true });
95
+
96
+ /** Form models memoized per schema value identity (state is immutable). */
97
+ const formModels = new WeakMap();
98
+
99
+ function formVnode(props) {
100
+ try {
101
+ const schema = props?.schema;
102
+ if (schema === null || typeof schema !== 'object') {
103
+ return ['p', { class: 'error-line' }, 'form: props.schema must be a JSON Schema object'];
104
+ }
105
+ let model = formModels.get(schema);
106
+ if (model === undefined) {
107
+ model = buildFormModel(schema);
108
+ formModels.set(schema, model);
109
+ }
110
+ return formStylesheet({
111
+ form: buildFormViewModel(model, props.data ?? null, { validateFields: true }),
112
+ });
113
+ }
114
+ catch (err) {
115
+ return ['p', { class: 'error-line' }, `form: ${message(err)}`];
116
+ }
117
+ }
118
+
119
+ function chartVnode(props) {
120
+ try {
121
+ // inline definitions carry their own data (the charts engine's
122
+ // static path); theme 'host' keeps light/dark live (docs/DESIGN.md §7)
123
+ return ['div', { class: 'studio-chart' },
124
+ compileChart(props.config, props.config, { theme: 'host' }).toVnode()];
125
+ }
126
+ catch (err) {
127
+ return ['p', { class: 'error-line' }, `chart: ${message(err)}`];
128
+ }
129
+ }
130
+
131
+ const markdownVnode = (props) => markdown(String(props?.source ?? ''));
132
+ const mermaidVnode = (props) => diagram(String(props?.source ?? ''));
133
+
134
+ /**
135
+ * The render capabilities a studio document may name (and nothing else).
136
+ * A composed host can register the same chart/mermaid/markdown/form widgets
137
+ * in its own view.
138
+ */
139
+ const STUDIO_WIDGETS = {
140
+ form: vnodeWidget(formVnode),
141
+ chart: vnodeWidget(chartVnode),
142
+ markdown: vnodeWidget(markdownVnode),
143
+ mermaid: vnodeWidget(mermaidVnode),
144
+ };
145
+
146
+ //#endregion
147
+
148
+ // $valid/$assert/$as and schema matches are compile-time capabilities
149
+ // of the document grammars (pure, no side channel) — granted, unlike
150
+ // effects and subs
151
+ const compileTypeTest = createTypeTestCompiler();
152
+
153
+ //#region the headless render audit
154
+
155
+ /** Render problems reported per audit: enough to repair, bounded. */
156
+ const MAX_RENDER_PROBLEMS = 8;
157
+
158
+ /**
159
+ * A tag `createElement` accepts: a letter, then letters, digits or
160
+ * hyphens. One-element arrays like `["hr"]` are valid void elements —
161
+ * the defect class is invalid tag NAMES, not array arity.
162
+ */
163
+ const VALID_TAG = /^[a-zA-Z][a-zA-Z0-9-]*$/;
164
+
165
+ /**
166
+ * Walk one rendered vnode with the renderer's own shape rules (text,
167
+ * skipped, `[tag, props?, ...children]`, non-string-head arrays splice
168
+ * as lists) and collect what a live mount would host: the widgets it
169
+ * names, and the shapes that render as junk even though the meta-schema
170
+ * accepted the document.
171
+ * @param {any} node
172
+ * @param {string} path - a JSON-pointer-ish trail into the rendered tree
173
+ * @param {{ widgets: string[], problems: string[] }} out
174
+ */
175
+ function walkRenderedVnode(node, path, out) {
176
+ if (out.problems.length >= MAX_RENDER_PROBLEMS) return;
177
+ if (node == null || node === true || node === false) return; // skipped
178
+ if (typeof node === 'string' || typeof node === 'number') return; // text
179
+ if (!Array.isArray(node)) {
180
+ out.problems.push(`${path}: a bare object is not a vnode — it renders as nothing (expected ["tag", props, ...children] or text)`);
181
+ return;
182
+ }
183
+ if (typeof node[0] !== 'string') {
184
+ // a list: each item renders in place
185
+ for (let i = 0; i < node.length; i++) walkRenderedVnode(node[i], `${path}/${i}`, out);
186
+ return;
187
+ }
188
+ if (!VALID_TAG.test(node[0])) {
189
+ // this is a boot-stopper, not a cosmetic problem: the nested app
190
+ // dies on createElement and the whole stage ends empty
191
+ out.problems.push(`${path}: '${node[0]}' is not a valid element tag name — the app fails to boot on it (text belongs directly in the children, not wrapped in an array)`);
192
+ return;
193
+ }
194
+ if (node[0] === 'jaren-widget') {
195
+ const props = (node[1] !== null && typeof node[1] === 'object' && !Array.isArray(node[1])) ? node[1] : {};
196
+ const name = props.name;
197
+ out.widgets.push(String(name));
198
+ if (STUDIO_WIDGETS[name] === undefined) {
199
+ out.problems.push(`${path}: unknown widget '${String(name)}' — available: ${Object.keys(STUDIO_WIDGETS).join(', ')}`);
200
+ }
201
+ else if (name === 'form' && (props.props?.schema === null || typeof props.props?.schema !== 'object')) {
202
+ out.problems.push(`${path}: the form widget's props.schema did not resolve to a JSON Schema object — the form renders an error instead of fields`);
203
+ }
204
+ else if (name === 'chart' && (props.props?.config === null || typeof props.props?.config !== 'object')) {
205
+ out.problems.push(`${path}: the chart widget's props.config did not resolve to a chart definition object`);
206
+ }
207
+ if (name === 'form' && typeof props.props?.schema?.title === 'string' && props.props.schema.title !== '') {
208
+ out.formTitles.push(props.props.schema.title);
209
+ }
210
+ return;
211
+ }
212
+ if (node[0] === 'h1' || node[0] === 'h2' || node[0] === 'h3') {
213
+ const start = (node.length > 1 && node[1] !== null && typeof node[1] === 'object' && !Array.isArray(node[1])) ? 2 : 1;
214
+ let text = '';
215
+ for (let i = start; i < node.length; i++) {
216
+ if (typeof node[i] === 'string') text += node[i];
217
+ }
218
+ if (text.trim() !== '') out.headings.push(text.trim());
219
+ }
220
+ const start = (node.length > 1 && node[1] !== null && typeof node[1] === 'object' && !Array.isArray(node[1])) ? 2 : 1;
221
+ for (let i = start; i < node.length; i++) walkRenderedVnode(node[i], `${path}/${i}`, out);
222
+ }
223
+
224
+ /**
225
+ * The seed templates' rendered headings, each mapped to the form
226
+ * titles that seed legitimately pairs them with — the reference for
227
+ * the stale-heading note below. Built lazily from the templates
228
+ * themselves so it can never drift from the seed library.
229
+ * @type {Map<string, Set<string>> | null}
230
+ */
231
+ let seedHeadings = null;
232
+
233
+ function getSeedHeadings() {
234
+ if (seedHeadings === null) {
235
+ seedHeadings = new Map();
236
+ for (const template of STUDIO_TEMPLATES) {
237
+ /** @type {{ widgets: string[], problems: string[], headings: string[], formTitles: string[] }} */
238
+ const out = { widgets: [], problems: [], headings: [], formTitles: [] };
239
+ try {
240
+ walkRenderedVnode(
241
+ compileJsltStylesheet(template.doc.view, { compileTypeTest, memo: false })(template.doc.state),
242
+ '', out);
243
+ }
244
+ catch {
245
+ continue; // a seed that fails to render simply contributes nothing
246
+ }
247
+ for (const heading of out.headings) {
248
+ const titles = seedHeadings.get(heading) ?? new Set();
249
+ for (const title of out.formTitles) titles.add(title);
250
+ seedHeadings.set(heading, titles);
251
+ }
252
+ }
253
+ }
254
+ return seedHeadings;
255
+ }
256
+
257
+ /**
258
+ * Render the document's first frame headlessly — the same stylesheet
259
+ * compiler and state the live host boots with — and report the widgets
260
+ * it hosts plus any render problems the meta-schema cannot see. "It
261
+ * validates" is not "it renders": a valid document can still name an
262
+ * unknown widget, hand the form widget a non-object schema, or place a
263
+ * bare object where a vnode belongs.
264
+ *
265
+ * `notes` are soft semantic observations, not failures — today one
266
+ * rule: a SEED template's heading still on screen while the form's
267
+ * schema title has moved on (the repurposed-template leftover). The
268
+ * rule deliberately keys on the seed headings so a pristine template,
269
+ * or an authored heading of the user's own, never trips it.
270
+ * @param {any} doc
271
+ * @returns {{ widgets: string[], problems: string[], notes: string[] }}
272
+ */
273
+ function auditDocumentRender(doc) {
274
+ /** @type {{ widgets: string[], problems: string[], headings: string[], formTitles: string[] }} */
275
+ const out = { widgets: [], problems: [], headings: [], formTitles: [] };
276
+ let vnode;
277
+ try {
278
+ vnode = compileJsltStylesheet(doc.view, { compileTypeTest, memo: false })(doc.state);
279
+ }
280
+ catch (err) {
281
+ out.problems.push(`the view failed to render its first frame: ${message(err)}`);
282
+ return { widgets: out.widgets, problems: out.problems, notes: [] };
283
+ }
284
+ walkRenderedVnode(vnode, '', out);
285
+
286
+ /** @type {string[]} */
287
+ const notes = [];
288
+ const markers = getSeedHeadings();
289
+ for (const heading of out.headings) {
290
+ const seedTitles = markers.get(heading);
291
+ if (seedTitles === undefined) continue;
292
+ const departed = out.formTitles.find((title) => !seedTitles.has(title)
293
+ && !heading.toLowerCase().includes(title.toLowerCase()));
294
+ if (departed !== undefined) {
295
+ notes.push(`the heading "${heading}" is still the seed template's while the form is now titled "${departed}" — patch the heading to match the repurposed form`);
296
+ break;
297
+ }
298
+ }
299
+ return { widgets: out.widgets, problems: out.problems, notes };
300
+ }
301
+
302
+ //#endregion
303
+
304
+ /**
305
+ * Validate a document against the meta-schema and boot it as an
306
+ * isolated app: no effects, no subs, its own `onError` sink, the
307
+ * render-capability widgets, and the runtime's atomic-boot guarantee.
308
+ * @param {any} doc
309
+ * @param {{ node?: any, document?: any, schedule?: (flush: () => void) => void,
310
+ * onError?: (err: Error) => void }} env
311
+ * @returns {{ ok: true, app: any } | { ok: false, errors: any[], total: number, message: string }}
312
+ */
313
+ function loadStudioDocument(doc, env = {}) {
314
+ const report = validateAppDocument(doc);
315
+ if (!report.valid) {
316
+ return {
317
+ ok: false,
318
+ errors: report.errors,
319
+ total: report.total,
320
+ message: `the document does not validate against the jaren-app meta-schema (${report.total} error${report.total === 1 ? '' : 's'})`,
321
+ };
322
+ }
323
+ try {
324
+ const app = createApp(doc, {
325
+ node: env.node,
326
+ document: env.document,
327
+ schedule: env.schedule,
328
+ widgets: STUDIO_WIDGETS,
329
+ compileTypeTest,
330
+ // the form widget's typed selects and json editor decode here
331
+ eventFields: { ...formEventFields() },
332
+ onError: env.onError,
333
+ // deliberately absent: effects, subs — the isolation boundary
334
+ });
335
+ return { ok: true, app };
336
+ }
337
+ catch (err) {
338
+ // JA0007: boot rolled back atomically, the container ends empty
339
+ return { ok: false, errors: [], total: 0, message: message(err) };
340
+ }
341
+ }
342
+
343
+ return { validateAppDocument, STUDIO_WIDGETS, auditDocumentRender, loadStudioDocument };
344
+ }