flexdesk 0.4.7 → 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 (76) hide show
  1. package/README.md +1036 -301
  2. package/css/base.css +2958 -2
  3. package/css/flexdesk.css +2962 -4
  4. package/css/overrides.css +4 -2
  5. package/css/tokens.css +86 -0
  6. package/dist/canvas.js +390 -0
  7. package/dist/canvas.js.map +7 -0
  8. package/dist/charts.js +782 -782
  9. package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
  10. package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
  11. package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
  12. package/dist/chunk-6NG7YVBG.js.map +7 -0
  13. package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
  14. package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
  15. package/dist/chunk-H4LT43RS.js +475 -0
  16. package/dist/chunk-H4LT43RS.js.map +7 -0
  17. package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
  18. package/dist/chunk-KH2GJTKR.js.map +7 -0
  19. package/dist/chunk-SN4LSOAR.js +532 -0
  20. package/dist/chunk-SN4LSOAR.js.map +7 -0
  21. package/dist/flexdesk.css +2962 -4
  22. package/dist/flow.js +10030 -0
  23. package/dist/flow.js.map +7 -0
  24. package/dist/tiles.js +2195 -2195
  25. package/dist/tokens.css +86 -0
  26. package/dist/widgets.js +5824 -5796
  27. package/dist/widgets.js.map +1 -1
  28. package/dist/wm.js +8077 -8074
  29. package/dist/wm.js.map +7 -7
  30. package/package.json +89 -81
  31. package/src/canvas/canvas_adapter.js +523 -0
  32. package/src/canvas/edge_router.js +392 -0
  33. package/src/canvas/graph/connector_router.js +189 -0
  34. package/src/canvas/graph/history_service.js +170 -0
  35. package/src/canvas/graph/node_platform.js +116 -0
  36. package/src/canvas/index.js +16 -0
  37. package/src/flow/canvas_editor/editor.js +1081 -0
  38. package/src/flow/canvas_editor/index.js +16 -0
  39. package/src/flow/canvas_editor/layout.js +118 -0
  40. package/src/flow/canvas_editor/strings.js +37 -0
  41. package/src/flow/kit/always_before.js +325 -0
  42. package/src/flow/kit/catalogue.js +170 -0
  43. package/src/flow/kit/chip_input.js +461 -0
  44. package/src/flow/kit/dom.js +205 -0
  45. package/src/flow/kit/findings.js +129 -0
  46. package/src/flow/kit/history.js +173 -0
  47. package/src/flow/kit/index.js +38 -0
  48. package/src/flow/kit/keys.js +127 -0
  49. package/src/flow/kit/logic_graph.js +210 -0
  50. package/src/flow/kit/references.js +199 -0
  51. package/src/flow/kit/settings_panel.js +357 -0
  52. package/src/flow/kit/settings_schema.js +175 -0
  53. package/src/flow/kit/step_picker.js +209 -0
  54. package/src/flow/kit/strings.js +102 -0
  55. package/src/flow/kit/value_picker.js +238 -0
  56. package/src/flow/kit/widgets.js +763 -0
  57. package/src/flow/lanes/editor.js +1469 -0
  58. package/src/flow/lanes/index.js +26 -0
  59. package/src/flow/lanes/layout.js +525 -0
  60. package/src/flow/lanes/pipeline.js +327 -0
  61. package/src/flow/lanes/preview.js +109 -0
  62. package/src/flow/lanes/strings.js +98 -0
  63. package/src/flow/outline/editor.js +1511 -0
  64. package/src/flow/outline/index.js +20 -0
  65. package/src/flow/outline/mapping.js +286 -0
  66. package/src/flow/outline/menu.js +154 -0
  67. package/src/flow/outline/operations.js +738 -0
  68. package/src/flow/outline/recognise.js +755 -0
  69. package/src/flow/outline/strings.js +135 -0
  70. package/src/tiling/landing_table.js +13 -5
  71. package/src/tiling/wm.js +3916 -3916
  72. package/src/ui/components/data_table.js +4205 -3294
  73. package/src/ui/components/row_activation.js +70 -0
  74. package/src/ui/components/table_values.js +423 -0
  75. package/dist/chunk-ELXVW542.js.map +0 -7
  76. package/dist/chunk-WMGOJ3I6.js.map +0 -7
@@ -0,0 +1,1511 @@
1
+ /**
2
+ * THE OUTLINE EDITOR — a logic flow drawn as a list of blocks (36 §6).
3
+ *
4
+ * const editor = createOutlineEditor(host, {
5
+ * catalogue, widgets, references, blocks, // the consumer's types, controls, syntaxes, mapping
6
+ * values, summarise, summariseReads, flowSettings, // Insert a value, a row's line, the panel's id line
7
+ * actions, slots, strings, services, // toolbar verbs, consumer areas, words, the bag
8
+ * onChange, onSelect, onFoldChange, initialFolds, history,
9
+ * });
10
+ * editor.load({ graph, flowSettings });
11
+ *
12
+ * THE OUTLINE IS THE GRAPH. The editor holds the consumer's graph and reads
13
+ * the outline from it (`recognise.js`); every edit is an operation on the
14
+ * graph (`operations.js`) and the outline is read again. Opened and saved with
15
+ * no edit, `serialise(getGraph())` is the text it was loaded from (I2); node
16
+ * positions are kept and never used. A graph that is not block-shaped is
17
+ * REFUSED, never repaired: it opens read-only, listed in the order it runs,
18
+ * with the sentence saying why above it.
19
+ *
20
+ * ══ WHAT IT DOES NOT DO ═════════════════════════════════════════════════
21
+ *
22
+ * It never fetches, never saves and starts no timer that writes. Every byte
23
+ * that leaves it leaves through `onChange` — after every committed edit, an
24
+ * undo and a redo — and the consumer autosaves. Whether a flow is valid is the
25
+ * consumer's validator: its findings are drawn (`setFindings`), never made.
26
+ *
27
+ * ══ GESTURES ════════════════════════════════════════════════════════════
28
+ *
29
+ * A press SELECTS — it toggles classes and fills the settings panel, and
30
+ * rebuilds no row, so the click lands on the node the press went down on.
31
+ * "+" between rows (on hover AND on focus), the add rows and a row's menu
32
+ * open the step picker and the step menu, both the kit's popovers. A step is
33
+ * dragged by its grip with POINTER EVENTS and a ghost on `document.body`
34
+ * after 4 px — never `draggable` (a native drag takes the pointer at ~5 px
35
+ * and fires `pointercancel`) and never `setPointerCapture` (it retargets the
36
+ * release). The drop line names where the step would go; a refused place
37
+ * says why instead.
38
+ *
39
+ * Keys are the kit's (`bindFlowKeys`), bound on the editor's root: ↑ ↓ move
40
+ * the selection, ← → fold and unfold, Enter opens the settings, Delete
41
+ * removes, Ctrl+D/C/X/V, F2, Alt+↑ ↓ ←, Ctrl+Z/Y. Backspace is never the
42
+ * editor's.
43
+ */
44
+
45
+ import { el, icon, button, toneClass, uid, closeFlowPopovers } from '../kit/dom.js';
46
+ import { createStrings, say } from '../kit/strings.js';
47
+ import { createSettingsPanel } from '../kit/settings_panel.js';
48
+ import { fieldsFromSchema } from '../kit/settings_schema.js';
49
+ import { openStepPicker } from '../kit/step_picker.js';
50
+ import { alwaysBefore, enclosingArms, enclosingLoops } from '../kit/always_before.js';
51
+ import { createFindingsStrip, findingsList, groupFindings } from '../kit/findings.js';
52
+ import { FlowHistory } from '../kit/history.js';
53
+ import { bindFlowKeys } from '../kit/keys.js';
54
+ import { emptyGraph, serialise } from '../kit/logic_graph.js';
55
+ import { OUTLINE_STRINGS } from './strings.js';
56
+ import { createBlockMapping, displayName } from './mapping.js';
57
+ import { describeOutline, flatRunOrder, subtreeIds } from './recognise.js';
58
+ import { createOutlineOperations } from './operations.js';
59
+ import { openStepMenu } from './menu.js';
60
+
61
+ /** The edits the outline records (36 §6.6); history refuses any other. */
62
+ export const OUTLINE_ACTIONS = Object.freeze([
63
+ 'flow:step:add', 'flow:step:remove', 'flow:step:move', 'flow:step:wrap', 'flow:step:duplicate',
64
+ 'flow:step:paste', 'flow:step:label', 'flow:step:config', 'flow:arm', 'flow:branch:add', 'flow:branch:remove',
65
+ 'flow:settings',
66
+ ]);
67
+
68
+ /** Pixels a level of the outline is indented by (the mock's 22). The stylesheet holds the same number. */
69
+ export const OUTLINE_INDENT = 22;
70
+
71
+ const DRAG_START_PX = 4;
72
+
73
+ /** What Copy holds — one per page, so a step copied in one outline pastes into another. */
74
+ let CLIPBOARD = null;
75
+
76
+ /**
77
+ * @param {HTMLElement} host
78
+ * @param {object} options 36 §3.14 and §6
79
+ */
80
+ export function createOutlineEditor(host, options = {}) {
81
+ const {
82
+ catalogue, widgets, references = {}, blocks = {}, values = null, valuesNote = null,
83
+ summarise = null, summariseReads = null, flowSettings = null, readOnly = false, actions = [],
84
+ slots = {}, strings = null, services = null, onChange = null, onSelect = null,
85
+ onFoldChange = null, initialFolds = [], history: historyOptions = {},
86
+ } = options;
87
+ if (!catalogue?.get) throw new Error('createOutlineEditor needs a step catalogue (createStepCatalogue()).');
88
+ if (!widgets?.get) throw new Error('createOutlineEditor needs a widget registry (createWidgetRegistry()).');
89
+
90
+ const S = createStrings(OUTLINE_STRINGS, strings);
91
+ const map = createBlockMapping(blocks, catalogue);
92
+ const ops = createOutlineOperations({ catalogue, blocks: map, strings, references });
93
+
94
+ // ── state ──────────────────────────────────────────────────────────
95
+ let graph = emptyGraph();
96
+ let settings = flowSettings ? { ...flowSettings, value: structuredClone(flowSettings.value ?? {}) } : null;
97
+ let view = null;
98
+ let refusal = null;
99
+ let consumerRO = readOnly || false;
100
+ let selected = 'flow';
101
+ const folds = new Set(Array.isArray(initialFolds) ? initialFolds : []);
102
+ let findings = [];
103
+ let overlay = null;
104
+ let findText = '';
105
+ let destroyed = false;
106
+ let drag = null;
107
+ const actionState = new Map();
108
+ const itemEls = new Map();
109
+
110
+ const nodeOf = (id) => graph.nodes.find((n) => n.id === id) ?? null;
111
+ const typeOf = (id) => catalogue.get(nodeOf(id)?.type) ?? null;
112
+ const nameOf = (id) => (id ? displayName(nodeOf(id), catalogue) || String(id) : say(S, 'theFlow'));
113
+ const editable = () => !refusal && !consumerRO;
114
+ const roReason = () => (refusal ? say(S, 'readOnlyBecause', refusal.message)
115
+ : (consumerRO && typeof consumerRO === 'object' && consumerRO.reason ? String(consumerRO.reason) : ''));
116
+ const startId = () => graph.nodes.find((n) => map.kindOf(n.type) === 'start')?.id ?? null;
117
+
118
+ // ── the frame ──────────────────────────────────────────────────────
119
+ const root = el('div', 'twm-flow-outline');
120
+ root.tabIndex = -1;
121
+
122
+ const toolbar = el('div', 'twm-flow-outline__toolbar');
123
+ toolbar.setAttribute('role', 'toolbar');
124
+ const undoBtn = button({ label: say(S, 'undo'), icon: 'undo', title: say(S, 'undoTitle'), className: 'twm-flow-outline__tool' });
125
+ const redoBtn = button({ label: say(S, 'redo'), icon: 'redo', title: say(S, 'redoTitle'), className: 'twm-flow-outline__tool' });
126
+ undoBtn.dataset.action = 'undo';
127
+ redoBtn.dataset.action = 'redo';
128
+ undoBtn.addEventListener('click', () => api.undo());
129
+ redoBtn.addEventListener('click', () => api.redo());
130
+ toolbar.append(undoBtn, redoBtn);
131
+ const actionEls = new Map();
132
+ if (actions.length) {
133
+ const sep = el('span', 'twm-flow-outline__sep');
134
+ sep.setAttribute('aria-hidden', 'true');
135
+ toolbar.appendChild(sep);
136
+ }
137
+ for (const a of actions) {
138
+ const wrap = el('span', 'twm-flow-outline__tool-wrap');
139
+ const b = button({ label: a.label, icon: a.icon || null, primary: Boolean(a.primary),
140
+ className: 'twm-flow-outline__tool' });
141
+ b.dataset.action = a.id;
142
+ b.addEventListener('click', () => { if (!b.disabled) a.run?.(api); });
143
+ const why = el('span', 'twm-flow-outline__refusal');
144
+ why.hidden = true;
145
+ wrap.append(b, why);
146
+ toolbar.appendChild(wrap);
147
+ actionEls.set(a.id, { button: b, why });
148
+ }
149
+ const statusEl = el('span', 'twm-flow-outline__status');
150
+ statusEl.setAttribute('aria-live', 'polite');
151
+ const spacer = el('span', 'twm-flow-outline__spacer');
152
+ const toolbarEnd = el('div', 'twm-flow-outline__toolbar-end');
153
+ toolbar.append(statusEl, spacer, toolbarEnd);
154
+
155
+ const roBanner = el('div', 'twm-flow-outline__readonly');
156
+ roBanner.setAttribute('role', 'status');
157
+ roBanner.hidden = true;
158
+
159
+ const strip = createFindingsStrip({
160
+ strings: S,
161
+ nameOf: (id) => nameOf(id),
162
+ onGoTo: (f) => goTo(f),
163
+ });
164
+
165
+ const runBanner = el('div', 'twm-flow-outline__banner');
166
+ runBanner.hidden = true;
167
+
168
+ const body = el('div', 'twm-flow-outline__body');
169
+ const list = el('section', 'twm-flow-outline__list');
170
+ list.setAttribute('aria-label', say(S, 'stepsHeading'));
171
+ const head = el('div', 'twm-flow-outline__list-head');
172
+ const heading = el('h2', 'twm-flow-outline__heading', say(S, 'stepsHeading'));
173
+ const countEl = el('span', 'twm-flow-outline__count');
174
+ const foldAllBtn = el('button', 'twm-flow-outline__fold-all', say(S, 'foldAll'));
175
+ foldAllBtn.type = 'button';
176
+ const unfoldAllBtn = el('button', 'twm-flow-outline__fold-all', say(S, 'unfoldAll'));
177
+ unfoldAllBtn.type = 'button';
178
+ foldAllBtn.addEventListener('click', () => setFolds(blockIds()));
179
+ unfoldAllBtn.addEventListener('click', () => setFolds([]));
180
+ head.append(heading, countEl, el('span', 'twm-flow-outline__spacer'), foldAllBtn, unfoldAllBtn);
181
+ const findBox = el('input', 'twm-flow-outline__find');
182
+ findBox.type = 'search';
183
+ findBox.placeholder = say(S, 'findStep');
184
+ findBox.setAttribute('aria-label', say(S, 'findStep'));
185
+ findBox.autocomplete = 'off';
186
+ findBox.addEventListener('input', () => { findText = findBox.value; render(); });
187
+ findBox.addEventListener('keydown', (ev) => {
188
+ if (ev.key === 'Escape' && findBox.value) {
189
+ ev.preventDefault();
190
+ ev.stopPropagation();
191
+ findBox.value = '';
192
+ findText = '';
193
+ render();
194
+ } else if (ev.key === 'Enter') {
195
+ ev.preventDefault();
196
+ const first = [...tree.querySelectorAll('[role="treeitem"][data-match]')][0];
197
+ if (first) select(first.dataset.step || 'flow', { focus: true });
198
+ }
199
+ });
200
+ const scroller = el('div', 'twm-flow-outline__scroll');
201
+ const tree = el('div', 'twm-flow-outline__tree');
202
+ tree.setAttribute('role', 'tree');
203
+ tree.setAttribute('aria-label', say(S, 'stepsHeading'));
204
+ scroller.appendChild(tree);
205
+ const message = el('p', 'twm-flow-outline__message');
206
+ message.setAttribute('aria-live', 'polite');
207
+ message.hidden = true;
208
+ const keysLine = el('p', 'twm-flow-outline__keys', say(S, 'statusKeys'));
209
+ list.append(head, findBox, scroller, message, keysLine);
210
+
211
+ const settingsHost = el('section', 'twm-flow-outline__settings');
212
+ const aside = el('aside', 'twm-flow-outline__aside');
213
+ aside.hidden = true;
214
+ body.append(list, settingsHost, aside);
215
+ root.append(toolbar, roBanner, strip.el, runBanner, body);
216
+ host.appendChild(root);
217
+ if (typeof slots.toolbarEnd === 'function') slots.toolbarEnd(toolbarEnd);
218
+ toolbarEnd.hidden = toolbarEnd.childNodes.length === 0;
219
+
220
+ // ── the settings panel (the kit's) ─────────────────────────────────
221
+ const panel = createSettingsPanel({
222
+ widgets, references, strings: S, services, readOnly: false, host: settingsHost, valuesNote,
223
+ onChange: (stepId, key, value) => onPanelChange(stepId, key, value),
224
+ onRename: (stepId, label) => onRename(stepId, label),
225
+ onFocusLost: () => focusSelected(),
226
+ values: values ? (q) => valuesFor(q) : null,
227
+ columns: null,
228
+ });
229
+
230
+ function valuesFor({ stepId, field, key }) {
231
+ const g = structuredClone(graph);
232
+ if (!stepId) return values({ graph: g, stepId: null, field, key, before: [], loops: [], arms: [], parameters: null });
233
+ const loopPorts = map.loopPorts;
234
+ const before = alwaysBefore(graph, catalogue, stepId, {
235
+ loopPorts, waitsForAll: (join) => map.joinValue(join) !== 'any',
236
+ });
237
+ const loops = enclosingLoops(graph, catalogue, stepId, { loopPorts });
238
+ // The arms it is inside, each with its port: in an error arm its head
239
+ // FAILED, and the consumer offers it by its error output alone (36 §3.7).
240
+ const arms = enclosingArms(graph, catalogue, stepId, {
241
+ loopPorts, kindOf: (type) => map.kindOf(type), continuePort: (n) => map.cont(n?.type),
242
+ });
243
+ return values({ graph: g, stepId, step: nodeOf(stepId), field, key, before, loops, arms, parameters: null });
244
+ }
245
+
246
+ // ── history ────────────────────────────────────────────────────────
247
+ const stateText = () => JSON.stringify({ graph, settings: settings ? settings.value : null });
248
+ const history = new FlowHistory({
249
+ actions: OUTLINE_ACTIONS,
250
+ limit: historyOptions.limit ?? 100,
251
+ mergeMs: historyOptions.mergeMs ?? 1000,
252
+ now: historyOptions.now,
253
+ restore: (state) => {
254
+ const s = JSON.parse(state);
255
+ graph = s.graph;
256
+ if (settings) settings.value = s.settings ?? {};
257
+ reread();
258
+ if (selected !== 'flow' && selected !== null && !nodeOf(selected)) selected = 'flow';
259
+ render();
260
+ showPanel();
261
+ drawAside();
262
+ },
263
+ onState: () => updateTools(),
264
+ });
265
+
266
+ function emit(action, key = null) {
267
+ onChange?.({ graph: structuredClone(graph), flowSettings: settings ? structuredClone(settings.value) : null,
268
+ text: serialise(graph), action, key });
269
+ }
270
+
271
+ function commit(action, key = null) {
272
+ if (history.commit(stateText(), action, key)) emit(action, key);
273
+ }
274
+
275
+ // ── reading the outline ────────────────────────────────────────────
276
+ function reread() {
277
+ const r = ops.read(graph);
278
+ view = r.ok ? r : null;
279
+ refusal = r.ok ? null : r.refusal;
280
+ drawReadOnly();
281
+ }
282
+
283
+ function drawReadOnly() {
284
+ const why = roReason();
285
+ roBanner.replaceChildren();
286
+ roBanner.hidden = !why;
287
+ if (why) roBanner.append(icon('lock', 'twm-flow-outline__readonly-icon'), el('span', 'twm-flow-outline__readonly-text', why));
288
+ root.classList.toggle('twm-flow-outline--readonly', !editable());
289
+ updateTools();
290
+ }
291
+
292
+ function updateTools() {
293
+ const can = editable();
294
+ undoBtn.disabled = !can || !history.canUndo;
295
+ redoBtn.disabled = !can || !history.canRedo;
296
+ foldAllBtn.disabled = !view;
297
+ unfoldAllBtn.disabled = !view;
298
+ for (const [id, { button: b, why }] of actionEls) {
299
+ const st = actionState.get(id) || {};
300
+ b.disabled = Boolean(st.disabled || st.busy);
301
+ b.classList.toggle('twm-flow-outline__tool--busy', Boolean(st.busy));
302
+ why.textContent = st.disabled && st.reason ? String(st.reason) : '';
303
+ why.hidden = !why.textContent;
304
+ }
305
+ }
306
+
307
+ function announce(text) {
308
+ message.textContent = text || '';
309
+ message.hidden = !text;
310
+ }
311
+
312
+ // ── what is drawn ──────────────────────────────────────────────────
313
+ const findingsBy = () => groupFindings(findings, { graph });
314
+
315
+ function blockIds() {
316
+ if (!view) return [];
317
+ const out = [];
318
+ for (const p of view.index.at.values()) {
319
+ if (!p.seq || p.join) continue;
320
+ if (hasChildren(p.item)) out.push(p.item.id);
321
+ }
322
+ return out;
323
+ }
324
+
325
+ function drawnArms(item) {
326
+ if (item.kind === 'step' || item.kind === 'start') {
327
+ return item.arms.filter((a) => !(a.seq.exit === 'open' && a.seq.items.length === 0));
328
+ }
329
+ return item.arms || [];
330
+ }
331
+
332
+ function hasChildren(item) {
333
+ if (item.kind === 'loop' || item.kind === 'fanout' || item.kind === 'branch') return true;
334
+ return drawnArms(item).length > 0;
335
+ }
336
+
337
+ /** Steps inside an item, for "N steps inside". */
338
+ function stepsInside(item) {
339
+ return subtreeIds(item).filter((id) => id !== item.id && map.kindOf(nodeOf(id)?.type) !== 'join').length;
340
+ }
341
+
342
+ /**
343
+ * The consumer's one line for a row: `summarise` answers text, or
344
+ * `{text, mono}` for a line that is code (a formula) and is drawn in the
345
+ * code face, as the mock draws a condition's.
346
+ */
347
+ function lineOf(node, type) {
348
+ const s = node && summarise ? summarise(node, type) : null;
349
+ if (s && typeof s === 'object') return { text: s.text === undefined || s.text === null ? '' : String(s.text), mono: Boolean(s.mono) };
350
+ return { text: s === undefined || s === null ? '' : String(s), mono: false };
351
+ }
352
+
353
+ function findFilter() {
354
+ const q = findText.trim().toLowerCase();
355
+ if (!q || !view) return null;
356
+ const match = new Set();
357
+ for (const n of graph.nodes) {
358
+ if (map.kindOf(n.type) === 'start' || map.kindOf(n.type) === 'join') continue;
359
+ const t = catalogue.get(n.type);
360
+ const hay = [displayName(n, catalogue), t?.label, lineOf(n, t).text].map((s) => String(s ?? '').toLowerCase()).join(' ');
361
+ if (hay.includes(q)) match.add(n.id);
362
+ }
363
+ const show = new Set(match);
364
+ for (const id of match) {
365
+ const p = view.index.at.get(id);
366
+ for (const step of p?.path || []) show.add(step.owner.id);
367
+ }
368
+ return { match, show, query: findText.trim() };
369
+ }
370
+
371
+ // overlay helpers
372
+ const stepRun = (id) => overlay?.steps?.[id] ?? null;
373
+ const portRun = (id, port) => overlay?.ports?.[id]?.[port] ?? null;
374
+ const RUN_TONES = { completed: 'ok', failed: 'fail', skipped: 'skip', 'not-reached': 'skip', running: 'running',
375
+ cancelled: 'skip' };
376
+ const isOff = (id) => ['skipped', 'not-reached'].includes(stepRun(id)?.state);
377
+
378
+ // ── rendering ──────────────────────────────────────────────────────
379
+ function render() {
380
+ if (destroyed) return;
381
+ // Focus in the tree survives a redraw: on the same step's row (or its
382
+ // menu button), else on the selection — never on <body>.
383
+ const active = root.ownerDocument.activeElement;
384
+ const held = tree.contains(active)
385
+ ? { step: active.closest('[role="treeitem"]')?.dataset.step || 'flow',
386
+ menu: Boolean(active.closest('.twm-flow-outline__menu')) }
387
+ : null;
388
+ tree.replaceChildren();
389
+ itemEls.clear();
390
+ gapEls = [];
391
+ offArm.clear();
392
+ const filter = findFilter();
393
+ if (view) renderOutline(filter);
394
+ else renderFlat();
395
+ countEl.textContent = say(S, 'stepsCount', graph.nodes.filter((n) => !['start', 'join'].includes(map.kindOf(n.type))).length);
396
+ if (filter && !filter.match.size) announce(say(S, 'findNothing', filter.query));
397
+ else if (filter || message.dataset.kind === 'find') announce('');
398
+ message.dataset.kind = filter ? 'find' : '';
399
+ drawRunBanner();
400
+ applySelection();
401
+ if (held) {
402
+ const item = itemEls.get(held.step) ?? itemEls.get(selected) ?? itemEls.get('flow');
403
+ const target = held.menu ? item?.querySelector(':scope > .twm-flow-outline__row > .twm-flow-outline__menu') : null;
404
+ (target || item)?.focus({ preventScroll: true });
405
+ }
406
+ // A redraw in the middle of a drag (a consumer's findings arriving)
407
+ // keeps the drag: its line, its places, its marked row.
408
+ if (drag?.started) {
409
+ tree.appendChild(drag.line);
410
+ drag.targets = dropTargets(drag.id);
411
+ itemEls.get(drag.id)?.querySelector(':scope > .twm-flow-outline__row')?.classList.add('twm-flow-outline__row--dragging');
412
+ }
413
+ }
414
+
415
+ /**
416
+ * A redraw the reader did not ask for — findings, a run overlay, a
417
+ * read-only switch — waits while a press is held in the editor. The
418
+ * browser fires a click only when press and release land on the SAME
419
+ * node, and a run refreshed every second would otherwise rebuild the row
420
+ * under a press now and then and swallow its click.
421
+ */
422
+ let pressing = false;
423
+ let pendingRender = false;
424
+ function renderSoon() {
425
+ if (pressing) { pendingRender = true; return; }
426
+ render();
427
+ }
428
+ const onPressStart = () => { pressing = true; };
429
+ const onPressEnd = () => {
430
+ if (!pressing) return;
431
+ pressing = false;
432
+ if (!pendingRender) return;
433
+ pendingRender = false;
434
+ // After the click this release makes, which is dispatched in this same turn.
435
+ setTimeout(() => { if (!destroyed && !pressing) render(); else if (pressing) pendingRender = true; }, 0);
436
+ };
437
+
438
+ function guides(row, depth) {
439
+ for (let k = 0; k < depth; k += 1) {
440
+ const g = el('span', 'twm-flow-outline__guide');
441
+ g.setAttribute('aria-hidden', 'true');
442
+ g.style.setProperty('--twm-outline-guide', String(k));
443
+ row.appendChild(g);
444
+ }
445
+ }
446
+
447
+ function rowAt(cls, depth) {
448
+ const row = el('div', `twm-flow-outline__row ${cls}`);
449
+ row.style.setProperty('--twm-outline-depth', String(depth));
450
+ guides(row, depth);
451
+ return row;
452
+ }
453
+
454
+ function renderFlat() {
455
+ const order = flatRunOrder(graph, catalogue, map);
456
+ for (const id of order) {
457
+ const n = nodeOf(id);
458
+ if (!n) continue;
459
+ if (map.kindOf(n.type) === 'start') tree.appendChild(flowItem(null));
460
+ else tree.appendChild(stepItem({ kind: 'step', id, arms: [] }, 0, null));
461
+ }
462
+ }
463
+
464
+ function renderOutline(filter) {
465
+ const flow = flowItem(filter);
466
+ tree.appendChild(flow);
467
+ const flowRow = flow.querySelector(':scope > .twm-flow-outline__row');
468
+ gap(flowRow, view.tree.top, 0, 0);
469
+ renderSeq(view.tree.top, 0, tree, filter);
470
+ if (editable() && !filter) {
471
+ const add = addRow(0, map.addLabel(), () => addAnchor(view.tree.top));
472
+ if (add) tree.appendChild(add);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Where an add row inserts: the end of its Seq, or just before the End
478
+ * that closes it. Null when nothing could be added there — no add row is
479
+ * drawn that every entry would refuse — and when the end of the Seq is
480
+ * really another place: after a branch only one arm of which goes on,
481
+ * "after the block" IS the end of that arm, and a row drawn at the foot of
482
+ * the Seq would add a step somewhere it is not drawn.
483
+ */
484
+ function addAnchor(seq) {
485
+ let at = ops.resolve(view, ops.anchorOf(seq, seq.items.length)) ?? { seq, index: seq.items.length };
486
+ if (at.seq !== seq) return null;
487
+ const last = at.seq.items[at.index - 1];
488
+ if (at.index === at.seq.items.length && last?.kind === 'end') at = { seq: at.seq, index: at.index - 1 };
489
+ const facts = ops.gapAt(view, at.seq, at.index);
490
+ if (facts.afterEnd || !facts.arriving.length) return null;
491
+ return { anchor: ops.anchorOf(at.seq, at.index), seq: at.seq, index: at.index };
492
+ }
493
+
494
+ function renderSeq(seq, depth, container, filter) {
495
+ seq.items.forEach((item, i) => {
496
+ if (filter && !filter.show.has(item.id)) return;
497
+ const itemEl = stepItem(item, depth, filter);
498
+ container.appendChild(itemEl);
499
+ const rows = itemEl.querySelectorAll('.twm-flow-outline__row');
500
+ const visible = [...rows].filter((r) => !r.closest('[hidden]'));
501
+ gap(visible[visible.length - 1] || rows[0], seq, i + 1, depth);
502
+ });
503
+ }
504
+
505
+ function flowItem(filter) {
506
+ const id = startId();
507
+ const node = id ? nodeOf(id) : null;
508
+ const type = node ? catalogue.get(node.type) : null;
509
+ const item = el('div', 'twm-flow-outline__item twm-flow-outline__item--flow');
510
+ item.setAttribute('role', 'treeitem');
511
+ item.setAttribute('aria-level', '1');
512
+ item.dataset.twmFlowItem = '';
513
+ item.dataset.flow = '';
514
+ item.tabIndex = -1;
515
+ const row = rowAt('twm-flow-outline__row--flow', 0);
516
+ const chip = el('span', `twm-flow-outline__chip ${toneClass('twm-flow-outline__chip', map.start.tone || 'blue')}`);
517
+ chip.setAttribute('aria-hidden', 'true');
518
+ chip.appendChild(icon(map.start.icon || type?.icon || 'play_arrow'));
519
+ const words = el('span', 'twm-flow-outline__words');
520
+ const line = el('span', 'twm-flow-outline__line');
521
+ line.appendChild(el('span', 'twm-flow-outline__title', map.startLabel(S)));
522
+ words.appendChild(line);
523
+ const said = node ? lineOf(node, type) : { text: '', mono: false };
524
+ const sum = said.text;
525
+ if (sum) words.appendChild(summaryEl(said));
526
+ row.append(chip, words);
527
+ const fs = findingsBy().get(null) || [];
528
+ if (fs.length) row.appendChild(badge(fs));
529
+ item.appendChild(row);
530
+ item.setAttribute('aria-label', [map.startLabel(S), sum].filter(Boolean).join(', '));
531
+ row.addEventListener('click', () => select('flow', { focus: true }));
532
+ if (filter) item.classList.add('twm-flow-outline__item--context');
533
+ itemEls.set('flow', item);
534
+ return item;
535
+ }
536
+
537
+ function summaryEl({ text, mono }) {
538
+ return el('span', `twm-flow-outline__summary${mono ? ' twm-flow-outline__summary--mono' : ''}`, text);
539
+ }
540
+
541
+ function badge(list) {
542
+ const errors = list.filter((f) => f.severity !== 'warning');
543
+ const b = el('span', `twm-flow-outline__badge${errors.length ? '' : ' twm-flow-outline__badge--warning'}`);
544
+ b.appendChild(icon(errors.length ? 'error' : 'warning'));
545
+ b.title = errors.length ? say(S, 'toFix', errors.length) : say(S, 'toLookAt', list.length);
546
+ return b;
547
+ }
548
+
549
+ function stepItem(item, depth, filter) {
550
+ const blocky = view ? hasChildren(item) : false;
551
+ const folded = blocky && folds.has(item.id) && !filter;
552
+ const li = el('div', 'twm-flow-outline__item');
553
+ li.setAttribute('role', 'treeitem');
554
+ li.setAttribute('aria-level', String(depth + 1));
555
+ li.dataset.twmFlowItem = '';
556
+ li.dataset.step = item.id;
557
+ li.tabIndex = -1;
558
+ if (blocky) li.setAttribute('aria-expanded', String(!folded));
559
+ if (filter) {
560
+ if (filter.match.has(item.id)) li.dataset.match = '';
561
+ else li.classList.add('twm-flow-outline__item--context');
562
+ }
563
+ const { row, label } = stepRow(item, depth, filter);
564
+ li.setAttribute('aria-label', label);
565
+ li.appendChild(row);
566
+ itemEls.set(item.id, li);
567
+ if (view && blocky) {
568
+ const inside = el('div', 'twm-flow-outline__inside');
569
+ inside.hidden = folded;
570
+ buildInside(item, depth, inside, filter, row);
571
+ li.appendChild(inside);
572
+ }
573
+ return li;
574
+ }
575
+
576
+ /** One step's row: grip, fold, chip, words, the run's word, a finding's mark, the menu. */
577
+ function stepRow(item, depth, filter) {
578
+ const node = nodeOf(item.id);
579
+ const type = catalogue.get(node?.type);
580
+ const name = nameOf(item.id);
581
+ const blocky = view ? hasChildren(item) : false;
582
+ const folded = blocky && folds.has(item.id) && !filter;
583
+ const row = rowAt('twm-flow-outline__row--step', depth);
584
+ if (isOff(item.id) || offArm.has(item.id)) row.classList.add('twm-flow-outline__row--off');
585
+ const grip = el('span', 'twm-flow-outline__grip');
586
+ grip.setAttribute('aria-hidden', 'true');
587
+ grip.title = say(S, 'dragToMove');
588
+ grip.appendChild(icon('drag_indicator'));
589
+ if (editable() && view) grip.addEventListener('pointerdown', (ev) => armDrag(ev, item.id));
590
+ else grip.classList.add('twm-flow-outline__grip--off');
591
+ row.appendChild(grip);
592
+ if (blocky) {
593
+ const f = el('button', 'twm-flow-outline__fold');
594
+ f.type = 'button';
595
+ f.tabIndex = -1;
596
+ f.setAttribute('aria-label', say(S, folded ? 'unfold' : 'fold', name));
597
+ f.setAttribute('aria-expanded', String(!folded));
598
+ f.appendChild(icon(folded ? 'chevron_right' : 'expand_more'));
599
+ f.addEventListener('click', (ev) => { ev.stopPropagation(); toggleFold(item.id); });
600
+ row.appendChild(f);
601
+ } else {
602
+ const sp = el('span', 'twm-flow-outline__fold-space');
603
+ sp.setAttribute('aria-hidden', 'true');
604
+ row.appendChild(sp);
605
+ }
606
+ const chip = el('span', `twm-flow-outline__chip ${toneClass('twm-flow-outline__chip', catalogue.tone(node?.type))}`);
607
+ chip.setAttribute('aria-hidden', 'true');
608
+ chip.appendChild(icon(type?.icon || ''));
609
+ row.appendChild(chip);
610
+ const words = el('span', 'twm-flow-outline__words');
611
+ const line = el('span', 'twm-flow-outline__line');
612
+ line.appendChild(el('span', 'twm-flow-outline__title', name));
613
+ if (type?.label && type.label !== name) line.appendChild(el('span', 'twm-flow-outline__type', type.label));
614
+ words.appendChild(line);
615
+ const mine = findingsBy().get(item.id) || [];
616
+ const said = lineOf(node, type);
617
+ const summary = said.text;
618
+ if (mine.length) {
619
+ const errors = mine.filter((f) => f.severity !== 'warning');
620
+ words.appendChild(el('span', `twm-flow-outline__summary twm-flow-outline__summary--${errors.length ? 'error' : 'warning'}`,
621
+ String((errors[0] || mine[0]).message ?? '')));
622
+ } else if (summary) {
623
+ words.appendChild(summaryEl(said));
624
+ }
625
+ row.appendChild(words);
626
+ if (folded) row.appendChild(el('span', 'twm-flow-outline__note', say(S, 'stepsInside', stepsInside(item))));
627
+ const run = stepRun(item.id);
628
+ if (run) {
629
+ const tone = run.tone || RUN_TONES[run.state] || 'skip';
630
+ const pill = el('span', `twm-flow-outline__run twm-flow-outline__run--${['ok', 'warn', 'fail', 'skip', 'running'].includes(tone) ? tone : 'skip'}`,
631
+ run.line ?? say(S, 'runState', run.state));
632
+ row.appendChild(pill);
633
+ }
634
+ if (mine.length) row.appendChild(badge(mine));
635
+ if (view) {
636
+ const menu = el('button', 'twm-flow-outline__menu');
637
+ menu.type = 'button';
638
+ menu.tabIndex = -1;
639
+ menu.setAttribute('aria-label', say(S, 'menuFor', name));
640
+ menu.setAttribute('aria-haspopup', 'menu');
641
+ menu.appendChild(icon('more_horiz'));
642
+ // The menu acts on its step, so its step is the one selected: the
643
+ // settings beside it are the ones its verbs change.
644
+ menu.addEventListener('click', (ev) => { ev.stopPropagation(); select(item.id); openMenu(item.id, menu); });
645
+ row.appendChild(menu);
646
+ row.addEventListener('contextmenu', (ev) => { ev.preventDefault(); select(item.id); openMenu(item.id, menu); });
647
+ }
648
+ row.addEventListener('click', () => select(item.id, { focus: true }));
649
+ return { row, label: [name, type?.label !== name ? type?.label : null, summary].filter(Boolean).join(', ') };
650
+ }
651
+
652
+ const offArm = new Set();
653
+
654
+ function armGroup(label, depth, tone, container, filter, note = null, removable = null) {
655
+ const group = el('div', 'twm-flow-outline__arm');
656
+ group.setAttribute('role', 'group');
657
+ // An arm's words are drawn in one of three tones the stylesheet knows
658
+ // (Then, Otherwise, a failure); any other is the plain arm tone.
659
+ const known = ['then', 'else', 'fail'].includes(tone) ? ` twm-flow-outline__row--${tone}` : '';
660
+ const row = rowAt(`twm-flow-outline__row--arm${known}`, depth);
661
+ row.appendChild(icon('subdirectory_arrow_right', 'twm-flow-outline__arm-icon'));
662
+ const words = el('span', 'twm-flow-outline__arm-label', label);
663
+ words.id = uid('outline-arm');
664
+ row.appendChild(words);
665
+ if (note) row.appendChild(el('span', 'twm-flow-outline__arm-note', note));
666
+ if (removable) {
667
+ const x = el('button', 'twm-flow-outline__arm-remove');
668
+ x.type = 'button';
669
+ x.setAttribute('aria-label', say(S, 'removeBranch', label));
670
+ x.title = say(S, 'removeBranch', label);
671
+ x.appendChild(icon('close'));
672
+ x.addEventListener('click', (ev) => { ev.stopPropagation(); removable(); });
673
+ row.appendChild(x);
674
+ }
675
+ group.setAttribute('aria-labelledby', words.id);
676
+ group.appendChild(row);
677
+ container.appendChild(group);
678
+ return { group, row };
679
+ }
680
+
681
+ function footRow(depth, glyph, label, note, container) {
682
+ const row = rowAt('twm-flow-outline__row--foot', depth);
683
+ row.appendChild(icon(glyph, 'twm-flow-outline__foot-icon'));
684
+ row.appendChild(el('span', 'twm-flow-outline__foot-label', label));
685
+ if (note) row.appendChild(el('span', 'twm-flow-outline__foot-note', note));
686
+ container.appendChild(row);
687
+ return row;
688
+ }
689
+
690
+ function addRow(depth, label, where, onPick = null) {
691
+ if (!editable()) return null;
692
+ if (!onPick && !where()) return null;
693
+ const row = rowAt('twm-flow-outline__row--add', depth);
694
+ if (depth === 0) row.classList.add('twm-flow-outline__row--add-top');
695
+ const b = el('button', 'twm-flow-outline__add');
696
+ b.type = 'button';
697
+ b.appendChild(icon('add'));
698
+ b.appendChild(el('span', 'twm-flow-outline__add-label', label));
699
+ b.addEventListener('click', () => {
700
+ const w = where();
701
+ if (onPick) onPick(b);
702
+ else if (w) openPicker(b, w.seq, w.index);
703
+ });
704
+ row.appendChild(b);
705
+ return row;
706
+ }
707
+
708
+ function armNote(owner, arm) {
709
+ if (!overlay) return null;
710
+ const r = portRun(owner, arm.port);
711
+ if (!r) return null;
712
+ if (r.count) return say(S, 'runCount', r.count);
713
+ return r.taken ? say(S, 'runTaken') : say(S, 'runNotTaken');
714
+ }
715
+
716
+ function markOff(seq) {
717
+ for (const it of seq.items) for (const id of subtreeIds(it)) offArm.add(id);
718
+ }
719
+
720
+ function buildInside(item, depth, inside, filter, headRow) {
721
+ const node = nodeOf(item.id);
722
+ const d = depth + 1;
723
+ const seqFoot = (arm, owner) => {
724
+ if (filter) return;
725
+ if (arm.seq.exit === 'next') {
726
+ const loop = ops.loopAround(view, arm.seq);
727
+ footRow(d, 'skip_next', map.loopSkip(nodeOf(loop)), portRun(owner, arm.port)?.count
728
+ ? say(S, 'runCount', portRun(owner, arm.port).count) : null, armInside(arm));
729
+ } else if (arm.seq.exit === 'open' && (item.kind === 'branch' || arm.seq.items.length)) {
730
+ footRow(d, 'block', say(S, 'armStops'), null, armInside(arm));
731
+ }
732
+ };
733
+ const groups = new Map();
734
+ const armInside = (arm) => groups.get(arm);
735
+ const drawArm = (arm, label, tone, removable = null) => {
736
+ const visibleInFilter = !filter || arm.seq.items.some((x) => filter.show.has(x.id));
737
+ if (!visibleInFilter) return;
738
+ // A parallel's branches are all lines out of ONE port: what a run took
739
+ // of each is told by its first step, not by the port.
740
+ const perPort = item.kind !== 'fanout';
741
+ const note = perPort ? armNote(item.id, arm) : null;
742
+ const r = perPort ? portRun(item.id, arm.port) : null;
743
+ if (overlay && r && r.taken === false && !r.count) markOff(arm.seq);
744
+ const { group, row } = armGroup(label, d, tone, inside, filter, note, removable);
745
+ if (overlay && r && r.taken === false && !r.count) row.classList.add('twm-flow-outline__row--off');
746
+ groups.set(arm, group);
747
+ gap(row, arm.seq, 0, d);
748
+ renderSeq(arm.seq, d, group, filter);
749
+ seqFoot(arm, item.id);
750
+ };
751
+ if (item.kind === 'loop') {
752
+ const body = el('div', 'twm-flow-outline__arm');
753
+ body.setAttribute('role', 'group');
754
+ body.setAttribute('aria-label', nameOf(item.id));
755
+ inside.appendChild(body);
756
+ groups.set(item.body, body);
757
+ gap(headRow, item.body.seq, 0, d);
758
+ renderSeq(item.body.seq, d, body, filter);
759
+ if (!filter) {
760
+ const add = addRow(d, map.loopAddInside(node), () => addAnchor(item.body.seq));
761
+ if (add) body.appendChild(add);
762
+ const count = portRun(item.id, map.loopPorts.next)?.count;
763
+ footRow(d, 'repeat', map.loopFoot(node), count ? say(S, 'runCount', count) : null, body);
764
+ }
765
+ for (const arm of drawnArms(item)) drawArm(arm, map.armLabel(node, arm.port), map.armSetting(node, arm.port).tone);
766
+ return;
767
+ }
768
+ if (item.kind === 'fanout') {
769
+ item.arms.forEach((arm, i) => {
770
+ const removable = editable() && item.arms.length > 1
771
+ ? () => apply(ops.removeBranch(graph, item.id, i), 'flow:branch:remove') : null;
772
+ const first = arm.seq.items[0];
773
+ if (overlay && first && isOff(first.id)) markOff(arm.seq);
774
+ drawArm(arm, map.fanoutArmLabel(i + 1), 'arm', removable);
775
+ });
776
+ if (!filter) {
777
+ const add = addRow(d, map.fanoutAddArm(node), () => null, (anchorBtn) => openBranchPicker(anchorBtn, item.id));
778
+ if (add) inside.appendChild(add);
779
+ if (item.join) {
780
+ const jn = nodeOf(item.join.id);
781
+ footRow(d, 'merge', map.joinFoot(jn, S), stepRun(item.join.id)?.line ?? null, inside);
782
+ }
783
+ }
784
+ return;
785
+ }
786
+ if (item.kind === 'branch') {
787
+ item.arms.forEach((arm, i) => drawArm(arm, map.branchArmLabel(node, arm.port), map.branchArmTone(node, arm.port, i)));
788
+ return;
789
+ }
790
+ for (const arm of drawnArms(item)) drawArm(arm, map.armLabel(node, arm.port), map.armSetting(node, arm.port).tone);
791
+ }
792
+
793
+ /** The "+" between rows: a gap in `seq` before `index`, drawn at the bottom edge of `row`. */
794
+ function gap(row, seq, index, depth) {
795
+ if (!row || !editable() || !view || findText.trim()) return;
796
+ if (!ops.isCanonical(view, seq, index)) return;
797
+ const facts = ops.gapAt(view, seq, index);
798
+ if (facts.afterEnd || !facts.arriving.length) return;
799
+ const where = ops.gapWords(view, seq, index);
800
+ const b = el('button', 'twm-flow-outline__gap');
801
+ b.type = 'button';
802
+ b.tabIndex = -1;
803
+ b.style.setProperty('--twm-outline-depth', String(depth));
804
+ b.setAttribute('aria-label', say(S, 'addStepHere', where));
805
+ b.title = say(S, 'addStepHere', where);
806
+ b.dataset.gap = JSON.stringify(ops.anchorOf(seq, index));
807
+ const line = el('span', 'twm-flow-outline__gap-line');
808
+ line.setAttribute('aria-hidden', 'true');
809
+ b.append(line, icon('add', 'twm-flow-outline__gap-plus'));
810
+ b.addEventListener('click', (ev) => { ev.stopPropagation(); openPicker(b, seq, index); });
811
+ b.addEventListener('pointerdown', (ev) => ev.stopPropagation());
812
+ row.appendChild(b);
813
+ gapEls.push({ el: b, row, seq, index, depth });
814
+ }
815
+ let gapEls = [];
816
+
817
+ // ── selection and focus ────────────────────────────────────────────
818
+ function applySelection() {
819
+ const key = selected === null ? 'flow' : selected;
820
+ const current = itemEls.get(key) ?? itemEls.get('flow') ?? null;
821
+ for (const li of itemEls.values()) {
822
+ const on = li === current;
823
+ const row = li.querySelector(':scope > .twm-flow-outline__row');
824
+ li.setAttribute('aria-selected', String(on));
825
+ row?.classList.toggle('twm-flow-outline__row--selected', on);
826
+ if (on) row?.setAttribute('aria-current', 'true');
827
+ else row?.removeAttribute('aria-current');
828
+ }
829
+ // Roving: the current row, its menu and every "+" that belongs to it are
830
+ // the tree's tab stops — the "+" after a block sits on its last row (a
831
+ // foot, an arm's last step), and the one into an arm on the arm's row,
832
+ // so "belongs" is "the nearest step around it", never "on its own row".
833
+ for (const li of itemEls.values()) li.tabIndex = li === current ? 0 : -1;
834
+ for (const b of tree.querySelectorAll('.twm-flow-outline__menu, .twm-flow-outline__gap')) {
835
+ b.tabIndex = current && b.closest('[role="treeitem"]') === current ? 0 : -1;
836
+ }
837
+ }
838
+
839
+ function select(id, { focus = false, silent = false } = {}) {
840
+ const next = id === null || id === undefined ? 'flow' : id;
841
+ const changed = next !== selected;
842
+ selected = next;
843
+ applySelection();
844
+ if (changed) { showPanel(); drawAside(); }
845
+ void silent;
846
+ if (focus) itemEls.get(selected)?.focus({ preventScroll: false });
847
+ if (changed) onSelect?.({ kind: selected === 'flow' ? 'flow' : 'step', id: selected === 'flow' ? null : selected });
848
+ }
849
+
850
+ function focusSelected() {
851
+ const li = itemEls.get(selected) ?? itemEls.get('flow');
852
+ if (li) li.focus({ preventScroll: true });
853
+ else root.focus({ preventScroll: true });
854
+ }
855
+
856
+ function visibleItems() {
857
+ return [...tree.querySelectorAll('[role="treeitem"]')].filter((li) => !li.parentElement.closest('[hidden]'));
858
+ }
859
+
860
+ // ── the panel ──────────────────────────────────────────────────────
861
+ function showPanel() {
862
+ if (destroyed) return;
863
+ panel.setReadOnly(editable() ? false : true);
864
+ if (selected === 'flow' || selected === null || !nodeOf(selected)) {
865
+ const s = settings || {};
866
+ const sid = startId();
867
+ const st = sid ? typeOf(sid) : null;
868
+ panel.show({
869
+ step: null, type: null, title: s.title ?? map.startLabel(S), typeLabel: s.typeLabel ?? say(S, 'startType'),
870
+ where: s.where ?? '', description: s.description ?? '', idLine: s.idLine ?? null,
871
+ icon: map.start.icon || st?.icon || 'play_arrow', tone: map.start.tone || 'blue',
872
+ fields: s.schema ? fieldsFromSchema(s.schema) : [], value: s.value ?? {},
873
+ });
874
+ panel.setFindings(findings);
875
+ return;
876
+ }
877
+ const node = nodeOf(selected);
878
+ const type = catalogue.get(node.type);
879
+ const place = view?.index.at.get(selected);
880
+ const canEdit = editable();
881
+ const dup = canEdit && view ? ops.duplicate(graph, selected) : null;
882
+ panel.show({
883
+ step: node, type, title: node.label ?? '', placeholderTitle: type?.label ?? node.id, rename: true,
884
+ typeLabel: type?.label ?? node.type, where: view ? ops.whereText(view, selected) : '',
885
+ description: type?.description ?? '', idLine: summariseReads?.(node, type) ?? null,
886
+ icon: type?.icon ?? '', tone: catalogue.tone(node.type),
887
+ value: node.config ?? {},
888
+ extra: view && place ? extrasFor(place.item, node) : [],
889
+ actions: view ? [
890
+ { id: 'duplicate', label: say(S, 'duplicate'), icon: 'content_copy', disabled: !dup?.ok,
891
+ reason: dup && !dup.ok ? dup.reason : '', run: () => runOp(() => ops.duplicate(graph, selected), 'flow:step:duplicate') },
892
+ { id: 'remove', label: say(S, 'removeStep'), icon: 'delete', danger: true,
893
+ run: () => runOp(() => ops.remove(graph, selected), 'flow:step:remove') },
894
+ ] : [],
895
+ });
896
+ panel.setFindings(findings);
897
+ }
898
+
899
+ /** Ports drawn as settings (36 §6.5): an arm on or off, where it goes after, a parallel's join. */
900
+ function extrasFor(item, node) {
901
+ const out = [];
902
+ const note = map.note(item.kind, node);
903
+ if (note) out.push({ note, at: 'start' });
904
+ if (item.kind === 'step' || (item.kind === 'loop' && item.arms.length)) {
905
+ for (const arm of item.arms) {
906
+ const words = map.armSetting(node, arm.port);
907
+ const connected = !(arm.seq.exit === 'open' && !arm.seq.items.length);
908
+ const placed = words.after ? { after: words.after } : {};
909
+ out.push({
910
+ key: `__arm:${arm.port}`, ...placed,
911
+ spec: { type: 'string', title: words.label, enum: ['open', 'connected'], 'x-ui-widget': 'choice-cards',
912
+ 'x-ui-enum-labels': { open: words.unconnected, connected: words.connected } },
913
+ value: connected ? 'connected' : 'open',
914
+ set: (v) => runOp(() => ops.setArm(graph, item.id, arm.port, v === 'connected'), 'flow:arm',
915
+ { keepPanel: true }),
916
+ });
917
+ const loop = ops.loopAround(view, view.index.at.get(item.id).seq);
918
+ const cont = ops.gapAt(view, view.index.at.get(item.id).seq, view.index.at.get(item.id).index + 1).succ;
919
+ if (connected && loop && cont && !(cont.id === loop && cont.port === map.loopPorts.next)
920
+ && ['rejoin', 'next'].includes(arm.seq.exit)) {
921
+ out.push({
922
+ key: `__exit:${arm.port}`, ...placed,
923
+ spec: { type: 'string', title: say(S, 'armExitLabel'), enum: ['rejoin', 'next'], 'x-ui-widget': 'choice-cards',
924
+ 'x-ui-enum-labels': { rejoin: say(S, 'armExitRejoin', nameOf(cont.id)), next: map.loopSkip(nodeOf(loop)) } },
925
+ value: arm.seq.exit,
926
+ set: (v) => runOp(() => ops.setArmExit(graph, item.id, arm.port, v), 'flow:arm', { keepPanel: true }),
927
+ });
928
+ }
929
+ }
930
+ }
931
+ if (item.kind === 'fanout' && item.join) {
932
+ const jn = nodeOf(item.join.id);
933
+ const field = map.joinField;
934
+ const prop = catalogue.get(jn.type)?.config_schema?.properties?.[field] || {};
935
+ const labels = prop['x-ui-enum-labels'] || {};
936
+ out.push({
937
+ key: '__join',
938
+ spec: { type: 'string', title: map.joinChoices.label ?? say(S, 'joinSettingLabel'), enum: ['all', 'any'],
939
+ 'x-ui-widget': 'choice-cards',
940
+ 'x-ui-enum-labels': { all: map.joinChoices.all ?? labels.all ?? say(S, 'joinAll'),
941
+ any: map.joinChoices.any ?? labels.any ?? say(S, 'joinAny') } },
942
+ value: map.joinValue(jn),
943
+ set: (v) => runOp(() => ops.setJoin(graph, item.id, v), 'flow:arm', { keepPanel: true }),
944
+ });
945
+ }
946
+ return out;
947
+ }
948
+
949
+ function onPanelChange(stepId, key, value) {
950
+ if (!editable()) return;
951
+ if (stepId === null) {
952
+ if (!settings) return;
953
+ const next = { ...(settings.value || {}) };
954
+ if (value === undefined) delete next[key]; else next[key] = value;
955
+ settings.value = next;
956
+ commit('flow:settings', `settings:${key}`);
957
+ refreshFlowRow();
958
+ return;
959
+ }
960
+ const node = nodeOf(stepId);
961
+ if (!node) return;
962
+ const config = { ...(node.config || {}) };
963
+ if (value === undefined) delete config[key]; else config[key] = value;
964
+ node.config = config;
965
+ refreshRow(stepId);
966
+ commit('flow:step:config', `config:${stepId}:${key}`);
967
+ }
968
+
969
+ function onRename(stepId, label) {
970
+ if (!editable() || stepId === null) return;
971
+ const node = nodeOf(stepId);
972
+ if (!node) return;
973
+ if (label) node.label = label; else delete node.label;
974
+ refreshRow(stepId);
975
+ commit('flow:step:label', `label:${stepId}`);
976
+ }
977
+
978
+ /** Redraw one row's words in place — no other row is rebuilt, the panel is left alone. */
979
+ function swapRow(li, newRow, label) {
980
+ const oldRow = li.querySelector(':scope > .twm-flow-outline__row');
981
+ for (const g of oldRow.querySelectorAll(':scope > .twm-flow-outline__gap')) newRow.appendChild(g);
982
+ for (const g of gapEls) if (g.row === oldRow) g.row = newRow;
983
+ oldRow.replaceWith(newRow);
984
+ if (label) li.setAttribute('aria-label', label);
985
+ applySelection();
986
+ }
987
+
988
+ function refreshRow(id) {
989
+ const li = itemEls.get(id);
990
+ if (!li) return;
991
+ const item = view?.index.at.get(id)?.item ?? { kind: 'step', id, arms: [] };
992
+ const { row, label } = stepRow(item, Number(li.getAttribute('aria-level')) - 1, findFilter());
993
+ swapRow(li, row, label);
994
+ }
995
+
996
+ function refreshFlowRow() {
997
+ const li = itemEls.get('flow');
998
+ if (!li) return;
999
+ const fresh = flowItem(findFilter());
1000
+ itemEls.set('flow', li);
1001
+ swapRow(li, fresh.querySelector(':scope > .twm-flow-outline__row'), fresh.getAttribute('aria-label'));
1002
+ }
1003
+
1004
+ function drawAside() {
1005
+ aside.replaceChildren();
1006
+ if (selected !== 'flow' && selected !== null && nodeOf(selected)) {
1007
+ if (typeof slots.stepPanel === 'function') slots.stepPanel(aside, nodeOf(selected));
1008
+ } else if (typeof slots.flowPanel === 'function') {
1009
+ slots.flowPanel(aside);
1010
+ }
1011
+ aside.hidden = aside.childNodes.length === 0;
1012
+ }
1013
+
1014
+ function drawRunBanner() {
1015
+ runBanner.replaceChildren();
1016
+ const text = overlay?.banner;
1017
+ runBanner.hidden = !text;
1018
+ if (text) runBanner.append(icon('info', 'twm-flow-outline__banner-icon'), el('span', 'twm-flow-outline__banner-text', text));
1019
+ }
1020
+
1021
+ // ── operations ─────────────────────────────────────────────────────
1022
+ function apply(result, action, { keepPanel = false } = {}) {
1023
+ if (!result || !result.ok) {
1024
+ announce(result?.reason || '');
1025
+ return false;
1026
+ }
1027
+ announce('');
1028
+ graph = result.graph;
1029
+ reread();
1030
+ if (result.select && nodeOf(result.select) && !keepPanel) selected = result.select;
1031
+ const held = tree.contains(root.ownerDocument.activeElement);
1032
+ render();
1033
+ showPanel();
1034
+ drawAside();
1035
+ commit(action);
1036
+ if (held) focusSelected();
1037
+ return true;
1038
+ }
1039
+
1040
+ function runOp(fn, action, opts = {}) {
1041
+ if (!editable()) { announce(roReason()); return false; }
1042
+ return apply(fn(), action, opts);
1043
+ }
1044
+
1045
+ function insertAt(seq, index, entry) {
1046
+ const anchor = ops.anchorOf(seq, index);
1047
+ if (entry.paste) return runOp(() => ops.paste(graph, anchor, CLIPBOARD), 'flow:step:paste');
1048
+ return runOp(() => ops.insert(graph, anchor, entry.id), 'flow:step:add');
1049
+ }
1050
+
1051
+ function entriesFor(dryRun) {
1052
+ const order = { branch: 0, loop: 1, fanout: 2, step: 3, end: 4 };
1053
+ const types = catalogue.list().filter((t) => !['start', 'join'].includes(map.kindOf(t.type_id)));
1054
+ const cats = catalogue.categories.map((c) => c.id);
1055
+ types.sort((a, b) => (cats.indexOf(a.category ?? '') - cats.indexOf(b.category ?? ''))
1056
+ || (order[map.kindOf(a.type_id)] - order[map.kindOf(b.type_id)]) || String(a.label).localeCompare(String(b.label)));
1057
+ return types.map((t) => {
1058
+ const words = map.entryFor(t.type_id);
1059
+ const r = dryRun(t.type_id);
1060
+ return { id: t.type_id, label: words.label, sub: words.sub, description: words.description, icon: t.icon,
1061
+ tone: catalogue.tone(t.type_id), category: t.category ?? '', refusal: r.ok ? null : r.reason };
1062
+ });
1063
+ }
1064
+
1065
+ async function openPicker(anchor, seq, index) {
1066
+ if (!editable() || !view) return;
1067
+ const a = ops.anchorOf(seq, index);
1068
+ const pasteOk = CLIPBOARD ? ops.paste(graph, a, CLIPBOARD).ok : false;
1069
+ const picked = await openStepPicker({
1070
+ anchor, strings: S, categories: catalogue.categories, where: ops.gapWords(view, seq, index),
1071
+ paste: pasteOk ? { label: say(S, 'pasteStep') } : null,
1072
+ entries: entriesFor((typeId) => ops.insert(graph, a, typeId)),
1073
+ });
1074
+ if (!picked || destroyed) return;
1075
+ insertAt(seq, index, picked.paste ? { paste: true } : picked.entry);
1076
+ }
1077
+
1078
+ async function openBranchPicker(anchor, fanoutId) {
1079
+ if (!editable() || !view) return;
1080
+ const picked = await openStepPicker({
1081
+ anchor, strings: S, categories: catalogue.categories, title: map.fanoutAddArm(nodeOf(fanoutId)),
1082
+ where: say(S, 'insidePath', nameOf(fanoutId)),
1083
+ entries: entriesFor((typeId) => ops.addBranch(graph, fanoutId, typeId)),
1084
+ });
1085
+ if (!picked?.entry || destroyed) return;
1086
+ runOp(() => ops.addBranch(graph, fanoutId, picked.entry.id), 'flow:branch:add');
1087
+ }
1088
+
1089
+ function blockEntries() {
1090
+ return catalogue.list().filter((t) => ['branch', 'loop', 'fanout'].includes(map.kindOf(t.type_id)) && !t.unavailable);
1091
+ }
1092
+
1093
+ function openMenu(id, anchor) {
1094
+ if (!view) return;
1095
+ const can = editable();
1096
+ const ro = can ? null : (roReason() || say(S, 'readOnlyRefusal'));
1097
+ const why = (r) => (ro ?? (r.ok ? null : r.reason));
1098
+ const place = view.index.at.get(id);
1099
+ const owner = place?.owner;
1100
+ const up = can ? ops.moveUp(graph, id) : null;
1101
+ const down = can ? ops.moveDown(graph, id) : null;
1102
+ const out = can ? ops.moveOut(graph, id) : null;
1103
+ const dup = can ? ops.duplicate(graph, id) : null;
1104
+ const paste = can ? (CLIPBOARD ? ops.paste(graph, { after: id }, CLIPBOARD) : { ok: false, reason: say(S, 'nothingCopied') }) : null;
1105
+ const remove = can ? ops.remove(graph, id) : null;
1106
+ const wraps = blockEntries().map((t) => {
1107
+ const r = can ? ops.wrap(graph, id, t.type_id) : null;
1108
+ return { id: `wrap:${t.type_id}`, label: map.entryFor(t.type_id).label, icon: t.icon, tone: catalogue.tone(t.type_id),
1109
+ refusal: ro ?? (r.ok ? null : r.reason),
1110
+ run: () => runOp(() => ops.wrap(graph, id, t.type_id), 'flow:step:wrap') };
1111
+ });
1112
+ const outLabel = owner && owner.kind !== 'start' ? say(S, 'moveOut', nameOf(owner.id)) : say(S, 'moveOutTop');
1113
+ openStepMenu({
1114
+ anchor, label: nameOf(id), strings: S,
1115
+ items: [
1116
+ { id: 'up', label: say(S, 'moveUp'), keys: say(S, 'keyMoveUp'), refusal: ro ?? (up.ok ? null : up.reason),
1117
+ run: () => runOp(() => ops.moveUp(graph, id), 'flow:step:move') },
1118
+ { id: 'down', label: say(S, 'moveDown'), keys: say(S, 'keyMoveDown'), refusal: ro ?? (down.ok ? null : down.reason),
1119
+ run: () => runOp(() => ops.moveDown(graph, id), 'flow:step:move') },
1120
+ { id: 'out', label: outLabel, keys: say(S, 'keyMoveOut'), refusal: ro ?? (out.ok ? null : out.reason),
1121
+ run: () => runOp(() => ops.moveOut(graph, id), 'flow:step:move') },
1122
+ { id: 'wrap', label: say(S, 'wrapIn'), items: wraps, refusal: wraps.every((w) => w.refusal) ? (ro ?? wraps[0]?.refusal ?? null) : null },
1123
+ { separator: true },
1124
+ { id: 'duplicate', label: say(S, 'duplicate'), keys: say(S, 'keyDuplicate'), refusal: dup ? why(dup) : ro,
1125
+ run: () => runOp(() => ops.duplicate(graph, id), 'flow:step:duplicate') },
1126
+ { id: 'copy', label: say(S, 'copy'), keys: say(S, 'keyCopy'), run: () => copy(id) },
1127
+ { id: 'cut', label: say(S, 'cut'), keys: say(S, 'keyCut'), refusal: remove ? why(remove) : ro, run: () => cut(id) },
1128
+ { id: 'paste', label: say(S, 'pasteAfter'), keys: say(S, 'keyPaste'), refusal: paste ? why(paste) : ro,
1129
+ run: () => runOp(() => ops.paste(graph, { after: id }, CLIPBOARD), 'flow:step:paste') },
1130
+ { separator: true },
1131
+ { id: 'rename', label: say(S, 'rename'), keys: say(S, 'renameKey'), refusal: ro, run: () => rename(id) },
1132
+ { separator: true },
1133
+ { id: 'remove', label: say(S, 'removeStep'), keys: say(S, 'keyRemove'), danger: true,
1134
+ refusal: remove ? why(remove) : ro, run: () => runOp(() => ops.remove(graph, id), 'flow:step:remove') },
1135
+ ],
1136
+ });
1137
+ }
1138
+
1139
+ function copy(id) {
1140
+ const c = ops.copy(graph, id);
1141
+ if (c) CLIPBOARD = c;
1142
+ return Boolean(c);
1143
+ }
1144
+
1145
+ function cut(id) {
1146
+ if (!editable()) return false;
1147
+ const c = ops.copy(graph, id);
1148
+ const r = ops.remove(graph, id);
1149
+ if (!c || !r.ok) { announce(r.reason); return false; }
1150
+ CLIPBOARD = c;
1151
+ return apply(r, 'flow:step:remove');
1152
+ }
1153
+
1154
+ function rename(id) {
1155
+ select(id);
1156
+ const input = settingsHost.querySelector('[data-twm-flow-title]');
1157
+ if (input) { input.focus({ preventScroll: true }); input.select?.(); }
1158
+ }
1159
+
1160
+ /** Unfold the blocks around a step; true when one was folded. */
1161
+ function reveal(id) {
1162
+ let unfolded = false;
1163
+ for (const step of view?.index.at.get(id)?.path || []) if (folds.delete(step.owner.id)) unfolded = true;
1164
+ if (unfolded) onFoldChange?.([...folds]);
1165
+ return unfolded;
1166
+ }
1167
+
1168
+ function goTo(f) {
1169
+ const id = f?.node_id && nodeOf(f.node_id) ? f.node_id : 'flow';
1170
+ if (reveal(id)) render();
1171
+ select(id, { focus: true });
1172
+ if (f?.field) panel.focusField(f.field);
1173
+ }
1174
+
1175
+ // ── folding ────────────────────────────────────────────────────────
1176
+ function setFolds(ids) {
1177
+ folds.clear();
1178
+ for (const id of ids) folds.add(id);
1179
+ onFoldChange?.([...folds]);
1180
+ render();
1181
+ }
1182
+
1183
+ function toggleFold(id) {
1184
+ if (folds.has(id)) folds.delete(id); else folds.add(id);
1185
+ onFoldChange?.([...folds]);
1186
+ render();
1187
+ itemEls.get(id)?.focus({ preventScroll: true });
1188
+ }
1189
+
1190
+ // ── keys ───────────────────────────────────────────────────────────
1191
+ const inTree = (ev) => tree.contains(ev.target);
1192
+ const stepSelected = () => (selected !== 'flow' && selected !== null && nodeOf(selected) && view ? selected : null);
1193
+ /** A step verb from the tree: taken (its key prevented) whether it succeeds or is refused. */
1194
+ const onStep = (ev, fn) => {
1195
+ if (!inTree(ev) || !stepSelected()) return false;
1196
+ fn();
1197
+ return undefined;
1198
+ };
1199
+ const unbind = bindFlowKeys(root, {
1200
+ undo: () => { api.undo(); },
1201
+ redo: () => { api.redo(); },
1202
+ up: (ev) => (inTree(ev) ? moveSelection(-1) : false),
1203
+ down: (ev) => (inTree(ev) ? moveSelection(1) : false),
1204
+ left: (ev) => (inTree(ev) ? leftKey() : false),
1205
+ right: (ev) => (inTree(ev) ? rightKey() : false),
1206
+ open: (ev) => {
1207
+ if (!inTree(ev) || ev.target.closest('button')) return false;
1208
+ const f = settingsHost.querySelector('.twm-flow-panel__fields input, .twm-flow-panel__fields select, '
1209
+ + '.twm-flow-panel__fields textarea, .twm-flow-panel__fields [contenteditable="true"], .twm-flow-panel__fields button')
1210
+ || settingsHost.querySelector('[data-twm-flow-title]');
1211
+ f?.focus({ preventScroll: false });
1212
+ return Boolean(f);
1213
+ },
1214
+ remove: (ev) => onStep(ev, () => runOp(() => ops.remove(graph, selected), 'flow:step:remove')),
1215
+ duplicate: (ev) => onStep(ev, () => runOp(() => ops.duplicate(graph, selected), 'flow:step:duplicate')),
1216
+ copy: (ev) => onStep(ev, () => copy(selected)),
1217
+ cut: (ev) => onStep(ev, () => cut(selected)),
1218
+ paste: (ev) => {
1219
+ if (!inTree(ev) || !view) return false;
1220
+ if (!CLIPBOARD) { announce(say(S, 'nothingCopied')); return undefined; }
1221
+ const anchor = stepSelected() ? { after: selected } : { into: 'top' };
1222
+ runOp(() => ops.paste(graph, anchor, CLIPBOARD), 'flow:step:paste');
1223
+ return undefined;
1224
+ },
1225
+ rename: (ev) => onStep(ev, () => rename(selected)),
1226
+ moveUp: (ev) => onStep(ev, () => runOp(() => ops.moveUp(graph, selected), 'flow:step:move')),
1227
+ moveDown: (ev) => onStep(ev, () => runOp(() => ops.moveDown(graph, selected), 'flow:step:move')),
1228
+ moveOut: (ev) => onStep(ev, () => runOp(() => ops.moveOut(graph, selected), 'flow:step:move')),
1229
+ escape: () => {
1230
+ if (drag) { endDrag(false); return undefined; }
1231
+ return false;
1232
+ },
1233
+ });
1234
+ tree.addEventListener('keydown', (ev) => {
1235
+ if ((ev.key === 'ContextMenu' || (ev.key === 'F10' && ev.shiftKey)) && stepSelected()) {
1236
+ ev.preventDefault();
1237
+ const m = itemEls.get(selected)?.querySelector(':scope > .twm-flow-outline__row .twm-flow-outline__menu');
1238
+ if (m) openMenu(selected, m);
1239
+ }
1240
+ });
1241
+ const pressDoc = root.ownerDocument;
1242
+ root.addEventListener('pointerdown', onPressStart, true);
1243
+ pressDoc.addEventListener('pointerup', onPressEnd, true);
1244
+ pressDoc.addEventListener('pointercancel', onPressEnd, true);
1245
+ pressDoc.defaultView?.addEventListener('blur', onPressEnd);
1246
+
1247
+ function moveSelection(step) {
1248
+ const items = visibleItems();
1249
+ const cur = items.indexOf(itemEls.get(selected) ?? itemEls.get('flow'));
1250
+ const next = items[Math.max(0, Math.min(items.length - 1, cur + step))];
1251
+ if (next) select(next.dataset.step || 'flow', { focus: true });
1252
+ return undefined;
1253
+ }
1254
+
1255
+ function leftKey() {
1256
+ const li = itemEls.get(selected);
1257
+ if (!li) return false;
1258
+ if (li.getAttribute('aria-expanded') === 'true') { toggleFold(selected); return undefined; }
1259
+ const parent = li.parentElement?.closest('[role="treeitem"]');
1260
+ if (parent) select(parent.dataset.step || 'flow', { focus: true });
1261
+ return undefined;
1262
+ }
1263
+
1264
+ function rightKey() {
1265
+ const li = itemEls.get(selected);
1266
+ if (!li) return false;
1267
+ if (li.getAttribute('aria-expanded') === 'false') { toggleFold(selected); return undefined; }
1268
+ if (li.getAttribute('aria-expanded') === 'true') {
1269
+ const child = li.querySelector('.twm-flow-outline__inside [role="treeitem"]');
1270
+ if (child) select(child.dataset.step, { focus: true });
1271
+ }
1272
+ return undefined;
1273
+ }
1274
+
1275
+ // ── the grip drag ──────────────────────────────────────────────────
1276
+ function armDrag(ev, id) {
1277
+ if (ev.button !== 0 || !editable() || !view) return;
1278
+ ev.preventDefault();
1279
+ ev.stopPropagation();
1280
+ endDrag(false);
1281
+ drag = { id, x: ev.clientX, y: ev.clientY, started: false, ghost: null, line: null, target: null, cache: new Map() };
1282
+ const doc = root.ownerDocument;
1283
+ doc.addEventListener('pointermove', onDragMove, true);
1284
+ doc.addEventListener('pointerup', onDragUp, true);
1285
+ doc.addEventListener('pointercancel', onDragCancel, true);
1286
+ doc.addEventListener('keydown', onDragKey, true);
1287
+ root.ownerDocument.defaultView?.addEventListener('blur', onDragCancel);
1288
+ }
1289
+
1290
+ function startDrag() {
1291
+ const id = drag.id;
1292
+ const ghost = el('div', 'twm-flow-outline__ghost');
1293
+ ghost.setAttribute('aria-hidden', 'true');
1294
+ ghost.appendChild(icon('drag_indicator', 'twm-flow-outline__ghost-grip'));
1295
+ const chip = el('span', `twm-flow-outline__chip ${toneClass('twm-flow-outline__chip', catalogue.tone(nodeOf(id)?.type))}`);
1296
+ chip.appendChild(icon(typeOf(id)?.icon || ''));
1297
+ ghost.append(chip, el('span', 'twm-flow-outline__ghost-title', nameOf(id)));
1298
+ root.ownerDocument.body.appendChild(ghost);
1299
+ const line = el('div', 'twm-flow-outline__drop');
1300
+ line.setAttribute('aria-hidden', 'true');
1301
+ line.appendChild(el('span', 'twm-flow-outline__drop-label'));
1302
+ line.hidden = true;
1303
+ tree.appendChild(line);
1304
+ drag.ghost = ghost;
1305
+ drag.line = line;
1306
+ drag.started = true;
1307
+ root.classList.add('twm-flow-outline--dragging');
1308
+ itemEls.get(id)?.querySelector(':scope > .twm-flow-outline__row')?.classList.add('twm-flow-outline__row--dragging');
1309
+ drag.targets = dropTargets(id);
1310
+ }
1311
+
1312
+ /** Every place the dragged step could go: each "+" place, but not inside itself or where it already is. */
1313
+ function dropTargets(id) {
1314
+ const item = view.index.at.get(id)?.item;
1315
+ const inside = new Set(item ? subtreeIds(item) : [id]);
1316
+ const place = view.index.at.get(id);
1317
+ const out = [];
1318
+ for (const g of collectGaps()) {
1319
+ // A place inside a folded block is not on the screen to be dropped on.
1320
+ if (g.row.closest('[hidden]')) continue;
1321
+ const owner = view.index.seqs.find((x) => x.seq === g.seq)?.owner;
1322
+ if (owner && inside.has(owner.id)) continue;
1323
+ if (g.seq === place?.seq && (g.index === place.index || g.index === place.index + 1)) continue;
1324
+ out.push(g);
1325
+ }
1326
+ return out;
1327
+ }
1328
+
1329
+ /** Every gap position in the drawn outline, with the row it sits under (drawn or not — a gap button is only drawn where "+" is offered). */
1330
+ function collectGaps() {
1331
+ return gapEls.map((g) => ({ ...g }));
1332
+ }
1333
+
1334
+ function onDragMove(ev) {
1335
+ if (!drag) return;
1336
+ if (!drag.started) {
1337
+ if (Math.hypot(ev.clientX - drag.x, ev.clientY - drag.y) < DRAG_START_PX) return;
1338
+ startDrag();
1339
+ }
1340
+ ev.preventDefault();
1341
+ // Below the pointer: the drop line and its words are drawn AT the
1342
+ // pointer, and a ghost over them would hide the one thing to read.
1343
+ drag.ghost.style.left = `${Math.round(ev.clientX + 12)}px`;
1344
+ drag.ghost.style.top = `${Math.round(ev.clientY + 28)}px`;
1345
+ autoScroll(ev);
1346
+ pickTarget(ev);
1347
+ }
1348
+
1349
+ /** Near an edge of whatever scrolls the list — its own box, or, wrapped in a narrow editor, the body. */
1350
+ function autoScroll(ev) {
1351
+ const box = scroller.scrollHeight > scroller.clientHeight ? scroller : body;
1352
+ const r = box.getBoundingClientRect();
1353
+ if (!r.height) return;
1354
+ if (ev.clientY < r.top + 24) box.scrollTop -= 12;
1355
+ else if (ev.clientY > r.bottom - 24) box.scrollTop += 12;
1356
+ }
1357
+
1358
+ function pickTarget(ev) {
1359
+ let best = null;
1360
+ let bestScore = Infinity;
1361
+ for (const t of drag.targets) {
1362
+ const r = t.row.getBoundingClientRect();
1363
+ const y = r.bottom;
1364
+ const x = r.left + 8 + t.depth * OUTLINE_INDENT;
1365
+ const score = Math.abs(ev.clientY - y) * 4 + Math.abs(ev.clientX - x);
1366
+ if (score < bestScore) { bestScore = score; best = t; }
1367
+ }
1368
+ drag.target = best;
1369
+ if (!best) { drag.line.hidden = true; return; }
1370
+ const key = JSON.stringify(ops.anchorOf(best.seq, best.index));
1371
+ if (!drag.cache.has(key)) drag.cache.set(key, ops.move(graph, drag.id, JSON.parse(key)));
1372
+ const verdict = drag.cache.get(key);
1373
+ const tr = tree.getBoundingClientRect();
1374
+ const rr = best.row.getBoundingClientRect();
1375
+ drag.line.hidden = false;
1376
+ drag.line.style.top = `${Math.round(rr.bottom - tr.top)}px`;
1377
+ drag.line.style.setProperty('--twm-outline-depth', String(best.depth));
1378
+ drag.line.classList.toggle('twm-flow-outline__drop--refused', !verdict.ok);
1379
+ drag.line.firstChild.textContent = verdict.ok ? ops.dropWords(view, best.seq, best.index) : verdict.reason;
1380
+ }
1381
+
1382
+ function onDragUp(ev) {
1383
+ if (!drag) return;
1384
+ const wasDrag = drag.started;
1385
+ if (wasDrag) { ev.preventDefault(); ev.stopPropagation(); }
1386
+ endDrag(wasDrag);
1387
+ }
1388
+
1389
+ function onDragCancel() { endDrag(false); }
1390
+
1391
+ function onDragKey(ev) {
1392
+ if (ev.key === 'Escape' && drag) {
1393
+ ev.preventDefault();
1394
+ ev.stopPropagation();
1395
+ endDrag(false);
1396
+ }
1397
+ }
1398
+
1399
+ function endDrag(drop) {
1400
+ if (!drag) return;
1401
+ const d = drag;
1402
+ drag = null;
1403
+ const doc = root.ownerDocument;
1404
+ doc.removeEventListener('pointermove', onDragMove, true);
1405
+ doc.removeEventListener('pointerup', onDragUp, true);
1406
+ doc.removeEventListener('pointercancel', onDragCancel, true);
1407
+ doc.removeEventListener('keydown', onDragKey, true);
1408
+ doc.defaultView?.removeEventListener('blur', onDragCancel);
1409
+ d.ghost?.remove();
1410
+ d.line?.remove();
1411
+ root.classList.remove('twm-flow-outline--dragging');
1412
+ tree.querySelector('.twm-flow-outline__row--dragging')?.classList.remove('twm-flow-outline__row--dragging');
1413
+ if (!drop || !d.started) return;
1414
+ // The release that drops also makes a click, on whatever is under it now.
1415
+ const swallow = (e) => { e.stopPropagation(); e.preventDefault(); };
1416
+ doc.addEventListener('click', swallow, { capture: true, once: true });
1417
+ setTimeout(() => doc.removeEventListener('click', swallow, true), 0);
1418
+ if (!d.target) { announce(say(S, 'dropNowhere')); return; }
1419
+ const key = JSON.stringify(ops.anchorOf(d.target.seq, d.target.index));
1420
+ const verdict = d.cache.get(key) ?? ops.move(graph, d.id, JSON.parse(key));
1421
+ apply(verdict, 'flow:step:move');
1422
+ }
1423
+
1424
+ // ── the API ────────────────────────────────────────────────────────
1425
+ const api = {
1426
+ el: root,
1427
+ get history() { return history; },
1428
+ get selection() { return selected === 'flow' ? { kind: 'flow', id: null } : { kind: 'step', id: selected }; },
1429
+
1430
+ /** Replace the content; a baseline clears both undo stacks. */
1431
+ load({ graph: g = null, flowSettings: fs = null } = {}, { baseline = true } = {}) {
1432
+ graph = g && typeof g === 'object' ? structuredClone(g) : emptyGraph();
1433
+ if (!Array.isArray(graph.nodes)) graph.nodes = [];
1434
+ if (!Array.isArray(graph.connections)) graph.connections = [];
1435
+ if (fs) settings = { ...(settings || {}), ...fs, value: structuredClone(fs.value ?? settings?.value ?? {}) };
1436
+ reread();
1437
+ if (selected !== 'flow' && !nodeOf(selected)) selected = 'flow';
1438
+ render();
1439
+ showPanel();
1440
+ drawAside();
1441
+ if (baseline) history.baseline(stateText());
1442
+ updateTools();
1443
+ },
1444
+ /** The current graph, as the consumer's own shape (a copy). */
1445
+ getGraph() { return structuredClone(graph); },
1446
+ /** Its byte-stable text (`serialise`). */
1447
+ serialise() { return serialise(graph); },
1448
+ /** The flow's own settings, or null. */
1449
+ getFlowSettings() { return settings ? structuredClone(settings.value) : null; },
1450
+ /** The outline as text (`describeOutline`), or null when the graph is not block-shaped. */
1451
+ describe() { return view ? describeOutline(view.tree) : null; },
1452
+ /** Why the graph cannot be drawn as an outline, or null. */
1453
+ get refusal() { return refusal ? { ...refusal } : null; },
1454
+
1455
+ setFindings(list) {
1456
+ findings = findingsList(list);
1457
+ strip.set(findings);
1458
+ panel.setFindings(findings);
1459
+ renderSoon();
1460
+ },
1461
+ setRunOverlay(o) {
1462
+ overlay = o && typeof o === 'object' ? o : null;
1463
+ // A run's rows say the step and how it went; the type steps aside (the Run mock).
1464
+ root.classList.toggle('twm-flow-outline--run', Boolean(overlay));
1465
+ offArm.clear();
1466
+ renderSoon();
1467
+ drawAside();
1468
+ },
1469
+ setReadOnly(next) {
1470
+ consumerRO = next || false;
1471
+ drawReadOnly();
1472
+ renderSoon();
1473
+ showPanel();
1474
+ },
1475
+ setActionState(id, state = {}) {
1476
+ actionState.set(id, { ...(actionState.get(id) || {}), ...state });
1477
+ updateTools();
1478
+ },
1479
+ setStatus(text) { statusEl.textContent = text ?? ''; },
1480
+ /** Select a step (unfolding the blocks around it, so it is on the screen), the flow, or nothing. */
1481
+ select(id) {
1482
+ const want = id === null || id === undefined ? 'flow' : id;
1483
+ if (want !== 'flow' && reveal(want)) render();
1484
+ select(want);
1485
+ },
1486
+ focus() { focusSelected(); },
1487
+ undo() { if (editable() && history.undo()) emit('undo'); },
1488
+ redo() { if (editable() && history.redo()) emit('redo'); },
1489
+ /** The folded blocks' ids. */
1490
+ get folds() { return [...folds]; },
1491
+ setFolds(ids) { setFolds(Array.isArray(ids) ? ids : []); },
1492
+ destroy() {
1493
+ if (destroyed) return;
1494
+ destroyed = true;
1495
+ endDrag(false);
1496
+ closeFlowPopovers();
1497
+ unbind();
1498
+ root.removeEventListener('pointerdown', onPressStart, true);
1499
+ pressDoc.removeEventListener('pointerup', onPressEnd, true);
1500
+ pressDoc.removeEventListener('pointercancel', onPressEnd, true);
1501
+ pressDoc.defaultView?.removeEventListener('blur', onPressEnd);
1502
+ panel.destroy();
1503
+ strip.destroy();
1504
+ root.remove();
1505
+ },
1506
+ };
1507
+
1508
+ if (options.graph) api.load({ graph: options.graph });
1509
+ else { updateTools(); showPanel(); }
1510
+ return api;
1511
+ }