flexdesk 0.5.0 → 0.6.1

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 (70) hide show
  1. package/README.md +654 -1
  2. package/css/base.css +2868 -0
  3. package/css/flexdesk.css +2868 -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/charts.js +2 -2
  8. package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
  9. package/dist/chunk-6NG7YVBG.js.map +7 -0
  10. package/dist/chunk-H4LT43RS.js +475 -0
  11. package/dist/chunk-H4LT43RS.js.map +7 -0
  12. package/dist/{chunk-KH2GJTKR.js → chunk-IYV5JVYB.js} +29 -1
  13. package/dist/chunk-IYV5JVYB.js.map +7 -0
  14. package/dist/chunk-SN4LSOAR.js +532 -0
  15. package/dist/chunk-SN4LSOAR.js.map +7 -0
  16. package/dist/{chunk-CNXP3SIN.js → chunk-ZMCRDOCF.js} +2 -2
  17. package/dist/flexdesk.css +2868 -0
  18. package/dist/flow.js +10030 -0
  19. package/dist/flow.js.map +7 -0
  20. package/dist/tiles.js +9 -9
  21. package/dist/tokens.css +72 -0
  22. package/dist/widgets.js +87 -5
  23. package/dist/widgets.js.map +3 -3
  24. package/dist/wm.js +4 -2
  25. package/dist/wm.js.map +1 -1
  26. package/package.json +9 -1
  27. package/src/canvas/canvas_adapter.js +523 -0
  28. package/src/canvas/edge_router.js +392 -0
  29. package/src/canvas/graph/connector_router.js +189 -0
  30. package/src/canvas/graph/history_service.js +170 -0
  31. package/src/canvas/graph/node_platform.js +116 -0
  32. package/src/canvas/index.js +16 -0
  33. package/src/flow/canvas_editor/editor.js +1081 -0
  34. package/src/flow/canvas_editor/index.js +16 -0
  35. package/src/flow/canvas_editor/layout.js +118 -0
  36. package/src/flow/canvas_editor/strings.js +37 -0
  37. package/src/flow/kit/always_before.js +325 -0
  38. package/src/flow/kit/catalogue.js +170 -0
  39. package/src/flow/kit/chip_input.js +461 -0
  40. package/src/flow/kit/dom.js +205 -0
  41. package/src/flow/kit/findings.js +129 -0
  42. package/src/flow/kit/history.js +173 -0
  43. package/src/flow/kit/index.js +38 -0
  44. package/src/flow/kit/keys.js +127 -0
  45. package/src/flow/kit/logic_graph.js +210 -0
  46. package/src/flow/kit/references.js +199 -0
  47. package/src/flow/kit/settings_panel.js +357 -0
  48. package/src/flow/kit/settings_schema.js +175 -0
  49. package/src/flow/kit/step_picker.js +209 -0
  50. package/src/flow/kit/strings.js +102 -0
  51. package/src/flow/kit/value_picker.js +238 -0
  52. package/src/flow/kit/widgets.js +763 -0
  53. package/src/flow/lanes/editor.js +1469 -0
  54. package/src/flow/lanes/index.js +26 -0
  55. package/src/flow/lanes/layout.js +525 -0
  56. package/src/flow/lanes/pipeline.js +327 -0
  57. package/src/flow/lanes/preview.js +109 -0
  58. package/src/flow/lanes/strings.js +98 -0
  59. package/src/flow/outline/editor.js +1511 -0
  60. package/src/flow/outline/index.js +20 -0
  61. package/src/flow/outline/mapping.js +286 -0
  62. package/src/flow/outline/menu.js +154 -0
  63. package/src/flow/outline/operations.js +738 -0
  64. package/src/flow/outline/recognise.js +755 -0
  65. package/src/flow/outline/strings.js +135 -0
  66. package/src/ui/components/data_table.js +40 -0
  67. package/src/ui/utils/scroll_lock.js +111 -0
  68. package/dist/chunk-24MS7W5Y.js.map +0 -7
  69. package/dist/chunk-KH2GJTKR.js.map +0 -7
  70. /package/dist/{chunk-CNXP3SIN.js.map → chunk-ZMCRDOCF.js.map} +0 -0
@@ -0,0 +1,1469 @@
1
+ /**
2
+ * THE LANE EDITOR — a data flow, drawn left to right (36 §7).
3
+ *
4
+ * const editor = createLaneEditor(host, { catalogue, widgets, references, preview, describe, … });
5
+ * editor.load({ graph: pipeline });
6
+ *
7
+ * Every source starts a lane; a step follows the one before it; a join brings
8
+ * another lane in from below, and that line is drawn for you. Cards sit where
9
+ * the layout puts them (`layout.js`): NOTHING IS DRAGGED AND NO LINE IS DRAWN
10
+ * BY HAND. A step is added with the "+" on a card's right tip (spliced between
11
+ * the card and what it led to) or with *Add a source*, which starts a new
12
+ * lane; a join's other lanes are SETTINGS (*Joined with*), never lines.
13
+ *
14
+ * Under the lanes: a tab per step, and the dock — the kit's settings panel on
15
+ * the left, and on the right what the step gives: **Preview**, **Columns** and,
16
+ * for a sink, **Would be rejected**, from the consumer's providers (debounced,
17
+ * the newest answer winning: `preview.js`). A toolbar toggle, **Steps show:
18
+ * The preview | The last run**, picks what a card's status line says.
19
+ *
20
+ * ══ IT NEVER FETCHES, SAVES OR DECIDES ═════════════════════════════════
21
+ *
22
+ * The step types, every finding, the preview's rows, the columns, the last
23
+ * run and every byte of I/O are the consumer's. An edit is reported to
24
+ * `onChange` with the serialised flow, and the consumer saves it. The one
25
+ * timer is the preview's debounce, and it only ASKS.
26
+ *
27
+ * ══ NO PRESS REBUILDS A CARD ═══════════════════════════════════════════
28
+ *
29
+ * A click lands only on the node its press went down on, so choosing a card
30
+ * toggles classes and attributes and rebuilds nothing; the lanes are rebuilt
31
+ * only after an edit that changes their shape, and focus is put back on the
32
+ * same step's card (or "+") when they are.
33
+ *
34
+ * ══ KEYS ═══════════════════════════════════════════════════════════════
35
+ *
36
+ * The kit's (`bindFlowKeys`, on the editor's root): ← → ↑ ↓ move between cards
37
+ * (and between the step tabs), Enter opens the step's settings, Delete removes
38
+ * it, F2 renames it, Ctrl/⌘+Z and Ctrl/⌘+Y undo and redo, Escape goes back
39
+ * from the settings to the card. The context-menu key (or Shift+F10) opens a
40
+ * card's menu. Backspace is never the editor's: a host may climb a level on it.
41
+ */
42
+
43
+ import { createStrings, say } from '../kit/strings.js';
44
+ import { button, closeFlowPopovers, el, icon, openPopover, toneOf, uid } from '../kit/dom.js';
45
+ import { createStepCatalogue } from '../kit/catalogue.js';
46
+ import { fieldsFromSchema } from '../kit/settings_schema.js';
47
+ import { createWidgetRegistry } from '../kit/widgets.js';
48
+ import { createSettingsPanel } from '../kit/settings_panel.js';
49
+ import { openStepPicker } from '../kit/step_picker.js';
50
+ import { createFindingsStrip, findingsList, groupFindings } from '../kit/findings.js';
51
+ import { FlowHistory } from '../kit/history.js';
52
+ import { bindFlowKeys } from '../kit/keys.js';
53
+ import { DataTable } from '../../ui/components/data_table.js';
54
+ import { layoutLanes, placeLanes, stepPorts } from './layout.js';
55
+ import {
56
+ LANE_ACTIONS, PARAMETER_NAME, addSource, addStepAfter, emptyPipeline, inputCandidates, inputOf, laneFeedOf,
57
+ normalisePipeline, portsOf, removeParameter, removeStep, serialisePipeline, setInput, setParameter, upstreamOf,
58
+ } from './pipeline.js';
59
+ import { createPreviewRunner } from './preview.js';
60
+ import { LANE_STRINGS } from './strings.js';
61
+
62
+ const SVG = 'http://www.w3.org/2000/svg';
63
+ const ROLES = ['source', 'transform', 'operation', 'sink'];
64
+ const LINE_TONES = ['ok', 'warning', 'error', 'muted', 'info'];
65
+ /** A run overlay's `state`, as a status line's tone, when the overlay names none. */
66
+ const RUN_TONES = Object.freeze({ success: 'ok', cached: 'muted', error: 'error', skipped: 'muted',
67
+ cancelled: 'muted', running: 'info' });
68
+ const PARAMETER_TYPES = Object.freeze(['text', 'number', 'integer', 'boolean', 'date']);
69
+
70
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
71
+ const clone = (v) => (v === undefined ? undefined : structuredClone(v));
72
+ const lineTone = (t) => (t === 'warn' ? 'warning' : LINE_TONES.includes(t) ? t : 'muted');
73
+
74
+ function sortedKeys(value) {
75
+ if (Array.isArray(value)) return value.map(sortedKeys);
76
+ if (isObject(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortedKeys(value[k])]));
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * @param {HTMLElement} host
82
+ * @param {object} options 36 §3.14 and §7; the README's lane-editor section lists every one
83
+ */
84
+ export function createLaneEditor(host, options = {}) {
85
+ if (!host) throw new Error('createLaneEditor needs a host element.');
86
+ const S = createStrings(LANE_STRINGS, options.strings);
87
+ const compact = Boolean(options.compact);
88
+ const size = compact ? (options.compact?.size === 'small' ? 'small' : 'strip')
89
+ : (options.size === 'small' ? 'small' : 'regular');
90
+ const catalogue = Array.isArray(options.catalogue)
91
+ ? createStepCatalogue(options.catalogue, { categories: options.categories })
92
+ : options.catalogue;
93
+ if (!catalogue?.get) throw new Error('createLaneEditor needs a step catalogue (createStepCatalogue()).');
94
+ const widgets = options.widgets ?? createWidgetRegistry();
95
+ const references = options.references ?? {};
96
+ const slots = options.slots ?? {};
97
+ const showTabs = !compact && options.stepTabs !== false;
98
+ const showParams = !compact && options.parameters !== false;
99
+ const parameterTypes = (Array.isArray(options.parameterTypes) && options.parameterTypes.length
100
+ ? options.parameterTypes : PARAMETER_TYPES).map((t) => (isObject(t) ? { id: String(t.id), label: t.label ?? String(t.id) }
101
+ : { id: String(t), label: String(t) }));
102
+ const delayMs = Number.isFinite(options.previewDelayMs) ? options.previewDelayMs : 800;
103
+
104
+ // ── state ────────────────────────────────────────────────────────────
105
+ let pipeline = emptyPipeline();
106
+ let flowSchema = isObject(options.flowSettings?.schema) ? options.flowSettings.schema : null;
107
+ let flowValue = isObject(options.flowSettings?.value) ? clone(options.flowSettings.value) : (flowSchema ? {} : null);
108
+ let layout = layoutLanes(pipeline, catalogue);
109
+ let placed = placeLanes(layout, size);
110
+ let selected = null; // a step id, 'flow' or null
111
+ let readOnlyOpt = options.readOnly || false;
112
+ let findings = [];
113
+ let byNode = new Map();
114
+ let runOverlay = null;
115
+ let mode = 'preview';
116
+ let previewNodes = {};
117
+ let previewError = null;
118
+ let describeNodes = {};
119
+ let previewState = { enabled: false, waiting: false, busy: false };
120
+ let dockTab = 'preview';
121
+ let table = null;
122
+ let lastRunAsk = null;
123
+ let destroyed = false;
124
+ let travelling = null; // 'undo' | 'redo' while a history step is applied
125
+ let shownColumns = ''; // the selected step's input columns the panel was drawn with
126
+ let panelReadOnly = false; // what the panel was last told
127
+
128
+ const node = (id) => pipeline.nodes.find((n) => n.id === id) ?? null;
129
+ const typeOf = (n) => catalogue.get(n?.type) ?? null;
130
+ const typeLabel = (n) => typeOf(n)?.label ?? String(n?.type ?? '');
131
+ const titleOf = (id) => {
132
+ const n = node(id);
133
+ return n ? (n.label || typeLabel(n)) : String(id);
134
+ };
135
+ const roleOf = (n) => {
136
+ const r = typeOf(n)?.role;
137
+ return ROLES.includes(r) ? r : 'transform';
138
+ };
139
+ const problems = () => layout.problems;
140
+ const isReadOnly = () => compact || Boolean(readOnlyOpt) || problems().length > 0;
141
+ const readOnlyReason = () => {
142
+ const lines = [];
143
+ if (readOnlyOpt && typeof readOnlyOpt === 'object' && readOnlyOpt.reason) lines.push(String(readOnlyOpt.reason));
144
+ for (const p of problems()) lines.push(p.message);
145
+ if (!lines.length && readOnlyOpt) lines.push(say(S, 'readOnlyTitle'));
146
+ return lines;
147
+ };
148
+ /** Every step, left to right: by column, then by lane. */
149
+ const stepOrder = () => placed.cards.slice().sort((a, b) => (a.column - b.column) || (a.lane - b.lane))
150
+ .map((c) => c.id);
151
+
152
+ // ── the DOM ──────────────────────────────────────────────────────────
153
+ const root = el('div', `twm-flow-lanes twm-flow-lanes--${size}${compact ? ' twm-flow-lanes--compact' : ''}`
154
+ + `${options.height === 'auto' ? ' twm-flow-lanes--auto' : ''}`);
155
+ root.setAttribute('role', 'group');
156
+ root.setAttribute('aria-label', options.label ?? say(S, 'lanesLabel'));
157
+
158
+ const banner = el('div', 'twm-flow-lanes__readonly');
159
+ banner.setAttribute('role', 'status');
160
+ banner.hidden = true;
161
+
162
+ // The toolbar: Undo, Redo, the consumer's verbs, the status, Steps show.
163
+ let toolbar = null;
164
+ let undoBtn = null;
165
+ let redoBtn = null;
166
+ let statusEl = null;
167
+ let showPreviewBtn = null;
168
+ let showRunBtn = null;
169
+ const actionButtons = new Map();
170
+ if (!compact) {
171
+ toolbar = el('div', 'twm-flow-lanes__toolbar');
172
+ toolbar.setAttribute('role', 'toolbar');
173
+ toolbar.setAttribute('aria-label', say(S, 'toolbar'));
174
+ if (typeof slots.toolbarStart === 'function') {
175
+ const box = el('div', 'twm-flow-lanes__toolbar-slot');
176
+ slots.toolbarStart(box);
177
+ toolbar.appendChild(box);
178
+ }
179
+ undoBtn = button({ label: say(S, 'undo'), icon: 'undo', title: say(S, 'undoTitle'), onClick: () => undo() });
180
+ redoBtn = button({ label: say(S, 'redo'), icon: 'redo', title: say(S, 'redoTitle'), onClick: () => redo() });
181
+ undoBtn.dataset.action = 'undo';
182
+ redoBtn.dataset.action = 'redo';
183
+ toolbar.append(undoBtn, redoBtn);
184
+ const verbs = Array.isArray(options.actions) ? options.actions : [];
185
+ if (verbs.length) {
186
+ const sep = el('span', 'twm-flow-lanes__toolbar-sep');
187
+ sep.setAttribute('aria-hidden', 'true');
188
+ toolbar.appendChild(sep);
189
+ const box = el('div', 'twm-flow-lanes__actions');
190
+ for (const a of verbs) {
191
+ const wrap = el('span', 'twm-flow-lanes__action');
192
+ const b = button({ label: a.label ?? a.id, icon: a.icon || null, primary: Boolean(a.primary),
193
+ onClick: () => { if (!b.disabled) a.run?.({ editor: api }); } });
194
+ b.dataset.action = String(a.id);
195
+ const why = el('span', 'twm-flow-lanes__refusal');
196
+ why.hidden = true;
197
+ wrap.append(b, why);
198
+ box.appendChild(wrap);
199
+ actionButtons.set(String(a.id), { wrap, b, why });
200
+ }
201
+ toolbar.appendChild(box);
202
+ }
203
+ statusEl = el('span', 'twm-flow-lanes__status');
204
+ statusEl.setAttribute('aria-live', 'polite');
205
+ toolbar.appendChild(statusEl);
206
+ toolbar.appendChild(el('span', 'twm-flow-lanes__spacer'));
207
+ const showId = uid('lanes-show');
208
+ const showLabel = el('span', 'twm-flow-lanes__show-label', say(S, 'stepsShow'));
209
+ showLabel.id = showId;
210
+ const show = el('div', 'twm-flow-lanes__show');
211
+ show.setAttribute('role', 'group');
212
+ show.setAttribute('aria-labelledby', showId);
213
+ showPreviewBtn = el('button', 'twm-flow-lanes__show-btn', say(S, 'thePreview'));
214
+ showRunBtn = el('button', 'twm-flow-lanes__show-btn', say(S, 'theLastRun'));
215
+ for (const [b, m] of [[showPreviewBtn, 'preview'], [showRunBtn, 'run']]) {
216
+ b.type = 'button';
217
+ b.dataset.mode = m;
218
+ b.addEventListener('click', () => setStepsShow(m));
219
+ show.appendChild(b);
220
+ }
221
+ toolbar.append(showLabel, show);
222
+ if (typeof slots.toolbarEnd === 'function') {
223
+ const box = el('div', 'twm-flow-lanes__toolbar-slot');
224
+ slots.toolbarEnd(box);
225
+ toolbar.appendChild(box);
226
+ }
227
+ }
228
+
229
+ const strip = compact ? null : createFindingsStrip({
230
+ strings: S,
231
+ nameOf: (id) => (id ? titleOf(id) : say(S, 'theFlow')),
232
+ onGoTo: (f) => goTo(f),
233
+ });
234
+
235
+ const params = showParams ? el('div', 'twm-flow-lanes__params') : null;
236
+
237
+ const flow = el('section', 'twm-flow-lanes__flow');
238
+ flow.setAttribute('aria-label', say(S, 'lanesLabel'));
239
+ const runBanner = el('p', 'twm-flow-lanes__banner');
240
+ runBanner.hidden = true;
241
+ const scroller = el('div', 'twm-flow-lanes__scroller');
242
+ const surface = el('div', 'twm-flow-lanes__surface');
243
+ scroller.appendChild(surface);
244
+ const empty = el('p', 'twm-flow-lanes__empty', say(S, 'emptyFlow'));
245
+ empty.hidden = true;
246
+ flow.append(runBanner, scroller, empty);
247
+ let addSourceBtn = null;
248
+ if (!compact) {
249
+ const foot = el('div', 'twm-flow-lanes__foot');
250
+ addSourceBtn = el('button', 'twm-flow-lanes__add-source');
251
+ addSourceBtn.type = 'button';
252
+ addSourceBtn.setAttribute('aria-haspopup', 'dialog');
253
+ addSourceBtn.append(icon('add'), el('span', '', say(S, 'addSource')));
254
+ addSourceBtn.addEventListener('click', () => pickSource());
255
+ foot.append(addSourceBtn, el('span', 'twm-flow-lanes__hint', say(S, 'addHint')));
256
+ flow.appendChild(foot);
257
+ }
258
+
259
+ let tabs = null;
260
+ let dock = null;
261
+ let settingsCol = null;
262
+ let settingsHint = null;
263
+ let dataCol = null;
264
+ let dataTabs = null;
265
+ let dataState = null;
266
+ let dataBody = null;
267
+ let panel = null;
268
+ const dockId = uid('lanes-dock');
269
+ if (!compact) {
270
+ if (showTabs) {
271
+ tabs = el('div', 'twm-flow-lanes__tabs');
272
+ tabs.setAttribute('role', 'tablist');
273
+ tabs.setAttribute('aria-label', say(S, 'stepsLabel'));
274
+ }
275
+ dock = el('div', 'twm-flow-lanes__dock');
276
+ dock.id = dockId;
277
+ if (showTabs) dock.setAttribute('role', 'tabpanel');
278
+ if (options.height === 'auto') dock.style.height = `${Number(options.dockHeight) || 360}px`;
279
+ settingsCol = el('div', 'twm-flow-lanes__settings');
280
+ settingsHint = el('p', 'twm-flow-lanes__settings-hint', say(S, 'chooseStep'));
281
+ settingsHint.hidden = true;
282
+ settingsCol.appendChild(settingsHint);
283
+ dataCol = el('section', 'twm-flow-lanes__data');
284
+ dataCol.setAttribute('aria-label', say(S, 'dataLabel'));
285
+ dataTabs = el('div', 'twm-flow-lanes__datatabs');
286
+ dataState = el('span', 'twm-flow-lanes__datastate');
287
+ dataState.setAttribute('aria-live', 'polite');
288
+ dataBody = el('div', 'twm-flow-lanes__databody');
289
+ dataCol.append(dataTabs, dataBody);
290
+ dock.append(settingsCol, dataCol);
291
+ panel = createSettingsPanel({
292
+ widgets, references, strings: S, services: options.services ?? null, readOnly: false,
293
+ onChange: (stepId, key, value) => changeSetting(stepId, key, value),
294
+ onRename: (stepId, label) => rename(stepId, label),
295
+ onFocusLost: () => focusCard(selected),
296
+ values: typeof options.values === 'function' ? (q) => options.values({
297
+ pipeline: getGraph(), graph: getGraph(), stepId: q.stepId, field: q.field, key: q.key,
298
+ upstream: q.stepId ? upstreamOf(pipeline, q.stepId) : [], parameters: clone(pipeline.parameters),
299
+ }) : null,
300
+ valuesNote: options.valuesNote ?? null,
301
+ columns: (step) => (step ? inputColumns(step.id) : null),
302
+ host: settingsCol,
303
+ });
304
+ }
305
+
306
+ root.append(banner);
307
+ if (toolbar) root.appendChild(toolbar);
308
+ if (strip) root.appendChild(strip.el);
309
+ if (params) root.appendChild(params);
310
+ root.appendChild(flow);
311
+ if (tabs) root.appendChild(tabs);
312
+ if (dock) root.appendChild(dock);
313
+ host.appendChild(root);
314
+
315
+ // ── history ──────────────────────────────────────────────────────────
316
+ const snapshot = () => JSON.stringify([serialisePipeline(pipeline), flowValue === null ? null : sortedKeys(flowValue)]);
317
+ const history = new FlowHistory({
318
+ actions: LANE_ACTIONS,
319
+ restore: (state) => restore(state),
320
+ onState: () => paintToolbar(),
321
+ limit: options.history?.limit ?? 100,
322
+ mergeMs: options.history?.mergeMs ?? 1000,
323
+ ...(typeof options.history?.now === 'function' ? { now: options.history.now } : {}),
324
+ });
325
+
326
+ const runner = createPreviewRunner({
327
+ preview: compact ? null : options.preview,
328
+ describe: compact ? null : options.describe,
329
+ delayMs,
330
+ clock: options.clock ?? null,
331
+ request: () => ({ pipeline: getGraph(), text: serialisePipeline(pipeline), parameters: clone(pipeline.parameters) }),
332
+ onAnswer: ({ kind, answer }) => {
333
+ if (kind === 'preview') {
334
+ previewNodes = isObject(answer?.nodes) ? answer.nodes : {};
335
+ previewError = null;
336
+ } else {
337
+ describeNodes = isObject(answer?.nodes) ? answer.nodes : {};
338
+ }
339
+ paintCards();
340
+ paintDock();
341
+ reshowForColumns();
342
+ },
343
+ onError: ({ kind, error }) => {
344
+ if (error?.name === 'AbortError') return;
345
+ if (kind === 'preview') previewError = String(error?.message || say(S, 'previewFailed'));
346
+ paintDock();
347
+ },
348
+ onState: (s) => {
349
+ previewState = s;
350
+ paintDataState();
351
+ },
352
+ });
353
+ previewState = runner.state;
354
+
355
+ function report(action, key = null) {
356
+ options.onChange?.({ graph: getGraph(), pipeline: getGraph(), flowSettings: flowValue === null ? null : clone(flowValue),
357
+ text: serialisePipeline(pipeline), action, key });
358
+ }
359
+
360
+ /** Record an edit already made to `pipeline`; false when nothing changed. */
361
+ function commit(action, key = null, { preview = true } = {}) {
362
+ const moved = history.commit(snapshot(), action, key);
363
+ if (!moved) return false;
364
+ report(action, key);
365
+ if (preview) runner.schedule();
366
+ return true;
367
+ }
368
+
369
+ function restore(state) {
370
+ let text = null;
371
+ let settings = null;
372
+ try {
373
+ [text, settings] = JSON.parse(state);
374
+ } catch {
375
+ return;
376
+ }
377
+ pipeline = normalisePipeline(JSON.parse(text));
378
+ flowValue = settings === null ? null : settings;
379
+ relayout();
380
+ if (selected && selected !== 'flow' && !node(selected)) selected = firstStep();
381
+ paintAll();
382
+ runner.schedule();
383
+ report(travelling ?? 'undo');
384
+ }
385
+
386
+ function undo() {
387
+ if (isReadOnly() || !history.canUndo) return false;
388
+ travelling = 'undo';
389
+ try { return history.undo(); } finally { travelling = null; }
390
+ }
391
+
392
+ function redo() {
393
+ if (isReadOnly() || !history.canRedo) return false;
394
+ travelling = 'redo';
395
+ try { return history.redo(); } finally { travelling = null; }
396
+ }
397
+
398
+ // ── layout and painting ──────────────────────────────────────────────
399
+ function relayout() {
400
+ layout = layoutLanes(pipeline, catalogue, { strings: options.strings });
401
+ placed = placeLanes(layout, size);
402
+ byNode = groupFindings(findings, { graph: pipeline });
403
+ }
404
+
405
+ function firstStep() {
406
+ return stepOrder()[0] ?? null;
407
+ }
408
+
409
+ /** The step whose card is in the Tab order: the chosen one, else the first. */
410
+ function rovingStep() {
411
+ return selected && selected !== 'flow' && layout.at[selected] ? selected : firstStep();
412
+ }
413
+
414
+ function statusOf(id) {
415
+ const fs = byNode.get(id) || [];
416
+ if (fs.length) {
417
+ const err = fs.find((f) => f.severity !== 'warning');
418
+ return { text: String((err ?? fs[0]).message ?? ''), tone: err ? 'error' : 'warning' };
419
+ }
420
+ if (mode === 'run') {
421
+ if (!runOverlay) return { text: say(S, 'noRun'), tone: 'muted' };
422
+ const s = runOverlay.steps?.[id];
423
+ if (!s) return { text: say(S, 'notRun'), tone: 'muted' };
424
+ return { text: String(s.line ?? s.state ?? ''), tone: lineTone(s.tone ?? RUN_TONES[s.state]) };
425
+ }
426
+ if (!runner.enabled) return { text: '', tone: 'muted' };
427
+ const n = previewNodes[id];
428
+ if (!n) return { text: say(S, 'pending'), tone: 'muted' };
429
+ if (n.status === 'error') return { text: String(n.error?.message || say(S, 'stepFailed')), tone: 'error' };
430
+ if (n.status === 'skipped') return { text: say(S, 'skipped'), tone: 'muted' };
431
+ const rejected = Array.isArray(n.rejects) ? n.rejects.length : 0;
432
+ if (rejected) return { text: say(S, 'rejected', rejected), tone: 'warning' };
433
+ if (n.rows !== undefined && n.rows !== null) return { text: say(S, 'rows', n.rows), tone: 'ok' };
434
+ return { text: '', tone: 'muted' };
435
+ }
436
+
437
+ function subOf(n) {
438
+ const type = typeOf(n);
439
+ const line = typeof options.summarise === 'function' ? options.summarise(n, type) : typeLabel(n);
440
+ const title = n.label || typeLabel(n);
441
+ return line && line !== title ? String(line) : '';
442
+ }
443
+
444
+ function canAddAfter(id) {
445
+ const n = node(id);
446
+ if (!n || isReadOnly() || roleOf(n) === 'sink') return false;
447
+ return stepPorts(catalogue, n, pipeline.connections).continuation !== null;
448
+ }
449
+
450
+ function heldInSurface() {
451
+ const a = root.ownerDocument.activeElement;
452
+ if (!a || !surface.contains(a)) return null;
453
+ const holder = a.closest('[data-step]');
454
+ if (!holder) return null;
455
+ return { step: holder.dataset.step, add: a.classList.contains('twm-flow-lanes__add') };
456
+ }
457
+
458
+ function paintLanes() {
459
+ const held = heldInSurface();
460
+ surface.replaceChildren();
461
+ surface.style.width = `${placed.width}px`;
462
+ surface.style.height = `${placed.height}px`;
463
+ empty.hidden = placed.cards.length > 0;
464
+ scroller.hidden = placed.cards.length === 0;
465
+
466
+ const svg = document.createElementNS(SVG, 'svg');
467
+ svg.setAttribute('class', 'twm-flow-lanes__wires');
468
+ svg.setAttribute('aria-hidden', 'true');
469
+ svg.setAttribute('width', String(placed.width));
470
+ svg.setAttribute('height', String(placed.height));
471
+ svg.setAttribute('viewBox', `0 0 ${placed.width} ${placed.height}`);
472
+ for (const w of placed.wires) {
473
+ const path = document.createElementNS(SVG, 'path');
474
+ path.setAttribute('class', `twm-flow-lanes__wire${w.cycle ? ' twm-flow-lanes__wire--cycle' : ''}`);
475
+ path.dataset.kind = w.kind;
476
+ path.setAttribute('d', w.d);
477
+ path.dataset.from = w.from;
478
+ path.dataset.to = w.to;
479
+ path.dataset.port = w.toPort;
480
+ svg.appendChild(path);
481
+ }
482
+ for (const w of placed.wires) {
483
+ if (!w.port) continue;
484
+ const dot = document.createElementNS(SVG, 'circle');
485
+ dot.setAttribute('class', 'twm-flow-lanes__port');
486
+ dot.setAttribute('cx', String(w.port.x));
487
+ dot.setAttribute('cy', String(w.port.y));
488
+ dot.setAttribute('r', '5');
489
+ dot.dataset.to = w.to;
490
+ dot.dataset.port = w.toPort;
491
+ svg.appendChild(dot);
492
+ }
493
+
494
+ const bands = placed.bands.map((b) => {
495
+ const lane = el('div', `twm-flow-lanes__lane${b.unconnected ? ' twm-flow-lanes__lane--unconnected' : ''}`);
496
+ lane.style.top = `${b.top}px`;
497
+ lane.style.height = `${b.height}px`;
498
+ lane.dataset.lane = String(b.index);
499
+ const first = layout.lanes[b.index].steps[0]?.id;
500
+ lane.setAttribute('role', 'group');
501
+ lane.setAttribute('aria-label', say(S, b.unconnected ? 'laneNameUnconnected' : 'laneName', b.index + 1,
502
+ first ? titleOf(first) : ''));
503
+ if (b.unconnected && !compact) lane.appendChild(el('span', 'twm-flow-lanes__flag', say(S, 'notConnected')));
504
+ return lane;
505
+ });
506
+ surface.append(...bands, svg);
507
+ const roving = rovingStep();
508
+ for (const c of placed.cards) {
509
+ const n = node(c.id);
510
+ if (!n) continue;
511
+ bands[c.lane].appendChild(compact ? stripCard(n, c) : card(n, c, roving));
512
+ }
513
+ paintCards();
514
+ if (held && !destroyed) {
515
+ const target = surface.querySelector(`[data-step="${cssEscape(held.step)}"] ${held.add ? '.twm-flow-lanes__add' : '.twm-flow-lanes__card'}`)
516
+ || surface.querySelector(`[data-step="${cssEscape(held.step)}"] .twm-flow-lanes__card`);
517
+ if (target) target.focus({ preventScroll: true });
518
+ else focusCard(selected);
519
+ }
520
+ }
521
+
522
+ function card(n, c, roving) {
523
+ const role = roleOf(n);
524
+ const tone = toneOf(catalogue.tone(n.type));
525
+ const wrap = el('div', `twm-flow-lanes__node twm-flow-lanes__node--${role} twm-flow-lanes__node--${tone}`);
526
+ wrap.dataset.step = n.id;
527
+ wrap.style.left = `${c.x}px`;
528
+ wrap.style.top = `${c.y - placed.bands[c.lane].top}px`;
529
+ const b = el('button', 'twm-flow-lanes__card');
530
+ b.type = 'button';
531
+ b.setAttribute('data-twm-flow-item', '');
532
+ b.tabIndex = n.id === roving ? 0 : -1;
533
+ const body = el('span', 'twm-flow-lanes__body');
534
+ const glyph = el('span', 'twm-flow-lanes__icon');
535
+ glyph.setAttribute('aria-hidden', 'true');
536
+ glyph.appendChild(icon(typeOf(n)?.icon || ''));
537
+ const sep = el('span', 'twm-flow-lanes__sep');
538
+ sep.setAttribute('aria-hidden', 'true');
539
+ const text = el('span', 'twm-flow-lanes__text');
540
+ text.append(el('span', 'twm-flow-lanes__title'), el('span', 'twm-flow-lanes__sub'), el('span', 'twm-flow-lanes__line'));
541
+ body.append(glyph, sep, text);
542
+ b.appendChild(body);
543
+ b.addEventListener('click', () => select(n.id));
544
+ b.addEventListener('contextmenu', (ev) => {
545
+ ev.preventDefault();
546
+ select(n.id);
547
+ openMenu(n.id, b);
548
+ });
549
+ wrap.appendChild(b);
550
+ if (canAddAfter(n.id)) {
551
+ const add = el('button', 'twm-flow-lanes__add');
552
+ add.type = 'button';
553
+ add.setAttribute('aria-haspopup', 'dialog');
554
+ add.tabIndex = n.id === roving ? 0 : -1;
555
+ add.appendChild(icon('add'));
556
+ add.addEventListener('click', () => pickAfter(n.id, add));
557
+ wrap.appendChild(add);
558
+ }
559
+ return wrap;
560
+ }
561
+
562
+ function stripCard(n, c) {
563
+ const role = roleOf(n);
564
+ const tone = toneOf(catalogue.tone(n.type));
565
+ const wrap = el('div', `twm-flow-lanes__node twm-flow-lanes__node--${role} twm-flow-lanes__node--${tone}`);
566
+ wrap.dataset.step = n.id;
567
+ wrap.style.left = `${c.x}px`;
568
+ wrap.style.top = `${c.y - placed.bands[c.lane].top}px`;
569
+ wrap.setAttribute('role', 'img');
570
+ const body = el('span', 'twm-flow-lanes__body');
571
+ const text = el('span', 'twm-flow-lanes__text');
572
+ text.append(el('span', 'twm-flow-lanes__title'), el('span', 'twm-flow-lanes__line'));
573
+ body.appendChild(text);
574
+ wrap.appendChild(body);
575
+ return wrap;
576
+ }
577
+
578
+ /** Every card's words, status, selection and findings — IN PLACE, rebuilding nothing. */
579
+ function paintCards() {
580
+ const roving = rovingStep();
581
+ for (const wrap of surface.querySelectorAll('.twm-flow-lanes__node')) {
582
+ const id = wrap.dataset.step;
583
+ const n = node(id);
584
+ if (!n) continue;
585
+ const title = n.label || typeLabel(n);
586
+ const status = compact ? { text: '', tone: 'muted' } : statusOf(id);
587
+ wrap.querySelector('.twm-flow-lanes__title').textContent = title;
588
+ const sub = wrap.querySelector('.twm-flow-lanes__sub');
589
+ if (sub) {
590
+ const s = subOf(n);
591
+ sub.textContent = s;
592
+ sub.hidden = !s;
593
+ }
594
+ const line = wrap.querySelector('.twm-flow-lanes__line');
595
+ line.textContent = size === 'small' && !compact
596
+ ? [typeLabel(n), status.text].filter(Boolean).join(' · ') : status.text;
597
+ line.className = `twm-flow-lanes__line twm-flow-lanes__line--${status.tone}`;
598
+ if (compact) line.hidden = !line.textContent;
599
+ const fs = byNode.get(id) || [];
600
+ wrap.classList.toggle('twm-flow-lanes__node--error', fs.some((f) => f.severity !== 'warning'));
601
+ wrap.classList.toggle('twm-flow-lanes__node--warning', fs.length > 0 && fs.every((f) => f.severity === 'warning'));
602
+ const on = id === selected;
603
+ wrap.classList.toggle('twm-flow-lanes__node--selected', on);
604
+ const label = say(S, 'cardLabel', typeLabel(n), title, status.text);
605
+ if (compact) {
606
+ wrap.setAttribute('aria-label', label);
607
+ continue;
608
+ }
609
+ const b = wrap.querySelector('.twm-flow-lanes__card');
610
+ b.setAttribute('aria-label', label);
611
+ b.setAttribute('aria-current', on ? 'true' : 'false');
612
+ b.tabIndex = id === roving ? 0 : -1;
613
+ const add = wrap.querySelector('.twm-flow-lanes__add');
614
+ if (add) {
615
+ add.tabIndex = id === roving ? 0 : -1;
616
+ add.setAttribute('aria-label', say(S, 'addAfter', title));
617
+ add.title = say(S, 'addAfter', title);
618
+ }
619
+ }
620
+ }
621
+
622
+ function paintTabs() {
623
+ if (!tabs) return;
624
+ const focused = tabs.contains(root.ownerDocument.activeElement);
625
+ tabs.replaceChildren();
626
+ const make = (id, label, tone) => {
627
+ const t = el('button', `twm-flow-lanes__tab twm-flow-lanes__tab--${tone}`);
628
+ t.type = 'button';
629
+ t.id = `${dockId}-tab-${id}`;
630
+ t.setAttribute('role', 'tab');
631
+ t.setAttribute('data-twm-flow-item', '');
632
+ t.setAttribute('aria-controls', dockId);
633
+ t.dataset.tab = id;
634
+ const dot = el('span', 'twm-flow-lanes__tab-dot');
635
+ dot.setAttribute('aria-hidden', 'true');
636
+ t.append(dot, el('span', 'twm-flow-lanes__tab-label', label));
637
+ t.addEventListener('click', () => select(id));
638
+ tabs.appendChild(t);
639
+ };
640
+ if (flowSchema) make('flow', say(S, 'flowTab'), 'grey');
641
+ for (const id of stepOrder()) {
642
+ const n = node(id);
643
+ if (n) make(id, n.label || typeLabel(n), toneOf(catalogue.tone(n.type)));
644
+ }
645
+ paintTabState();
646
+ if (focused) tabs.querySelector('[aria-selected="true"]')?.focus({ preventScroll: true });
647
+ }
648
+
649
+ function paintTabState() {
650
+ if (!tabs) return;
651
+ const list = [...tabs.querySelectorAll('[role="tab"]')];
652
+ const current = list.find((t) => t.dataset.tab === selected) ?? null;
653
+ for (const t of list) {
654
+ const on = t === current;
655
+ t.setAttribute('aria-selected', on ? 'true' : 'false');
656
+ t.tabIndex = on || (!current && t === list[0]) ? 0 : -1;
657
+ const n = node(t.dataset.tab);
658
+ if (n) t.querySelector('.twm-flow-lanes__tab-label').textContent = n.label || typeLabel(n);
659
+ }
660
+ if (dock) {
661
+ if (current) dock.setAttribute('aria-labelledby', current.id);
662
+ else dock.removeAttribute('aria-labelledby');
663
+ }
664
+ }
665
+
666
+ function paintBanner() {
667
+ const lines = isReadOnly() && !compact ? readOnlyReason() : [];
668
+ banner.replaceChildren(...lines.map((l) => el('p', 'twm-flow-lanes__readonly-line', l)));
669
+ banner.hidden = lines.length === 0;
670
+ root.classList.toggle('twm-flow-lanes--readonly', isReadOnly() && !compact);
671
+ if (addSourceBtn) addSourceBtn.hidden = isReadOnly();
672
+ const runText = mode === 'run' && runOverlay?.banner ? String(runOverlay.banner) : '';
673
+ runBanner.textContent = runText;
674
+ runBanner.hidden = !runText;
675
+ }
676
+
677
+ function paintToolbar() {
678
+ if (!toolbar) return;
679
+ const ro = isReadOnly();
680
+ undoBtn.disabled = ro || !history.canUndo;
681
+ redoBtn.disabled = ro || !history.canRedo;
682
+ showPreviewBtn.setAttribute('aria-pressed', mode === 'preview' ? 'true' : 'false');
683
+ showRunBtn.setAttribute('aria-pressed', mode === 'run' ? 'true' : 'false');
684
+ }
685
+
686
+ function paintParams() {
687
+ if (!params) return;
688
+ const focused = root.ownerDocument.activeElement;
689
+ const heldName = focused && params.contains(focused) ? focused.dataset.parameter ?? (focused.dataset.add ? '+' : null) : null;
690
+ params.replaceChildren();
691
+ params.appendChild(el('span', 'twm-flow-lanes__params-title', say(S, 'parameters')));
692
+ const names = Object.keys(pipeline.parameters || {});
693
+ for (const name of names) {
694
+ const def = isObject(pipeline.parameters[name]) ? pipeline.parameters[name] : {};
695
+ const line = say(S, 'parameterLine', def.type, def.default === undefined ? undefined
696
+ : (typeof def.default === 'string' ? def.default : JSON.stringify(def.default)));
697
+ const chip = el('button', 'twm-flow-lanes__param');
698
+ chip.type = 'button';
699
+ chip.dataset.parameter = name;
700
+ chip.setAttribute('aria-haspopup', 'dialog');
701
+ chip.setAttribute('aria-label', say(S, 'parameterAria', name, line));
702
+ chip.append(el('span', 'twm-flow-lanes__param-name', name), el('span', 'twm-flow-lanes__param-line', line));
703
+ chip.addEventListener('click', () => editParameter(name, chip));
704
+ params.appendChild(chip);
705
+ }
706
+ if (!isReadOnly()) {
707
+ const add = el('button', 'twm-flow-lanes__param-add');
708
+ add.type = 'button';
709
+ add.dataset.add = '1';
710
+ add.setAttribute('aria-haspopup', 'dialog');
711
+ add.append(icon('add'), el('span', '', say(S, 'addParameter')));
712
+ add.addEventListener('click', () => editParameter(null, add));
713
+ params.appendChild(add);
714
+ }
715
+ if (names.length) {
716
+ const syntax = references.parameter;
717
+ const ref = syntax?.format ? syntax.format(names[0]) : `{{${names[0]}}}`;
718
+ params.appendChild(el('span', 'twm-flow-lanes__params-help',
719
+ `${say(S, 'parameterHelp', ref)} ${say(S, 'parameterSupplied')}`));
720
+ }
721
+ if (heldName) {
722
+ const again = heldName === '+' ? params.querySelector('[data-add]')
723
+ : [...params.querySelectorAll('[data-parameter]')].find((c) => c.dataset.parameter === heldName);
724
+ (again || params.querySelector('button'))?.focus({ preventScroll: true });
725
+ }
726
+ }
727
+
728
+ function stepPanelSpec(n) {
729
+ const type = typeOf(n);
730
+ const ports = portsOf(pipeline, catalogue, n.id);
731
+ const extra = [];
732
+ const inputs = ports?.inputs ?? [];
733
+ if (ports && ports.laneInput !== null && (inputs.length > 1 || !laneFeedOf(pipeline, catalogue, n.id))) {
734
+ extra.push(inputField(n, ports.laneInput, true, inputs));
735
+ }
736
+ for (const port of inputs.slice(1)) extra.push(inputField(n, port, false, inputs));
737
+ const ro = isReadOnly();
738
+ return {
739
+ step: n, type, value: n.config,
740
+ title: n.label ?? '', placeholderTitle: typeLabel(n), rename: true,
741
+ typeLabel: typeLabel(n), description: type?.description ?? '',
742
+ idLine: typeof options.summariseReads === 'function' ? options.summariseReads(n, type) : null,
743
+ icon: type?.icon ?? '', tone: toneOf(catalogue.tone(n.type)),
744
+ extra,
745
+ actions: [{ id: 'remove', label: say(S, 'removeStep'), icon: 'delete', danger: true, disabled: ro,
746
+ reason: ro ? readOnlyReason()[0] ?? '' : '', run: () => remove(n.id) }],
747
+ slots: { after: typeof slots.stepPanel === 'function' ? (box) => slots.stepPanel(box, n) : null },
748
+ };
749
+ }
750
+
751
+ function inputField(n, port, lane, inputs) {
752
+ const here = layout.at[n.id]?.lane ?? 0;
753
+ const candidates = inputCandidates(pipeline, catalogue, layout, n.id);
754
+ const labels = {};
755
+ for (const c of candidates) labels[c.id] = say(S, 'candidate', titleOf(c.id), say(S, 'laneRelation', c.lane - here, c.lane + 1));
756
+ const current = inputOf(pipeline, n.id, port)?.sourceId;
757
+ const portDef = catalogue.port(n.type, port, 'input');
758
+ const title = lane ? say(S, 'thisLane')
759
+ : (inputs.length > 2 ? say(S, 'joinedWithPort', portDef?.label ?? port) : say(S, 'joinedWith'));
760
+ return {
761
+ key: `__input:${port}`,
762
+ spec: { type: 'string', title, description: lane ? (current ? say(S, 'thisLaneHelp') : say(S, 'connectIt'))
763
+ : say(S, 'joinedWithHelp'), enum: candidates.map((c) => c.id), 'x-ui-enum-labels': labels },
764
+ required: lane || Boolean(portDef?.required),
765
+ value: current,
766
+ at: 'start',
767
+ set: (v) => setJoin(n.id, port, v ?? null),
768
+ };
769
+ }
770
+
771
+ function paintPanel() {
772
+ if (!panel) return;
773
+ if (panelReadOnly !== isReadOnly()) {
774
+ panelReadOnly = isReadOnly();
775
+ panel.setReadOnly(panelReadOnly ? { reason: '' } : false);
776
+ }
777
+ const n = selected && selected !== 'flow' ? node(selected) : null;
778
+ settingsHint.hidden = true;
779
+ if (n) {
780
+ panel.show(stepPanelSpec(n));
781
+ shownColumns = JSON.stringify(inputColumns(n.id));
782
+ } else if (selected === 'flow' && flowSchema) {
783
+ panel.show({ title: say(S, 'flowTitle'), typeLabel: say(S, 'flowType'),
784
+ description: options.flowSettings?.description ?? '', icon: 'tune', tone: 'grey',
785
+ fields: fieldsFromSchema(flowSchema), value: flowValue ?? {},
786
+ slots: { after: typeof slots.flowPanel === 'function' ? (box) => slots.flowPanel(box) : null } });
787
+ shownColumns = '';
788
+ } else {
789
+ panel.clear();
790
+ settingsHint.hidden = false;
791
+ shownColumns = '';
792
+ }
793
+ panel.setFindings(findings);
794
+ }
795
+
796
+ /** A describe or preview answer changed the selected step's input columns: redraw its fields, unless
797
+ * the reader is in them — a repaint the reader did not ask for rebuilds no field they hold. */
798
+ function reshowForColumns() {
799
+ if (!panel || !selected || selected === 'flow' || !node(selected)) return;
800
+ const now = JSON.stringify(inputColumns(selected));
801
+ if (now === shownColumns) return;
802
+ if (panel.el.contains(root.ownerDocument.activeElement)) return;
803
+ paintPanel();
804
+ }
805
+
806
+ function availableDataTabs() {
807
+ const n = selected && selected !== 'flow' ? node(selected) : null;
808
+ if (!n) return [];
809
+ const out = [];
810
+ if (typeof options.preview === 'function') out.push('preview');
811
+ if (typeof options.preview === 'function' || typeof options.describe === 'function') out.push('columns');
812
+ if (typeof options.preview === 'function' && roleOf(n) === 'sink') out.push('rejects');
813
+ return out;
814
+ }
815
+
816
+ function columnsOf(id) {
817
+ const d = describeNodes[id];
818
+ if (Array.isArray(d?.columns)) return { from: 'describe', list: d.columns };
819
+ const p = previewNodes[id];
820
+ if (Array.isArray(p?.columns)) return { from: 'preview', list: p.columns };
821
+ return { from: null, list: [] };
822
+ }
823
+
824
+ function paintDataState() {
825
+ if (!dataState) return;
826
+ const s = previewState;
827
+ dataState.replaceChildren();
828
+ if (!s.enabled) {
829
+ dataState.hidden = true;
830
+ return;
831
+ }
832
+ dataState.hidden = false;
833
+ const tone = previewError ? 'error' : (s.waiting || s.busy) ? 'busy' : 'ok';
834
+ dataState.className = `twm-flow-lanes__datastate twm-flow-lanes__datastate--${tone}`;
835
+ const dot = el('span', 'twm-flow-lanes__datastate-dot');
836
+ dot.setAttribute('aria-hidden', 'true');
837
+ dataState.append(dot, el('span', 'twm-flow-lanes__datastate-text',
838
+ tone === 'busy' ? say(S, 'previewing') : say(S, 'previewOn', delayMs)));
839
+ }
840
+
841
+ function paintDock() {
842
+ if (!dock) return;
843
+ const active = root.ownerDocument.activeElement;
844
+ const heldTab = active && dataTabs.contains(active) ? active.dataset.data ?? null : null;
845
+ table?.dispose?.();
846
+ table = null;
847
+ dataTabs.replaceChildren();
848
+ dataBody.replaceChildren();
849
+ const id = selected && selected !== 'flow' ? selected : null;
850
+ const avail = availableDataTabs();
851
+ dataCol.hidden = avail.length === 0;
852
+ if (!avail.length || !id) return;
853
+ if (!avail.includes(dockTab)) dockTab = avail[0];
854
+ const p = previewNodes[id];
855
+ const cols = columnsOf(id);
856
+ const rejects = Array.isArray(p?.rejects) ? p.rejects : [];
857
+ const words = { preview: say(S, 'previewTab'), columns: say(S, 'columnsTab', cols.list.length),
858
+ rejects: say(S, 'rejectsTab', rejects.length) };
859
+ for (const t of avail) {
860
+ const b = el('button', 'twm-flow-lanes__datatab', words[t]);
861
+ b.type = 'button';
862
+ b.dataset.data = t;
863
+ b.setAttribute('aria-pressed', t === dockTab ? 'true' : 'false');
864
+ b.addEventListener('click', () => {
865
+ dockTab = t;
866
+ paintDock();
867
+ });
868
+ dataTabs.appendChild(b);
869
+ }
870
+ dataTabs.appendChild(el('span', 'twm-flow-lanes__spacer'));
871
+ dataTabs.appendChild(dataState);
872
+ paintDataState();
873
+ if (heldTab) dataTabs.querySelector(`[data-data="${heldTab}"]`)?.focus({ preventScroll: true });
874
+
875
+ if (previewError && dockTab !== 'columns') dataBody.appendChild(el('p', 'twm-flow-lanes__error', previewError));
876
+ if (dockTab === 'preview') {
877
+ if (!p) {
878
+ dataBody.appendChild(el('p', 'twm-flow-lanes__nodata', say(S, 'noPreview')));
879
+ return;
880
+ }
881
+ if (p.status === 'error') {
882
+ dataBody.appendChild(el('p', 'twm-flow-lanes__error', String(p.error?.message || say(S, 'stepFailed'))));
883
+ return;
884
+ }
885
+ const head = Array.isArray(p.head) ? p.head : [];
886
+ const columns = Array.isArray(p.columns) ? p.columns : [];
887
+ dataBody.appendChild(el('p', 'twm-flow-lanes__caption',
888
+ p.caption ?? say(S, 'previewCaption', head.length, p.rows ?? head.length)));
889
+ const box = el('div', 'twm-flow-lanes__table');
890
+ dataBody.appendChild(box);
891
+ const marks = columns.map((c) => describeNodes[id]?.columns?.find((d) => d.name === c.name)?.change ?? null);
892
+ table = new DataTable(box, {
893
+ headers: columns.map((c) => String(c.name)),
894
+ rows: head.map((r) => (Array.isArray(r) ? r : columns.map((c) => r?.[c.name]))),
895
+ pagination: false, selectable: false, readonly: true, copyable: true, sortable: false,
896
+ mode: 'compact', firstColumn: 'plain', columnFit: 'content', nullDisplay: '',
897
+ emptyState: say(S, 'noRows'),
898
+ cellClass: (_v, colIdx) => (marks[colIdx] ? `twm-flow-lanes__cell--${marks[colIdx] === 'new' ? 'new' : 'changed'}` : null),
899
+ });
900
+ table.render();
901
+ } else if (dockTab === 'columns') {
902
+ if (!cols.list.length) {
903
+ dataBody.appendChild(el('p', 'twm-flow-lanes__nodata', say(S, 'columnsUnknown')));
904
+ return;
905
+ }
906
+ dataBody.appendChild(el('p', 'twm-flow-lanes__caption', say(S, cols.from === 'describe'
907
+ ? 'columnsFromDescription' : 'columnsFromPreview', cols.list.length)));
908
+ const list = el('ul', 'twm-flow-lanes__columns');
909
+ for (const c of cols.list) {
910
+ const change = c.change === 'new' || c.change === 'changed' ? c.change : null;
911
+ const li = el('li', `twm-flow-lanes__column${change ? ` twm-flow-lanes__column--${change}` : ''}`);
912
+ const type = [c.type, change ? say(S, change === 'new' ? 'columnNew' : 'columnChanged') : null]
913
+ .filter(Boolean).join(' · ');
914
+ li.append(el('span', 'twm-flow-lanes__column-name', String(c.name)),
915
+ el('span', 'twm-flow-lanes__column-type', type),
916
+ el('span', 'twm-flow-lanes__column-origin', c.origin ?? ''));
917
+ list.appendChild(li);
918
+ }
919
+ dataBody.appendChild(list);
920
+ } else {
921
+ dataBody.appendChild(el('p', 'twm-flow-lanes__caption', rejects.length
922
+ ? (p?.rejectsCaption ?? say(S, 'rejectsCaption', rejects.length)) : say(S, 'noRejects')));
923
+ const list = el('ul', 'twm-flow-lanes__rejects');
924
+ for (const r of rejects) {
925
+ const li = el('li', 'twm-flow-lanes__reject');
926
+ const row = Array.isArray(r.row) ? r.row : [];
927
+ if (row.length) li.appendChild(el('span', 'twm-flow-lanes__reject-key', String(row[0])));
928
+ if (row.length > 1) li.appendChild(el('span', 'twm-flow-lanes__reject-value', String(row[1])));
929
+ li.appendChild(el('span', 'twm-flow-lanes__reject-reason', say(S, 'rejectReason', r.column, r.reason)));
930
+ list.appendChild(li);
931
+ }
932
+ dataBody.appendChild(list);
933
+ }
934
+ }
935
+
936
+ function paintAll() {
937
+ paintBanner();
938
+ paintToolbar();
939
+ paintParams();
940
+ paintLanes();
941
+ paintTabs();
942
+ paintPanel();
943
+ paintDock();
944
+ }
945
+
946
+ // ── choosing and focusing ────────────────────────────────────────────
947
+ function select(id, { notify = true } = {}) {
948
+ const next = id === 'flow' ? (flowSchema ? 'flow' : null) : (id && node(id) ? id : null);
949
+ if (next === selected) return;
950
+ selected = next;
951
+ paintCards();
952
+ paintTabState();
953
+ paintPanel();
954
+ paintDock();
955
+ if (notify) options.onSelect?.({ kind: selected === null ? null : selected === 'flow' ? 'flow' : 'step', id: selected });
956
+ }
957
+
958
+ function cardEl(id) {
959
+ if (!id) return null;
960
+ return [...surface.querySelectorAll('.twm-flow-lanes__node')].find((w) => w.dataset.step === id)
961
+ ?.querySelector('.twm-flow-lanes__card') ?? null;
962
+ }
963
+
964
+ function focusCard(id) {
965
+ const b = cardEl(id && id !== 'flow' ? id : rovingStep());
966
+ if (b) {
967
+ b.focus({ preventScroll: true });
968
+ b.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
969
+ return true;
970
+ }
971
+ (addSourceBtn && !addSourceBtn.hidden ? addSourceBtn : root).focus?.({ preventScroll: true });
972
+ return false;
973
+ }
974
+
975
+ function goTo(f) {
976
+ if (f?.node_id && node(f.node_id)) {
977
+ select(f.node_id);
978
+ if (!(f.field && panel?.focusField(f.field))) focusCard(f.node_id);
979
+ return;
980
+ }
981
+ if (flowSchema) {
982
+ select('flow');
983
+ if (f?.field) panel?.focusField(f.field);
984
+ }
985
+ }
986
+
987
+ /** The step beside `id` in the direction of the arrow, or null. */
988
+ function neighbour(id, dir) {
989
+ const at = layout.at[id];
990
+ if (!at) return null;
991
+ const lane = layout.lanes[at.lane];
992
+ if (dir === 'left') return lane.steps[at.position - 1]?.id ?? null;
993
+ if (dir === 'right') return lane.steps[at.position + 1]?.id ?? null;
994
+ const other = layout.lanes[at.lane + (dir === 'down' ? 1 : -1)];
995
+ if (!other || !other.steps.length) return null;
996
+ let best = other.steps[0];
997
+ for (const s of other.steps) {
998
+ const d = Math.abs(s.column - at.column);
999
+ const bd = Math.abs(best.column - at.column);
1000
+ if (d < bd || (d === bd && s.column < best.column)) best = s;
1001
+ }
1002
+ return best.id;
1003
+ }
1004
+
1005
+ // ── edits ────────────────────────────────────────────────────────────
1006
+ function afterShapeChange(focusId = null) {
1007
+ relayout();
1008
+ paintAll();
1009
+ if (focusId) focusCard(focusId);
1010
+ }
1011
+
1012
+ async function pickAfter(id, anchor) {
1013
+ if (isReadOnly() || !node(id)) return;
1014
+ const entries = catalogue.list().filter((t) => t.role !== 'source').map((t) => ({
1015
+ id: t.type_id, label: t.label, sub: '', description: t.description, icon: t.icon,
1016
+ tone: catalogue.tone(t.type_id), category: t.category ?? '', refusal: t.unavailable || undefined,
1017
+ }));
1018
+ const picked = await openStepPicker({ anchor, entries, categories: pickerCategories(),
1019
+ where: say(S, 'afterStep', titleOf(id)), strings: S });
1020
+ if (destroyed || !picked?.entry || isReadOnly()) return;
1021
+ const created = addStepAfter(pipeline, catalogue, id, picked.entry.id);
1022
+ if (!created) return;
1023
+ selected = created.id;
1024
+ commit('flow:step:add');
1025
+ afterShapeChange(created.id);
1026
+ options.onSelect?.({ kind: 'step', id: created.id });
1027
+ }
1028
+
1029
+ async function pickSource() {
1030
+ if (isReadOnly() || !addSourceBtn) return;
1031
+ const entries = catalogue.list().filter((t) => t.role === 'source').map((t) => ({
1032
+ id: t.type_id, label: t.label, description: t.description, icon: t.icon,
1033
+ tone: catalogue.tone(t.type_id), category: t.category ?? '', refusal: t.unavailable || undefined,
1034
+ }));
1035
+ const picked = await openStepPicker({ anchor: addSourceBtn, entries, categories: pickerCategories(),
1036
+ title: say(S, 'addSourceTitle'), where: say(S, 'startsLane'), strings: S });
1037
+ if (destroyed || !picked?.entry || isReadOnly()) return;
1038
+ const created = addSource(pipeline, catalogue, picked.entry.id);
1039
+ selected = created.id;
1040
+ commit('flow:source:add');
1041
+ afterShapeChange(created.id);
1042
+ options.onSelect?.({ kind: 'step', id: created.id });
1043
+ }
1044
+
1045
+ function pickerCategories() {
1046
+ return catalogue.categories.map((c) => ({ id: c.id, label: c.label, layout: c.layout }));
1047
+ }
1048
+
1049
+ function remove(id) {
1050
+ if (isReadOnly() || !node(id)) return false;
1051
+ const at = layout.at[id];
1052
+ const lane = at ? layout.lanes[at.lane] : null;
1053
+ const fallback = lane ? (lane.steps[at.position - 1]?.id ?? lane.steps[at.position + 1]?.id ?? null) : null;
1054
+ const hadFocus = root.contains(root.ownerDocument.activeElement);
1055
+ removeStep(pipeline, catalogue, id);
1056
+ if (selected === id) selected = fallback && node(fallback) ? fallback : null;
1057
+ commit('flow:step:remove');
1058
+ relayout();
1059
+ if (!selected) selected = firstStep();
1060
+ paintAll();
1061
+ if (hadFocus) focusCard(selected);
1062
+ options.onSelect?.({ kind: selected ? 'step' : null, id: selected });
1063
+ return true;
1064
+ }
1065
+
1066
+ function changeSetting(stepId, key, value) {
1067
+ if (isReadOnly()) return;
1068
+ if (stepId === null) {
1069
+ flowValue = { ...(flowValue ?? {}) };
1070
+ if (value === undefined) delete flowValue[key]; else flowValue[key] = clone(value);
1071
+ commit('flow:settings', `settings:${key}`);
1072
+ return;
1073
+ }
1074
+ const n = node(stepId);
1075
+ if (!n) return;
1076
+ if (value === undefined) delete n.config[key]; else n.config[key] = clone(value);
1077
+ commit('flow:step:config', `config:${stepId}:${key}`);
1078
+ paintCards();
1079
+ }
1080
+
1081
+ function rename(stepId, label) {
1082
+ if (isReadOnly()) return;
1083
+ const n = node(stepId);
1084
+ if (!n) return;
1085
+ if (String(label ?? '').trim()) n.label = String(label); else delete n.label;
1086
+ commit('flow:step:label', `label:${stepId}`, { preview: false });
1087
+ paintCards();
1088
+ paintTabState();
1089
+ }
1090
+
1091
+ function setJoin(stepId, port, sourceId) {
1092
+ if (isReadOnly()) return;
1093
+ if (!setInput(pipeline, catalogue, stepId, port, sourceId)) return;
1094
+ commit('flow:join:set', null);
1095
+ afterShapeChange();
1096
+ }
1097
+
1098
+ // ── parameters ───────────────────────────────────────────────────────
1099
+ function editParameter(name, anchor) {
1100
+ if (!params) return;
1101
+ const editing = name !== null;
1102
+ const def = editing && isObject(pipeline.parameters[name]) ? pipeline.parameters[name] : {};
1103
+ const ro = isReadOnly();
1104
+ const form = el('form', 'twm-flow-lanes__form');
1105
+ form.noValidate = true;
1106
+ form.appendChild(el('div', 'twm-flow-lanes__form-title', editing ? say(S, 'parameterTitle') : say(S, 'addParameter')));
1107
+ const row = (label, control) => {
1108
+ const r = el('label', 'twm-flow-lanes__form-row');
1109
+ r.append(el('span', 'twm-flow-lanes__form-label', label), control);
1110
+ form.appendChild(r);
1111
+ return control;
1112
+ };
1113
+ const nameBox = row(say(S, 'parameterName'), el('input', 'twm-flow-input'));
1114
+ nameBox.type = 'text';
1115
+ nameBox.value = name ?? '';
1116
+ nameBox.spellcheck = false;
1117
+ nameBox.dataset.field = 'name';
1118
+ const typeBox = row(say(S, 'parameterType'), el('select', 'twm-flow-input twm-flow-input--select'));
1119
+ typeBox.dataset.field = 'type';
1120
+ for (const t of parameterTypes) typeBox.appendChild(new Option(t.label, t.id));
1121
+ if (def.type !== undefined && !parameterTypes.some((t) => t.id === def.type)) typeBox.appendChild(new Option(String(def.type), String(def.type)));
1122
+ typeBox.value = def.type !== undefined ? String(def.type) : parameterTypes[0].id;
1123
+ const defaultHolder = el('span', 'twm-flow-lanes__form-control');
1124
+ row(say(S, 'parameterDefault'), defaultHolder);
1125
+ let defaultBox = null;
1126
+ const drawDefault = () => {
1127
+ const was = defaultBox ? defaultBox.value : (def.default === undefined ? ''
1128
+ : typeof def.default === 'string' ? def.default : JSON.stringify(def.default));
1129
+ defaultHolder.replaceChildren();
1130
+ if (typeBox.value === 'boolean') {
1131
+ defaultBox = el('select', 'twm-flow-input twm-flow-input--select');
1132
+ defaultBox.append(new Option(say(S, 'parameterNoDefault'), ''), new Option('true', 'true'), new Option('false', 'false'));
1133
+ defaultBox.value = ['true', 'false'].includes(was) ? was : '';
1134
+ } else {
1135
+ defaultBox = el('input', 'twm-flow-input');
1136
+ defaultBox.type = 'text';
1137
+ defaultBox.value = was;
1138
+ }
1139
+ defaultBox.dataset.field = 'default';
1140
+ defaultBox.disabled = ro;
1141
+ defaultHolder.appendChild(defaultBox);
1142
+ };
1143
+ drawDefault();
1144
+ typeBox.addEventListener('change', drawDefault);
1145
+ const descBox = row(say(S, 'parameterDescription'), el('input', 'twm-flow-input'));
1146
+ descBox.type = 'text';
1147
+ descBox.value = def.description ?? '';
1148
+ descBox.dataset.field = 'description';
1149
+ for (const b of [nameBox, typeBox, descBox]) b.disabled = ro;
1150
+ const refusal = el('p', 'twm-flow-lanes__form-refusal');
1151
+ refusal.setAttribute('role', 'alert');
1152
+ refusal.hidden = true;
1153
+ form.appendChild(refusal);
1154
+ const acts = el('div', 'twm-flow-lanes__form-actions');
1155
+ let handle = null;
1156
+ const save = button({ label: say(S, 'parameterSave'), primary: true, type: 'submit' });
1157
+ const cancel = button({ label: say(S, 'parameterCancel'), onClick: () => handle?.close('cancel') });
1158
+ save.disabled = ro;
1159
+ acts.appendChild(save);
1160
+ if (editing) {
1161
+ const del = button({ label: say(S, 'parameterRemove'), danger: true, onClick: () => {
1162
+ if (isReadOnly()) return;
1163
+ removeParameter(pipeline, name);
1164
+ handle?.close('removed');
1165
+ commit('flow:parameter:remove');
1166
+ paintParams();
1167
+ params.querySelector('[data-add]')?.focus({ preventScroll: true });
1168
+ } });
1169
+ del.disabled = ro;
1170
+ acts.appendChild(del);
1171
+ }
1172
+ acts.appendChild(cancel);
1173
+ form.appendChild(acts);
1174
+ const refuse = (text, box) => {
1175
+ refusal.textContent = text;
1176
+ refusal.hidden = false;
1177
+ box?.focus();
1178
+ };
1179
+ form.addEventListener('submit', (ev) => {
1180
+ ev.preventDefault();
1181
+ if (isReadOnly()) return;
1182
+ const next = nameBox.value.trim();
1183
+ if (!PARAMETER_NAME.test(next)) return refuse(say(S, 'parameterNameRule'), nameBox);
1184
+ if (next !== name && Object.prototype.hasOwnProperty.call(pipeline.parameters, next)) {
1185
+ return refuse(say(S, 'parameterNameTaken', next), nameBox);
1186
+ }
1187
+ const type = typeBox.value;
1188
+ const raw = defaultBox.value;
1189
+ let value;
1190
+ if (raw !== '') {
1191
+ if (type === 'number' || type === 'integer') {
1192
+ const v = Number(raw);
1193
+ if (!Number.isFinite(v)) return refuse(say(S, 'parameterNotNumber'), defaultBox);
1194
+ if (type === 'integer' && !Number.isInteger(v)) return refuse(say(S, 'parameterNotInteger'), defaultBox);
1195
+ value = v;
1196
+ } else if (type === 'boolean') {
1197
+ value = raw === 'true';
1198
+ } else {
1199
+ value = raw;
1200
+ }
1201
+ }
1202
+ setParameter(pipeline, next, { type, default: value, description: descBox.value.trim() }, { rename: name });
1203
+ handle?.close('saved');
1204
+ commit(editing ? 'flow:parameter:change' : 'flow:parameter:add');
1205
+ paintParams();
1206
+ [...params.querySelectorAll('[data-parameter]')].find((c) => c.dataset.parameter === next)
1207
+ ?.focus({ preventScroll: true });
1208
+ return undefined;
1209
+ });
1210
+ handle = openPopover({ anchor, content: form, label: editing ? say(S, 'parameterTitle') : say(S, 'addParameter'),
1211
+ className: 'twm-flow-lanes__popover', focus: ro ? cancel : nameBox });
1212
+ }
1213
+
1214
+ // ── the card's menu ──────────────────────────────────────────────────
1215
+ function openMenu(id, anchor) {
1216
+ if (compact || !node(id)) return;
1217
+ const menu = el('div', 'twm-flow-lanes__menu');
1218
+ menu.setAttribute('role', 'menu');
1219
+ let handle = null;
1220
+ const item = (label, glyph, run, { danger = false, disabled = false } = {}) => {
1221
+ const b = el('button', `twm-flow-lanes__menu-item${danger ? ' twm-flow-lanes__menu-item--danger' : ''}`);
1222
+ b.type = 'button';
1223
+ b.setAttribute('role', 'menuitem');
1224
+ b.disabled = disabled;
1225
+ b.append(icon(glyph), el('span', '', label));
1226
+ b.addEventListener('click', () => {
1227
+ handle?.close('chosen');
1228
+ run();
1229
+ });
1230
+ menu.appendChild(b);
1231
+ return b;
1232
+ };
1233
+ const first = item(say(S, 'openSettings'), 'tune', () => openSettingsOf(id));
1234
+ if (canAddAfter(id)) {
1235
+ item(say(S, 'addStepAfter'), 'add', () => {
1236
+ const add = [...surface.querySelectorAll('.twm-flow-lanes__node')].find((w) => w.dataset.step === id)
1237
+ ?.querySelector('.twm-flow-lanes__add');
1238
+ if (add) pickAfter(id, add);
1239
+ });
1240
+ }
1241
+ if (!isReadOnly()) item(say(S, 'removeStep'), 'delete', () => remove(id), { danger: true });
1242
+ menu.addEventListener('keydown', (ev) => {
1243
+ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
1244
+ ev.preventDefault();
1245
+ const items = [...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')];
1246
+ const at = items.indexOf(root.ownerDocument.activeElement);
1247
+ const next = items[(at + (ev.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
1248
+ next?.focus();
1249
+ });
1250
+ handle = openPopover({ anchor, content: menu, label: say(S, 'stepMenu', titleOf(id)),
1251
+ className: 'twm-flow-lanes__popover', focus: first });
1252
+ }
1253
+
1254
+ /** Choose a step and put the focus on its FIRST FIELD (else its name, else the panel's first control). */
1255
+ function openSettingsOf(id) {
1256
+ if (id && node(id)) select(id);
1257
+ if (!panel) return;
1258
+ const controls = 'input, select, textarea, [contenteditable="true"], button';
1259
+ const target = [...panel.el.querySelectorAll('.twm-flow-panel__fields *')].find((c) => c.matches(controls) && !c.disabled)
1260
+ ?? panel.el.querySelector('[data-twm-flow-title]') ?? panel.el.querySelector(controls);
1261
+ target?.focus({ preventScroll: true });
1262
+ }
1263
+
1264
+ // ── keys ─────────────────────────────────────────────────────────────
1265
+ const inSurface = (ev) => surface.contains(ev.target);
1266
+ const inTabs = (ev) => Boolean(tabs && tabs.contains(ev.target));
1267
+ /** The step a key was pressed on: its card's, or its tab's. */
1268
+ const stepAt = (ev) => (inSurface(ev) ? ev.target.closest?.('[data-step]')?.dataset.step
1269
+ : inTabs(ev) ? ev.target.closest?.('[role="tab"]')?.dataset.tab : null) ?? null;
1270
+ const unbindKeys = compact ? () => {} : bindFlowKeys(root, {
1271
+ undo: () => { if (isReadOnly()) return false; undo(); return true; },
1272
+ redo: () => { if (isReadOnly()) return false; redo(); return true; },
1273
+ remove: (ev) => {
1274
+ if (!(inSurface(ev) || inTabs(ev)) || isReadOnly()) return false;
1275
+ const id = stepAt(ev) ?? selected;
1276
+ if (!id || id === 'flow' || !node(id)) return false;
1277
+ remove(id);
1278
+ return true;
1279
+ },
1280
+ rename: () => {
1281
+ if (!selected || selected === 'flow' || !panel) return false;
1282
+ const t = panel.el.querySelector('[data-twm-flow-title]');
1283
+ if (!t) return false;
1284
+ t.focus();
1285
+ t.select?.();
1286
+ return true;
1287
+ },
1288
+ left: (ev) => arrow(ev, 'left'),
1289
+ right: (ev) => arrow(ev, 'right'),
1290
+ up: (ev) => arrow(ev, 'up'),
1291
+ down: (ev) => arrow(ev, 'down'),
1292
+ open: (ev) => {
1293
+ if (!(inSurface(ev) || inTabs(ev))) return false;
1294
+ const id = stepAt(ev);
1295
+ if (id) select(id);
1296
+ openSettingsOf(selected && selected !== 'flow' ? selected : null);
1297
+ return true;
1298
+ },
1299
+ escape: (ev) => {
1300
+ if (!dock?.contains(ev.target) || settingsCol.contains(ev.target)) return false;
1301
+ return focusCard(selected);
1302
+ },
1303
+ });
1304
+
1305
+ // Escape in the settings — in a field too — goes back to the chosen card,
1306
+ // unless something in the panel took the key first (a picker, an editor's
1307
+ // own suggestion list).
1308
+ const onPanelKey = (ev) => {
1309
+ if (ev.key !== 'Escape' || ev.defaultPrevented || ev.isComposing) return;
1310
+ if (focusCard(selected)) ev.preventDefault();
1311
+ };
1312
+ settingsCol?.addEventListener('keydown', onPanelKey);
1313
+
1314
+ function arrow(ev, dir) {
1315
+ if (inTabs(ev)) {
1316
+ if (dir !== 'left' && dir !== 'right') return false;
1317
+ const list = [...tabs.querySelectorAll('[role="tab"]')];
1318
+ const at = list.indexOf(ev.target.closest('[role="tab"]'));
1319
+ const next = list[at + (dir === 'right' ? 1 : -1)];
1320
+ if (!next) return true;
1321
+ select(next.dataset.tab);
1322
+ next.focus({ preventScroll: true });
1323
+ return true;
1324
+ }
1325
+ if (!inSurface(ev) || !ev.target.closest('.twm-flow-lanes__card')) return false;
1326
+ const from = ev.target.closest('[data-step]')?.dataset.step;
1327
+ const to = from ? neighbour(from, dir) : null;
1328
+ if (!to) return true;
1329
+ select(to);
1330
+ focusCard(to);
1331
+ return true;
1332
+ }
1333
+
1334
+
1335
+ // ── the toggle and the last run ──────────────────────────────────────
1336
+ function setStepsShow(next) {
1337
+ const m = next === 'run' ? 'run' : 'preview';
1338
+ if (m === mode) return;
1339
+ mode = m;
1340
+ paintToolbar();
1341
+ paintBanner();
1342
+ paintCards();
1343
+ options.onStepsShow?.(mode);
1344
+ if (mode === 'run' && !runOverlay && typeof options.lastRun === 'function') {
1345
+ lastRunAsk?.abort();
1346
+ const controller = new AbortController();
1347
+ lastRunAsk = controller;
1348
+ Promise.resolve(options.lastRun({ pipeline: getGraph(), signal: controller.signal })).then((overlay) => {
1349
+ if (destroyed || controller.signal.aborted || lastRunAsk !== controller) return;
1350
+ lastRunAsk = null;
1351
+ if (overlay) api.setRunOverlay(overlay);
1352
+ }, () => { if (lastRunAsk === controller) lastRunAsk = null; });
1353
+ }
1354
+ }
1355
+
1356
+ // ── reading ──────────────────────────────────────────────────────────
1357
+ function getGraph() {
1358
+ return normalisePipeline(pipeline);
1359
+ }
1360
+
1361
+ /** The columns of the frame a step reads on `port` (its lane input by default), or null. */
1362
+ function inputColumns(stepId, port = null) {
1363
+ const ports = portsOf(pipeline, catalogue, stepId);
1364
+ const at = port ?? ports?.laneInput;
1365
+ if (!at) return null;
1366
+ const c = inputOf(pipeline, stepId, at);
1367
+ if (!c) return null;
1368
+ const cols = columnsOf(c.sourceId);
1369
+ return cols.from ? cols.list.map((x) => ({ ...x })) : null;
1370
+ }
1371
+
1372
+ function cssEscape(v) {
1373
+ return String(v).replace(/["\\]/g, '\\$&');
1374
+ }
1375
+
1376
+ // ── the API ──────────────────────────────────────────────────────────
1377
+ const api = {
1378
+ el: root,
1379
+ /** Replace the content. A baseline (the default) clears both undo stacks. */
1380
+ load({ graph = null, pipeline: given = null, flowSettings } = {}, { baseline = true } = {}) {
1381
+ pipeline = normalisePipeline(given ?? graph ?? emptyPipeline());
1382
+ if (flowSettings !== undefined) {
1383
+ flowSchema = isObject(flowSettings?.schema) ? flowSettings.schema : null;
1384
+ flowValue = isObject(flowSettings?.value) ? clone(flowSettings.value) : (flowSchema ? {} : null);
1385
+ }
1386
+ previewNodes = {};
1387
+ describeNodes = {};
1388
+ previewError = null;
1389
+ relayout();
1390
+ if (!(selected && (selected === 'flow' ? flowSchema : node(selected)))) {
1391
+ selected = options.select !== undefined && (options.select === 'flow' ? flowSchema : node(options.select))
1392
+ ? options.select : (compact ? null : firstStep());
1393
+ }
1394
+ paintAll();
1395
+ if (baseline) history.baseline(snapshot());
1396
+ runner.schedule({ immediate: true });
1397
+ },
1398
+ getGraph,
1399
+ /** The flow's byte-stable text. */
1400
+ serialise: () => serialisePipeline(pipeline),
1401
+ getFlowSettings: () => (flowValue === null ? null : clone(flowValue)),
1402
+ /** The current layout (`layoutLanes`) and its coordinates (`placeLanes`). */
1403
+ get layout() { return layout; },
1404
+ get placed() { return placed; },
1405
+ setFindings(list) {
1406
+ findings = findingsList(list);
1407
+ byNode = groupFindings(findings, { graph: pipeline });
1408
+ strip?.set(findings);
1409
+ paintCards();
1410
+ panel?.setFindings(findings);
1411
+ },
1412
+ setRunOverlay(overlay) {
1413
+ runOverlay = isObject(overlay) ? overlay : null;
1414
+ paintBanner();
1415
+ paintCards();
1416
+ },
1417
+ setStepsShow,
1418
+ get stepsShow() { return mode; },
1419
+ setReadOnly(next) {
1420
+ readOnlyOpt = next || false;
1421
+ paintAll();
1422
+ },
1423
+ get readOnly() { return isReadOnly(); },
1424
+ setActionState(id, { disabled = false, reason = '', busy = false } = {}) {
1425
+ const a = actionButtons.get(String(id));
1426
+ if (!a) return;
1427
+ a.b.disabled = Boolean(disabled || busy);
1428
+ a.b.setAttribute('aria-busy', busy ? 'true' : 'false');
1429
+ a.wrap.classList.toggle('twm-flow-lanes__action--busy', Boolean(busy));
1430
+ a.why.textContent = disabled && reason ? String(reason) : '';
1431
+ a.why.hidden = !(disabled && reason);
1432
+ },
1433
+ setStatus(text) {
1434
+ if (statusEl) statusEl.textContent = String(text ?? '');
1435
+ },
1436
+ select(id) { select(id); },
1437
+ get selected() { return selected; },
1438
+ focus() { focusCard(selected); },
1439
+ undo,
1440
+ redo,
1441
+ history,
1442
+ /** Ask the providers now, without waiting for the debounce. */
1443
+ refreshPreview() { runner.now(); },
1444
+ inputColumns,
1445
+ destroy() {
1446
+ if (destroyed) return;
1447
+ destroyed = true;
1448
+ runner.destroy();
1449
+ lastRunAsk?.abort();
1450
+ unbindKeys();
1451
+ settingsCol?.removeEventListener('keydown', onPanelKey);
1452
+ closeFlowPopovers();
1453
+ table?.dispose?.();
1454
+ table = null;
1455
+ panel?.destroy();
1456
+ strip?.destroy();
1457
+ root.remove();
1458
+ },
1459
+ };
1460
+
1461
+ if (isObject(options.graph) || isObject(options.pipeline)) {
1462
+ api.load({ graph: options.graph ?? options.pipeline });
1463
+ } else {
1464
+ relayout();
1465
+ paintAll();
1466
+ history.baseline(snapshot());
1467
+ }
1468
+ return api;
1469
+ }