@jarenjs/play 0.34.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/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@jarenjs/play",
3
+ "private": false,
4
+ "version": "0.34.0",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./dist/types/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./component": {
15
+ "types": "./dist/types/component/index.d.ts",
16
+ "default": "./src/component/index.js"
17
+ },
18
+ "./styles/play.css": "./styles/play.css",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist/types/",
23
+ "src/",
24
+ "docs/",
25
+ "styles/"
26
+ ],
27
+ "description": "The jaren engine playground: pick a JSON engine (JSONPath, JSON Pointer, JSON Patch, query, JSLT, markdown, mermaid, …), feed it an input and one or more datasets from a curated example library, and watch it run. Understand an engine standalone before composing it in the studio. The engine is headless; the component is the playground UI.",
28
+ "author": "joham",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/jklarenbeek/jarenjs.git",
32
+ "directory": "components/play"
33
+ },
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=24"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "keywords": [
43
+ "jaren",
44
+ "json",
45
+ "playground",
46
+ "playground",
47
+ "examples",
48
+ "jsonpath",
49
+ "jslt",
50
+ "jsonquery",
51
+ "headless"
52
+ ],
53
+ "scripts": {
54
+ "build": "npm run build:types",
55
+ "build:types": "tsc -p tsconfig.json",
56
+ "prepack": "npm run build:types"
57
+ },
58
+ "dependencies": {
59
+ "@jarenjs/core": "^0.34.0",
60
+ "@jarenjs/josl": "^0.34.0",
61
+ "@jarenjs/json": "^0.34.0",
62
+ "@jarenjs/validate": "^0.34.0"
63
+ }
64
+ }
@@ -0,0 +1,53 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The playground COMPONENT — `createPlayComponent(options)`, the
4
+ * suite's factory convention (like `@jarenjs/calc` / `@jarenjs/studio`). It
5
+ * hands the host the JSLT view (`rules` + `mode` + `modes`), the pure
6
+ * derivation (`viewModel`), and the headless engine surface its run loop
7
+ * binds (`runExample`, the registry, the example library). The reducer
8
+ * `play/*` actions and the debounced live-run are wired at the host.
9
+ */
10
+
11
+ import { ENGINES, EXAMPLES, engineIds, runExample } from '../index.js';
12
+ import { playViewModel } from './viewmodel.js';
13
+ import { playRules, playModes, PLAY_MODE, PLAY_BASE } from './view.js';
14
+
15
+ /**
16
+ * Build the playground component.
17
+ *
18
+ * The three host seams are configured ONCE here and become defaults for
19
+ * every `runExample` call the returned component makes; a per-call option
20
+ * of the same name still wins, so a caller can vary one run. Configuring
21
+ * them at the factory and having them silently ignored is the trap this
22
+ * shape exists to close — a host that registered its operator packs here
23
+ * would otherwise watch `$mean` and `$npv` go missing at run time.
24
+ *
25
+ * @param {{ operators?: { toOptions: () => any },
26
+ * renderers?: Record<string, Function>, validate?: Function }} [options]
27
+ * `operators` reaches the query/jslt engines, `renderers` the visual
28
+ * engines (markdown/mermaid/charts/mdx), `validate` the JSON Schema one
29
+ */
30
+ export function createPlayComponent(options = {}) {
31
+ const seams = {};
32
+ if (options.operators !== undefined) seams.operators = options.operators;
33
+ if (options.renderers !== undefined) seams.renderers = options.renderers;
34
+ if (options.validate !== undefined) seams.validate = options.validate;
35
+ return {
36
+ mode: PLAY_MODE,
37
+ rules: playRules,
38
+ modes: playModes,
39
+ viewModel: playViewModel,
40
+ // the engine surface the host's run loop binds
41
+ engines: ENGINES,
42
+ examples: EXAMPLES,
43
+ engineIds,
44
+ runExample: (engineId, source, data, perCall = {}) =>
45
+ runExample(engineId, source, data, { ...seams, ...perCall }),
46
+ operators: options.operators,
47
+ };
48
+ }
49
+
50
+ export {
51
+ playViewModel, playRules, playModes, PLAY_MODE, PLAY_BASE,
52
+ ENGINES, EXAMPLES, engineIds, runExample,
53
+ };
@@ -0,0 +1,318 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The playground as a JSLT view — the chrome is a document, rendered
4
+ * by the same engine as the rest of the suite. One `play` mode, rules
5
+ * matched by their ABSOLUTE slice path (`$.ui.play`), `$apply` and body
6
+ * references RELATIVE to the matched node. The host mounts the view model
7
+ * at `$.ui.play`. No imperative islands — the whole playground is data.
8
+ */
9
+
10
+ /** The one mode this view uses. */
11
+ export const PLAY_MODE = 'play';
12
+ /** The slice the host mounts the view model at. */
13
+ export const PLAY_BASE = '$.ui.play';
14
+ /** The modes the host merges into the site stylesheet. */
15
+ export const playModes = Object.freeze({ [PLAY_MODE]: { unmatched: 'error' } });
16
+
17
+ /** One segment of the phone pane switcher — active when it is the live pane. */
18
+ const paneButton = (pane, label) => ['button', {
19
+ type: 'button',
20
+ class: { $if: [{ $eq: ['$.mobilePane', pane] }, 'seg-btn active', 'seg-btn'] },
21
+ 'aria-pressed': { $if: [{ $eq: ['$.mobilePane', pane] }, 'true', 'false'] },
22
+ on: { click: { action: 'play/mobile-pane', with: pane } },
23
+ }, label];
24
+
25
+ /** The shell (matches the whole slice): bar / rail | editors | split | stage. */
26
+ const shell = {
27
+ match: PLAY_BASE, mode: PLAY_MODE,
28
+ body: ['div', { class: 'jplay', 'data-pane': '$.mobilePane' },
29
+ // ——— the IDE bar: engine title, session name, New/Save/Save As/Share,
30
+ // the Load dropdown, and the last share status (a play session is a
31
+ // saveable document) ———
32
+ ['div', { class: 'jplay-bar' },
33
+ ['strong', { class: 'jplay-title' }, '$.engine.label'],
34
+ // a NAME field, not a code editor: `.editor` paints the code-block
35
+ // background, which put a black hole in the middle of a light toolbar
36
+ ['input', {
37
+ class: 'jplay-name', value: '$.name', spellcheck: 'false',
38
+ autocapitalize: 'off', autocomplete: 'off', placeholder: 'name this session…',
39
+ 'aria-label': 'session name', on: { input: 'play/name' },
40
+ }],
41
+ ['div', { class: 'jplay-actions' },
42
+ ['button', { class: 'btn small', type: 'button', on: { click: 'play/new' } }, 'New'],
43
+ ['button', {
44
+ class: 'btn small', type: 'button',
45
+ title: { $if: ['$.savedName',
46
+ { $concat: ['Overwrite “', '$.savedName', '”'] },
47
+ 'Save this session under the name above'] },
48
+ on: { click: 'play/save' },
49
+ }, 'Save'],
50
+ ['button', {
51
+ class: 'btn small', type: 'button',
52
+ title: 'Save the name above as a SEPARATE session, keeping the original',
53
+ on: { click: 'play/save-as' },
54
+ }, 'Save As'],
55
+ // the way out of the browser, and back in: a session too large for
56
+ // a share link still has a file
57
+ ['button', {
58
+ class: 'btn small', type: 'button',
59
+ title: 'Download this session as a file',
60
+ on: { click: 'play/download' },
61
+ }, 'Download'],
62
+ ['button', {
63
+ class: 'btn small', type: 'button',
64
+ title: 'Open a downloaded session file',
65
+ on: { click: 'play/import' },
66
+ }, 'Import'],
67
+ ['button', { class: 'btn small', type: 'button', on: { click: 'play/share' } }, 'Share'],
68
+ // Delete the current named session (only meaningful once named+saved)
69
+ { $if: ['$.name',
70
+ ['button', { class: 'btn small', type: 'button', title: 'delete this saved session',
71
+ on: { click: { action: 'play/delete-session', with: '$.name' } } }, 'Delete'],
72
+ ''] },
73
+ // the Load dropdown appears once there is something saved
74
+ { $if: ['$.hasSaved',
75
+ ['select', { class: 'jplay-load editor line', 'aria-label': 'load a saved session', value: '', on: { change: 'play/open' } },
76
+ ['option', { value: '' }, 'Load…'],
77
+ [{ $apply: '$.names[*]' }]],
78
+ ''] },
79
+ ],
80
+ // the title has been edited away from the bound record: say which
81
+ // button does what, rather than letting Save silently overwrite the
82
+ // session the user opened under a name they have already changed
83
+ { $if: ['$.renamed',
84
+ ['span', { class: 'jplay-bound muted', role: 'status' },
85
+ 'Save overwrites “', ['text', '$.savedName'], '” · Save As keeps both'],
86
+ ''] },
87
+ { $if: ['$.shared', ['span', { class: 'jplay-shared muted', role: 'status' }, '$.shared'], ''] },
88
+ ],
89
+ // ——— the phone pane switcher: below the breakpoint the rail | editors
90
+ // | result grid is ONE column, one pane at a time — a segmented bar
91
+ // instead of a tall stack (desktop hides this bar entirely). A pane
92
+ // is a grid area, not a `tabpanel`, so these are toggle buttons in a
93
+ // group with `aria-pressed`, not a `tablist` with `aria-selected`
94
+ // (the RESULT strip below is a real tablist and says so) ———
95
+ ['div', { class: 'jplay-mobilebar seg', role: 'group', 'aria-label': 'pane' },
96
+ paneButton('examples', 'Examples'),
97
+ paneButton('editor', 'Editor'),
98
+ paneButton('result', 'Result'),
99
+ ],
100
+ // ——— the example picker (a "file tree" grouped by engine) ———
101
+ ['nav', { class: 'jplay-rail', 'aria-label': 'examples' }, [{ $apply: '$.rail[*]' }]],
102
+ // ——— the source + data editors ———
103
+ ['div', { class: 'jplay-editors' },
104
+ ['div', { class: 'jplay-head' },
105
+ ['strong', { class: 'jplay-engine' }, '$.engine.label'],
106
+ ['span', { class: 'muted jplay-lead' }, '$.engine.lead'],
107
+ ],
108
+ // live mode selects (josl dialect, csv strict/repair) — empty for most
109
+ ['div', { class: 'jplay-options' }, [{ $apply: '$.optionPanes[*]' }]],
110
+ [{ $apply: '$.sourcePanes[*]' }],
111
+ { $if: ['$.hasSwitcher',
112
+ ['div', { class: 'jplay-datasets seg', role: 'group', 'aria-label': 'dataset' }, [{ $apply: '$.datasets[*]' }]],
113
+ ''] },
114
+ // the validate engine offers a JSON ↔ generated-form toggle on its data
115
+ { $if: ['$.hasForm',
116
+ ['div', { class: 'jplay-dataview seg', role: 'group', 'aria-label': 'data view' },
117
+ ['button', { type: 'button', class: { $if: [{ $eq: ['$.dataView', 'json'] }, 'seg-btn active', 'seg-btn'] },
118
+ on: { click: { action: 'play/data-view', with: 'json' } } }, 'JSON'],
119
+ ['button', { type: 'button', class: { $if: [{ $eq: ['$.dataView', 'form'] }, 'seg-btn active', 'seg-btn'] },
120
+ on: { click: { action: 'play/data-view', with: 'form' } } }, 'Form'],
121
+ ], ''] },
122
+ // form mode → the host-supplied generated form (a two-way seam); else
123
+ // the JSON data editor(s)
124
+ { $if: ['$.showForm',
125
+ ['div', { class: 'jplay-form' }, { $apply: ['$.dataForm', PLAY_MODE] }],
126
+ [{ $apply: '$.dataPanes[*]' }]] },
127
+ ],
128
+ // ——— the editors|result splitter (a pointer-capture widget; its host
129
+ // IS the grab bar — drives --jplay-ratio live, commits on pointer-up) ———
130
+ ['jaren-widget', {
131
+ name: 'play-splitter', class: 'jplay-split',
132
+ role: 'separator', 'aria-orientation': 'vertical',
133
+ 'aria-label': 'Resize the editors and result',
134
+ 'aria-valuemin': '10', 'aria-valuemax': '90', 'aria-valuenow': '$.ratioPct',
135
+ tabindex: '0',
136
+ props: { ratio: '$.ratio' },
137
+ }],
138
+ // ——— the run stage ———
139
+ ['div', { class: 'jplay-stage' },
140
+ ['div', { class: 'jplay-stage-head muted' }, 'Result'],
141
+ { $if: ['$.result.ran',
142
+ { $if: ['$.result.ok',
143
+ ['div', { class: { $if: ['$.result.deepOn', 'jplay-result deep-on', 'jplay-result'] } },
144
+ // the SIMPLE half — the calm answer (on a phone, the open
145
+ // drill-down swaps this half out entirely; see play.css)
146
+ ['div', { class: 'jplay-simple' },
147
+ { $if: ['$.result.timing', ['p', { class: 'muted jplay-timing' }, '$.result.timing'], ''] },
148
+ // >1 screen → a tab strip selecting the active one; 1 → no tabs
149
+ { $if: ['$.result.tabbed',
150
+ ['div', { class: 'jplay-tabs seg', role: 'tablist' }, [{ $apply: '$.result.tabs[*]' }]], ''] },
151
+ // the active panel body, rendered by its kind (a single object →
152
+ // the explicit [path, mode] apply form)
153
+ { $apply: [`$.result.activePanel`, PLAY_MODE] },
154
+ ],
155
+ // the DRILL-DOWN half — the engine's rich explainers, opt-in:
156
+ // a quiet affordance beneath the simple answer reveals the deep
157
+ // panels as an additional tab row (desktop) or a full-pane
158
+ // sub-view with a ← back (mobile)
159
+ { $if: ['$.result.hasDeep',
160
+ ['div', { class: 'jplay-deep' },
161
+ ['button', {
162
+ type: 'button', class: 'jplay-deep-toggle',
163
+ 'aria-expanded': { $if: ['$.result.deepOn', 'true', 'false'] },
164
+ on: { click: { action: 'play/deep', with: '$.result.deepNext' } },
165
+ }, '$.result.deepLabel'],
166
+ { $if: ['$.result.deepOn',
167
+ ['div', { class: 'jplay-deep-body' },
168
+ ['button', { type: 'button', class: 'jplay-deep-back', on: { click: { action: 'play/deep', with: '$.result.deepNext' } } }, '← Back to the result'],
169
+ { $if: ['$.result.deepTabbed',
170
+ ['div', { class: 'jplay-deep-tabs seg', role: 'tablist' }, [{ $apply: '$.result.deepTabs[*]' }]], ''] },
171
+ { $apply: [`$.result.deepPanel`, PLAY_MODE] },
172
+ ], ''] },
173
+ ], ''] },
174
+ ],
175
+ ['p', { class: 'error-line' }, ['strong', {}, '$.result.error.code'], ' ', '$.result.error.message']] },
176
+ ['p', { class: 'muted jplay-hint' }, 'Pick an example, or edit the source or data — it runs live.']] },
177
+ ],
178
+ ],
179
+ };
180
+
181
+ /** One engine group in the rail. */
182
+ const railGroup = {
183
+ match: `${PLAY_BASE}.rail[*]`, mode: PLAY_MODE,
184
+ body: ['div', { class: { $if: ['$.active', 'jplay-group active', 'jplay-group'] } },
185
+ ['div', { class: 'jplay-group-head muted' }, '$.label'],
186
+ [{ $apply: '$.examples[*]' }],
187
+ ],
188
+ };
189
+
190
+ /** One example in a group — picking it loads its source + first dataset. */
191
+ const railExample = {
192
+ match: `${PLAY_BASE}.rail[*].examples[*]`, mode: PLAY_MODE,
193
+ body: ['button', {
194
+ type: 'button',
195
+ class: { $if: ['$.active', 'jplay-ex active', 'jplay-ex'] },
196
+ on: { click: { action: 'play/example', with: '$.id' } },
197
+ }, '$.label'],
198
+ };
199
+
200
+ /** One source editor (a `text` control renders an input, else a textarea). */
201
+ const sourcePane = {
202
+ match: `${PLAY_BASE}.sourcePanes[*]`, mode: PLAY_MODE,
203
+ body: ['label', { class: 'jplay-pane' },
204
+ ['span', { class: 'jplay-pane-label muted' }, '$.label'],
205
+ { $if: [{ $eq: ['$.control', 'text'] },
206
+ ['input', { type: 'text', class: 'editor line', spellcheck: 'false', value: '$.value',
207
+ on: { input: { action: 'play/source', with: { key: '$.key' } } } }],
208
+ ['textarea', { class: 'editor', rows: 6, spellcheck: 'false', value: '$.value',
209
+ on: { input: { action: 'play/source', with: { key: '$.key' } } } }]] },
210
+ ],
211
+ };
212
+
213
+ /** One data editor. */
214
+ const dataPane = {
215
+ match: `${PLAY_BASE}.dataPanes[*]`, mode: PLAY_MODE,
216
+ body: ['label', { class: 'jplay-pane' },
217
+ ['span', { class: 'jplay-pane-label muted' }, '$.label'],
218
+ ['textarea', { class: 'editor', rows: 8, spellcheck: 'false', value: '$.value',
219
+ on: { input: { action: 'play/data', with: { key: '$.key' } } } }],
220
+ ],
221
+ };
222
+
223
+ /** One option pane — a live mode select (its value lives in host `config`). */
224
+ const optionPane = {
225
+ match: `${PLAY_BASE}.optionPanes[*]`, mode: PLAY_MODE,
226
+ body: ['label', { class: 'jplay-option' },
227
+ ['span', { class: 'jplay-pane-label muted' }, '$.label'],
228
+ ['select', { class: 'editor line', on: { change: { action: 'play/option', with: { key: '$.key' } } } },
229
+ [{ $apply: '$.choices[*]' }]],
230
+ ],
231
+ };
232
+
233
+ /** One choice inside an option select. */
234
+ const optionChoice = {
235
+ match: `${PLAY_BASE}.optionPanes[*].choices[*]`, mode: PLAY_MODE,
236
+ body: ['option', { value: '$.value', selected: '$.selected' }, '$.label'],
237
+ };
238
+
239
+ /** One dataset segment in the switcher. */
240
+ const datasetOption = {
241
+ match: `${PLAY_BASE}.datasets[*]`, mode: PLAY_MODE,
242
+ body: ['button', {
243
+ type: 'button',
244
+ class: { $if: ['$.active', 'seg-btn active', 'seg-btn'] },
245
+ on: { click: { action: 'play/dataset', with: '$.index' } },
246
+ }, '$.label'],
247
+ };
248
+
249
+ /** One saved session in the Load dropdown. */
250
+ const savedOption = {
251
+ match: `${PLAY_BASE}.names[*]`, mode: PLAY_MODE,
252
+ body: ['option', { value: '$.name' }, '$.name'],
253
+ };
254
+
255
+ /** One tab in the result strip (only shown when a result has >1 panel). */
256
+ const resultTab = {
257
+ match: `${PLAY_BASE}.result.tabs[*]`, mode: PLAY_MODE,
258
+ body: ['button', {
259
+ type: 'button', role: 'tab',
260
+ class: { $if: ['$.active', 'seg-btn active', 'seg-btn'] },
261
+ 'aria-selected': { $if: ['$.active', 'true', 'false'] },
262
+ on: { click: { action: 'play/panel', with: '$.id' } },
263
+ }, '$.label'],
264
+ };
265
+
266
+ /** One tab in the drill-down's own row (revealed by the depth toggle). */
267
+ const deepTab = {
268
+ match: `${PLAY_BASE}.result.deepTabs[*]`, mode: PLAY_MODE,
269
+ body: ['button', {
270
+ type: 'button', role: 'tab',
271
+ class: { $if: ['$.active', 'seg-btn active', 'seg-btn'] },
272
+ 'aria-selected': { $if: ['$.active', 'true', 'false'] },
273
+ on: { click: { action: 'play/deep-pick', with: '$.id' } },
274
+ }, '$.label'],
275
+ };
276
+
277
+ /** A result panel, rendered by its kind (code | view | table | note | cards) —
278
+ * one body, matched at both the simple and the deep panel slot. */
279
+ const panelBody = { $if: ['$.isView', ['div', { class: 'jplay-view' }, '$.vnode'],
280
+ { $if: ['$.isTable',
281
+ ['div', { class: 'jplay-table-wrap' },
282
+ ['table', { class: 'jplay-table' },
283
+ ['thead', {}, ['tr', {}, [{ $apply: '$.columns[*]' }]]],
284
+ ['tbody', {}, [{ $apply: '$.rows[*]' }]]]],
285
+ { $if: ['$.isNote', ['p', { class: '$.noteClass' }, '$.text'],
286
+ { $if: ['$.isCards',
287
+ ['div', { class: 'jplay-cards' }, [{ $apply: '$.items[*]' }]],
288
+ ['pre', { class: 'code-block' }, ['code', {}, '$.text']]] }] }] }] };
289
+
290
+ const activePanel = { match: `${PLAY_BASE}.result.activePanel`, mode: PLAY_MODE, body: panelBody };
291
+ const deepPanel = { match: `${PLAY_BASE}.result.deepPanel`, mode: PLAY_MODE, body: panelBody };
292
+
293
+ /** One stat card in a `cards` panel. */
294
+ const cardBody = ['div', { class: 'jplay-card' },
295
+ ['span', { class: 'jplay-card-title muted' }, '$.title'],
296
+ ['strong', { class: 'jplay-card-value' }, '$.value'],
297
+ { $if: ['$.note', ['span', { class: 'jplay-card-note muted' }, '$.note'], ''] },
298
+ ];
299
+ const activeCard = { match: `${PLAY_BASE}.result.activePanel.items[*]`, mode: PLAY_MODE, body: cardBody };
300
+ const deepCard = { match: `${PLAY_BASE}.result.deepPanel.items[*]`, mode: PLAY_MODE, body: cardBody };
301
+
302
+ /** A table header cell / body row / body cell — again at both slots. */
303
+ const columnBody = ['th', {}, '$.label'];
304
+ const rowBody = ['tr', {}, [{ $apply: '$.cells[*]' }]];
305
+ const cellBody = ['td', {}, '$.text'];
306
+ const tableColumn = { match: `${PLAY_BASE}.result.activePanel.columns[*]`, mode: PLAY_MODE, body: columnBody };
307
+ const tableRow = { match: `${PLAY_BASE}.result.activePanel.rows[*]`, mode: PLAY_MODE, body: rowBody };
308
+ const tableCell = { match: `${PLAY_BASE}.result.activePanel.rows[*].cells[*]`, mode: PLAY_MODE, body: cellBody };
309
+ const deepTableColumn = { match: `${PLAY_BASE}.result.deepPanel.columns[*]`, mode: PLAY_MODE, body: columnBody };
310
+ const deepTableRow = { match: `${PLAY_BASE}.result.deepPanel.rows[*]`, mode: PLAY_MODE, body: rowBody };
311
+ const deepTableCell = { match: `${PLAY_BASE}.result.deepPanel.rows[*].cells[*]`, mode: PLAY_MODE, body: cellBody };
312
+
313
+ /** The playground's JSLT rules — spread into the site stylesheet. */
314
+ export const playRules = [
315
+ shell, railGroup, railExample, optionPane, optionChoice, sourcePane, dataPane, datasetOption,
316
+ savedOption, resultTab, deepTab, activePanel, deepPanel, activeCard, deepCard,
317
+ tableColumn, tableRow, tableCell, deepTableColumn, deepTableRow, deepTableCell,
318
+ ];
@@ -0,0 +1,181 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `playViewModel(state)` — the playground's pure derivation. From
4
+ * the `state.play` slice it derives the example rail (the picker, grouped
5
+ * by engine — the "file-picker of sorts"), the active engine's source
6
+ * editors, the dataset switcher, the data editors, and the run result for
7
+ * the stage. Pure: nothing here is stored back in state.
8
+ */
9
+ import { pickAllowed } from '@jarenjs/core/array';
10
+ import { ENGINES, EXAMPLES } from '../index.js';
11
+ import { formatMs } from '../format.js';
12
+
13
+ /** The phone panes, in switcher order. */
14
+ const MOBILE_PANES = ['examples', 'editor', 'result'];
15
+
16
+ /**
17
+ * The timing line, built only from the phases that were actually measured.
18
+ * A `null` half means "no such phase" (patch merge has nothing to compile)
19
+ * or "the host did not report it" — either way it is omitted rather than
20
+ * printed as `0 ms`, which read as "rendering was free".
21
+ */
22
+ function formatTiming(timing) {
23
+ if (!timing) return null;
24
+ const parts = [];
25
+ if (typeof timing.compileMs === 'number') parts.push(`compiled ${formatMs(timing.compileMs)}`);
26
+ if (typeof timing.runMs === 'number') parts.push(`ran ${formatMs(timing.runMs)}`);
27
+ return parts.length === 0 ? null : parts.join(' · ');
28
+ }
29
+
30
+ /**
31
+ * Shape one panel for the view: kind flags for the `$if` dispatch plus the
32
+ * per-kind content (a `table` becomes column/cell records the JSLT can walk).
33
+ */
34
+ function shapePanel(p) {
35
+ const kind = p.kind;
36
+ const base = {
37
+ id: p.id, label: p.label ?? p.id, kind,
38
+ isCode: kind === 'code', isView: kind === 'view', isTable: kind === 'table',
39
+ isNote: kind === 'note', isCards: kind === 'cards',
40
+ };
41
+ if (kind === 'view') return { ...base, vnode: p.vnode ?? null };
42
+ if (kind === 'note') return { ...base, text: p.text ?? '', noteClass: `jplay-note ${p.tone ?? 'info'}` };
43
+ if (kind === 'table') return {
44
+ ...base,
45
+ columns: (p.columns ?? []).map((label) => ({ label: String(label) })),
46
+ rows: (p.rows ?? []).map((cells) => ({ cells: (cells ?? []).map((text) => ({ text: String(text) })) })),
47
+ };
48
+ if (kind === 'cards') return {
49
+ ...base,
50
+ items: (p.items ?? []).map((i) => ({ title: String(i.title), value: String(i.value), note: i.note ?? '' })),
51
+ };
52
+ return { ...base, text: p.text ?? '' }; // code
53
+ }
54
+
55
+ /**
56
+ * Derive the run result for the stage. The `simple` panels are the calm
57
+ * default: they become a tab strip (when >1) plus the single ACTIVE panel
58
+ * body. The `deep` panels — the engine's rich explainers — stay hidden
59
+ * until the student toggles the drill-down (`state.play.deep`); then they
60
+ * form their OWN tab row (`deepPick` selects among several). Each active
61
+ * panel is the one the host asked for when it still exists in this result,
62
+ * else the first — so a fresh result with different screens never strands
63
+ * the view on a tab that is gone.
64
+ */
65
+ function deriveResult(r, wantedId, deepWanted, deepPick) {
66
+ const all = Array.isArray(r.panels) ? r.panels : [];
67
+ const panels = all.filter((p) => p.depth !== 'deep');
68
+ const deep = all.filter((p) => p.depth === 'deep');
69
+ const activeId = panels.some((p) => p.id === wantedId) ? wantedId : (panels[0]?.id ?? null);
70
+ const active = panels.find((p) => p.id === activeId) ?? null;
71
+ const hasDeep = deep.length > 0;
72
+ const deepOn = hasDeep && deepWanted === true;
73
+ const deepId = deep.some((p) => p.id === deepPick) ? deepPick : (deep[0]?.id ?? null);
74
+ const activeDeep = deep.find((p) => p.id === deepId) ?? null;
75
+ return {
76
+ ran: true,
77
+ ok: r.ok === true,
78
+ error: r.error ? { code: r.error.code ?? '', message: r.error.message ?? '' } : null,
79
+ timing: formatTiming(r.timing),
80
+ tabbed: panels.length > 1,
81
+ tabs: panels.map((p) => ({ id: p.id, label: p.label ?? p.id, active: p.id === activeId })),
82
+ activePanel: active ? shapePanel(active) : null,
83
+ // the drill-deeper half: the affordance, its state, and the deep screens
84
+ hasDeep,
85
+ deepOn,
86
+ deepNext: !deepOn,
87
+ deepLabel: deepOn ? 'Explain ▾' : 'Explain ▸',
88
+ deepTabbed: deep.length > 1,
89
+ deepTabs: deep.map((p) => ({ id: p.id, label: p.label ?? p.id, active: p.id === deepId })),
90
+ deepPanel: deepOn && activeDeep ? shapePanel(activeDeep) : null,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * @param {{ play?: any }} state
96
+ * @returns {any}
97
+ */
98
+ export function playViewModel(state) {
99
+ const s = state.play ?? {};
100
+ const engineId = s.engine ?? EXAMPLES[0]?.engine ?? '';
101
+ const engine = ENGINES[engineId] ?? null;
102
+
103
+ // the rail: examples grouped by engine (a folder per engine)
104
+ const groups = new Map();
105
+ for (const ex of EXAMPLES) {
106
+ if (!groups.has(ex.engine)) groups.set(ex.engine, []);
107
+ groups.get(ex.engine).push(ex);
108
+ }
109
+ const rail = [...groups.entries()].map(([id, exs]) => ({
110
+ engine: id,
111
+ label: ENGINES[id]?.label ?? id,
112
+ active: id === engineId,
113
+ examples: exs.map((ex) => ({ id: ex.id, label: ex.label, active: ex.id === s.exampleId })),
114
+ }));
115
+
116
+ const sourcePanes = (engine?.sourcePanes ?? []).map((p) => ({
117
+ key: p.key, label: p.label, control: p.control ?? 'code', value: s.source?.[p.key] ?? '',
118
+ }));
119
+ const dataPanes = (engine?.dataPanes ?? []).map((p) => ({
120
+ key: p.key, label: p.label, value: s.data?.[p.key] ?? '',
121
+ }));
122
+
123
+ // option panes: live mode selects (josl dialect, csv repair/headers/…);
124
+ // the selected value is the host config override or the pane's default
125
+ const config = s.config ?? {};
126
+ const optionPanes = (engine?.optionPanes ?? []).map((p) => ({
127
+ key: p.key, label: p.label,
128
+ choices: p.choices.map((c) => ({ value: c.value, label: c.label, selected: (config[p.key] ?? p.default) === c.value })),
129
+ }));
130
+
131
+ const active = EXAMPLES.find((e) => e.id === s.exampleId) ?? null;
132
+ const datasetIndex = s.datasetIndex ?? 0;
133
+ const datasets = (active?.datasets ?? []).map((ds, i) => ({ index: i, label: ds.label, active: i === datasetIndex }));
134
+
135
+ const r = s.result ?? null;
136
+ const result = r === null ? { ran: false } : deriveResult(r, s.panel, s.deep, s.deepPick);
137
+
138
+ // the IDE half: the saveable-session chrome
139
+ const names = Array.isArray(s.names) ? s.names : [];
140
+ const ratio = typeof s.ratio === 'number' ? s.ratio : 0.5;
141
+
142
+ // the generated-form half: the validate engine's data pane can
143
+ // swap the JSON textarea for a schema-generated form. The form tree itself
144
+ // (`dataForm`) is host-supplied (it needs @jarenjs/forms) — the package only
145
+ // decides WHEN to show it.
146
+ const hasForm = engineId === 'validate';
147
+ const dataView = s.dataView === 'form' ? 'form' : 'json';
148
+
149
+ return {
150
+ engine: { id: engineId, label: engine?.label ?? engineId, lead: engine?.lead ?? '' },
151
+ active: active ? { id: active.id, label: active.label } : null,
152
+ rail,
153
+ optionPanes,
154
+ sourcePanes,
155
+ dataPanes,
156
+ datasets,
157
+ hasSwitcher: datasets.length >= 2,
158
+ result,
159
+ // IDE chrome: the session name, the saved list, the last share status,
160
+ // and the editor|result split (the shared splitter reads `ratio`)
161
+ name: s.name ?? '',
162
+ names: names.map((n) => ({ name: n, active: n === s.name })),
163
+ hasSaved: names.length > 0,
164
+ // the record this session is bound to, and whether the title has been
165
+ // edited away from it. Without this the two save buttons look
166
+ // identical: the hint is what tells the reader that Save lands on the
167
+ // record they opened and Save As lands on the name they just typed.
168
+ savedName: s.savedName ?? null,
169
+ renamed: s.savedName != null && (s.name ?? '') !== s.savedName,
170
+ shared: s.shared ?? null,
171
+ ratio,
172
+ ratioPct: String(Math.round(ratio * 100)),
173
+ // the form/JSON toggle (validate only); `showForm` gates the form seam
174
+ hasForm,
175
+ dataView,
176
+ showForm: hasForm && dataView === 'form',
177
+ // the phone layout: one pane at a time behind a segmented switcher
178
+ // (Examples · Editor · Result); desktop ignores it (CSS)
179
+ mobilePane: pickAllowed(s.mobilePane, MOBILE_PANES, 'editor'),
180
+ };
181
+ }