flexdesk 0.5.0 → 0.6.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 (62) hide show
  1. package/README.md +621 -1
  2. package/css/base.css +2835 -0
  3. package/css/flexdesk.css +2835 -0
  4. package/css/tokens.css +72 -0
  5. package/dist/canvas.js +390 -0
  6. package/dist/canvas.js.map +7 -0
  7. package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
  8. package/dist/chunk-6NG7YVBG.js.map +7 -0
  9. package/dist/chunk-H4LT43RS.js +475 -0
  10. package/dist/chunk-H4LT43RS.js.map +7 -0
  11. package/dist/chunk-SN4LSOAR.js +532 -0
  12. package/dist/chunk-SN4LSOAR.js.map +7 -0
  13. package/dist/flexdesk.css +2835 -0
  14. package/dist/flow.js +10030 -0
  15. package/dist/flow.js.map +7 -0
  16. package/dist/tiles.js +9 -9
  17. package/dist/tokens.css +72 -0
  18. package/dist/widgets.js +6 -4
  19. package/dist/widgets.js.map +1 -1
  20. package/dist/wm.js +4 -2
  21. package/dist/wm.js.map +1 -1
  22. package/package.json +9 -1
  23. package/src/canvas/canvas_adapter.js +523 -0
  24. package/src/canvas/edge_router.js +392 -0
  25. package/src/canvas/graph/connector_router.js +189 -0
  26. package/src/canvas/graph/history_service.js +170 -0
  27. package/src/canvas/graph/node_platform.js +116 -0
  28. package/src/canvas/index.js +16 -0
  29. package/src/flow/canvas_editor/editor.js +1081 -0
  30. package/src/flow/canvas_editor/index.js +16 -0
  31. package/src/flow/canvas_editor/layout.js +118 -0
  32. package/src/flow/canvas_editor/strings.js +37 -0
  33. package/src/flow/kit/always_before.js +325 -0
  34. package/src/flow/kit/catalogue.js +170 -0
  35. package/src/flow/kit/chip_input.js +461 -0
  36. package/src/flow/kit/dom.js +205 -0
  37. package/src/flow/kit/findings.js +129 -0
  38. package/src/flow/kit/history.js +173 -0
  39. package/src/flow/kit/index.js +38 -0
  40. package/src/flow/kit/keys.js +127 -0
  41. package/src/flow/kit/logic_graph.js +210 -0
  42. package/src/flow/kit/references.js +199 -0
  43. package/src/flow/kit/settings_panel.js +357 -0
  44. package/src/flow/kit/settings_schema.js +175 -0
  45. package/src/flow/kit/step_picker.js +209 -0
  46. package/src/flow/kit/strings.js +102 -0
  47. package/src/flow/kit/value_picker.js +238 -0
  48. package/src/flow/kit/widgets.js +763 -0
  49. package/src/flow/lanes/editor.js +1469 -0
  50. package/src/flow/lanes/index.js +26 -0
  51. package/src/flow/lanes/layout.js +525 -0
  52. package/src/flow/lanes/pipeline.js +327 -0
  53. package/src/flow/lanes/preview.js +109 -0
  54. package/src/flow/lanes/strings.js +98 -0
  55. package/src/flow/outline/editor.js +1511 -0
  56. package/src/flow/outline/index.js +20 -0
  57. package/src/flow/outline/mapping.js +286 -0
  58. package/src/flow/outline/menu.js +154 -0
  59. package/src/flow/outline/operations.js +738 -0
  60. package/src/flow/outline/recognise.js +755 -0
  61. package/src/flow/outline/strings.js +135 -0
  62. package/dist/chunk-24MS7W5Y.js.map +0 -7
@@ -0,0 +1,1081 @@
1
+ /**
2
+ * THE CANVAS EDITOR — a flow drawn as nodes and lines (36 §5).
3
+ *
4
+ * Three columns — the step palette, the canvas, the settings of whatever is
5
+ * picked — under a toolbar: Undo, Redo, Arrange, Fit, then the consumer's own
6
+ * verbs. It is a designer that was built for one product and moved here MINUS
7
+ * everything that was that product's: loading, saving, autosave, validating,
8
+ * publishing, running and a run's records are all the consumer's, reached
9
+ * through options and callbacks. What stayed is what any flow on a canvas
10
+ * needs.
11
+ *
12
+ * ══ THE EDITOR DECIDES NOTHING A PUBLISH DEPENDS ON ═════════════════════
13
+ *
14
+ * Whether the graph is a valid flow is the consumer's validator's answer,
15
+ * handed in as findings and drawn where it applies: one with a `node_id` on
16
+ * that node (and on its field while its settings are open), every one of them
17
+ * in the strip with *Go to it*. The editor has no validator of its own to
18
+ * disagree with. `canConnect` refuses only what the canvas cannot DRAW
19
+ * honestly — a port that does not exist, a flow line into a data port, a
20
+ * second line out of a port that carries one, a step connected to itself —
21
+ * each refusal a sentence on the screen.
22
+ *
23
+ * ══ THE EDITOR NEVER FETCHES, NEVER SAVES, NEVER STARTS A TIMER THAT WRITES
24
+ *
25
+ * Every committed edit calls `onChange({graph, flowSettings, text, action,
26
+ * key})` and the CONSUMER autosaves — undo and redo included (`via: 'undo'` or
27
+ * `'redo'`), because an undo is a change to what the consumer holds. `load` is
28
+ * a baseline, so opening a flow and looking at it makes nothing to undo; a
29
+ * consumer's publish is a baseline too (`history.baseline(editor.snapshot())`).
30
+ * `destroy` flushes nothing, because nothing is held.
31
+ *
32
+ * ══ THE KEYS STOP AT THIS ROOT ══════════════════════════════════════════
33
+ *
34
+ * `bindFlowKeys` binds on the editor's root and stops Ctrl+Z there even inside
35
+ * a text field (whose own undo is the browser's), so a tile beside it that
36
+ * listens on `window` never hears it. Backspace is never the editor's: Delete
37
+ * removes the picked step or line. And a panel repaint that takes the focused
38
+ * control away puts focus back on the rebuilt control of the same field — or,
39
+ * when that field is gone, on the canvas; never on `<body>`, where the keys
40
+ * never arrive. A repaint the reader did not ask for (a run overlay, new
41
+ * findings) rebuilds no field at all.
42
+ *
43
+ * ══ GESTURES ════════════════════════════════════════════════════════════
44
+ *
45
+ * Separated by WHERE the press lands: a node's header moves it; a port
46
+ * connects, by click-then-click (an output, then an input) — which is also
47
+ * what a render test can drive, where a drag ending in `elementFromPoint`
48
+ * cannot be. No press rebuilds a node: pressing a header toggles classes, so
49
+ * the node the click lands on is the node the press went down on (a click
50
+ * fires only when press and release land on the same node).
51
+ *
52
+ * A palette step is added by a CLICK (at the centre of the view) or by a DRAG
53
+ * onto the canvas (where it is dropped). The drag is pointer events on the
54
+ * palette button and a ghost on `document.body` — NEVER `draggable` (a native
55
+ * drag cancels the pointer at ~5 px) and NEVER `setPointerCapture` (capture
56
+ * retargets the release, and then the click, to the button, which would add a
57
+ * second step). A drag swallows the click that follows it; Escape,
58
+ * `pointercancel` and a destroy each take the ghost away.
59
+ */
60
+
61
+ import { CanvasAdapter } from '../../canvas/canvas_adapter.js';
62
+ import { returnEdge, routeEdge } from '../../canvas/edge_router.js';
63
+ import { button, el, icon, toneClass, closeFlowPopovers } from '../kit/dom.js';
64
+ import { createStrings, say } from '../kit/strings.js';
65
+ import { createStepCatalogue } from '../kit/catalogue.js';
66
+ import { createWidgetRegistry } from '../kit/widgets.js';
67
+ import { createSettingsPanel } from '../kit/settings_panel.js';
68
+ import { fieldsFromSchema } from '../kit/settings_schema.js';
69
+ import { FORMULA_REFERENCES, PARAMETER_REFERENCES, TEMPLATE_REFERENCES } from '../kit/references.js';
70
+ import { alwaysBefore, enclosingArms, enclosingLoops, DEFAULT_LOOP_PORTS } from '../kit/always_before.js';
71
+ import { createFindingsStrip, findingsList, groupFindings } from '../kit/findings.js';
72
+ import { FlowHistory } from '../kit/history.js';
73
+ import { bindFlowKeys } from '../kit/keys.js';
74
+ import { addNode, connect, disconnect, emptyGraph, normalise, removeNode, sameEdge, serialise } from '../kit/logic_graph.js';
75
+ import { CANVAS_STRINGS } from './strings.js';
76
+ import { CANVAS_NODE, isReturnEdge, layoutGraph, nodeBottom, portAnchor } from './layout.js';
77
+
78
+ /** Every edit the canvas editor records (36 §5.4). Anything else is a programming error. */
79
+ export const CANVAS_ACTIONS = Object.freeze([
80
+ 'flow:node:add',
81
+ 'flow:node:remove',
82
+ 'flow:node:move',
83
+ 'flow:edge:connect',
84
+ 'flow:edge:disconnect',
85
+ 'flow:node:label',
86
+ 'flow:node:config',
87
+ 'flow:settings',
88
+ 'flow:arrange',
89
+ ]);
90
+
91
+ /** The scale a fit frames at most: natural size. The kernel's own ceiling is
92
+ * 4×, so a new flow — one step — was framed at it: the step drew 514 px wide
93
+ * on a 634 px canvas, and every step dropped beside it landed on top of it.
94
+ * Zooming in past this is the reader's choice (the wheel), never a fit's. */
95
+ const NATURAL = 1;
96
+ /** Opening (and Arrange) keeps a long flow at this or above, from its first
97
+ * step on the left; a press on Fit frames ALL of it, however small. */
98
+ const READABLE = 0.8;
99
+ /** How far a palette press must travel before it is a drag, not a click. */
100
+ const DRAG_PX = 4;
101
+ /** How far below the lower of its two ends a loop's return line runs. */
102
+ const RETURN_DROP = 40;
103
+ const SVG = 'http://www.w3.org/2000/svg';
104
+
105
+ const DEFAULT_REFERENCES = Object.freeze({
106
+ template: TEMPLATE_REFERENCES, formula: FORMULA_REFERENCES, parameter: PARAMETER_REFERENCES,
107
+ });
108
+
109
+ function sortedKeys(value) {
110
+ if (Array.isArray(value)) return value.map(sortedKeys);
111
+ if (value && typeof value === 'object') {
112
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortedKeys(value[k])]));
113
+ }
114
+ return value;
115
+ }
116
+
117
+ const plainObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
118
+
119
+ /**
120
+ * @param {HTMLElement|null} host the editor is appended here when given
121
+ * @param {object} options 36 §3.14 and §5.2; the README lists every one
122
+ */
123
+ export function createCanvasEditor(host, options = {}) {
124
+ const {
125
+ widgets = createWidgetRegistry(), references = DEFAULT_REFERENCES, values = null, valuesNote = null,
126
+ summarise = null, summariseReads = null, flowSettings = null, readOnly = false, actions = [],
127
+ slots = {}, strings = null, services = null, onChange = null, onSelect = null,
128
+ history: historyOptions = {}, loop = {}, node: nodeOptions = {}, dataPortRead = null,
129
+ removable = null, paletteTypes = null, label = null, gridSize = 16, waitsForAll = undefined,
130
+ } = options;
131
+ const catalogue = Array.isArray(options.catalogue) ? createStepCatalogue(options.catalogue)
132
+ : options.catalogue;
133
+ if (!catalogue?.get || !catalogue?.byCategory) {
134
+ throw new Error('createCanvasEditor needs a step catalogue (createStepCatalogue(types, …)).');
135
+ }
136
+ const S = createStrings(CANVAS_STRINGS, strings);
137
+ const BOX = Object.freeze({
138
+ width: Number(nodeOptions.width) || CANVAS_NODE.width,
139
+ header: Number(nodeOptions.header) || CANVAS_NODE.header,
140
+ row: Number(nodeOptions.row) || CANVAS_NODE.row,
141
+ });
142
+ const loopPorts = Object.freeze({ ...DEFAULT_LOOP_PORTS, ...plainObject(loop) });
143
+ const canRemove = typeof removable === 'function' ? removable
144
+ : (_node, type) => (type?.role || 'step') !== 'start';
145
+ const offered = typeof paletteTypes === 'function' ? paletteTypes : (type) => type.role !== 'start';
146
+
147
+ // ── the frame ───────────────────────────────────────────────────────
148
+ const root = el('div', 'twm-flow-graph');
149
+ if (BOX.width !== CANVAS_NODE.width) root.style.setProperty('--twm-canvas-node-width', `${BOX.width}px`);
150
+ if (BOX.header !== CANVAS_NODE.header) root.style.setProperty('--twm-canvas-node-header', `${BOX.header}px`);
151
+ if (BOX.row !== CANVAS_NODE.row) root.style.setProperty('--twm-canvas-node-row', `${BOX.row}px`);
152
+ const toolbar = el('div', 'twm-flow-graph__toolbar');
153
+ toolbar.setAttribute('role', 'toolbar');
154
+ toolbar.setAttribute('aria-label', say(S, 'toolbarLabel'));
155
+ const status = el('span', 'twm-flow-graph__status');
156
+ const toolbarEnd = el('span', 'twm-flow-graph__toolbar-end');
157
+ const why = el('p', 'twm-flow-graph__why');
158
+ const readonlyBanner = el('p', 'twm-flow-graph__readonly');
159
+ readonlyBanner.hidden = true;
160
+ const message = el('p', 'twm-flow-graph__message');
161
+ message.setAttribute('aria-live', 'polite');
162
+ const banner = el('p', 'twm-flow-graph__banner');
163
+ banner.hidden = true;
164
+ const body = el('div', 'twm-flow-graph__body');
165
+ const palette = el('nav', 'twm-flow-graph__palette');
166
+ palette.setAttribute('aria-label', say(S, 'palette'));
167
+ const canvasHost = el('div', 'twm-flow-graph__canvas');
168
+ const aside = el('aside', 'twm-flow-graph__aside');
169
+ const connectionBox = el('section', 'twm-flow-graph__connection');
170
+ connectionBox.hidden = true;
171
+ body.append(palette, canvasHost, aside);
172
+
173
+ // Everything `onViewportChange` could read is declared BEFORE the adapter
174
+ // is built: its constructor fires that callback synchronously.
175
+ let graph = emptyGraph();
176
+ let settingsSchema = flowSettings?.schema ?? null;
177
+ let settingsTitle = flowSettings?.title ?? null;
178
+ let settingsValue = structuredClone(plainObject(flowSettings?.value));
179
+ let ro = readOnly;
180
+ let findings = [];
181
+ let findingsBy = new Map();
182
+ let overlay = null;
183
+ /** {kind: 'flow'} | {kind: 'node', id} | {kind: 'edge', edge} | null */
184
+ let selected = null;
185
+ /** The output a click-then-click connection started from: {source, sourcePort}. */
186
+ let connecting = null;
187
+ let suppressClick = false;
188
+ let dragCleanup = null;
189
+ let destroyed = false;
190
+ let fitFrame = null;
191
+ let travelling = null;
192
+ let lastSelect = '';
193
+ const nodeEls = new Map();
194
+ const consumerState = new Map();
195
+
196
+ const canvas = new CanvasAdapter({ container: canvasHost, gridSize });
197
+ canvas.root.setAttribute('role', 'application');
198
+ canvas.root.setAttribute('aria-label', label || say(S, 'canvasLabel'));
199
+ // A press on the empty surface lets go of the pick (and of a connection
200
+ // being drawn); with nothing but the flow picked it repaints nothing.
201
+ canvas.onSelectionChange = () => {
202
+ if (connecting) cancelConnect();
203
+ if (selected && selected.kind !== 'flow') select(null);
204
+ };
205
+
206
+ const isReadOnly = () => Boolean(ro);
207
+ const readOnlyReason = () => (ro && typeof ro === 'object' && ro.reason ? String(ro.reason) : '');
208
+ const nodeOf = (id) => graph.nodes.find((x) => x.id === id) || null;
209
+ const typeOf = (n) => catalogue.get(n?.type);
210
+
211
+ // ── state ───────────────────────────────────────────────────────────
212
+
213
+ /** The whole flow as ONE text: what undo puts back and what `onChange` hands on. */
214
+ const snapshot = () => JSON.stringify({ graph: JSON.parse(serialise(graph)), flowSettings: sortedKeys(settingsValue) });
215
+
216
+ const history = new FlowHistory({
217
+ actions: CANVAS_ACTIONS,
218
+ restore: (state) => restore(state),
219
+ onState: () => paintToolbar(),
220
+ limit: historyOptions.limit ?? 100,
221
+ mergeMs: historyOptions.mergeMs ?? 1000,
222
+ now: historyOptions.now,
223
+ });
224
+
225
+ function emitChange(action, key, via = null) {
226
+ if (!onChange || destroyed) return;
227
+ const text = snapshot();
228
+ onChange({ graph: normalise(graph), flowSettings: structuredClone(settingsValue), text, action, key, via });
229
+ }
230
+
231
+ /** After every edit: one undo entry (the closed list), and the consumer told. */
232
+ function commit(action, key = null) {
233
+ if (history.commit(snapshot(), action, key)) emitChange(action, key);
234
+ paintToolbar();
235
+ }
236
+
237
+ function restore(state) {
238
+ const p = JSON.parse(state);
239
+ graph = normalise(p.graph);
240
+ settingsValue = plainObject(p.flowSettings);
241
+ if (connecting) cancelConnect();
242
+ if (selected?.kind === 'node' && !nodeOf(selected.id)) selected = null;
243
+ if (selected?.kind === 'edge' && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
244
+ findingsBy = groupFindings(findings, { graph });
245
+ paintNodes();
246
+ paintPanel();
247
+ keepFocus();
248
+ emitChange(null, null, travelling);
249
+ announceSelection();
250
+ }
251
+
252
+ /** A verb that repainted away the focused control leaves focus on the body,
253
+ * where the root's keys never arrive — so it goes back to the canvas. */
254
+ function keepFocus() {
255
+ if (destroyed || !root.isConnected) return;
256
+ const active = document.activeElement;
257
+ if (!active || active === document.body || !active.isConnected) canvas.root.focus({ preventScroll: true });
258
+ }
259
+
260
+ function setMessage(line, tone = null) {
261
+ message.textContent = line || '';
262
+ message.className = 'twm-flow-graph__message' + (tone ? ` twm-flow-graph__message--${tone === 'ok' ? 'ok' : 'warning'}` : '');
263
+ }
264
+
265
+ // ── the toolbar ─────────────────────────────────────────────────────
266
+
267
+ // BUILT ONCE AND REFUSED IN PLACE. A toolbar rebuilt on every edit would
268
+ // replace the button under a press that an edit's event lands between (a
269
+ // field's blur fires on the next button's mousedown), and the click would
270
+ // reach a node that is gone.
271
+ const tool = (id, opts) => {
272
+ const b = button(opts);
273
+ b.dataset.action = id;
274
+ b.classList.add('twm-flow-graph__tool');
275
+ b.dataset.tip = opts.title || '';
276
+ return b;
277
+ };
278
+ const tools = {
279
+ undo: tool('undo', { label: say(S, 'undo'), icon: 'undo', title: say(S, 'undoTitle'), onClick: () => undo() }),
280
+ redo: tool('redo', { label: say(S, 'redo'), icon: 'redo', title: say(S, 'redoTitle'), onClick: () => redo() }),
281
+ arrange: tool('arrange', { label: say(S, 'arrange'), icon: 'account_tree', title: say(S, 'arrangeTitle'),
282
+ onClick: () => arrangeGraph() }),
283
+ fit: tool('fit', { label: say(S, 'fit'), icon: 'fit_screen', title: say(S, 'fitTitle'),
284
+ onClick: () => fit({ whole: true }) }),
285
+ };
286
+ const own = new Set(Object.keys(tools));
287
+ const consumerTools = [];
288
+ for (const a of Array.isArray(actions) ? actions : []) {
289
+ if (!a || !a.id || own.has(a.id)) continue;
290
+ const b = tool(a.id, { label: a.label ?? a.id, icon: a.icon || null, primary: Boolean(a.primary),
291
+ danger: Boolean(a.danger), title: a.title || null,
292
+ onClick: () => { if (!b.disabled) a.run?.(api); } });
293
+ consumerTools.push({ spec: a, el: b });
294
+ }
295
+ toolbar.append(tools.undo, tools.redo, tools.arrange, tools.fit, ...consumerTools.map((t) => t.el),
296
+ status, toolbarEnd);
297
+ if (typeof slots.toolbarEnd === 'function') slots.toolbarEnd(toolbarEnd);
298
+
299
+ function refusals() {
300
+ const stop = isReadOnly() ? (readOnlyReason() || say(S, 'readOnlyRefusal')) : null;
301
+ return {
302
+ undo: stop || (history.canUndo ? null : say(S, 'nothingToUndo')),
303
+ redo: stop || (history.canRedo ? null : say(S, 'nothingToRedo')),
304
+ arrange: stop || (graph.nodes.length < 2 ? say(S, 'arrangeNeedsTwo') : null),
305
+ fit: null,
306
+ };
307
+ }
308
+
309
+ function refuse(control, sentence) {
310
+ control.disabled = Boolean(sentence);
311
+ control.title = sentence || control.dataset.tip || '';
312
+ if (sentence) control.dataset.refusal = sentence; else delete control.dataset.refusal;
313
+ }
314
+
315
+ function paintToolbar() {
316
+ if (destroyed) return;
317
+ const r = refusals();
318
+ for (const id of Object.keys(tools)) {
319
+ const extra = consumerState.get(id);
320
+ refuse(tools[id], r[id] || (extra?.disabled ? extra.reason || '' : null) || null);
321
+ if (extra?.disabled && !r[id] && !extra.reason) tools[id].disabled = true;
322
+ }
323
+ const said = [];
324
+ for (const { spec, el: b } of consumerTools) {
325
+ const state = consumerState.get(spec.id) || {};
326
+ const reason = state.disabled ? (state.reason || '') : '';
327
+ refuse(b, reason || null);
328
+ if (state.disabled && !reason) b.disabled = true;
329
+ b.classList.toggle('twm-flow-graph__tool--busy', Boolean(state.busy));
330
+ if (state.busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
331
+ // THE REFUSAL ON THE SCREEN, not only in a tooltip: a disabled verb
332
+ // with its reason in `title` reads as "the button does nothing".
333
+ if (reason && !said.includes(reason)) said.push(reason);
334
+ }
335
+ why.textContent = said.join(' · ');
336
+ why.hidden = said.length === 0;
337
+ }
338
+
339
+ function undo() {
340
+ if (refusals().undo) return false;
341
+ if (connecting) { cancelConnect(); setMessage(''); }
342
+ travelling = 'undo';
343
+ try { return history.undo(); } finally { travelling = null; }
344
+ }
345
+
346
+ function redo() {
347
+ if (refusals().redo) return false;
348
+ if (connecting) { cancelConnect(); setMessage(''); }
349
+ travelling = 'redo';
350
+ try { return history.redo(); } finally { travelling = null; }
351
+ }
352
+
353
+ // ── the palette ─────────────────────────────────────────────────────
354
+
355
+ function paintPalette() {
356
+ palette.replaceChildren(el('h3', 'twm-flow-graph__palette-title', say(S, 'palette')));
357
+ for (const { category, types } of catalogue.byCategory()) {
358
+ const list = types.filter((t) => offered(t));
359
+ if (!list.length) continue;
360
+ if (category.label) palette.appendChild(el('h4', 'twm-flow-graph__palette-group', category.label));
361
+ for (const t of list) {
362
+ const item = el('button', 'twm-flow-graph__palette-item');
363
+ item.type = 'button';
364
+ item.dataset.type = t.type_id;
365
+ const chip = el('span', `twm-flow-graph__palette-icon ${toneClass('twm-flow-graph__palette-icon', category.tone)}`);
366
+ chip.appendChild(icon(t.icon || 'widgets'));
367
+ item.append(chip, el('span', 'twm-flow-graph__palette-label', t.label));
368
+ // The label first: a narrow palette ellipsises it.
369
+ item.title = t.description ? `${t.label} — ${t.description}` : t.label;
370
+ if (t.unavailable) {
371
+ // Greyed WITH its sentence under it, never hidden.
372
+ item.disabled = true;
373
+ item.dataset.refusal = String(t.unavailable);
374
+ palette.append(item, el('p', 'twm-flow-graph__palette-why', String(t.unavailable)));
375
+ continue;
376
+ }
377
+ item.disabled = isReadOnly();
378
+ item.addEventListener('pointerdown', (ev) => startPaletteDrag(ev, t));
379
+ item.addEventListener('click', () => {
380
+ if (suppressClick || isReadOnly()) return; // the tail of a drag
381
+ addStep(t.type_id);
382
+ });
383
+ palette.appendChild(item);
384
+ }
385
+ }
386
+ }
387
+
388
+ /** Add a step at `at`, or — a click — at the centre of what is on screen. */
389
+ function addStep(typeId, at = null) {
390
+ if (isReadOnly()) return null;
391
+ const n = graph.nodes.length;
392
+ let where = at;
393
+ if (!where) {
394
+ const box = canvas.visibleBox();
395
+ where = {
396
+ x: canvas.snap((box.width ? box.x + box.width / 2 - BOX.width / 2 : 80) + (n % 4) * 24),
397
+ y: canvas.snap((box.height ? box.y + box.height / 3 : 80) + (n % 4) * 24),
398
+ };
399
+ }
400
+ const added = addNode(graph, typeId, where, { catalogue });
401
+ selected = { kind: 'node', id: added.id };
402
+ paintNodes();
403
+ paintPanel();
404
+ announceSelection();
405
+ commit('flow:node:add');
406
+ return added;
407
+ }
408
+
409
+ /** Is the client point over the canvas? Rect arithmetic, because the ghost
410
+ * follows the pointer and `elementFromPoint` would answer "the ghost". */
411
+ function overCanvas(x, y) {
412
+ const r = canvas.root.getBoundingClientRect();
413
+ return r.width > 0 && r.height > 0 && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
414
+ }
415
+
416
+ /** The click that follows a drag's release is swallowed, and only that one. */
417
+ function swallowNextClick({ afterRelease = false } = {}) {
418
+ suppressClick = true;
419
+ const clear = () => setTimeout(() => { suppressClick = false; }, 0);
420
+ if (afterRelease) clear();
421
+ else window.addEventListener('pointerup', clear, { once: true, capture: true });
422
+ }
423
+
424
+ function startPaletteDrag(ev, t) {
425
+ if (ev.button !== 0 || ev.isPrimary === false || isReadOnly()) return;
426
+ dragCleanup?.();
427
+ const start = { x: ev.clientX, y: ev.clientY };
428
+ let ghost = null;
429
+ const onMove = (m) => {
430
+ if (!ghost) {
431
+ if (Math.abs(m.clientX - start.x) + Math.abs(m.clientY - start.y) < DRAG_PX) return;
432
+ ghost = el('div', 'twm-flow-graph__ghost');
433
+ ghost.append(icon(t.icon || 'widgets'), el('span', 'twm-flow-graph__ghost-label', t.label));
434
+ document.body.appendChild(ghost);
435
+ }
436
+ ghost.style.left = `${m.clientX + 8}px`;
437
+ ghost.style.top = `${m.clientY + 8}px`;
438
+ canvasHost.classList.toggle('twm-flow-graph__canvas--drop', overCanvas(m.clientX, m.clientY));
439
+ };
440
+ const finish = () => {
441
+ window.removeEventListener('pointermove', onMove);
442
+ window.removeEventListener('pointerup', onUp);
443
+ window.removeEventListener('pointercancel', onCancel);
444
+ window.removeEventListener('keydown', onKey, true);
445
+ ghost?.remove();
446
+ canvasHost.classList.remove('twm-flow-graph__canvas--drop');
447
+ dragCleanup = null;
448
+ };
449
+ function onUp(u) {
450
+ const dragged = Boolean(ghost);
451
+ finish();
452
+ if (!dragged) return; // a click: the click handler adds it
453
+ swallowNextClick({ afterRelease: true });
454
+ if (!overCanvas(u.clientX, u.clientY)) {
455
+ setMessage(say(S, 'dropHere'));
456
+ return;
457
+ }
458
+ const p = canvas.clientToCanvas(u.clientX, u.clientY);
459
+ addStep(t.type_id, { x: canvas.snap(p.x - BOX.width / 2), y: canvas.snap(p.y - BOX.header / 2) });
460
+ }
461
+ function onCancel() {
462
+ finish();
463
+ }
464
+ function onKey(k) {
465
+ if (k.key !== 'Escape') return;
466
+ k.preventDefault();
467
+ k.stopPropagation();
468
+ const dragged = Boolean(ghost);
469
+ finish();
470
+ // The button is still pressed: its release would click it.
471
+ if (dragged) swallowNextClick();
472
+ }
473
+ window.addEventListener('pointermove', onMove);
474
+ window.addEventListener('pointerup', onUp);
475
+ window.addEventListener('pointercancel', onCancel);
476
+ window.addEventListener('keydown', onKey, true);
477
+ dragCleanup = finish;
478
+ }
479
+
480
+ // ── the canvas ──────────────────────────────────────────────────────
481
+
482
+ const anchor = (n, port, direction) => portAnchor(catalogue, n, port, direction, BOX);
483
+ const bottomOf = (n) => nodeBottom(catalogue, n, BOX);
484
+ const isReturn = (c, target) => isReturnEdge(c, target, catalogue, loopPorts.entry);
485
+
486
+ function buildNode(nodeRow) {
487
+ const def = typeOf(nodeRow);
488
+ const tone = catalogue.tone(nodeRow.type);
489
+ const n = el('div', 'twm-flow-graph__node');
490
+ n.dataset.nodeId = nodeRow.id;
491
+ n.setAttribute('data-twm-flow-item', '');
492
+ n.setAttribute('role', 'group');
493
+ n.tabIndex = 0;
494
+ const header = el('div', 'twm-flow-graph__node-head');
495
+ const chip = el('span', `twm-flow-graph__node-icon ${toneClass('twm-flow-graph__node-icon', tone)}`);
496
+ chip.appendChild(icon(def?.icon || 'widgets'));
497
+ // Each says itself on hover: a label or an id too long for the header
498
+ // is cut with an ellipsis, and a cut name with no way to read the rest
499
+ // is the first thing a reader notices.
500
+ const name = nodeRow.label || def?.label || nodeRow.type;
501
+ n.setAttribute('aria-label', name);
502
+ const title = el('span', 'twm-flow-graph__node-title', name);
503
+ title.title = name;
504
+ const tag = el('span', 'twm-flow-graph__node-tag', nodeRow.id);
505
+ tag.title = say(S, 'stepId', nodeRow.id);
506
+ header.append(chip, title, tag);
507
+ header.addEventListener('pointerdown', (ev) => startMove(ev, nodeRow.id));
508
+ n.appendChild(header);
509
+ const inputs = catalogue.inputs(nodeRow.type);
510
+ const outputs = catalogue.outputs(nodeRow.type);
511
+ const rows = Math.max(inputs.length, outputs.length);
512
+ for (let i = 0; i < rows; i += 1) {
513
+ const row = el('div', 'twm-flow-graph__node-row');
514
+ row.append(inputs[i] ? port(nodeRow, inputs[i]) : el('span', 'twm-flow-graph__node-gap'),
515
+ outputs[i] ? port(nodeRow, outputs[i]) : el('span', 'twm-flow-graph__node-gap'));
516
+ n.appendChild(row);
517
+ }
518
+ if (!def) n.appendChild(el('p', 'twm-flow-graph__node-finding', say(S, 'unknownType', nodeRow.type)));
519
+ if (typeof summarise === 'function') n.appendChild(el('p', 'twm-flow-graph__node-summary'));
520
+ const notes = el('div', 'twm-flow-graph__node-findings');
521
+ const runLine = el('p', 'twm-flow-graph__node-run');
522
+ n.append(notes, runLine);
523
+ n.addEventListener('click', (ev) => {
524
+ if (ev.target.closest('.twm-flow-graph__port')) return;
525
+ if (!(selected?.kind === 'node' && selected.id === nodeRow.id)) select({ kind: 'node', id: nodeRow.id });
526
+ });
527
+ n.addEventListener('focus', () => {
528
+ if (!(selected?.kind === 'node' && selected.id === nodeRow.id)) select({ kind: 'node', id: nodeRow.id });
529
+ });
530
+ return n;
531
+ }
532
+
533
+ function port(nodeRow, p) {
534
+ const b = el('button', `twm-flow-graph__port twm-flow-graph__port--${p.direction === 'input' ? 'in' : 'out'}`);
535
+ b.type = 'button';
536
+ b.dataset.port = p.name;
537
+ b.dataset.direction = p.direction;
538
+ b.append(el('span', 'twm-flow-graph__port-dot'), el('span', 'twm-flow-graph__port-label', p.label || p.name),
539
+ el('span', 'twm-flow-graph__port-count'));
540
+ if ((p.port_type || 'FLOW') !== 'FLOW') {
541
+ b.classList.add('twm-flow-graph__port--data');
542
+ b.disabled = true;
543
+ b.dataset.data = '';
544
+ b.title = say(S, 'dataPort', dataPortRead ? dataPortRead(nodeRow, p) : null);
545
+ return b;
546
+ }
547
+ b.title = p.direction === 'output' ? say(S, 'connectFrom') : say(S, 'connectTo');
548
+ b.addEventListener('click', (ev) => {
549
+ ev.stopPropagation();
550
+ portClicked(nodeRow.id, p);
551
+ });
552
+ return b;
553
+ }
554
+
555
+ function portClicked(nodeId, p) {
556
+ if (isReadOnly()) return;
557
+ if (p.direction === 'output') {
558
+ connecting = { source: nodeId, sourcePort: p.name };
559
+ root.classList.add('twm-flow-graph--connecting');
560
+ paintNodes();
561
+ setMessage(say(S, 'connectStarted'));
562
+ return;
563
+ }
564
+ if (!connecting) {
565
+ setMessage(say(S, 'connectFirst'));
566
+ return;
567
+ }
568
+ const edge = { ...connecting, target: nodeId, targetPort: p.name };
569
+ const verdict = connect(graph, catalogue, edge, { strings: S });
570
+ cancelConnect();
571
+ if (!verdict.ok) { setMessage(verdict.reason, 'warning'); return; }
572
+ setMessage(isReturn(edge, nodeOf(nodeId)) ? say(S, 'connectedReturn') : '');
573
+ paintEdges();
574
+ commit('flow:edge:connect');
575
+ }
576
+
577
+ function cancelConnect() {
578
+ connecting = null;
579
+ root.classList.remove('twm-flow-graph--connecting');
580
+ paintNodes();
581
+ }
582
+
583
+ function startMove(ev, nodeId) {
584
+ if (ev.button !== 0) return;
585
+ ev.stopPropagation();
586
+ select({ kind: 'node', id: nodeId });
587
+ if (isReadOnly()) return;
588
+ const nodeRow = nodeOf(nodeId);
589
+ const n = nodeEls.get(nodeId);
590
+ if (!nodeRow || !n) return;
591
+ const from = { ...nodeRow.position };
592
+ const start = { x: ev.clientX, y: ev.clientY };
593
+ let moved = false;
594
+ const onMove = (m) => {
595
+ const dx = (m.clientX - start.x) / canvas.scale;
596
+ const dy = (m.clientY - start.y) / canvas.scale;
597
+ if (!moved && Math.abs(dx) + Math.abs(dy) < 3) return;
598
+ moved = true;
599
+ nodeRow.position = { x: canvas.snap(from.x + dx), y: canvas.snap(from.y + dy) };
600
+ n.style.left = `${nodeRow.position.x}px`;
601
+ n.style.top = `${nodeRow.position.y}px`;
602
+ paintEdges();
603
+ };
604
+ const onUp = () => {
605
+ window.removeEventListener('pointermove', onMove);
606
+ window.removeEventListener('pointerup', onUp);
607
+ window.removeEventListener('pointercancel', onUp);
608
+ if (moved) commit('flow:node:move');
609
+ };
610
+ window.addEventListener('pointermove', onMove);
611
+ window.addEventListener('pointerup', onUp);
612
+ window.addEventListener('pointercancel', onUp);
613
+ }
614
+
615
+ /** Positions and classes onto the nodes that exist; a node is BUILT only
616
+ * when it is new or its type or label changed, never on a press. */
617
+ function paintNodes() {
618
+ if (destroyed) return;
619
+ const live = new Set(graph.nodes.map((x) => x.id));
620
+ for (const [id, n] of nodeEls) {
621
+ if (!live.has(id)) { n.remove(); nodeEls.delete(id); }
622
+ }
623
+ for (const nodeRow of graph.nodes) {
624
+ let n = nodeEls.get(nodeRow.id);
625
+ const shape = `${nodeRow.type}|${nodeRow.label || ''}`;
626
+ if (!n || n.dataset.shape !== shape) {
627
+ const fresh = buildNode(nodeRow);
628
+ fresh.dataset.shape = shape;
629
+ const focused = n && n.contains(document.activeElement);
630
+ if (n) n.replaceWith(fresh); else canvas.nodeLayer.appendChild(fresh);
631
+ nodeEls.set(nodeRow.id, fresh);
632
+ n = fresh;
633
+ if (focused) fresh.focus({ preventScroll: true });
634
+ }
635
+ n.style.left = `${nodeRow.position.x}px`;
636
+ n.style.top = `${nodeRow.position.y}px`;
637
+ n.style.width = `${BOX.width}px`;
638
+ const picked = selected?.kind === 'node' && selected.id === nodeRow.id;
639
+ n.classList.toggle('twm-flow-graph__node--picked', picked);
640
+ if (picked) n.setAttribute('aria-current', 'true'); else n.removeAttribute('aria-current');
641
+ n.classList.toggle('twm-flow-graph__node--connect-source', connecting?.source === nodeRow.id);
642
+ const mine = findingsBy.get(nodeRow.id) || [];
643
+ const errors = mine.filter((f) => f.severity !== 'warning');
644
+ n.classList.toggle('twm-flow-graph__node--error', errors.length > 0);
645
+ n.classList.toggle('twm-flow-graph__node--warning', errors.length === 0 && mine.length > 0);
646
+ n.querySelector('.twm-flow-graph__node-findings').replaceChildren(...mine.slice(0, 2).map((f) => el('p',
647
+ `twm-flow-graph__node-finding${f.severity === 'warning' ? ' twm-flow-graph__node-finding--warning' : ''}`,
648
+ String(f.message ?? ''))));
649
+ const summary = n.querySelector('.twm-flow-graph__node-summary');
650
+ if (summary) summary.textContent = summarise(nodeRow, typeOf(nodeRow)) || '';
651
+ const step = overlay?.steps?.[nodeRow.id] || null;
652
+ const line = n.querySelector('.twm-flow-graph__node-run');
653
+ line.textContent = step ? String(step.line ?? step.state ?? '') : '';
654
+ if (step?.state) line.dataset.state = String(step.state); else delete line.dataset.state;
655
+ if (step?.tone) line.dataset.tone = String(step.tone); else delete line.dataset.tone;
656
+ n.classList.toggle('twm-flow-graph__node--skipped', step?.state === 'skipped' || step?.state === 'not-reached');
657
+ const ports = overlay?.ports?.[nodeRow.id] || null;
658
+ for (const b of n.querySelectorAll('.twm-flow-graph__port')) {
659
+ if (!('data' in b.dataset)) b.disabled = isReadOnly();
660
+ const mark = b.dataset.direction === 'output' ? ports?.[b.dataset.port] : null;
661
+ b.classList.toggle('twm-flow-graph__port--taken', Boolean(mark?.taken));
662
+ b.classList.toggle('twm-flow-graph__port--untaken', Boolean(mark) && !mark.taken);
663
+ const count = b.querySelector('.twm-flow-graph__port-count');
664
+ count.textContent = mark?.taken && Number.isFinite(Number(mark.count)) && mark.count !== null
665
+ ? say(S, 'portCount', mark.count) : mark && !mark.taken ? say(S, 'portNotTaken') : '';
666
+ }
667
+ }
668
+ paintEdges();
669
+ }
670
+
671
+ function paintEdges() {
672
+ if (destroyed) return;
673
+ const byId = new Map(graph.nodes.map((x) => [x.id, x]));
674
+ const paths = [];
675
+ graph.connections.forEach((c) => {
676
+ const a = byId.get(c.source);
677
+ const b = byId.get(c.target);
678
+ if (!a || !b) return;
679
+ const from = anchor(a, c.sourcePort, 'output');
680
+ const to = anchor(b, c.targetPort, 'input');
681
+ const back = isReturn(c, b);
682
+ // A loop's return runs UNDER both steps: drawn as a forward curve it
683
+ // doubles back across them and reads as one more step.
684
+ const d = back ? returnEdge(from, to, { floor: Math.max(bottomOf(a), bottomOf(b)) + RETURN_DROP })
685
+ : routeEdge(from, to, { simple: true });
686
+ const picked = selected?.kind === 'edge' && sameEdge(selected.edge, c);
687
+ const mark = overlay?.ports?.[c.source]?.[c.sourcePort];
688
+ const line = document.createElementNS(SVG, 'path');
689
+ line.setAttribute('class', ['twm-flow-graph__edge', back ? 'twm-flow-graph__edge--back' : '',
690
+ picked ? 'twm-flow-graph__edge--picked' : '',
691
+ mark?.taken ? 'twm-flow-graph__edge--taken' : '',
692
+ mark && !mark.taken ? 'twm-flow-graph__edge--untaken' : '']
693
+ .filter(Boolean).join(' '));
694
+ line.setAttribute('d', d);
695
+ const hit = document.createElementNS(SVG, 'path');
696
+ hit.setAttribute('class', 'twm-flow-graph__edge-hit');
697
+ hit.setAttribute('d', d);
698
+ if (back) {
699
+ const title = document.createElementNS(SVG, 'title');
700
+ title.textContent = say(S, 'returnLine');
701
+ hit.appendChild(title);
702
+ }
703
+ hit.addEventListener('pointerdown', (ev) => {
704
+ ev.stopPropagation();
705
+ select({ kind: 'edge', edge: { ...c } });
706
+ });
707
+ paths.push(line, hit);
708
+ });
709
+ canvas.edgeLayer.replaceChildren(...paths);
710
+ }
711
+
712
+ /** Frame the graph. Never above NATURAL: a short flow is not blown up until
713
+ * there is no room to add to it. Opening (and Arrange) also keeps it at
714
+ * READABLE or above — below that a long flow fitted whole draws steps too
715
+ * small to read or hit — and starts at its first step, at the left, so the
716
+ * rest is a pan away. **Fit (`whole`) frames ALL of it**, however small:
717
+ * the reader asked to see the whole, and a Fit that left a long flow where
718
+ * it was looked like a button that does nothing. */
719
+ function fit({ whole = false } = {}) {
720
+ if (!graph.nodes.length || destroyed) return;
721
+ const xs = graph.nodes.map((x) => x.position.x);
722
+ const ys = graph.nodes.map((x) => x.position.y);
723
+ const x = Math.min(...xs);
724
+ const y = Math.min(...ys);
725
+ const height = Math.max(...graph.nodes.map(bottomOf)) - y;
726
+ canvas.fit({ x, y, width: Math.max(...xs) + BOX.width - x, height });
727
+ const scale = canvas.scale > NATURAL ? NATURAL
728
+ : !whole && canvas.scale < READABLE ? READABLE : null;
729
+ if (scale !== null) {
730
+ const box = canvas.root.getBoundingClientRect();
731
+ canvas.setViewport({ scale, tx: 40 - x * scale, ty: box.height / 2 - (y + height / 2) * scale });
732
+ }
733
+ }
734
+
735
+ /** ARRANGE: `layoutGraph`, anchored at the graph's current top-left corner
736
+ * so nothing jumps off screen, and ONE undo entry. */
737
+ function arrangeGraph() {
738
+ if (refusals().arrange) return false;
739
+ const at = layoutGraph(graph, catalogue, { ...BOX, loopEntry: loopPorts.entry });
740
+ const ox = Math.min(...graph.nodes.map((x) => x.position.x));
741
+ const oy = Math.min(...graph.nodes.map((x) => x.position.y));
742
+ for (const nodeRow of graph.nodes) {
743
+ const p = at.get(nodeRow.id);
744
+ if (p) nodeRow.position = { x: canvas.snap(ox + p.x), y: canvas.snap(oy + p.y) };
745
+ }
746
+ paintNodes();
747
+ commit('flow:arrange');
748
+ fit();
749
+ return true;
750
+ }
751
+
752
+ // ── the panel ───────────────────────────────────────────────────────
753
+
754
+ const panel = createSettingsPanel({
755
+ widgets, references, strings: S, services, readOnly: isReadOnly(),
756
+ onChange: (stepId, key, value) => {
757
+ if (isReadOnly()) return;
758
+ if (stepId === null) {
759
+ if (value === undefined) delete settingsValue[key]; else settingsValue[key] = value;
760
+ commit('flow:settings', `settings:${key}`);
761
+ return;
762
+ }
763
+ const nodeRow = nodeOf(stepId);
764
+ if (!nodeRow) return;
765
+ if (value === undefined) delete nodeRow.config[key]; else nodeRow.config[key] = value;
766
+ paintNodes();
767
+ commit('flow:node:config', `config:${stepId}:${key}`);
768
+ },
769
+ onRename: (stepId, text) => {
770
+ if (isReadOnly()) return;
771
+ const nodeRow = nodeOf(stepId);
772
+ if (!nodeRow) return;
773
+ if (text) nodeRow.label = text; else delete nodeRow.label;
774
+ // In place: the node's title and its shape key, so the next paint
775
+ // does not rebuild a node a press may be on.
776
+ const n = nodeEls.get(nodeRow.id);
777
+ const name = text || typeOf(nodeRow)?.label || nodeRow.type;
778
+ const title = n?.querySelector('.twm-flow-graph__node-title');
779
+ if (title) title.textContent = title.title = name;
780
+ if (n) {
781
+ n.dataset.shape = `${nodeRow.type}|${nodeRow.label || ''}`;
782
+ n.setAttribute('aria-label', name);
783
+ }
784
+ commit('flow:node:label', `label:${stepId}`);
785
+ },
786
+ onFocusLost: () => keepFocus(),
787
+ values: typeof values === 'function' ? (q) => values({
788
+ graph: normalise(graph), stepId: q.stepId, field: q.field, key: q.key,
789
+ before: q.stepId ? alwaysBefore(graph, catalogue, q.stepId, { loopPorts,
790
+ ...(waitsForAll ? { waitsForAll } : {}) }) : [],
791
+ loops: q.stepId ? enclosingLoops(graph, catalogue, q.stepId, { loopPorts }) : [],
792
+ arms: q.stepId ? enclosingArms(graph, catalogue, q.stepId, { loopPorts }) : [],
793
+ parameters: null,
794
+ }) : null,
795
+ valuesNote,
796
+ host: aside,
797
+ });
798
+ aside.appendChild(connectionBox);
799
+
800
+ const strip = createFindingsStrip({
801
+ strings: S,
802
+ nameOf: (id) => {
803
+ if (id === null) return say(S, 'theFlow');
804
+ const n = nodeOf(id);
805
+ return n ? (n.label || typeOf(n)?.label || n.id) : String(id);
806
+ },
807
+ onGoTo: (f) => {
808
+ const target = f.node_id && nodeOf(f.node_id) ? { kind: 'node', id: f.node_id } : { kind: 'flow' };
809
+ select(target);
810
+ if (target.kind === 'node') {
811
+ const n = nodeOf(target.id);
812
+ canvas.bringIntoView({ x: n.position.x, y: n.position.y, width: BOX.width,
813
+ height: bottomOf(n) - n.position.y });
814
+ }
815
+ if (!(f.field && panel.focusField(f.field))) focusFirstField();
816
+ },
817
+ });
818
+
819
+ function focusFirstField() {
820
+ const control = panel.el.querySelector('[data-field] input, [data-field] select, [data-field] textarea, '
821
+ + '[data-field] [contenteditable="true"]')
822
+ || panel.el.querySelector('[data-twm-flow-title]');
823
+ if (control) { control.focus({ preventScroll: true }); return true; }
824
+ return false;
825
+ }
826
+
827
+ function select(next) {
828
+ selected = next;
829
+ for (const [id, n] of nodeEls) {
830
+ const picked = next?.kind === 'node' && next.id === id;
831
+ n.classList.toggle('twm-flow-graph__node--picked', picked);
832
+ if (picked) n.setAttribute('aria-current', 'true'); else n.removeAttribute('aria-current');
833
+ }
834
+ paintEdges();
835
+ paintPanel();
836
+ announceSelection();
837
+ }
838
+
839
+ function announceSelection() {
840
+ const now = selected?.kind === 'node' ? `step:${selected.id}`
841
+ : selected?.kind === 'edge' ? `connection:${JSON.stringify(selected.edge)}`
842
+ : selected?.kind === 'flow' ? 'flow' : '';
843
+ if (now === lastSelect) return;
844
+ lastSelect = now;
845
+ if (!onSelect || destroyed) return;
846
+ if (selected?.kind === 'node') onSelect({ kind: 'step', id: selected.id });
847
+ else if (selected?.kind === 'edge') onSelect({ kind: 'connection', id: null, connection: { ...selected.edge } });
848
+ else if (selected?.kind === 'flow') onSelect({ kind: 'flow', id: null });
849
+ else onSelect({ kind: null, id: null });
850
+ }
851
+
852
+ function removeSelected() {
853
+ if (!selected || isReadOnly()) return false;
854
+ if (selected.kind === 'node') {
855
+ const nodeRow = nodeOf(selected.id);
856
+ if (!nodeRow || !canRemove(nodeRow, typeOf(nodeRow))) return false;
857
+ removeNode(graph, selected.id);
858
+ findingsBy = groupFindings(findings, { graph });
859
+ select(null);
860
+ paintNodes();
861
+ commit('flow:node:remove');
862
+ } else if (selected.kind === 'edge') {
863
+ disconnect(graph, selected.edge);
864
+ select(null);
865
+ paintNodes();
866
+ commit('flow:edge:disconnect');
867
+ } else return false;
868
+ keepFocus();
869
+ return true;
870
+ }
871
+
872
+ function paintConnection() {
873
+ const e = selected.edge;
874
+ const remove = button({ label: say(S, 'removeConnection'), icon: 'link_off', danger: true,
875
+ className: 'twm-flow-graph__connection-remove', onClick: () => removeSelected() });
876
+ remove.dataset.action = 'remove-connection';
877
+ remove.disabled = isReadOnly();
878
+ connectionBox.replaceChildren(el('h3', 'twm-flow-graph__connection-title', say(S, 'connection')),
879
+ el('p', 'twm-flow-graph__connection-line', say(S, 'connectionLine', e)),
880
+ remove);
881
+ connectionBox.hidden = false;
882
+ }
883
+
884
+ /**
885
+ * The panel for whatever is picked: a step's settings, a line's, or — with
886
+ * nothing (or the flow) picked — the flow's own. The kit's panel puts focus
887
+ * back on the rebuilt control of the same field, caret where it was, and
888
+ * hands it to `keepFocus` when the field is gone.
889
+ */
890
+ function paintPanel() {
891
+ if (destroyed) return;
892
+ if (selected?.kind === 'edge') {
893
+ panel.clear();
894
+ panel.el.hidden = true;
895
+ paintConnection();
896
+ return;
897
+ }
898
+ connectionBox.hidden = true;
899
+ connectionBox.replaceChildren();
900
+ panel.el.hidden = false;
901
+ const nodeRow = selected?.kind === 'node' ? nodeOf(selected.id) : null;
902
+ if (nodeRow) {
903
+ const def = typeOf(nodeRow);
904
+ const after = typeof slots.stepPanel === 'function' ? (box) => slots.stepPanel(box, normalise({
905
+ nodes: [nodeRow], connections: [] }).nodes[0], api) : null;
906
+ panel.show({
907
+ step: nodeRow, type: def, value: nodeRow.config,
908
+ title: nodeRow.label || '', placeholderTitle: def?.label || nodeRow.type, rename: true,
909
+ typeLabel: def?.label || nodeRow.type,
910
+ description: def ? (def.description || '') : say(S, 'unknownType', nodeRow.type),
911
+ idLine: typeof summariseReads === 'function' ? summariseReads(nodeRow, def) || null : null,
912
+ icon: def?.icon || 'widgets', tone: catalogue.tone(nodeRow.type),
913
+ actions: canRemove(nodeRow, def) ? [{ id: 'remove', label: say(S, 'removeStep'), icon: 'delete',
914
+ danger: true, run: () => removeSelected() }] : [],
915
+ slots: { after },
916
+ });
917
+ } else {
918
+ const after = typeof slots.flowPanel === 'function' ? (box) => slots.flowPanel(box, api) : null;
919
+ panel.show({
920
+ step: null, type: null, value: settingsValue,
921
+ title: settingsTitle || say(S, 'theFlow'),
922
+ fields: settingsSchema ? fieldsFromSchema(settingsSchema) : [],
923
+ icon: 'tune', tone: 'grey',
924
+ slots: { after },
925
+ });
926
+ }
927
+ panel.setFindings(findings);
928
+ }
929
+
930
+ // ── keys ────────────────────────────────────────────────────────────
931
+
932
+ const unbindKeys = bindFlowKeys(root, {
933
+ undo: () => { undo(); },
934
+ redo: () => { redo(); },
935
+ remove: () => (removeSelected() ? undefined : false),
936
+ rename: () => {
937
+ if (selected?.kind !== 'node' || isReadOnly()) return false;
938
+ const t = panel.el.querySelector('[data-twm-flow-title]');
939
+ if (!t) return false;
940
+ t.focus({ preventScroll: true });
941
+ t.select?.();
942
+ return undefined;
943
+ },
944
+ open: (ev) => {
945
+ if (ev.target.closest?.('.twm-flow-graph__port')) return false; // a port's Enter is its own
946
+ const nodeEl = ev.target.closest?.('[data-node-id]');
947
+ if (nodeEl && canvas.nodeLayer.contains(nodeEl)) {
948
+ if (!(selected?.kind === 'node' && selected.id === nodeEl.dataset.nodeId)) {
949
+ select({ kind: 'node', id: nodeEl.dataset.nodeId });
950
+ }
951
+ return focusFirstField() ? undefined : false;
952
+ }
953
+ return false;
954
+ },
955
+ escape: () => {
956
+ if (!connecting) return false;
957
+ cancelConnect();
958
+ setMessage('');
959
+ return undefined;
960
+ },
961
+ });
962
+
963
+ // ── the page ────────────────────────────────────────────────────────
964
+
965
+ root.append(toolbar, why, readonlyBanner, message, strip.el, banner, body);
966
+ if (host) host.appendChild(root);
967
+
968
+ function paintReadOnly() {
969
+ root.classList.toggle('twm-flow-graph--readonly', isReadOnly());
970
+ readonlyBanner.textContent = readOnlyReason() || (isReadOnly() ? say(S, 'readOnlyRefusal') : '');
971
+ readonlyBanner.hidden = !isReadOnly();
972
+ }
973
+
974
+ const api = {
975
+ el: root,
976
+ /** The editor's own history — `keep()`, `adopt(kept, editor.snapshot())`, `baseline(...)`. */
977
+ history,
978
+ /** The kernel underneath, for a consumer that needs the viewport. */
979
+ canvas,
980
+ /**
981
+ * Replace the content. `baseline` (the default) clears both stacks: what
982
+ * was loaded is where undo starts. With `baseline: false` the stacks are
983
+ * kept and the loaded content is the present they undo from.
984
+ */
985
+ load({ graph: g = null, flowSettings: fs } = {}, { baseline = true, fit: andFit = true } = {}) {
986
+ if (destroyed) return;
987
+ if (connecting) cancelConnect();
988
+ graph = normalise(g);
989
+ if (fs !== undefined) {
990
+ settingsSchema = fs?.schema ?? settingsSchema;
991
+ settingsTitle = fs?.title ?? settingsTitle;
992
+ settingsValue = structuredClone(plainObject(fs?.value));
993
+ }
994
+ if (selected?.kind === 'node' && !nodeOf(selected.id)) selected = null;
995
+ if (selected?.kind === 'edge' && !graph.connections.some((c) => sameEdge(c, selected.edge))) selected = null;
996
+ findingsBy = groupFindings(findings, { graph });
997
+ paintNodes();
998
+ paintPanel();
999
+ const state = snapshot();
1000
+ if (baseline) history.baseline(state);
1001
+ else if (state !== history.settled) history.adopt({ ...history.keep(), settled: state }, state);
1002
+ paintToolbar();
1003
+ announceSelection();
1004
+ if (andFit) {
1005
+ if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
1006
+ fitFrame = globalThis.requestAnimationFrame
1007
+ ? globalThis.requestAnimationFrame(() => { fitFrame = null; fit(); }) : (fit(), null);
1008
+ }
1009
+ },
1010
+ getGraph: () => normalise(graph),
1011
+ serialise: () => serialise(graph),
1012
+ /** The text `onChange` hands on and the history holds: the graph and the flow's settings. */
1013
+ snapshot,
1014
+ getFlowSettings: () => structuredClone(settingsValue),
1015
+ setFindings(list) {
1016
+ findings = findingsList(list);
1017
+ findingsBy = groupFindings(findings, { graph });
1018
+ strip.set(findings);
1019
+ paintNodes();
1020
+ panel.setFindings(findings);
1021
+ },
1022
+ /** Draw a run on the canvas — never changes the graph and rebuilds no field. */
1023
+ setRunOverlay(next) {
1024
+ overlay = next && typeof next === 'object' ? next : null;
1025
+ banner.textContent = overlay?.banner ? String(overlay.banner) : '';
1026
+ banner.hidden = !overlay?.banner;
1027
+ paintNodes();
1028
+ panel.repaintSlots();
1029
+ },
1030
+ setReadOnly(next) {
1031
+ ro = next;
1032
+ if (ro && connecting) cancelConnect();
1033
+ dragCleanup?.();
1034
+ paintReadOnly();
1035
+ paintPalette();
1036
+ paintNodes();
1037
+ panel.setReadOnly(isReadOnly());
1038
+ paintToolbar();
1039
+ },
1040
+ /** A verb refused IN PLACE, its reason beside it; or marked busy. */
1041
+ setActionState(id, { disabled = false, reason = null, busy = false } = {}) {
1042
+ consumerState.set(id, { disabled: Boolean(disabled), reason, busy: Boolean(busy) });
1043
+ paintToolbar();
1044
+ },
1045
+ setStatus(text) { status.textContent = text || ''; },
1046
+ setMessage,
1047
+ select(target) {
1048
+ if (target === null || target === undefined) select(null);
1049
+ else if (target === 'flow') select({ kind: 'flow' });
1050
+ else if (nodeOf(String(target))) select({ kind: 'node', id: String(target) });
1051
+ },
1052
+ /** Rebuild the panel's fields, keeping the focus and the caret — for a
1053
+ * consumer whose widget's data has arrived. */
1054
+ repaintPanel() { paintPanel(); },
1055
+ focus() { canvas.root.focus({ preventScroll: true }); },
1056
+ undo,
1057
+ redo,
1058
+ arrange: () => arrangeGraph(),
1059
+ fit,
1060
+ destroy() {
1061
+ if (destroyed) return;
1062
+ dragCleanup?.();
1063
+ if (fitFrame !== null) globalThis.cancelAnimationFrame?.(fitFrame);
1064
+ closeFlowPopovers();
1065
+ unbindKeys();
1066
+ panel.destroy();
1067
+ strip.destroy();
1068
+ destroyed = true;
1069
+ canvas.destroy();
1070
+ root.remove();
1071
+ },
1072
+ };
1073
+ // The first paint, once `api` exists: a consumer's slot is handed it.
1074
+ paintReadOnly();
1075
+ paintPalette();
1076
+ paintNodes();
1077
+ paintPanel();
1078
+ paintToolbar();
1079
+ history.baseline(snapshot());
1080
+ return api;
1081
+ }