@jarenjs/studio 0.83.3 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +21 -6
  2. package/contracts/data.contract.json +243 -0
  3. package/dist/types/component/data/actions.d.ts +234 -0
  4. package/dist/types/component/data/index.d.ts +9 -0
  5. package/dist/types/component/data/mount.d.ts +87 -0
  6. package/dist/types/component/data/project-widget.d.ts +27 -0
  7. package/dist/types/component/data/viewmodel.d.ts +85 -0
  8. package/dist/types/component/data/views.d.ts +148 -0
  9. package/dist/types/component/document.d.ts +82 -0
  10. package/dist/types/component/flow/actions.d.ts +708 -0
  11. package/dist/types/component/flow/index.d.ts +7 -0
  12. package/dist/types/component/flow/mount.d.ts +66 -0
  13. package/dist/types/component/flow/project-widget.d.ts +8 -0
  14. package/dist/types/component/flow/runtime.d.ts +57 -0
  15. package/dist/types/component/flow/views.d.ts +242 -0
  16. package/dist/types/component/host.d.ts +2 -2
  17. package/dist/types/component/index.d.ts +21 -15
  18. package/dist/types/component/mount.d.ts +51 -0
  19. package/dist/types/component/project-actions.d.ts +302 -0
  20. package/dist/types/component/project-controller.d.ts +81 -0
  21. package/dist/types/component/project-state.d.ts +1 -0
  22. package/dist/types/component/project.d.ts +327 -0
  23. package/dist/types/component/shared/host-widget.d.ts +24 -0
  24. package/dist/types/component/shared/memo.d.ts +12 -0
  25. package/dist/types/component/shared/nodes.d.ts +92 -0
  26. package/dist/types/component/shared/schema-options.d.ts +28 -0
  27. package/dist/types/component/shared/studio-kit.d.ts +78 -0
  28. package/dist/types/component/shared/ui.d.ts +136 -0
  29. package/dist/types/component/view.d.ts +6 -6
  30. package/dist/types/data/boot-stages.d.ts +97 -0
  31. package/dist/types/data/browser-worker.d.ts +22 -0
  32. package/dist/types/data/contract.d.ts +9 -0
  33. package/dist/types/data/editor.d.ts +81 -0
  34. package/dist/types/data/handlers.d.ts +130 -0
  35. package/dist/types/data/host.d.ts +8 -0
  36. package/dist/types/data/project-worker.d.ts +8 -0
  37. package/dist/types/data/runtime.d.ts +46 -0
  38. package/dist/types/data/state.d.ts +33 -0
  39. package/dist/types/data/storage.d.ts +26 -0
  40. package/dist/types/data/transport.d.ts +69 -0
  41. package/dist/types/flow-document.d.ts +19 -0
  42. package/dist/types/flow-editor.d.ts +88 -0
  43. package/docs/EDITORS.md +158 -0
  44. package/docs/PROJECT-FORMAT.md +1 -11
  45. package/package.json +32 -15
  46. package/src/component/data/actions.js +138 -0
  47. package/src/component/data/index.js +10 -0
  48. package/src/component/data/mount.js +63 -0
  49. package/src/component/data/project-widget.js +164 -0
  50. package/src/component/data/viewmodel.js +185 -0
  51. package/src/component/data/views.js +241 -0
  52. package/src/component/document.js +344 -0
  53. package/src/component/flow/actions.js +331 -0
  54. package/src/component/flow/index.js +8 -0
  55. package/src/component/flow/mount.js +53 -0
  56. package/src/component/flow/project-widget.js +44 -0
  57. package/src/component/flow/runtime.js +481 -0
  58. package/src/component/flow/views.js +196 -0
  59. package/src/component/host.js +2 -2
  60. package/src/component/index.js +19 -9
  61. package/src/component/mount.js +43 -0
  62. package/src/component/project-actions.js +189 -0
  63. package/src/component/project-controller.js +248 -0
  64. package/src/component/project-state.js +30 -0
  65. package/src/component/project.js +308 -0
  66. package/src/component/shared/host-widget.js +35 -0
  67. package/src/component/shared/memo.js +28 -0
  68. package/src/component/shared/nodes.js +94 -0
  69. package/src/component/shared/schema-options.js +30 -0
  70. package/src/component/shared/studio-kit.js +59 -0
  71. package/src/component/shared/ui.js +134 -0
  72. package/src/data/boot-stages.js +202 -0
  73. package/src/data/browser-worker.js +247 -0
  74. package/src/data/contract.js +7 -0
  75. package/src/data/editor.js +95 -0
  76. package/src/data/handlers.js +349 -0
  77. package/src/data/host.js +8 -0
  78. package/src/data/project-worker.js +26 -0
  79. package/src/data/runtime.js +462 -0
  80. package/src/data/state.js +46 -0
  81. package/src/data/storage.js +61 -0
  82. package/src/data/transport.js +215 -0
  83. package/src/flow-document.js +24 -0
  84. package/src/flow-editor.js +98 -0
  85. package/styles/data.css +53 -0
  86. package/styles/editor.css +150 -0
  87. package/styles/flow.css +99 -0
  88. package/styles/studio.css +1 -0
  89. package/dist/types/author.d.ts +0 -27
  90. package/src/author.js +0 -55
@@ -0,0 +1,481 @@
1
+ import { validateDagDoc } from '../../flow-document.js';
2
+ //@ts-check
3
+ /**
4
+ * The Flow studio's JS boundary: the projection chain (flow document →
5
+ * mermaid text → decorated SVG vnode), the selection vocabulary, the
6
+ * inspector form models, and the run-mode runtime (a nested @jarenjs/app
7
+ * driven by fsmToApp's generated actions, and a compileDag runner with
8
+ * abort). The page itself stays a stylesheet; JavaScript enters here,
9
+ * at the derivation and registry boundaries only.
10
+ *
11
+ * One document, three faces: every gesture patches `/flow/doc`, and the
12
+ * diagram, the text pane and the inspector are all derived projections
13
+ * of it — they cannot disagree.
14
+ */
15
+
16
+ import { compileFsm, compileDag, fsmToApp } from '@jarenjs/flow';
17
+ import { parseMermaid, toMermaid, diagramDocument, compileMermaid } from '@jarenjs/mermaid';
18
+ import { transformJson } from '@jarenjs/json/jslt';
19
+ import { pickAllowed } from '@jarenjs/core/array';
20
+ import { encodeJSONPointerSegment, decodeJSONPointerSegment } from '@jarenjs/json/pointer';
21
+ import { createApp } from '@jarenjs/app';
22
+ import { buildFormModel, buildFormViewModel } from '@jarenjs/forms';
23
+
24
+
25
+ import workflowToState from '@jarenjs/mermaid/stylesheets/workflow-to-state.jslt.json' with { type: 'json' };
26
+ import stateToWorkflow from '@jarenjs/mermaid/stylesheets/state-to-workflow.jslt.json' with { type: 'json' };
27
+ import dagToFlowchart from '@jarenjs/mermaid/stylesheets/dag-to-flowchart.jslt.json' with { type: 'json' };
28
+ import flowchartToDag from '@jarenjs/mermaid/stylesheets/flowchart-to-dag.jslt.json' with { type: 'json' };
29
+
30
+ import { memo1 } from '../shared/memo.js';
31
+ import { errorMessage } from '../shared/nodes.js';
32
+ import { createHostWidget } from '../shared/host-widget.js';
33
+
34
+ /** The phone panes, in switcher order. */
35
+ const FLOW_PANES = ['diagram', 'inspector', 'run'];
36
+
37
+ // ------------------------------------------------------------------
38
+ // The projection chain: document → mermaid text → decorated vnode
39
+ // ------------------------------------------------------------------
40
+
41
+ /** Read a JSON Pointer from a document (the inspector's selection). */
42
+ export function memberAt(doc, pointer) {
43
+ if (pointer === '') return doc;
44
+ let value = doc;
45
+ for (const raw of pointer.split('/').slice(1)) {
46
+ const seg = decodeJSONPointerSegment(raw);
47
+ if (value === null || typeof value !== 'object') return undefined;
48
+ value = Array.isArray(value) ? value[Number(seg)] : value[seg];
49
+ }
50
+ return value;
51
+ }
52
+
53
+ const fsmToText = memo1((doc) => {
54
+ const ast = transformJson(workflowToState, doc);
55
+ return toMermaid(diagramDocument('state', {}, ast, { hash: '0', direction: null, title: null }));
56
+ });
57
+
58
+ const dagToText = memo1((doc) => {
59
+ const ast = transformJson(dagToFlowchart, doc);
60
+ return toMermaid(diagramDocument('flowchart', {}, ast, { hash: '0', direction: 'TD', title: null }));
61
+ });
62
+
63
+ /** @param {'fsm'|'dag'} kind @param {any} doc @returns {string} */
64
+ export function flowText(kind, doc) {
65
+ return kind === 'fsm' ? fsmToText(doc) : dagToText(doc);
66
+ }
67
+
68
+ const compiledFor = memo1((text) => compileMermaid(text));
69
+
70
+ /**
71
+ * The selection targets a click can produce, precomputed per document
72
+ * so the decorated vnode's bindings carry ready JSON Pointers.
73
+ * @param {'fsm'|'dag'} kind @param {any} doc
74
+ */
75
+ const selectables = memo1((kind, doc) => {
76
+ /** @type {Record<string, { type: string, id: string, path: string }>} */
77
+ const nodes = {};
78
+ /** @type {Record<number, { type: string, index: number, path: string }>} */
79
+ const edges = {};
80
+ if (kind === 'fsm') {
81
+ doc.states.forEach((s, i) => {
82
+ const id = typeof s === 'string' ? s : s.id;
83
+ nodes[id] = { type: 'state', id, path: `/states/${i}` };
84
+ });
85
+ const offset = doc.initial !== null ? 1 : 0; // the [*] pseudo-edge
86
+ doc.transitions.forEach((t, i) => {
87
+ edges[i + offset] = { type: 'transition', index: i, path: `/transitions/${i}` };
88
+ });
89
+ }
90
+ else {
91
+ for (const id of Object.keys(doc.nodes)) {
92
+ nodes[id] = { type: 'node', id, path: `/nodes/${encodeJSONPointerSegment(id)}` };
93
+ }
94
+ doc.edges.forEach((_e, i) => {
95
+ edges[i] = { type: 'edge', index: i, path: `/edges/${i}` };
96
+ });
97
+ }
98
+ return { nodes, edges };
99
+ });
100
+
101
+ /**
102
+ * Decorate the compiled SVG vnode: click bindings on every selectable
103
+ * group (by `data-id`/`data-edge`) plus the marker classes — selection,
104
+ * the armed connect source, the running machine's active state, the
105
+ * dag run's per-node status. Pure copy; the compiled vnode is shared
106
+ * and never mutated.
107
+ */
108
+ function decorate(vnode, targets, marks) {
109
+ const walk = (node) => {
110
+ if (!Array.isArray(node) || typeof node[0] !== 'string') {
111
+ return Array.isArray(node) ? node.map(walk) : node;
112
+ }
113
+ const [tag, props, ...children] = node;
114
+ let nextProps = props;
115
+ if (props !== null && typeof props === 'object' && !Array.isArray(props)) {
116
+ const id = props['data-id'];
117
+ const edge = props['data-edge'];
118
+ if (typeof id === 'string' && targets.nodes[id] !== undefined) {
119
+ const target = targets.nodes[id];
120
+ let cls = `${props.class ?? ''} mm-pick`;
121
+ if (marks.selectedPath === target.path) cls += ' mm-selected';
122
+ if (marks.armed === id) cls += ' mm-armed';
123
+ if (marks.activeId === id) cls += ' mm-active';
124
+ const status = marks.nodeStatus?.[id];
125
+ if (status !== undefined) cls += ` mm-run-${status}`;
126
+ nextProps = {
127
+ ...props, class: cls, role: 'button', tabindex: '0',
128
+ 'aria-label': `${target.type} ${target.id ?? target.index}`,
129
+ on: { click: { action: 'flow/pick', with: target }, keydown: { action: 'flow/key-pick', with: target } },
130
+ };
131
+ }
132
+ else if (typeof edge === 'number' && targets.edges[edge] !== undefined) {
133
+ const target = targets.edges[edge];
134
+ let cls = `${props.class ?? ''} mm-pick`;
135
+ if (marks.selectedPath === target.path) cls += ' mm-selected';
136
+ // the transition just taken flows from the old state to the new
137
+ // one — matched by endpoints, so every parallel edge that could
138
+ // have fired lights up (a machine with 4 drinks → 4 glowing arcs)
139
+ if (marks.firedFrom != null
140
+ && props['data-from'] === marks.firedFrom && props['data-to'] === marks.firedTo) {
141
+ cls += ' mm-fired';
142
+ }
143
+ nextProps = {
144
+ ...props, class: cls, role: 'button', tabindex: '0',
145
+ 'aria-label': `${target.type} ${target.id ?? target.index}`,
146
+ on: { click: { action: 'flow/pick', with: target }, keydown: { action: 'flow/key-pick', with: target } },
147
+ };
148
+ }
149
+ }
150
+ return [tag, nextProps, ...children.map(walk)];
151
+ };
152
+ return walk(vnode);
153
+ }
154
+
155
+ // ------------------------------------------------------------------
156
+ // Text round-trip honesty: which documents survive the text projection
157
+ // ------------------------------------------------------------------
158
+
159
+ /**
160
+ * The text pane is read-only when the document carries members the
161
+ * diagram text cannot represent — committing an edit would silently
162
+ * destroy them (APP-INTEGRATION honesty rule, MERMAID-FORMAT §5.1).
163
+ * @param {'fsm'|'dag'} kind @param {any} doc
164
+ * @returns {string|null} the human explanation, or null when editable
165
+ */
166
+ export function textLossReason(kind, doc) {
167
+ if (kind === 'fsm') {
168
+ if (doc.states.some((s) => typeof s !== 'string')) {
169
+ return 'states carry entry/exit/final members, which diagram text cannot express';
170
+ }
171
+ if (doc.transitions.some((t) => t.guard !== undefined && t.guard !== null && typeof t.guard !== 'string')) {
172
+ return 'a structured guard would flatten to the […] placeholder';
173
+ }
174
+ if (doc.transitions.some((t) => Array.isArray(t.effects)
175
+ && t.effects.some((e) => e.with !== undefined))) {
176
+ return 'an effect carries props, which the / label drops';
177
+ }
178
+ return null;
179
+ }
180
+ const stubOnly = Object.entries(doc.nodes).every(([id, n]) =>
181
+ (n.kind === 'task' && n.run === id && n.with === undefined)
182
+ || n.kind === 'input' || n.kind === 'output');
183
+ if (!stubOnly) return 'query, stylesheet and task documents do not fit in diagram text';
184
+ if (doc.edges.some((e) => e.select !== undefined)) {
185
+ return 'edge selectors do not fit in diagram text';
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // ------------------------------------------------------------------
191
+ // Inspector: forms-generated, zero hand-written field rendering
192
+ // ------------------------------------------------------------------
193
+
194
+ const TRANSITION_MODEL = buildFormModel({
195
+ type: 'object',
196
+ properties: {
197
+ from: { type: 'string', title: 'From state' },
198
+ event: { type: ['string', 'null'], title: 'Event (empty = any)' },
199
+ guard: { title: 'Guard (query document)' },
200
+ to: { type: 'string', title: 'To state' },
201
+ effects: { title: 'Effects' },
202
+ },
203
+ });
204
+ const DAG_EDGE_MODEL = buildFormModel({
205
+ type: 'object',
206
+ properties: {
207
+ from: { type: 'string', title: 'From node' },
208
+ to: { type: 'string', title: 'To node' },
209
+ port: { type: 'string', title: 'Port' },
210
+ select: { title: 'Select (query document)' },
211
+ },
212
+ });
213
+ /** Whole-member JSON editor: string states, object states, dag nodes. */
214
+ const MEMBER_MODEL = buildFormModel({ title: 'Member (JSON)' });
215
+
216
+ const MODELS = {
217
+ transition: TRANSITION_MODEL,
218
+ edge: DAG_EDGE_MODEL,
219
+ state: MEMBER_MODEL,
220
+ node: MEMBER_MODEL,
221
+ };
222
+
223
+ const inspectorForm = memo1((type, value) =>
224
+ buildFormViewModel(MODELS[type], value, { session: { idPrefix: 'flow-insp' } }));
225
+
226
+ // ------------------------------------------------------------------
227
+ // The page view model (called from app/viewmodel.js)
228
+ // ------------------------------------------------------------------
229
+
230
+ const picker = memo1((templates) => ({ templates: templates.map(t => ({
231
+ name: t.name, title: t.title, lead: t.lead,
232
+ preview: JSON.stringify(t.doc, null, 1).slice(0, 220) + '…',
233
+ })) }));
234
+ const NO_TEMPLATES = [];
235
+
236
+ const fsmEvents = memo1((doc) => {
237
+ const names = [];
238
+ for (const t of doc.transitions) {
239
+ if (typeof t.event === 'string' && !names.includes(t.event)) names.push(t.event);
240
+ }
241
+ return names;
242
+ });
243
+
244
+ const composeDiagram = memo1((kind, doc, selectedPath, armed, activeId, nodeStatus, firedFrom, firedTo) => {
245
+ const compiled = compiledFor(flowText(kind, doc));
246
+ return decorate(compiled.toVnode(), selectables(kind, doc), {
247
+ selectedPath, armed, activeId, nodeStatus, firedFrom, firedTo,
248
+ });
249
+ });
250
+
251
+ /**
252
+ * @param {any} flow - the `state.flow` slice
253
+ * @returns {any} the `$.ui.flow` node
254
+ */
255
+ export function flowPageViewModel(flow, templates = NO_TEMPLATES) {
256
+ if (flow.doc === null) return { picker: picker(templates) };
257
+ const kind = flow.kind;
258
+ const run = flow.run;
259
+ const selection = flow.selection;
260
+ const lossReason = textLossReason(kind, flow.doc);
261
+ const value = selection === null ? undefined : memberAt(flow.doc, selection.path);
262
+ return {
263
+ live: {
264
+ kind,
265
+ tab: flow.tab,
266
+ diagram: composeDiagram(kind, flow.doc, selection?.path ?? null, flow.connect,
267
+ run !== null && kind === 'fsm' ? run.current : null,
268
+ run !== null && kind === 'dag' ? run.nodes : null,
269
+ // the transition just taken: old → new state, only on a real move
270
+ run !== null && kind === 'fsm' && run.prev != null && run.prev !== run.current ? run.prev : null,
271
+ run !== null && kind === 'fsm' ? run.current : null),
272
+ text: flowText(kind, flow.doc),
273
+ textReadOnly: lossReason !== null,
274
+ textLossReason: lossReason,
275
+ parseError: flow.parseError,
276
+ connectArmed: flow.connect !== null,
277
+ canConnect: selection !== null && (selection.type === 'state' || selection.type === 'node'),
278
+ canDelete: selection !== null,
279
+ canUndo: flow.history.past.length > 0,
280
+ canRedo: flow.history.future.length > 0,
281
+ isFsm: kind === 'fsm',
282
+ selection: selection === null || value === undefined ? null : {
283
+ ...selection,
284
+ label: `${selection.type} ${selection.type === 'transition' || selection.type === 'edge'
285
+ ? '#' + selection.index : selection.id}`,
286
+ },
287
+ inspector: selection === null || value === undefined ? null : {
288
+ form: inspectorForm(selection.type, value),
289
+ },
290
+ run: run === null ? null : {
291
+ ...run,
292
+ revision: flow.revision,
293
+ logLines: run.log.map((entry) => JSON.stringify(entry)),
294
+ outputText: run.output === undefined || run.output === null
295
+ ? null : JSON.stringify(run.output, null, 1),
296
+ },
297
+ events: kind === 'fsm' ? fsmEvents(flow.doc) : [],
298
+ dagInput: flow.dagInput,
299
+ // the phone pane (Diagram · Inspector · Run)
300
+ mobilePane: pickAllowed(flow.mobilePane, FLOW_PANES, 'diagram'),
301
+ mount: run !== null && kind === 'fsm'
302
+ ? runMount(flow.doc, flow.revision, flow.runContext)
303
+ : null,
304
+ },
305
+ };
306
+ }
307
+
308
+ const runMount = memo1((doc, revision, context) => ({ doc, revision, context }));
309
+
310
+ // ------------------------------------------------------------------
311
+ // The run-mode runtime: nested app widget + dag runner + parse effect
312
+ // ------------------------------------------------------------------
313
+
314
+ /** Every effect run name a machine document can schedule. */
315
+ function runNames(doc) {
316
+ const names = new Set();
317
+ for (const s of doc.states) {
318
+ if (typeof s === 'object') {
319
+ for (const e of s.entry ?? []) names.add(e.run);
320
+ for (const e of s.exit ?? []) names.add(e.run);
321
+ }
322
+ }
323
+ for (const t of doc.transitions) for (const e of t.effects ?? []) names.add(e.run);
324
+ return [...names];
325
+ }
326
+
327
+ // One shared normalizer (lib/nodes.js): coded errors compose their own
328
+ // `code: reason at path` message now, so the hand prefix this module
329
+ // used to add would print the code twice.
330
+ const message = errorMessage;
331
+
332
+ /**
333
+ * The Flow studio runtime: the nested-app host widget for machine runs
334
+ * and the effect registry (template loading, fail-closed text parsing,
335
+ * machine sends, dag runs with abort). One closure ref ties the
336
+ * running instances to their effects.
337
+ * @param {{ schedule?: any, template?: (name: string) => any, tasks?: Record<string, Function>, getFlow?: () => any, mintFromCount?: boolean }} [env]
338
+ */
339
+ export function createFlowRuntime(env = {}) {
340
+ const ref = { app: null, controller: null, generation: 0, disposed: false };
341
+
342
+ const boot = (handle, props) => {
343
+ try {
344
+ const { slice, actions } = fsmToApp(props.doc);
345
+ /** @type {Record<string, any>} */
346
+ const effects = {
347
+ };
348
+ for (const name of runNames(props.doc)) {
349
+ effects[name] = (p) => handle.emit({
350
+ action: 'flow/run-log',
351
+ with: { run: name, props: p ?? null },
352
+ });
353
+ }
354
+ const app = createApp({
355
+ state: { fsm: slice, ...(props.context ?? {}) },
356
+ view: [{ match: '$', body: ['div', { class: 'flow-run-current' }, 'current: ', '$.fsm.current'] }],
357
+ actions,
358
+ }, {
359
+ node: handle.host,
360
+ document: handle.host.ownerDocument,
361
+ schedule: env.schedule,
362
+ effects,
363
+ onError: (err) => handle.emit({ action: 'flow/run-log', with: { run: 'error', props: message(err) } }),
364
+ });
365
+ app.observe((tx) => handle.emit({
366
+ action: 'flow/run-tx',
367
+ with: { action: tx.action, status: tx.status, current: app.getState().fsm.current },
368
+ }));
369
+ handle.app = app;
370
+ ref.app = app;
371
+ }
372
+ catch (err) {
373
+ handle.app = null;
374
+ handle.emit({ action: 'flow/run-log', with: { run: 'error', props: message(err) } });
375
+ }
376
+ };
377
+ const destroy = (handle) => {
378
+ try { handle.app?.destroy(); }
379
+ catch { /* teardown must never throw into the patcher */ }
380
+ if (ref.app === handle.app) ref.app = null;
381
+ handle.app = null;
382
+ };
383
+
384
+ const widget = createHostWidget({ boot, destroy });
385
+
386
+ const effects = {
387
+ 'flow-mint': (props, dispatch) => {
388
+ const doc = env.getFlow?.().doc;
389
+ if (doc == null) return;
390
+ const ids = props.kind === 'state'
391
+ ? new Set((doc.states ?? []).map(s => typeof s === 'string' ? s : s.id))
392
+ : new Set(Object.keys(doc.nodes ?? {}));
393
+ const prefix = props.kind === 'state' ? 's' : 'n';
394
+ let n = env.mintFromCount === false ? 1 : ids.size + 1;
395
+ while (ids.has(prefix + n)) n++;
396
+ dispatch(props.kind === 'state' ? 'flow/state-minted' : 'flow/node-minted', prefix + n);
397
+ },
398
+
399
+ 'flow-template': (props, dispatch) => {
400
+ const t = env.template?.(props.name);
401
+ if (t == null) return;
402
+ dispatch('flow/load', {
403
+ kind: t.kind, doc: t.doc,
404
+ runContext: t.runContext ?? null,
405
+ dagInput: t.runInput === undefined ? '' : JSON.stringify(t.runInput, null, 1),
406
+ });
407
+ },
408
+ // fail closed: a broken edit never replaces the document — the last
409
+ // good diagram stays live and the error strip says why
410
+ 'flow-parse': (props, dispatch) => {
411
+ try {
412
+ const parsed = parseMermaid(props.text);
413
+ const expected = props.kind === 'fsm' ? 'state' : 'flowchart';
414
+ if (parsed.diagram !== expected) {
415
+ throw new Error(`expected a ${expected} diagram, got '${parsed.diagram}'`);
416
+ }
417
+ const doc = props.kind === 'fsm'
418
+ ? transformJson(stateToWorkflow, parsed)
419
+ : transformJson(flowchartToDag, parsed);
420
+ // fsm: the projection always compiles (the superset contract).
421
+ // dag: a text-projected SKELETON is deliberately incomplete
422
+ // (all-task stubs, no output yet), so the gate is the published
423
+ // schema — compile problems surface at run, where they belong.
424
+ if (props.kind === 'fsm') compileFsm(doc);
425
+ else if (!validateDagDoc(doc)) throw new Error('the projected dag failed jaren-dag schema validation');
426
+ dispatch('flow/parsed', { doc });
427
+ }
428
+ catch (err) {
429
+ dispatch('flow/parse-error', { message: message(err) });
430
+ }
431
+ },
432
+ 'flow-run-send': (props) => {
433
+ ref.app?.dispatch(`fsm/${props.event}`);
434
+ },
435
+ 'flow-dag-run': (props, dispatch) => {
436
+ if (ref.disposed) return Promise.resolve({ ok: false, error: 'The Flow editor is disposed.' });
437
+ ref.controller?.abort();
438
+ const generation = ++ref.generation;
439
+ const send = (action, payload) => {
440
+ if (!ref.disposed && generation === ref.generation) dispatch(action, payload);
441
+ };
442
+ let input = null;
443
+ if (typeof props.inputText === 'string' && props.inputText.trim() !== '') {
444
+ try { input = JSON.parse(props.inputText); }
445
+ catch (err) {
446
+ const error = `the input is not JSON: ${message(err)}`;
447
+ send('flow/dag-fail', { message: error });
448
+ return Promise.resolve({ ok: false, error });
449
+ }
450
+ }
451
+ let dag;
452
+ try { dag = compileDag(props.doc, { tasks: env.tasks ?? {} }); }
453
+ catch (err) {
454
+ send('flow/dag-fail', { message: message(err) });
455
+ return Promise.resolve({ ok: false, error: message(err) });
456
+ }
457
+ const controller = new AbortController();
458
+ ref.controller = controller;
459
+ return dag.run(input, {
460
+ signal: controller.signal,
461
+ onNode: (rec) => send('flow/dag-node', rec),
462
+ }).then(
463
+ output => {
464
+ if (ref.disposed || generation !== ref.generation) return { ok: false, error: 'The run was superseded or disposed.' };
465
+ send('flow/dag-done', { output }); return { ok: true, output };
466
+ },
467
+ err => {
468
+ const error = err?.nodeId !== undefined ? `${message(err)} (node '${err.nodeId}')` : message(err);
469
+ send('flow/dag-fail', { message: error }); return { ok: false, error };
470
+ },
471
+ );
472
+ },
473
+ 'flow-dag-abort': () => {
474
+ ref.controller?.abort();
475
+ },
476
+ };
477
+
478
+ const dispose = () => { ref.disposed = true; ref.generation++; ref.controller?.abort(); };
479
+ effects['flow-dag-run'].dispose = dispose;
480
+ return { widget, effects, dispose };
481
+ }
@@ -0,0 +1,196 @@
1
+ //@ts-check
2
+ /**
3
+ * The Flow studio — mode 'flow', dispatched with `$.ui.flow` as the
4
+ * current node. One jaren-fsm or jaren-dag document, edited three ways
5
+ * that cannot disagree: clicking the diagram (every gesture an RFC 6902
6
+ * patch), the forms-generated inspector, and the mermaid text pane —
7
+ * plus a live run mode where the same document boots a nested
8
+ * @jarenjs/app (machines) or a compileDag run (dataflow).
9
+ *
10
+ * The stylesheet renders derived projections only; the document itself
11
+ * lives at `state.flow.doc` and every mutation goes through the
12
+ * actions' patches — no pane owns private truth.
13
+ *
14
+ * Below the breakpoint the three cards become one pane at a time behind
15
+ * the shared segmented switcher (Diagram · Inspector · Run); selecting
16
+ * a node jumps to the inspector and running jumps to the run pane, so a
17
+ * phone never leaves the user looking at the pane they just left.
18
+ */
19
+
20
+ import { createFormView } from '@jarenjs/app';
21
+ import { paneSwitcher } from '../shared/studio-kit.js';
22
+
23
+ const paletteBtn = (label, action, opts = {}) =>
24
+ ['button', {
25
+ type: 'button', class: 'btn small',
26
+ ...(opts.disabledIf ? { disabled: { $if: [opts.disabledIf, true, false] } } : {}),
27
+ on: { click: action },
28
+ }, label];
29
+
30
+ const canvasCard =
31
+ ['div', { key: 'flow-canvas-card', class: 'card pg-card flow-canvas-card' },
32
+ ['div', { class: 'card-head' },
33
+ ['h3', {}, { $if: ['$.isFsm', 'Machine', 'Dataflow'] }],
34
+ ['div', { class: 'flow-palette' },
35
+ { $if: ['$.isFsm',
36
+ paletteBtn('Add state', 'flow/add-state'),
37
+ paletteBtn('Add node', 'flow/add-node')] },
38
+ { $if: ['$.connectArmed',
39
+ ['button', { type: 'button', class: 'btn small flow-armed', on: { click: 'flow/connect-cancel' } },
40
+ 'Click a target…'],
41
+ paletteBtn('Connect', 'flow/connect-arm', { disabledIf: { $not: '$.canConnect' } })] },
42
+ paletteBtn('Delete', 'flow/delete', { disabledIf: { $not: '$.canDelete' } }),
43
+ paletteBtn('Undo', 'flow/undo', { disabledIf: { $not: '$.canUndo' } }),
44
+ paletteBtn('Redo', 'flow/redo', { disabledIf: { $not: '$.canRedo' } }),
45
+ paletteBtn('New', 'flow/clear'),
46
+ ],
47
+ ],
48
+ ['div', { class: 'flow-tabs' },
49
+ ['button', {
50
+ type: 'button',
51
+ class: { $if: [{ $eq: ['$.tab', 'diagram'] }, 'btn small active', 'btn small'] },
52
+ on: { click: { action: 'flow/tab', with: 'diagram' } },
53
+ }, 'Diagram'],
54
+ ['button', {
55
+ type: 'button',
56
+ class: { $if: [{ $eq: ['$.tab', 'text'] }, 'btn small active', 'btn small'] },
57
+ on: { click: { action: 'flow/tab', with: 'text' } },
58
+ }, 'Text'],
59
+ ],
60
+ { $if: [{ $eq: ['$.tab', 'diagram'] },
61
+ ['div', { key: 'flow-canvas', class: 'flow-canvas' }, '$.diagram'],
62
+ ['div', { key: 'flow-text' },
63
+ ['textarea', {
64
+ class: 'editor flow-text',
65
+ rows: 14,
66
+ spellcheck: 'false',
67
+ value: '$.text',
68
+ readonly: { $if: ['$.textReadOnly', true, false] },
69
+ on: { change: 'flow/text' },
70
+ }],
71
+ { $if: ['$.textReadOnly',
72
+ ['p', { class: 'muted' },
73
+ 'Read-only: ', '$.textLossReason',
74
+ ' — edit through the diagram or the inspector instead.'],
75
+ ['p', { class: 'muted' },
76
+ 'Edits apply when the field loses focus — the text re-parses, projects back to the document, and a broken edit never replaces it.']] },
77
+ ]] },
78
+ { $if: ['$.parseError', ['p', { class: 'error-line' }, '$.parseError']] },
79
+ ];
80
+
81
+ const inspectorCard =
82
+ ['div', { key: 'flow-inspector-card', class: 'card pg-card flow-inspector' },
83
+ ['div', { class: 'card-head' },
84
+ ['h3', {}, 'Inspector'],
85
+ { $if: ['$.selection', ['span', { class: 'muted' }, '$.selection.label']] },
86
+ ],
87
+ { $if: ['$.inspector',
88
+ ['div', { key: 'flow-inspector-form' }, { $apply: '$.inspector.form' }],
89
+ ['p', { class: 'muted' },
90
+ 'Select a state, node or edge in the diagram to edit it here — every field writes straight into the document.']] },
91
+ ];
92
+
93
+ const fsmRun =
94
+ ['div', { key: 'flow-run-fsm' },
95
+ { $if: ['$.run',
96
+ ['div', {},
97
+ ['div', { class: 'flow-run-head' },
98
+ ['div', { class: 'flow-run-buttons' },
99
+ [{ $for: { e: '$.events[*]' },
100
+ $return: ['button', {
101
+ type: 'button', class: 'btn small',
102
+ on: { click: { action: 'flow/send', with: '$e' } },
103
+ }, '$e'] }],
104
+ ],
105
+ ['button', { type: 'button', class: 'btn small', on: { click: 'flow/stop' } }, 'Stop'],
106
+ ],
107
+ ['div', { class: 'flow-run-mount' },
108
+ ['jaren-widget', { name: 'flow-doc', key: 'flow-run-app', props: '$.mount' }]],
109
+ ['ul', { class: 'flow-log' }, [{ $for: { l: '$.run.logLines[*]' },
110
+ $return: ['li', { class: 'flow-log-line' }, '$l'] }]],
111
+ ],
112
+ ['div', {},
113
+ ['p', { class: 'muted' },
114
+ 'Boot the machine as a real nested @jarenjs/app: the generated per-event actions drive it, and the diagram highlights the current state.'],
115
+ ['button', { type: 'button', class: 'btn', on: { click: 'flow/run' } }, 'Run machine'],
116
+ ]] },
117
+ ];
118
+
119
+ const dagRun =
120
+ ['div', { key: 'flow-run-dag' },
121
+ ['label', { class: 'muted' }, 'Run input (JSON)'],
122
+ // published per keystroke, not on blur: a run streams per-node updates,
123
+ // and each render reasserts this controlled textarea — with a stale
124
+ // value it would overwrite whatever was being typed for the next run
125
+ ['textarea', {
126
+ class: 'editor flow-dag-input', rows: 4, spellcheck: 'false',
127
+ value: '$.dagInput', on: { input: 'flow/dag-input', change: 'flow/dag-input' },
128
+ }],
129
+ ['div', { class: 'flow-run-head' },
130
+ ['button', { type: 'button', class: 'btn', on: { click: 'flow/dag-run' } }, 'Run graph'],
131
+ { $if: ['$.run.running',
132
+ ['button', { type: 'button', class: 'btn small', on: { click: 'flow/dag-abort' } }, 'Abort']] },
133
+ ],
134
+ { $if: ['$.run',
135
+ ['div', {},
136
+ { $if: ['$.run.error', ['p', { class: 'error-line' }, '$.run.error']] },
137
+ { $if: ['$.run.outputText',
138
+ ['pre', { class: 'code-block flow-output' }, ['code', {}, '$.run.outputText']]] },
139
+ ['ul', { class: 'flow-log' }, [{ $for: { l: '$.run.logLines[*]' },
140
+ $return: ['li', { class: 'flow-log-line' }, '$l'] }]],
141
+ ]] },
142
+ ];
143
+
144
+ const runCard =
145
+ ['div', { key: 'flow-run-card', class: 'card pg-card flow-run' },
146
+ ['div', { class: 'card-head' }, ['h3', {}, 'Run']],
147
+ { $if: ['$.isFsm', fsmRun, dagRun] },
148
+ ];
149
+
150
+ export const FLOW_RULES = [
151
+ {
152
+ match: '$.ui.flow', mode: 'flow',
153
+ body: ['div', { class: 'jaren-flow-editor' },
154
+ { $apply: '$.picker' },
155
+ { $apply: '$.live' },
156
+ ],
157
+ },
158
+ {
159
+ match: '$.ui.flow.picker', mode: 'flow',
160
+ body: ['div', { key: 'flow-picker' },
161
+ ['p', { class: 'page-lead' },
162
+ 'Start from a seed — a complete, runnable document either way.'],
163
+ ['div', { class: 'example-grid' }, [{ $apply: '$.templates[*]' }]],
164
+ ],
165
+ },
166
+ {
167
+ match: '$.ui.flow.picker.templates[*]', mode: 'flow',
168
+ body: ['article', { class: 'card example-card' },
169
+ ['div', { class: 'card-head' },
170
+ ['h3', {}, '$.title'],
171
+ ['button', {
172
+ type: 'button', class: 'btn small',
173
+ on: { click: { action: 'flow/template', with: '$.name' } },
174
+ }, 'Load'],
175
+ ],
176
+ ['p', { class: 'muted' }, '$.lead'],
177
+ ['pre', { class: 'code-block clamp' }, ['code', {}, '$.preview']],
178
+ ],
179
+ },
180
+ {
181
+ match: '$.ui.flow.live', mode: 'flow',
182
+ body: ['div', { key: 'flow-live', class: 'flow-grid', 'data-pane': '$.mobilePane' },
183
+ paneSwitcher({
184
+ class: 'flow-panebar', pane: '$.mobilePane', action: 'flow/pane',
185
+ panes: [['diagram', 'Diagram'], ['inspector', 'Inspector'], ['run', 'Run']],
186
+ }),
187
+ canvasCard,
188
+ ['div', { key: 'flow-side', class: 'flow-side' }, inspectorCard, runCard],
189
+ ],
190
+ },
191
+ ];
192
+
193
+ export const FLOW_FORM_RULES = createFormView({ root: '$.ui.flow.live.inspector.form', actions: {
194
+ input: 'flow/f-input', check: 'flow/f-check', number: 'flow/f-number',
195
+ json: 'flow/f-json', add: 'flow/f-add', remove: 'flow/f-remove',
196
+ } }).map(rule => ({ ...rule, mode: 'flow' }));