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,199 @@
1
+ /**
2
+ * REFERENCE SYNTAXES — what a chip is recognised as, supplied by the consumer
3
+ * (36 §3.6).
4
+ *
5
+ * A syntax is a RECOGNISER FOR DRAWING, not a grammar. Which references are
6
+ * valid, and what one means, is the consumer's server's to say; a reference
7
+ * it would refuse still draws as a chip (toned `known: false` by the
8
+ * consumer's `describe`) and is kept exactly as text.
9
+ *
10
+ * syntax.find(text) → [{start, end, text, path}]
11
+ * syntax.format(path) → the text that refers to `path`
12
+ * syntax.rename(text, map) → `text` with every reference to an old step
13
+ * id in `map` pointed at its new id
14
+ * syntax.describe(match, ctx) → {label, tone, known} ← the consumer sets this
15
+ *
16
+ * Three ship ready-made: TEMPLATE (`${a.b[0]}`), FORMULA (`[a.b]`) and
17
+ * PARAMETER (`{{name}}`). They are plain objects on purpose: a consumer sets
18
+ * `describe` — and, for a syntax whose paths name steps, `stepSegment` — on
19
+ * the shared object, or makes its own with `createReferenceSyntax`.
20
+ *
21
+ * ══ WHICH SEGMENT NAMES A STEP ═════════════════════════════════════════
22
+ *
23
+ * `rename` rewrites only the segment that names a STEP, and only the consumer
24
+ * knows which one that is — in one grammar it is the segment after a scope
25
+ * word, in another the first. A syntax renames nothing until it is told:
26
+ * `stepScope: 'scope'` says "the segment after `scope`", and `stepSegment:
27
+ * (segments) => index` says anything else. Renaming every segment that happens
28
+ * to equal an old id would rename a column called like a step.
29
+ */
30
+
31
+ const TEMPLATE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+|\[\d+\]|\["[^"\\]*"\])*)\}/g;
32
+ const FORMULA_PATTERN = /\[([^[\]\n]+)\]/g;
33
+ const PARAMETER_PATTERN = /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g;
34
+
35
+ /**
36
+ * A path's segments, as `{text, start, end}` over the path: `a.b[0]["c.d"]` →
37
+ * `a`, `b`, `[0]`, `["c.d"]`. A dot inside brackets or quotes does not split.
38
+ */
39
+ export function pathSegments(path) {
40
+ const out = [];
41
+ let start = 0;
42
+ let depth = 0;
43
+ let quote = false;
44
+ const s = String(path);
45
+ const push = (end) => { if (end > start) out.push({ text: s.slice(start, end), start, end }); };
46
+ for (let i = 0; i < s.length; i += 1) {
47
+ const ch = s[i];
48
+ if (quote) {
49
+ if (ch === '\\') i += 1;
50
+ else if (ch === '"') quote = false;
51
+ continue;
52
+ }
53
+ if (ch === '"' && depth > 0) { quote = true; continue; }
54
+ if (ch === '[') {
55
+ if (depth === 0) { push(i); start = i; }
56
+ depth += 1;
57
+ } else if (ch === ']' && depth > 0) {
58
+ depth -= 1;
59
+ if (depth === 0) { push(i + 1); start = i + 1; }
60
+ } else if (ch === '.' && depth === 0) {
61
+ push(i);
62
+ start = i + 1;
63
+ }
64
+ }
65
+ push(s.length);
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * @param {object} o
71
+ * @param {string} o.name
72
+ * @param {RegExp} o.pattern group 1 is the path; made global if it is not
73
+ * @param {(path: string) => string} o.format
74
+ * @param {(match: object, ctx: object) => {label?: string, tone?: string, known?: boolean}} [o.describe]
75
+ * @param {string|string[]} [o.stepScope] the segment after this word names a step
76
+ * @param {(segments: string[]) => number} [o.stepSegment] which segment names a step, or -1
77
+ * @param {string} [o.tone] its chips' tone when `describe` gives none — two syntaxes in one
78
+ * field are two tones
79
+ */
80
+ export function createReferenceSyntax({ name, pattern, format, describe = null, stepScope = null,
81
+ stepSegment = null, tone = 'blue' }) {
82
+ if (!(pattern instanceof RegExp)) throw new Error(`Reference syntax "${name}" needs a RegExp pattern.`);
83
+ if (typeof format !== 'function') throw new Error(`Reference syntax "${name}" needs a format(path) function.`);
84
+ const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
85
+ const syntax = {
86
+ name: String(name),
87
+ pattern: new RegExp(pattern.source, flags),
88
+ format,
89
+ describe,
90
+ tone,
91
+ stepScope,
92
+ stepSegment,
93
+
94
+ find(text) {
95
+ const out = [];
96
+ if (typeof text !== 'string' || !text) return out;
97
+ const re = new RegExp(syntax.pattern.source, syntax.pattern.flags);
98
+ let m;
99
+ while ((m = re.exec(text)) !== null) {
100
+ if (m[0] === '') { re.lastIndex += 1; continue; }
101
+ out.push({ start: m.index, end: m.index + m[0].length, text: m[0], path: m[1] ?? m[0] });
102
+ }
103
+ return out;
104
+ },
105
+
106
+ /** The index of the segment of `path` that names a step, or -1. */
107
+ stepIndex(path) {
108
+ const segs = pathSegments(path).map((s) => s.text);
109
+ if (typeof syntax.stepSegment === 'function') {
110
+ const i = syntax.stepSegment(segs);
111
+ return Number.isInteger(i) && i >= 0 && i < segs.length ? i : -1;
112
+ }
113
+ const scopes = [].concat(syntax.stepScope ?? []);
114
+ if (!scopes.length) return -1;
115
+ return scopes.includes(segs[0]) && segs.length > 1 ? 1 : -1;
116
+ },
117
+
118
+ rename(text, map) {
119
+ if (typeof text !== 'string' || !map) return text;
120
+ const lookup = map instanceof Map ? map : new Map(Object.entries(map));
121
+ if (!lookup.size) return text;
122
+ const matches = syntax.find(text);
123
+ if (!matches.length) return text;
124
+ let out = '';
125
+ let at = 0;
126
+ for (const m of matches) {
127
+ const i = syntax.stepIndex(m.path);
128
+ if (i < 0) continue;
129
+ const seg = pathSegments(m.path)[i];
130
+ if (!lookup.has(seg.text)) continue;
131
+ const path = m.path.slice(0, seg.start) + lookup.get(seg.text) + m.path.slice(seg.end);
132
+ out += text.slice(at, m.start) + syntax.format(path);
133
+ at = m.end;
134
+ }
135
+ return out + text.slice(at);
136
+ },
137
+ };
138
+ return syntax;
139
+ }
140
+
141
+ export const TEMPLATE_REFERENCES = createReferenceSyntax({
142
+ name: 'template', pattern: TEMPLATE_PATTERN, format: (path) => `\${${path}}`,
143
+ });
144
+ export const FORMULA_REFERENCES = createReferenceSyntax({
145
+ name: 'formula', pattern: FORMULA_PATTERN, format: (path) => `[${path}]`,
146
+ });
147
+ export const PARAMETER_REFERENCES = createReferenceSyntax({
148
+ name: 'parameter', pattern: PARAMETER_PATTERN, format: (name) => `{{${name}}}`, tone: 'violet',
149
+ });
150
+
151
+ /**
152
+ * Every reference of every syntax in `text`, as non-overlapping spans in text
153
+ * order, each carrying its `syntax`. Where two overlap, the one that starts
154
+ * first wins, then the longer, then the syntax listed first.
155
+ */
156
+ export function findReferences(text, syntaxes = []) {
157
+ const all = [];
158
+ syntaxes.forEach((syntax, rank) => {
159
+ for (const m of syntax?.find?.(text) || []) all.push({ ...m, syntax, rank });
160
+ });
161
+ all.sort((a, b) => (a.start - b.start) || ((b.end - b.start) - (a.end - a.start)) || (a.rank - b.rank));
162
+ const out = [];
163
+ let end = -1;
164
+ for (const m of all) {
165
+ if (m.start < end) continue;
166
+ out.push({ start: m.start, end: m.end, text: m.text, path: m.path, syntax: m.syntax });
167
+ end = m.end;
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /** `text` cut into `{text}` and `{ref}` parts, in order — what a chip input draws. */
173
+ export function tokenize(text, syntaxes = []) {
174
+ const parts = [];
175
+ let at = 0;
176
+ for (const m of findReferences(text, syntaxes)) {
177
+ if (m.start > at) parts.push({ kind: 'text', text: text.slice(at, m.start) });
178
+ parts.push({ kind: 'ref', text: m.text, match: m });
179
+ at = m.end;
180
+ }
181
+ if (at < String(text ?? '').length) parts.push({ kind: 'text', text: text.slice(at) });
182
+ return parts;
183
+ }
184
+
185
+ /** How a chip reads: the syntax's `describe`, else the path, in the syntax's tone, known. */
186
+ export function describeReference(match, ctx = {}) {
187
+ const d = typeof match?.syntax?.describe === 'function' ? match.syntax.describe(match, ctx) : null;
188
+ return {
189
+ label: d?.label ?? match?.path ?? match?.text ?? '',
190
+ tone: d?.tone ?? match?.syntax?.tone ?? null,
191
+ known: d?.known !== false,
192
+ };
193
+ }
194
+
195
+ /** Every syntax name in `names` that `references` (name → syntax) holds, as syntax objects. */
196
+ export function resolveSyntaxes(names, references) {
197
+ const map = references instanceof Map ? references : new Map(Object.entries(references || {}));
198
+ return [].concat(names ?? []).map((n) => map.get(n)).filter(Boolean);
199
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * THE SETTINGS PANEL — one step's settings, or the flow's own (36 §3.4).
3
+ *
4
+ * The header is the type, the step's name, WHERE it is ("Inside Anything new?
5
+ * › Then"), its description and the line saying how later steps read it; under
6
+ * it, the step's fields, drawn from its schema by the widget registry, with
7
+ * the editor's synthetic fields (`extra`) among them and the consumer's own
8
+ * areas (`slots`) before and after.
9
+ *
10
+ * ══ TWO RULES IT KEEPS ══════════════════════════════════════════════════
11
+ *
12
+ * **A repaint puts focus back** on the rebuilt control of the same field —
13
+ * caret and selection where they were — or, when that field is gone, hands it
14
+ * to the editor (`onFocusLost`) to put on its surface. Never on `<body>`, where
15
+ * an editor's keys never arrive.
16
+ *
17
+ * **A repaint the reader did not ask for rebuilds no field.** Findings are
18
+ * applied to the fields in place (`setFindings`), and the consumer's slots are
19
+ * redrawn on their own (`repaintSlots`) — so a run overlay refreshed every
20
+ * second leaves a name being typed with its control, its caret and its own
21
+ * undo. Only `show()`, and a setting whose change reshapes the form (a reshape,
22
+ * or a key some field's `x-ui-when` reads), rebuild the fields.
23
+ *
24
+ * A refused control is disabled AND SAYS WHY beside it, on the screen — never
25
+ * only on a `title` nobody hovers.
26
+ */
27
+
28
+ import { button, el, icon, toneClass } from './dom.js';
29
+ import { createStrings, say } from './strings.js';
30
+ import { fieldsFromSchema, visibleFields, whenKeys } from './settings_schema.js';
31
+ import { renderField } from './widgets.js';
32
+ import { matchFindingField } from './findings.js';
33
+ import { openValuePicker } from './value_picker.js';
34
+ import { ownsUndo } from './keys.js';
35
+
36
+ const TITLE = 'data-twm-flow-title';
37
+
38
+ /**
39
+ * @param {object} o
40
+ * @param {object} o.widgets `createWidgetRegistry()`'s
41
+ * @param {object} [o.references] syntax name → syntax
42
+ * @param {object} [o.strings]
43
+ * @param {object} [o.services] the consumer's bag, handed to every widget untouched
44
+ * @param {false|true|{reason: string}} [o.readOnly]
45
+ * @param {(stepId: string|null, key: string, value: any) => void} [o.onChange]
46
+ * @param {(stepId: string|null, label: string) => void} [o.onRename] the title, typed
47
+ * @param {() => void} [o.onFocusLost] a repaint took the focused control away
48
+ * @param {(q: {stepId, step, field, key}) => Promise<object[]|{groups, note}>} [o.values]
49
+ * *Insert a value*'s groups for one field; absent, no field offers the picker
50
+ * @param {string} [o.valuesNote] the picker's footer sentence
51
+ * @param {(step: object) => object[]|null} [o.columns] a step's input columns (a data flow)
52
+ * @param {HTMLElement} [o.host] appended here when given
53
+ */
54
+ export function createSettingsPanel({ widgets, references = {}, strings = null, services = null, readOnly = false,
55
+ onChange = null, onRename = null, onFocusLost = null, values = null,
56
+ valuesNote = null, columns = null, host = null } = {}) {
57
+ if (!widgets?.get) throw new Error('createSettingsPanel needs a widget registry (createWidgetRegistry()).');
58
+ const S = createStrings(strings);
59
+ const root = el('section', 'twm-flow-panel');
60
+ root.setAttribute('role', 'region');
61
+ root.setAttribute('aria-label', say(S, 'settings'));
62
+ if (host) host.appendChild(root);
63
+
64
+ let ro = readOnly;
65
+ let spec = null;
66
+ let current = {};
67
+ let fields = []; // the rendered fields, in order
68
+ let allFields = []; // the schema's fields, before x-ui-when
69
+ let findings = [];
70
+ let findingsBox = null;
71
+ let slotBefore = null;
72
+ let slotAfter = null;
73
+ let destroyed = false;
74
+
75
+ const stepId = () => spec?.step?.id ?? null;
76
+ const isReadOnly = () => Boolean(ro);
77
+ const readOnlyReason = () => (ro && typeof ro === 'object' && ro.reason ? String(ro.reason) : '');
78
+
79
+ function pickerFor() {
80
+ if (!values) return null;
81
+ return async ({ anchor, insert, path, key }) => {
82
+ const answer = await values({ stepId: stepId(), step: spec?.step ?? null, field: path, key });
83
+ const groups = Array.isArray(answer) ? answer : (answer?.groups || []);
84
+ const note = Array.isArray(answer) ? valuesNote : (answer?.note ?? valuesNote);
85
+ if (destroyed || !anchor.isConnected) return;
86
+ openValuePicker({ anchor, groups, note, strings: S, onPick: (item) => insert(item.ref) });
87
+ };
88
+ }
89
+
90
+ function setValue(key, v, opts = {}) {
91
+ if (v === undefined) delete current[key]; else current[key] = v;
92
+ onChange?.(stepId(), key, v);
93
+ if (opts?.reshape || whenKeys(allFields).has(key)) repaint();
94
+ }
95
+
96
+ function fieldCtx() {
97
+ return {
98
+ widgets, referenceMap: references, strings: S, services, readOnly: isReadOnly(),
99
+ step: spec?.step ?? null, type: spec?.type ?? null, path: '',
100
+ openValuePicker: pickerFor(),
101
+ columns: columns ? () => columns(spec?.step ?? null) : () => null,
102
+ describeContext: { step: spec?.step ?? null, type: spec?.type ?? null, services },
103
+ set: setValue,
104
+ };
105
+ }
106
+
107
+ function paintHeader() {
108
+ const head = el('header', 'twm-flow-panel__head');
109
+ const chip = el('span', `twm-flow-panel__icon ${toneClass('twm-flow-panel__icon', spec.tone)}`);
110
+ chip.appendChild(icon(spec.icon || ''));
111
+ chip.setAttribute('aria-hidden', 'true');
112
+ const titles = el('div', 'twm-flow-panel__titles');
113
+ if (spec.typeLabel) titles.appendChild(el('span', 'twm-flow-panel__type', spec.typeLabel));
114
+ if (spec.rename && spec.step) {
115
+ const input = el('input', 'twm-flow-panel__title-input');
116
+ input.type = 'text';
117
+ input.value = spec.title ?? '';
118
+ input.placeholder = spec.placeholderTitle ?? spec.typeLabel ?? '';
119
+ input.setAttribute('aria-label', say(S, 'rename'));
120
+ input.setAttribute(TITLE, '');
121
+ input.readOnly = isReadOnly();
122
+ input.addEventListener('input', () => {
123
+ // What was typed is the title now: a repaint the reader asks for
124
+ // next (a reshape) must not draw the old one back.
125
+ spec.title = input.value;
126
+ onRename?.(stepId(), input.value);
127
+ });
128
+ titles.appendChild(input);
129
+ } else {
130
+ titles.appendChild(el('h3', 'twm-flow-panel__title', spec.title ?? ''));
131
+ }
132
+ if (spec.where) titles.appendChild(el('span', 'twm-flow-panel__where', spec.where));
133
+ head.append(chip, titles);
134
+ const acts = Array.isArray(spec.actions) ? spec.actions : [];
135
+ if (acts.length) {
136
+ const box = el('div', 'twm-flow-panel__actions');
137
+ for (const a of acts) {
138
+ const refusal = isReadOnly() ? (readOnlyReason() || say(S, 'readOnly')) : (a.disabled ? a.reason || '' : '');
139
+ const b = button({ label: a.label, icon: a.icon || null, danger: Boolean(a.danger),
140
+ primary: Boolean(a.primary), className: 'twm-flow-panel__action',
141
+ onClick: () => { if (!b.disabled) a.run?.(); } });
142
+ b.dataset.action = a.id ?? '';
143
+ b.disabled = Boolean(isReadOnly() || a.disabled);
144
+ const wrap = el('span', 'twm-flow-panel__action-wrap');
145
+ wrap.appendChild(b);
146
+ if (b.disabled && refusal && !isReadOnly()) wrap.appendChild(el('span', 'twm-flow-panel__refusal', refusal));
147
+ box.appendChild(wrap);
148
+ }
149
+ head.appendChild(box);
150
+ }
151
+ return head;
152
+ }
153
+
154
+ function paintSlots() {
155
+ for (const [box, name] of [[slotBefore, 'before'], [slotAfter, 'after']]) {
156
+ if (!box) continue;
157
+ box.replaceChildren();
158
+ const fn = spec?.slots?.[name];
159
+ if (typeof fn === 'function') fn(box);
160
+ box.hidden = box.childNodes.length === 0;
161
+ }
162
+ }
163
+
164
+ function paint() {
165
+ for (const f of fields) f.destroy?.();
166
+ fields = [];
167
+ root.replaceChildren();
168
+ if (!spec) return;
169
+ if (isReadOnly() && readOnlyReason()) root.appendChild(el('p', 'twm-flow-panel__readonly', readOnlyReason()));
170
+ root.appendChild(paintHeader());
171
+ if (spec.description) root.appendChild(el('p', 'twm-flow-panel__description', spec.description));
172
+ if (spec.idLine) root.appendChild(el('div', 'twm-flow-panel__id', spec.idLine));
173
+ findingsBox = el('ul', 'twm-flow-panel__findings');
174
+ findingsBox.hidden = true;
175
+ root.appendChild(findingsBox);
176
+ slotBefore = el('div', 'twm-flow-panel__slot');
177
+ slotBefore.dataset.slot = 'before';
178
+ root.appendChild(slotBefore);
179
+
180
+ const list = el('div', 'twm-flow-panel__fields');
181
+ allFields = Array.isArray(spec.fields) ? spec.fields : fieldsFromSchema(spec.type?.config_schema);
182
+ const drawn = visibleFields(allFields, current);
183
+ const extra = Array.isArray(spec.extra) ? spec.extra : [];
184
+ const placed = (e) => e.after ?? e.before ?? null;
185
+ const extrasAt = (key, where) => extra.filter((e) => (where === 'after' ? e.after === key : e.before === key));
186
+ const drawExtra = (e) => {
187
+ if (e.note !== undefined) {
188
+ list.appendChild(el('p', `twm-flow-panel__note${e.tone ? ` twm-flow-panel__note--${e.tone === 'warning' ? 'warning' : 'info'}` : ''}`, e.note));
189
+ return;
190
+ }
191
+ const f = renderField({ key: e.key, spec: e.spec || {}, required: Boolean(e.required) }, e.value, {
192
+ ...fieldCtx(),
193
+ set: (_k, v, opts) => {
194
+ e.set?.(v, opts);
195
+ if (opts?.reshape) repaint();
196
+ },
197
+ });
198
+ f.el.dataset.extra = '';
199
+ fields.push(f);
200
+ list.appendChild(f.el);
201
+ };
202
+ for (const e of extra.filter((x) => x.at === 'start')) drawExtra(e);
203
+ for (const f of drawn) {
204
+ for (const e of extrasAt(f.key, 'before')) drawExtra(e);
205
+ const field = renderField(f, current[f.key], fieldCtx());
206
+ fields.push(field);
207
+ list.appendChild(field.el);
208
+ for (const e of extrasAt(f.key, 'after')) drawExtra(e);
209
+ }
210
+ const keys = new Set(drawn.map((f) => f.key));
211
+ for (const e of extra) {
212
+ if (e.at === 'start') continue;
213
+ const anchor = placed(e);
214
+ if (anchor === null || !keys.has(anchor)) drawExtra(e);
215
+ }
216
+ root.appendChild(list);
217
+ slotAfter = el('div', 'twm-flow-panel__slot');
218
+ slotAfter.dataset.slot = 'after';
219
+ root.appendChild(slotAfter);
220
+ paintSlots();
221
+ applyFindings();
222
+ }
223
+
224
+ /** Where focus is in the panel, so a repaint can put it back. */
225
+ function heldFocus() {
226
+ const active = root.ownerDocument.activeElement;
227
+ if (!active || active === root || !root.contains(active)) return null;
228
+ if (active.hasAttribute?.(TITLE)) {
229
+ return { title: true, caret: { start: active.selectionStart, end: active.selectionEnd } };
230
+ }
231
+ const path = active.closest('[data-field]')?.dataset.field ?? null;
232
+ const field = path ? fields.map((f) => f.fieldAt(path)).find(Boolean) : null;
233
+ let caret = field?.caret?.() ?? null;
234
+ if (!caret && ownsUndo(active) && active.matches?.('input, textarea')) {
235
+ try {
236
+ if (Number.isInteger(active.selectionStart)) {
237
+ caret = { start: active.selectionStart, end: active.selectionEnd, direction: active.selectionDirection };
238
+ }
239
+ } catch { /* a type without a caret */ }
240
+ }
241
+ return { path, caret, tag: active.tagName };
242
+ }
243
+
244
+ function restoreFocus(held) {
245
+ if (!held || destroyed || !root.isConnected) return;
246
+ if (held.title) {
247
+ const t = root.querySelector(`[${TITLE}]`);
248
+ if (t) {
249
+ t.focus({ preventScroll: true });
250
+ try { t.setSelectionRange(held.caret.start, held.caret.end); } catch { /* no caret */ }
251
+ return;
252
+ }
253
+ }
254
+ const field = held.path ? fields.map((f) => f.fieldAt(held.path)).find(Boolean) : null;
255
+ if (field && field.focus()) {
256
+ if (held.caret) {
257
+ if (field.control?.restoreCaret) field.restoreCaret(held.caret);
258
+ else {
259
+ const box = root.ownerDocument.activeElement;
260
+ try {
261
+ const end = box.value.length;
262
+ box.setSelectionRange(Math.min(held.caret.start, end), Math.min(held.caret.end, end),
263
+ held.caret.direction || 'none');
264
+ } catch { /* no caret */ }
265
+ }
266
+ }
267
+ return;
268
+ }
269
+ onFocusLost?.();
270
+ }
271
+
272
+ function repaint() {
273
+ const held = heldFocus();
274
+ paint();
275
+ restoreFocus(held);
276
+ }
277
+
278
+ function applyFindings() {
279
+ if (!spec || !findingsBox) return;
280
+ const mine = findings.filter((f) => (f.node_id ?? null) === stepId());
281
+ const paths = fields.flatMap((f) => f.paths());
282
+ const byPath = new Map();
283
+ const loose = [];
284
+ for (const f of mine) {
285
+ const hit = matchFindingField(paths, f.field);
286
+ if (hit) {
287
+ if (!byPath.has(hit)) byPath.set(hit, []);
288
+ byPath.get(hit).push(f);
289
+ } else loose.push(f);
290
+ }
291
+ for (const field of fields) {
292
+ for (const p of field.paths()) {
293
+ const target = field.fieldAt(p);
294
+ const list = byPath.get(p) || [];
295
+ const errors = list.filter((f) => f.severity !== 'warning');
296
+ target.setError(list.map((f) => f.message).join(' '), errors.length ? 'error' : 'warning');
297
+ }
298
+ }
299
+ findingsBox.replaceChildren();
300
+ for (const f of loose) {
301
+ const li = el('li', `twm-flow-panel__finding${f.severity === 'warning' ? ' twm-flow-panel__finding--warning' : ''}`);
302
+ li.append(icon(f.severity === 'warning' ? 'warning' : 'error', 'twm-flow-panel__finding-icon'),
303
+ el('span', 'twm-flow-panel__finding-text', String(f.message ?? '')));
304
+ findingsBox.appendChild(li);
305
+ }
306
+ findingsBox.hidden = loose.length === 0;
307
+ }
308
+
309
+ return {
310
+ el: root,
311
+ /** Draw a step's (or, with no `step`, the flow's) settings. */
312
+ show(next) {
313
+ const sameStep = spec && next && (spec.step?.id ?? null) === (next.step?.id ?? null);
314
+ const held = heldFocus();
315
+ spec = { ...next };
316
+ current = { ...(next.value ?? next.step?.config ?? {}) };
317
+ paint();
318
+ if (held && sameStep) restoreFocus(held);
319
+ else if (held) onFocusLost?.();
320
+ },
321
+ /** Empty the panel. */
322
+ clear() {
323
+ const held = heldFocus();
324
+ spec = null;
325
+ current = {};
326
+ paint();
327
+ if (held) onFocusLost?.();
328
+ },
329
+ get stepId() { return stepId(); },
330
+ get value() { return { ...current }; },
331
+ setFindings(list) {
332
+ findings = Array.isArray(list) ? list.filter((f) => f && typeof f === 'object')
333
+ : [...(list?.errors || []), ...(list?.warnings || [])];
334
+ applyFindings();
335
+ },
336
+ /** Focus the field a finding's `field` names (the longest prefix); false when none. */
337
+ focusField(path) {
338
+ const hit = matchFindingField(fields.flatMap((f) => f.paths()), path);
339
+ const field = hit ? fields.map((f) => f.fieldAt(hit)).find(Boolean) : null;
340
+ if (!field) return false;
341
+ field.el.scrollIntoView?.({ block: 'nearest' });
342
+ return field.focus();
343
+ },
344
+ /** Redraw the consumer's slots only — no field is rebuilt. */
345
+ repaintSlots() { paintSlots(); },
346
+ setReadOnly(next) {
347
+ ro = next;
348
+ repaint();
349
+ },
350
+ destroy() {
351
+ destroyed = true;
352
+ for (const f of fields) f.destroy?.();
353
+ fields = [];
354
+ root.remove();
355
+ },
356
+ };
357
+ }