@jarenjs/studio 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.
@@ -0,0 +1,127 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Files → runnable artifacts, and the change-classification the IDE
4
+ * needs to decide reboot-vs-hot-update.
5
+ *
6
+ * `assembleArtifacts` composes the project's files into the runnable set.
7
+ * This order ships the WHOLE-DOCUMENT contract: a runnable file (`app`,
8
+ * `fsm`, `dag`, `model`, `jslt`, `query`, `schema`) is its own artifact;
9
+ * `state`/`data` files are inputs, not artifacts. Fragment assembly —
10
+ * composing separate `state` + `view` + `actions` files into ONE
11
+ * `jaren-app` document (the true HTML/CSS/JS split) — is the model's
12
+ * headline enhancement and layers on top without changing this contract.
13
+ *
14
+ * `classifyChange` is the load-bearing UX datum: a `state`-only edit must
15
+ * HOT-DISPATCH into a running app (no reboot, the user keeps scroll and
16
+ * inputs), while a `view`/`actions` change must reboot. It compares a
17
+ * STRUCTURAL key (an app doc minus its `state`) via the suite's own
18
+ * `contentKey`, so the policy in the widget reads clean, tested data.
19
+ */
20
+
21
+ import { contentKey } from '@jarenjs/core/object';
22
+ import { validateFile } from './validate.js';
23
+
24
+ /** Kinds that are runnable artifacts on their own (vs. `state`/`data`
25
+ * inputs). */
26
+ const RUNNABLE = new Set(['app', 'jslt', 'query', 'schema', 'fsm', 'dag', 'model']);
27
+
28
+ /** A human role per kind, for the file rail's legibility. */
29
+ const ROLE = Object.freeze({
30
+ app: 'application', jslt: 'transform', query: 'query', state: 'state',
31
+ data: 'data', schema: 'schema', fsm: 'state machine', dag: 'dataflow', model: 'data store',
32
+ });
33
+
34
+ /**
35
+ * Compose the project's files into runnable artifacts (whole-document).
36
+ * @param {any} project - a normalized project (from `parseProject`)
37
+ * @returns {{ artifacts: Array<{ name: string, kind: string, role: string,
38
+ * doc: any, sourceFiles: string[] }>, errors: Array<{ file: string, message: string }> }}
39
+ */
40
+ export function assembleArtifacts(project) {
41
+ const artifacts = [];
42
+ const errors = [];
43
+ for (const file of project.files) {
44
+ if (!RUNNABLE.has(file.kind)) continue;
45
+ let doc;
46
+ try { doc = JSON.parse(file.text); }
47
+ catch (err) {
48
+ errors.push({ file: file.name, message: `not valid JSON: ${String(/** @type {any} */ (err)?.message ?? err)}` });
49
+ continue;
50
+ }
51
+ artifacts.push({
52
+ name: file.name, kind: file.kind, role: ROLE[file.kind], doc, sourceFiles: [file.name],
53
+ });
54
+ }
55
+ return { artifacts, errors };
56
+ }
57
+
58
+ /** The structural identity of an artifact — an `app`'s view/actions/subs
59
+ * (its `state` excluded); everything else in full. */
60
+ function structuralKey(artifact) {
61
+ return artifact.kind === 'app'
62
+ ? contentKey({ view: artifact.doc.view, actions: artifact.doc.actions, subs: artifact.doc.subs })
63
+ : contentKey(artifact.doc);
64
+ }
65
+
66
+ const RANK = { none: 0, 'state-only': 1, structural: 2 };
67
+
68
+ /**
69
+ * Classify what changed between two projects, PER artifact — the datum
70
+ * the widget's reboot-vs-hot-update policy consumes. `state-only` means
71
+ * an app's state moved but its structure did not (hot-dispatch it);
72
+ * `structural` means reboot; `none` means nothing changed.
73
+ * @param {any} prevProject
74
+ * @param {any} nextProject
75
+ * @returns {{ overall: 'none' | 'state-only' | 'structural',
76
+ * perArtifact: Record<string, 'none' | 'state-only' | 'structural'> }}
77
+ */
78
+ export function classifyChange(prevProject, nextProject) {
79
+ const prev = new Map(assembleArtifacts(prevProject).artifacts.map((a) => [a.name, a]));
80
+ const next = new Map(assembleArtifacts(nextProject).artifacts.map((a) => [a.name, a]));
81
+ /** @type {Record<string, 'none' | 'state-only' | 'structural'>} */
82
+ const perArtifact = {};
83
+ let overall = /** @type {'none' | 'state-only' | 'structural'} */ ('none');
84
+ const bump = (c) => { if (RANK[c] > RANK[overall]) overall = c; };
85
+
86
+ for (const [name, a] of next) {
87
+ const b = prev.get(name);
88
+ let c;
89
+ if (b === undefined) c = 'structural';
90
+ else if (structuralKey(b) !== structuralKey(a)) c = 'structural';
91
+ else if (contentKey(b.doc) !== contentKey(a.doc)) c = 'state-only';
92
+ else c = 'none';
93
+ perArtifact[name] = c;
94
+ bump(c);
95
+ }
96
+ for (const name of prev.keys()) {
97
+ if (!next.has(name)) { perArtifact[name] = 'structural'; bump('structural'); }
98
+ }
99
+ return { overall, perArtifact };
100
+ }
101
+
102
+ /**
103
+ * Per-file metadata for the IDE file rail and the AI `list_files` tool:
104
+ * kind, validity, size, its role, and which runnable artifact it is (a
105
+ * `state`/`data` input is not itself an artifact).
106
+ * @param {any} project - a normalized project
107
+ * @param {{ operators?: { toOptions: () => any } }} [options]
108
+ */
109
+ export function describe(project, options = {}) {
110
+ return {
111
+ active: project.active,
112
+ layout: project.layout,
113
+ files: project.files.map((file) => {
114
+ const v = validateFile(file, options);
115
+ return {
116
+ name: file.name,
117
+ kind: file.kind,
118
+ role: ROLE[file.kind] ?? file.kind,
119
+ size: file.text.length,
120
+ valid: v.valid,
121
+ total: v.total,
122
+ errors: v.errors,
123
+ artifact: RUNNABLE.has(file.kind) ? file.name : null,
124
+ };
125
+ }),
126
+ };
127
+ }
@@ -0,0 +1,59 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The editor + rail vnode primitives — the baseline IDE, graduated
4
+ * from the site's studio-kit. Everything is a plain `@jarenjs/view` vnode
5
+ * (a tagged array), so the whole IDE renders through the same JSLT engine
6
+ * the rest of the suite uses; no imperative editor widget, no syntax
7
+ * highlighting (a later concern). The concrete kind→badge map lives here;
8
+ * its colours are in `styles/studio.css`.
9
+ */
10
+
11
+ /**
12
+ * A baseline code editor: a `<textarea>` with the ergonomics that keep it
13
+ * from fighting the shell (no browser resize — the splitter owns width;
14
+ * spell/autocap/autocorrect off so a mobile keyboard does not rewrite
15
+ * JSON keys). Tab-to-indent and the debounce live in the shell's edit
16
+ * loop; the value is bound to the active file.
17
+ *
18
+ * `inputAction` is load-bearing, not a convenience: this is a CONTROLLED
19
+ * textarea, and the renderer reasserts a control's authoritative value
20
+ * after every settled render. If the document only learned about an edit
21
+ * on `change` (blur), any render in between would rewrite the box with
22
+ * the still-stale text — and writing `.value` clears the browser's
23
+ * dirty-value flag, so `change` would then never fire and the typing
24
+ * would vanish. Publishing each keystroke to the typing buffer keeps the
25
+ * authoritative value equal to what the user typed, so the reassert is a
26
+ * no-op and the caret survives.
27
+ * @param {{ value: any, action: string, inputAction?: string, rows?: number,
28
+ * readonly?: boolean }} options
29
+ */
30
+ export function editorTextarea(options) {
31
+ return ['textarea', {
32
+ class: 'js-editor-input',
33
+ rows: options.rows ?? 20,
34
+ spellcheck: 'false',
35
+ autocapitalize: 'off',
36
+ autocorrect: 'off',
37
+ autocomplete: 'off',
38
+ value: options.value,
39
+ ...(options.readonly === true ? { readonly: '' } : {}),
40
+ on: options.inputAction === undefined
41
+ ? { change: options.action }
42
+ : { input: options.inputAction, change: options.action },
43
+ }];
44
+ }
45
+
46
+ /** One line of the docked error strip. */
47
+ export function errorLine(content) {
48
+ return ['p', { class: 'js-errorline' }, content];
49
+ }
50
+
51
+ /**
52
+ * The five concrete kind badge classes (colours are CONSTANTS in
53
+ * studio.css, not aliased status tokens): view (blue), query (cyan),
54
+ * json (slate), model (green), flow (amber).
55
+ */
56
+ export const KIND_BADGE = Object.freeze({
57
+ app: 'view', jslt: 'view', query: 'query', state: 'json',
58
+ data: 'json', schema: 'json', model: 'model', fsm: 'flow', dag: 'flow',
59
+ });
@@ -0,0 +1,53 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The two hard-problem POLICIES, pure and tested — the DOM
4
+ * mechanics that consume them live in the widget.
5
+ *
6
+ * `hostPolicy` (reboot vs. hot-update): a `state-only` edit hot-dispatches
7
+ * the new state into the RUNNING nested app (the user keeps scroll and
8
+ * inputs, no reboot); a `structural` edit reboots; an unchanged artifact
9
+ * is skipped.
10
+ *
11
+ * `reconcileBuffer` (editor buffer ↔ document): a CLEAN buffer adopts an
12
+ * incoming write (share / undo / an AI edit lands); a DIRTY buffer whose
13
+ * text differs from the incoming write keeps the human's text and records
14
+ * the write as a recoverable draft — never a silent clobber, never a
15
+ * hidden write.
16
+ */
17
+
18
+ import { classifyChange } from '../assemble.js';
19
+
20
+ /**
21
+ * Per-artifact action a stage host should take between two project
22
+ * revisions: `reboot` (destroy + boot), `hot` (dispatch new state into
23
+ * the running app), or `skip` (nothing changed).
24
+ * @param {any} prevProject
25
+ * @param {any} nextProject
26
+ * @returns {Record<string, 'reboot' | 'hot' | 'skip'>}
27
+ */
28
+ export function hostPolicy(prevProject, nextProject) {
29
+ const { perArtifact } = classifyChange(prevProject, nextProject);
30
+ /** @type {Record<string, 'reboot' | 'hot' | 'skip'>} */
31
+ const out = {};
32
+ for (const [name, change] of Object.entries(perArtifact)) {
33
+ out[name] = change === 'structural' ? 'reboot' : change === 'state-only' ? 'hot' : 'skip';
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * Reconcile the editor's local typing buffer against an incoming
40
+ * committed text (a share/undo restore, or an AI write onto the same
41
+ * file). A clean buffer adopts; a dirty buffer that already matches the
42
+ * incoming text simply clears (the commit landed); a dirty buffer that
43
+ * differs keeps the human's text and surfaces the incoming version as a
44
+ * recoverable `conflict`.
45
+ * @param {{ text: string, dirty: boolean }} buffer
46
+ * @param {string} incoming - the file's committed text
47
+ * @returns {{ text: string, dirty: boolean, conflict: { incoming: string } | null }}
48
+ */
49
+ export function reconcileBuffer(buffer, incoming) {
50
+ if (!buffer.dirty) return { text: incoming, dirty: false, conflict: null };
51
+ if (buffer.text === incoming) return { text: incoming, dirty: false, conflict: null };
52
+ return { text: buffer.text, dirty: true, conflict: { incoming } };
53
+ }
@@ -0,0 +1,47 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The Studio COMPONENT — the IDE. Following the suite's component
4
+ * convention (`createXComponent`), `createStudioComponent(options)` hands
5
+ * the host the pieces it composes into the site's `@jarenjs/app` document:
6
+ * the JSLT view (`rules` + `mode`), the derivation (`viewModel`), the two
7
+ * hard-problem policies (`hostPolicy`, `reconcileBuffer`), and the engine
8
+ * surface. The reducer `project/*` actions, the DOM stage/splitter
9
+ * widgets, and the live site mount are wired at the host; the chrome and
10
+ * its derivation — everything renderable without a DOM — live here and
11
+ * are tested headlessly.
12
+ */
13
+
14
+ import { describe, validateFile, classifyChange, parseProject } from '../index.js';
15
+ import { projectViewModel } from './viewmodel.js';
16
+ import { projectRules, projectModes, PROJECT_MODE, PROJECT_BASE } from './view.js';
17
+ import { hostPolicy, reconcileBuffer } from './host.js';
18
+ import { editorTextarea, errorLine, KIND_BADGE } from './editor.js';
19
+
20
+ /**
21
+ * Build the Studio component.
22
+ * @param {{ operators?: { toOptions: () => any } }} [options] - a host
23
+ * operator registry threaded to every per-file validator/derivation
24
+ */
25
+ export function createStudioComponent(options = {}) {
26
+ const operators = options.operators;
27
+ return {
28
+ mode: PROJECT_MODE,
29
+ rules: projectRules,
30
+ modes: projectModes,
31
+ /** The IDE view model for the `$.project` slice. */
32
+ viewModel: (state) => projectViewModel(state, { operators }),
33
+ // the two hard-problem policies the stage host / editor consume
34
+ hostPolicy,
35
+ reconcileBuffer,
36
+ // the engine surface a host binds at mount time
37
+ describe: (project) => describe(project, { operators }),
38
+ validateFile: (file) => validateFile(file, { operators }),
39
+ classifyChange,
40
+ parseProject,
41
+ };
42
+ }
43
+
44
+ export {
45
+ projectViewModel, projectRules, projectModes, PROJECT_MODE, PROJECT_BASE,
46
+ hostPolicy, reconcileBuffer, editorTextarea, errorLine, KIND_BADGE,
47
+ };
@@ -0,0 +1,198 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The IDE shell as a JSLT view — the chrome is a document, rendered
4
+ * by the same engine as the rest of the suite (no imperative chrome, no
5
+ * syntax highlighting). Following the suite's rule convention: one mode
6
+ * (`project`), rules matched by their ABSOLUTE slice path, `$apply` and
7
+ * body references RELATIVE to the matched node. The host mounts the view
8
+ * model at `$.ui.project`.
9
+ *
10
+ * The stage's live nested app and the drag splitter are the only
11
+ * imperative islands — `jaren-widget`s the host registers. The layout
12
+ * mode rides a `data-mode` attribute so the grid switches in CSS with no
13
+ * computed class; the phone pane rides `data-pane` the same way, and a
14
+ * kind badge rides `data-badge`.
15
+ */
16
+
17
+ import { editorTextarea } from './editor.js';
18
+
19
+ /** The one mode this view uses. */
20
+ export const PROJECT_MODE = 'project';
21
+ /** The slice the host mounts the view model at. */
22
+ export const PROJECT_BASE = '$.ui.project';
23
+
24
+ /** The modes the host merges into the site stylesheet. */
25
+ export const projectModes = Object.freeze({ [PROJECT_MODE]: { unmatched: 'error' } });
26
+
27
+ /** One segment of the layout switcher — active when it is the live mode. */
28
+ const layoutButton = (mode, label, title) => ['button', {
29
+ type: 'button', title,
30
+ class: { $if: [{ $eq: ['$.layout.mode', mode] }, 'seg-btn active', 'seg-btn'] },
31
+ on: { click: { action: 'project/layout-mode', with: mode } },
32
+ }, label];
33
+
34
+ /**
35
+ * One segment of the phone pane switcher. Below the breakpoint the rail
36
+ * | editor | stage grid shows ONE pane at a time and this bar picks it;
37
+ * above the breakpoint the bar does not exist (CSS) and the layout
38
+ * switcher beside it is what the user reaches for instead.
39
+ *
40
+ * A pane is a grid area, not a `tabpanel`, so these are toggle buttons
41
+ * in a group with `aria-pressed` — not a `tablist` with `aria-selected`.
42
+ */
43
+ const paneButton = (pane, label) => ['button', {
44
+ type: 'button',
45
+ class: { $if: [{ $eq: ['$.mobilePane', pane] }, 'seg-btn active', 'seg-btn'] },
46
+ 'aria-pressed': { $if: [{ $eq: ['$.mobilePane', pane] }, 'true', 'false'] },
47
+ on: { click: { action: 'project/pane', with: pane } },
48
+ }, label];
49
+
50
+ /** The shell (matches the whole slice). */
51
+ const shell = {
52
+ match: PROJECT_BASE, mode: PROJECT_MODE,
53
+ body: ['div', { class: 'jstudio', 'data-mode': '$.layout.mode', 'data-pane': '$.mobilePane' },
54
+ // ——— the phone pane switcher (its own grid row; display:none above
55
+ // the breakpoint, so on a desktop it costs one hidden element) ———
56
+ ['div', { class: 'js-panebar seg', role: 'group', 'aria-label': 'pane' },
57
+ paneButton('files', 'Files'),
58
+ paneButton('editor', 'Editor'),
59
+ paneButton('stage', 'Stage'),
60
+ ],
61
+ // ——— pen bar ———
62
+ ['div', { class: 'js-penbar' },
63
+ ['strong', { class: 'js-penname' }, '$.name'],
64
+ ['span', { class: 'js-savestate' }, '$.saveState'],
65
+ // the template gallery — opening one replaces the project (host
66
+ // provides `$.templates`; absent → renders nothing)
67
+ ['div', { class: 'js-gallery' }, ['span', { class: 'muted' }, 'New'], [{ $apply: '$.templates[*]' }]],
68
+ ['span', { class: 'js-spacer' }],
69
+ ['span', { class: 'js-filecount muted' }, ['text', '$.fileCount'], ' files'],
70
+ // the layout switcher: the three grid modes, riding `layout.mode` →
71
+ // the `data-mode` attribute the grid switches on (the drag splitter
72
+ // below drives `layout.ratio` within the chosen mode)
73
+ ['div', { class: 'js-layout seg', role: 'group', 'aria-label': 'layout' },
74
+ layoutButton('classic', 'Side', 'Editor beside the stage'),
75
+ layoutButton('right', 'Swap', 'Stage beside the editor'),
76
+ layoutButton('top', 'Stack', 'Editor over the stage'),
77
+ ],
78
+ ['button', { class: 'btn small', type: 'button', on: { click: 'project/run' } }, 'Run'],
79
+ ],
80
+ // ——— file rail ———
81
+ ['nav', { class: 'js-rail', 'aria-label': 'files' },
82
+ ['select', { class: 'js-addfile', 'aria-label': 'add a file', value: '', on: { change: 'project/add-file' } },
83
+ ['option', { value: '' }, '+ add file…'],
84
+ ['option', { value: 'app' }, 'app'],
85
+ ['option', { value: 'jslt' }, 'jslt'],
86
+ ['option', { value: 'query' }, 'query'],
87
+ ['option', { value: 'state' }, 'state'],
88
+ ['option', { value: 'data' }, 'data'],
89
+ ['option', { value: 'schema' }, 'schema'],
90
+ ],
91
+ [{ $apply: '$.rail[*]' }]],
92
+ // ——— editor ———
93
+ ['div', { class: 'js-editor' },
94
+ ['div', { class: 'js-editor-head' },
95
+ // The active file name is editable here → project/rename on commit.
96
+ // It needs the SAME two-action shape as the editor textarea and for
97
+ // the same reason (see `editorTextarea`): a controlled value is
98
+ // reasserted after every settled render, so a rename typed while the
99
+ // debounced edit loop fires was rewritten mid-word and the commit
100
+ // then never came. The draft is a separate buffer rather than the
101
+ // committed value because a rename per keystroke would rename the
102
+ // file once per letter.
103
+ ['input', { class: 'js-editor-name', value: '$.renameDraft', spellcheck: 'false',
104
+ autocapitalize: 'off', autocomplete: 'off', 'aria-label': 'file name',
105
+ on: { input: 'project/rename-draft', change: 'project/rename' } }],
106
+ ['span', { class: 'js-editor-kind muted' }, '$.activeKind'],
107
+ ['span', { class: 'js-spacer' }],
108
+ ['span', { class: 'js-linecount' }, ['text', '$.lineCount'], ' lines'],
109
+ ],
110
+ // a write landed on this file while the buffer was dirty: the human's
111
+ // text stays in the box, the incoming version stays one click away
112
+ { $if: ['$.conflict',
113
+ ['div', { class: 'js-conflict', role: 'status' },
114
+ ['span', {}, 'This file changed while you were editing — your text is kept.'],
115
+ ['button', {
116
+ type: 'button', class: 'js-conflict-take',
117
+ title: 'Discard your edit and take the version that arrived',
118
+ on: { click: 'project/buffer-accept' },
119
+ }, 'Take theirs'],
120
+ ],
121
+ ''] },
122
+ editorTextarea({
123
+ value: '$.editorValue',
124
+ action: 'project/file-text',
125
+ inputAction: 'project/buffer-text',
126
+ }),
127
+ { $if: ['$.problemCount',
128
+ ['div', { class: 'js-errorstrip', role: 'status' }, [{ $apply: '$.problems[*]' }]],
129
+ ''] },
130
+ ],
131
+ // ——— splitter (a pointer-capture widget; its host IS the grab bar) ———
132
+ ['jaren-widget', {
133
+ name: 'studio-splitter', class: 'js-split',
134
+ role: 'separator', 'aria-orientation': 'vertical',
135
+ 'aria-label': 'Resize the editor and stage',
136
+ 'aria-valuemin': '10', 'aria-valuemax': '90', 'aria-valuenow': '$.ratioPct',
137
+ tabindex: '0',
138
+ props: { ratio: '$.layout.ratio', mode: '$.layout.mode' },
139
+ }],
140
+ // ——— stage ———
141
+ ['div', { class: 'js-stage' },
142
+ ['div', { class: 'js-stage-head' }, 'Stage'],
143
+ { $if: [{ $eq: ['$.stage.kind', 'app'] },
144
+ ['div', { class: 'js-stage-mount' }, ['jaren-widget', { name: 'studio-stage', props: '$.stage.mount' }]],
145
+ { $if: [{ $eq: ['$.stage.kind', 'result'] },
146
+ { $if: ['$.stage.ran',
147
+ ['div', { class: 'js-stage-result' }, [{ $apply: ['$.stage.nodes[*]', 'ui'] }]],
148
+ ['p', { class: 'muted js-stage-note' }, 'Edit — this file runs live against the data file here.']] },
149
+ { $if: [{ $eq: ['$.stage.kind', 'boot-failed'] },
150
+ ['div', { class: 'js-stage-fail card' }, '$.stage.note'],
151
+ ['p', { class: 'muted js-stage-note' }, '$.stage.note']] }] }] },
152
+ ],
153
+ ],
154
+ };
155
+
156
+ /** One file-rail row: the select button + a delete affordance. */
157
+ const fileRow = {
158
+ match: `${PROJECT_BASE}.rail[*]`, mode: PROJECT_MODE,
159
+ body: ['div', { class: 'js-file-row' },
160
+ ['button', {
161
+ type: 'button',
162
+ class: { $if: ['$.active', 'js-file active', 'js-file'] },
163
+ title: '$.role',
164
+ on: { click: { action: 'project/active', with: '$.name' } },
165
+ },
166
+ ['span', { class: 'js-badge', 'data-badge': '$.badge' }, '$.kind'],
167
+ ['span', { class: 'js-file-name' }, '$.name'],
168
+ { $if: ['$.valid', '', ['span', { class: 'js-file-warn', title: 'this file has errors' }, '●']] },
169
+ ],
170
+ ['button', {
171
+ type: 'button', class: 'js-file-del', title: 'delete this file',
172
+ 'aria-label': 'delete file',
173
+ on: { click: { action: 'project/delete', with: '$.name' } },
174
+ }, '×'],
175
+ ],
176
+ };
177
+
178
+ /** One template-gallery card — opening it replaces the whole project. */
179
+ const templateRow = {
180
+ match: `${PROJECT_BASE}.templates[*]`, mode: PROJECT_MODE,
181
+ body: ['button', {
182
+ type: 'button', class: 'js-template', title: '$.lead',
183
+ on: { click: { action: 'project/template', with: '$.id' } },
184
+ }, '$.title'],
185
+ };
186
+
187
+ /** One error-strip line — a problem prefixed by its file; click activates it. */
188
+ const errorRow = {
189
+ match: `${PROJECT_BASE}.problems[*]`, mode: PROJECT_MODE,
190
+ body: ['button', {
191
+ class: 'js-errorline', type: 'button',
192
+ on: { click: { action: 'project/active', with: '$.file' } },
193
+ },
194
+ ['strong', {}, '$.file'], ' ', ['code', {}, '$.code'], ' — ', '$.message'],
195
+ };
196
+
197
+ /** The studio's JSLT rules — spread into the site stylesheet. */
198
+ export const projectRules = [shell, fileRow, templateRow, errorRow];
@@ -0,0 +1,150 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `projectViewModel(state)` — the IDE's derivation boundary. Given
4
+ * the `state.project` slice it derives everything the JSLT shell renders:
5
+ * the file rail (from the engine's `describe`), the active file's editor
6
+ * value and its coded errors, the docked error strip across every file,
7
+ * and the stage — an assembled app document to mount, a run result to
8
+ * show, or an inert note for a kind that has no editor of its own yet.
9
+ * Pure: nothing here is stored back in state.
10
+ */
11
+
12
+ import { pickAllowed } from '@jarenjs/core/array';
13
+ import { LAYOUT_DEFAULT } from '../project.js';
14
+ import { describe, assembleArtifacts } from '../assemble.js';
15
+ import { reconcileBuffer } from './host.js';
16
+ import { KIND_BADGE } from './editor.js';
17
+
18
+ /** The phone panes, in switcher order. */
19
+ const MOBILE_PANES = ['files', 'editor', 'stage'];
20
+
21
+ /** The stage the active file drives. */
22
+ function deriveStage(project, activeMeta, results, revision, committed) {
23
+ if (activeMeta === null) return { kind: 'empty', note: 'Add a file to begin.' };
24
+ if (activeMeta.kind === 'app') {
25
+ // A host that runs the app live commits the LAST-GOOD assembled
26
+ // document (with its own reboot revision). That wins over the current
27
+ // text: a parse error in the editor must never blank the stage — the
28
+ // last good frame stays until the next VALID commit replaces it.
29
+ if (committed !== null && committed.doc) {
30
+ return { kind: 'app', mount: { doc: committed.doc, revision: committed.revision } };
31
+ }
32
+ const artifact = assembleArtifacts(project).artifacts.find((a) => a.name === activeMeta.name);
33
+ if (artifact === undefined || !activeMeta.valid) {
34
+ return { kind: 'boot-failed', note: `${activeMeta.name} does not boot yet — fix the file (the last good render stays).` };
35
+ }
36
+ // the reference-stable mount is memoized by the host at wiring time;
37
+ // here it is the data: the assembled document + the reboot revision
38
+ return { kind: 'app', mount: { doc: artifact.doc, revision } };
39
+ }
40
+ if (activeMeta.kind === 'jslt' || activeMeta.kind === 'query' || activeMeta.kind === 'schema') {
41
+ // the host runs the file (a transform, or a schema validating the data
42
+ // file) and stores its render nodes; the stage renders them in `ui`
43
+ // mode, or a hint until the first run lands
44
+ const result = results[activeMeta.name] ?? null;
45
+ return { kind: 'result', ran: result !== null, nodes: result?.nodes ?? [] };
46
+ }
47
+ if (activeMeta.kind === 'state' || activeMeta.kind === 'data') {
48
+ return { kind: 'inert', note: 'An input — edit it as text; it feeds the app, a query or a validation.' };
49
+ }
50
+ // fsm / dag / model validate and assemble, but have no editor and no
51
+ // runner here yet — the flow canvas and the worker-backed store still
52
+ // live on their own website surfaces (both are tracked in ROADMAP.md)
53
+ return { kind: 'inert', note: `The ${activeMeta.role} editor is not built yet; edit it as text meanwhile.` };
54
+ }
55
+
56
+ /**
57
+ * Derive the IDE view model from `state.project`.
58
+ * @param {{ project: any }} state - the site state carrying the `project` slice
59
+ * @param {{ operators?: { toOptions: () => any } }} [options]
60
+ * @returns {any}
61
+ */
62
+ export function projectViewModel(state, options = {}) {
63
+ const slice = state.project ?? {};
64
+ const project = {
65
+ project: slice.project ?? '0.1',
66
+ files: slice.files ?? [],
67
+ active: slice.active ?? (slice.files?.[0]?.name ?? null),
68
+ layout: { ...LAYOUT_DEFAULT, ...slice.layout },
69
+ };
70
+ const results = slice.results ?? {};
71
+ const revision = slice.revision ?? 0;
72
+
73
+ const d = describe(project, options);
74
+ const activeName = project.active;
75
+ const activeMeta = d.files.find((f) => f.name === activeName) ?? null;
76
+ const activeFile = project.files.find((f) => f.name === activeName) ?? null;
77
+
78
+ const rail = d.files.map((f) => ({
79
+ name: f.name,
80
+ kind: f.kind,
81
+ role: f.role,
82
+ badge: KIND_BADGE[f.kind] ?? 'json',
83
+ valid: f.valid,
84
+ active: f.name === activeName,
85
+ artifact: f.artifact,
86
+ }));
87
+
88
+ // the docked strip: every file's coded errors, prefixed by file name,
89
+ // so a broken file anywhere is visible and one click reaches it
90
+ const problems = [];
91
+ for (const f of d.files) {
92
+ for (const e of f.errors) {
93
+ problems.push({ file: f.name, code: e.code ?? '', message: e.message, docPath: e.docPath ?? '' });
94
+ }
95
+ }
96
+
97
+ // the host's last-good committed app mount ({ name, doc, revision }),
98
+ // used only while it is the active file — a reference-stable document
99
+ // the stage widget reboots (revision change) or hot-updates (same
100
+ // revision, new state) against
101
+ const committed = (slice.mount && slice.mount.name === activeName) ? slice.mount : null;
102
+
103
+ // The editor's value is the typing BUFFER reconciled against the file's
104
+ // committed text, never the committed text alone: the buffer is what the
105
+ // user has typed but not yet committed (the commit lands on blur), and a
106
+ // controlled textarea must be reasserted with THAT or the renderer
107
+ // overwrites the user mid-edit. A write that lands on the file while the
108
+ // buffer is dirty keeps the human's text and surfaces the incoming
109
+ // version as a recoverable conflict — never a silent clobber, never a
110
+ // hidden write.
111
+ const committedText = activeFile ? activeFile.text : '';
112
+ const buffer = (slice.buffer !== null && slice.buffer !== undefined
113
+ && slice.buffer.file === activeName)
114
+ ? { text: slice.buffer.text, dirty: slice.buffer.dirty === true }
115
+ : { text: committedText, dirty: false };
116
+ const reconciled = reconcileBuffer(buffer, committedText);
117
+ const editorValue = reconciled.text;
118
+ return {
119
+ name: slice.name ?? 'Untitled project',
120
+ // the incoming text a conflicting write carried, or null
121
+ conflict: reconciled.conflict === null ? null : reconciled.conflict.incoming,
122
+ layout: project.layout,
123
+ // the splitter's committed handle position, as an integer percent for
124
+ // aria-valuenow (the widget updates it live during a drag)
125
+ ratioPct: Math.round((project.layout.ratio ?? 0.5) * 100),
126
+ active: activeName,
127
+ // the name field's own typing buffer, defaulting to the committed name.
128
+ // It follows the active file, so switching files shows that file's name
129
+ // rather than a half-typed rename of the previous one.
130
+ renameDraft: (slice.renameDraft !== null && slice.renameDraft !== undefined
131
+ && slice.renameDraft.file === activeName)
132
+ ? slice.renameDraft.text
133
+ : activeName,
134
+ activeKind: activeMeta?.kind ?? null,
135
+ activeValid: activeMeta?.valid ?? true,
136
+ activeErrors: activeMeta?.errors ?? [],
137
+ editorValue,
138
+ lineCount: editorValue === '' ? 0 : editorValue.split('\n').length,
139
+ rail,
140
+ fileCount: project.files.length,
141
+ problems,
142
+ problemCount: problems.length,
143
+ stage: deriveStage(project, activeMeta, results, revision, committed),
144
+ saveState: (slice.dirty === true || reconciled.dirty) ? 'Unsaved ●' : 'Saved',
145
+ // the phone pane (Files · Editor · Stage). This is host chrome, not a
146
+ // project member: `layout` travels with the saved document, which pane
147
+ // a phone was showing does not.
148
+ mobilePane: pickAllowed(slice.mobilePane, MOBILE_PANES, 'editor'),
149
+ };
150
+ }
package/src/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Coded errors for `@jarenjs/studio`, on `@jarenjs/core`'s coded
4
+ * contract: a stable `code` (JS0xxx compile/parse time), a bare
5
+ * `reason`, and — where a position in the project exists — a `docPath`.
6
+ *
7
+ * Only the ENVELOPE raises these: a malformed project document, or a
8
+ * duplicate file name. A single FILE's grammar problem is never thrown —
9
+ * it is REPORTED by {@link module:validate.validateFile} as the file
10
+ * kind's own coded errors (`JQ`/`JA`/`JD`…) with their docPaths, so the
11
+ * IDE can show them without stopping the world.
12
+ */
13
+
14
+ import { CodedError } from '@jarenjs/core/errors';
15
+
16
+ /** The code table (kept in sync with docs/PROJECT-FORMAT.md). */
17
+ export const STUDIO_CODES = Object.freeze({
18
+ JS0001: 'the project document is invalid',
19
+ JS0002: 'a file name is duplicated in the project',
20
+ });
21
+
22
+ /** A studio-envelope error. */
23
+ export class StudioError extends CodedError {
24
+ /**
25
+ * @param {keyof typeof STUDIO_CODES} code
26
+ * @param {string} reason
27
+ * @param {string | { docPath?: string }} [location]
28
+ * @param {{ cause?: unknown }} [options]
29
+ */
30
+ constructor(code, reason, location = undefined, options = undefined) {
31
+ super('StudioError', code, reason, location, options);
32
+ }
33
+ }