@kubex/zinc 1.1.23 → 1.1.25

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 (44) hide show
  1. package/custom-elements-manifest.config.js +2 -4
  2. package/dist/custom-elements.json +1333 -249
  3. package/dist/vscode.html-custom-data.json +59 -37
  4. package/dist/web-types.json +190 -81
  5. package/dist/zn.d.ts +864 -0
  6. package/dist/zn.min.js +875 -464
  7. package/docs/_includes/default.njk +1 -0
  8. package/docs/_includes/full-page.njk +38 -0
  9. package/docs/pages/components/flow-builder-demo.njk +194 -0
  10. package/docs/pages/components/flow-builder-troubleshooter-demo.njk +282 -0
  11. package/docs/pages/components/flow-builder.md +377 -0
  12. package/package.json +1 -1
  13. package/src/components/flow-builder/flow-builder.component.ts +1171 -0
  14. package/src/components/flow-builder/flow-builder.scss +489 -0
  15. package/src/components/flow-builder/flow-builder.test.ts +59 -0
  16. package/src/components/flow-builder/flow-layout.ts +139 -0
  17. package/src/components/flow-builder/flow-registry.ts +52 -0
  18. package/src/components/flow-builder/flow.types.ts +407 -0
  19. package/src/components/flow-builder/index.ts +14 -0
  20. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +1110 -0
  21. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +371 -0
  22. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.test.ts +39 -0
  23. package/src/components/flow-builder/modules/flow-canvas/index.ts +12 -0
  24. package/src/components/flow-builder/modules/flow-node/flow-node.component.ts +174 -0
  25. package/src/components/flow-builder/modules/flow-node/flow-node.scss +180 -0
  26. package/src/components/flow-builder/modules/flow-node/flow-node.test.ts +50 -0
  27. package/src/components/flow-builder/modules/flow-node/index.ts +12 -0
  28. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +88 -0
  29. package/src/components/flow-builder/modules/flow-step/flow-step.scss +52 -0
  30. package/src/components/flow-builder/modules/flow-step/flow-step.test.ts +21 -0
  31. package/src/components/flow-builder/modules/flow-step/index.ts +12 -0
  32. package/src/components/flow-builder/modules/flow-step-group/flow-step-group.component.ts +35 -0
  33. package/src/components/flow-builder/modules/flow-step-group/flow-step-group.scss +28 -0
  34. package/src/components/flow-builder/modules/flow-step-group/flow-step-group.test.ts +20 -0
  35. package/src/components/flow-builder/modules/flow-step-group/index.ts +12 -0
  36. package/src/components/flow-builder/modules/flow-steps/flow-steps.component.ts +88 -0
  37. package/src/components/flow-builder/modules/flow-steps/flow-steps.scss +24 -0
  38. package/src/components/flow-builder/modules/flow-steps/flow-steps.test.ts +17 -0
  39. package/src/components/flow-builder/modules/flow-steps/index.ts +12 -0
  40. package/src/events/events.ts +3 -0
  41. package/src/events/zn-flow-change.ts +9 -0
  42. package/src/events/zn-flow-connect.ts +9 -0
  43. package/src/events/zn-flow-selection-change.ts +7 -0
  44. package/src/zinc.ts +4 -0
@@ -0,0 +1,1171 @@
1
+ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
2
+ import {guard} from 'lit/directives/guard.js';
3
+ import {ifDefined} from 'lit/directives/if-defined.js';
4
+ import {property, state} from 'lit/decorators.js';
5
+ import ZincElement from '../../internal/zinc-element';
6
+ import ZnFlowCanvas from './modules/flow-canvas';
7
+ import ZnFlowStepGroup from './modules/flow-step-group';
8
+ import ZnIcon from '../icon';
9
+ import ZnInput from '../input';
10
+ import ZnNavbar from '../navbar';
11
+ import ZnTabs from '../tabs';
12
+
13
+ import {
14
+ cardCollides,
15
+ DEFAULT_OUTPUT,
16
+ descendantIds,
17
+ emptyDragImage,
18
+ emptyFlowState,
19
+ firstInputId,
20
+ FLOW_TYPE_MIME,
21
+ type FlowConnection,
22
+ type FlowGroup,
23
+ type FlowNodeInstance,
24
+ type FlowNodeType,
25
+ type FlowPort,
26
+ type FlowState,
27
+ GRID_SIZE,
28
+ loopConnections,
29
+ NEW_OUTPUT_PORT,
30
+ NODE_HEIGHT,
31
+ NODE_WIDTH,
32
+ nodeInputs,
33
+ nodeOutputs,
34
+ portAnchor,
35
+ snapToGrid,
36
+ typeInputs,
37
+ typeOutputs,
38
+ } from './flow.types';
39
+ import {FlowRegistry} from './flow-registry';
40
+ import {HasSlotController} from '../../internal/slot';
41
+ import {LAYOUT_V_GAP, untangledPositions} from './flow-layout';
42
+
43
+ import styles from './flow-builder.scss';
44
+
45
+ const HISTORY_LIMIT = 50;
46
+ const TYPE_MIME = FLOW_TYPE_MIME;
47
+
48
+ const TABS: { group: FlowGroup; label: string }[] = [
49
+ {group: 'entrypoint', label: 'Entrypoint'},
50
+ {group: 'trigger', label: 'Triggers'},
51
+ {group: 'action', label: 'Actions'},
52
+ {group: 'rule', label: 'Rules'},
53
+ ];
54
+
55
+ interface PickerTarget { kind: 'wire'; connectionId: string }
56
+
57
+ /**
58
+ * @summary A drag-and-drop visual flow builder: steps panel, pan/zoom canvas, and a config inspector.
59
+ * @documentation https://zinc.style/components/flow-builder
60
+ * @status experimental
61
+ * @since 1.0
62
+ *
63
+ * @dependency zn-icon
64
+ * @dependency zn-input
65
+ * @dependency zn-tabs
66
+ * @dependency zn-navbar
67
+ * @dependency zn-flow-canvas
68
+ * @dependency zn-flow-node
69
+ *
70
+ * @event zn-flow-change - Emitted whenever the flow state changes. `event.detail.state` is the new FlowState.
71
+ * @event zn-flow-selection-change - Emitted when the selected node changes. `event.detail.nodeId`.
72
+ * @event zn-flow-connect - Emitted when a connection is created. `event.detail.connection`.
73
+ *
74
+ * @slot - `<zn-flow-step>` type declarations; never displayed, each `group`/`category` routes the
75
+ * step into the right tab and collapsible grouping of the rendered panel.
76
+ * @slot header-left - Actions shown on the left of the header bar (e.g. Close / Undo All Changes).
77
+ * @slot header-right - Actions shown on the right of the header bar (e.g. Apply Changes).
78
+ * @slot sidebar - Extra right-panel content (status, version history), below the configuration errors.
79
+ *
80
+ * @csspart base - The grid wrapper.
81
+ * @csspart header - The full-width header action bar (only rendered when header slots are filled).
82
+ * @csspart steps - The left steps panel.
83
+ * @csspart inspector - The right panel while a node or branch is selected.
84
+ */
85
+ export default class ZnFlowBuilder extends ZincElement {
86
+ static styles: CSSResultGroup = unsafeCSS(styles);
87
+
88
+ static dependencies = {
89
+ 'zn-icon': ZnIcon,
90
+ 'zn-input': ZnInput,
91
+ 'zn-tabs': ZnTabs,
92
+ 'zn-navbar': ZnNavbar,
93
+ 'zn-flow-canvas': ZnFlowCanvas,
94
+ 'zn-flow-step-group': ZnFlowStepGroup,
95
+ };
96
+
97
+ /** Node types to make available, registered into the internal registry. */
98
+ @property({attribute: false}) nodeTypes: FlowNodeType[] = [];
99
+
100
+ @property({reflect: true}) heading = '';
101
+ @property({attribute: 'subheading', reflect: true}) subheading = '';
102
+
103
+ /** Node ids flagged as having configuration errors (drives the red node styling). */
104
+ @property({attribute: false}) errorNodes: string[] = [];
105
+
106
+ /** Optional hint shown beneath each steps-panel tab. */
107
+ @property({attribute: 'entrypoints-hint'}) entrypointsHint = '';
108
+ @property({attribute: 'triggers-hint'}) triggersHint = '';
109
+ @property({attribute: 'actions-hint'}) actionsHint = '';
110
+ @property({attribute: 'rules-hint'}) rulesHint = '';
111
+
112
+ private registry = new FlowRegistry();
113
+
114
+ @state() private _state: FlowState = emptyFlowState();
115
+ @state() private _selectedNodeId: string | null = null;
116
+ /** The output branch open in the branch editor, if any. */
117
+ @state() private _selectedBranch: { nodeId: string; port: string } | null = null;
118
+ @state() private _search = '';
119
+ /** The steps-panel tab currently shown; the search only filters this tab. */
120
+ @state() private _activeGroup: FlowGroup | null = null;
121
+
122
+ private readonly _hasSlot = new HasSlotController(this, 'header-left', 'header-right');
123
+ /** The node being relocated via the MOVE menu action, if any. */
124
+ @state() private _movingNodeId: string | null = null;
125
+ /** The "+" picker popover target (an open output, or a wire to insert into), if open. */
126
+ @state() private _picker: { x: number; y: number; target: PickerTarget } | null = null;
127
+ /** The node type currently being dragged from the steps panel, for the canvas drop preview. */
128
+ @state() private _draggingType: string | null = null;
129
+
130
+ private _history: FlowState[] = [];
131
+ private _redo: FlowState[] = [];
132
+ private _seq = 0;
133
+ private _untangleRaf: number | null = null;
134
+ /** Applies the in-flight untangle's final positions; used when it's cut short. */
135
+ private _untangleSettle: (() => void) | null = null;
136
+ /**
137
+ * Bumped whenever the state is replaced wholesale (undo / redo / setState) so the
138
+ * guarded renderConfig / renderBranchConfig bodies rebuild against the fresh node
139
+ * objects. Value edits don't bump it — the consumer's config DOM stays in place,
140
+ * which is what lets its inputs commit live without losing focus.
141
+ */
142
+ private _configRevision = 0;
143
+
144
+ private get _listeners(): [string, EventListener][] {
145
+ return [
146
+ ['flow-node-select', this._onSelect as EventListener],
147
+ ['flow-node-action', this._onNodeAction as EventListener],
148
+ ['flow-interaction-start', this._onInteractionStart as EventListener],
149
+ ['flow-change-commit', this._commit as EventListener],
150
+ ['flow-output-assign', this._onOutputAssign as EventListener],
151
+ ['flow-link-assign', this._onLinkAssign as EventListener],
152
+ ['flow-output-move-target', this._onOutputMoveTarget as EventListener],
153
+ ['flow-wire-pick', this._onWirePick as EventListener],
154
+ ['flow-wire-assign', this._onWireAssign as EventListener],
155
+ ['flow-branch-pick', this._onBranchPick as EventListener],
156
+ ['flow-branch-delete', this._onBranchDelete as EventListener],
157
+ ['flow-step-drag', this._onStepDrag as EventListener],
158
+ ['flow-step-drag-end', this._onDragEnd as EventListener],
159
+ ['flow-undo', this.undo as EventListener],
160
+ ['flow-redo', this.redo as EventListener],
161
+ ['flow-untangle', this.untangle as EventListener],
162
+ ['flow-add-note', this._onAddNote as EventListener],
163
+ ['flow-note-change', this._onNoteChange as EventListener],
164
+ ['flow-note-delete', this._onNoteDelete as EventListener],
165
+ ];
166
+ }
167
+
168
+ connectedCallback() {
169
+ super.connectedCallback();
170
+ this._listeners.forEach(([name, fn]) => this.addEventListener(name, fn));
171
+ document.addEventListener('keydown', this._onKeyDown);
172
+ }
173
+
174
+ disconnectedCallback() {
175
+ this._listeners.forEach(([name, fn]) => this.removeEventListener(name, fn));
176
+ document.removeEventListener('keydown', this._onKeyDown);
177
+ this._cancelUntangle();
178
+ super.disconnectedCallback();
179
+ }
180
+
181
+ private _onKeyDown = (e: KeyboardEvent) => {
182
+ if (e.key === 'Escape' && (this._movingNodeId || this._picker || this._selectedBranch)) {
183
+ this._movingNodeId = null;
184
+ this._picker = null;
185
+ this._selectedBranch = null;
186
+ }
187
+ };
188
+
189
+ protected willUpdate(changed: PropertyValues) {
190
+ if (changed.has('nodeTypes') && this.nodeTypes?.length) {
191
+ this.registry.registerAll(this.nodeTypes);
192
+ }
193
+ super.willUpdate(changed);
194
+ }
195
+
196
+ protected firstUpdated(changed: PropertyValues) {
197
+ super.firstUpdated(changed);
198
+ this._registerSlottedTypes();
199
+ }
200
+
201
+ // --- Slotted steps define node types -------------------------------
202
+
203
+ private static _parsePorts(attr: string | null): FlowPort[] | undefined {
204
+ if (attr === null) return undefined; // omitted → use the default
205
+ const trimmed = attr.trim();
206
+ if (trimmed === '') return []; // present but empty → no ports
207
+ try {
208
+ // A JSON array of port ids ("a") and/or port objects ({id, label}).
209
+ const parsed = JSON.parse(trimmed) as unknown;
210
+ if (!Array.isArray(parsed)) return undefined;
211
+ return parsed.map(p => (typeof p === 'string' ? {id: p} : (p as FlowPort)));
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ }
216
+
217
+ private _typeFromStep(el: Element): FlowNodeType | null {
218
+ const type = el.getAttribute('type');
219
+ if (!type) return null;
220
+ return {
221
+ type,
222
+ label: el.getAttribute('label') ?? el.textContent?.trim() ?? type,
223
+ group: (el.getAttribute('group') as FlowGroup) ?? 'action',
224
+ category: el.getAttribute('category') ?? undefined,
225
+ icon: el.getAttribute('icon') ?? undefined,
226
+ iconLibrary: el.getAttribute('icon-library') ?? undefined,
227
+ color: el.getAttribute('color') ?? undefined,
228
+ description: el.getAttribute('description') ?? undefined,
229
+ inputs: ZnFlowBuilder._parsePorts(el.getAttribute('inputs')),
230
+ outputs: ZnFlowBuilder._parsePorts(el.getAttribute('outputs')),
231
+ };
232
+ }
233
+
234
+ /** Register a FlowNodeType for every slotted <zn-flow-step>. */
235
+ private _registerSlottedTypes = () => {
236
+ let added = false;
237
+ this.querySelectorAll('zn-flow-step').forEach(el => {
238
+ const type = this._typeFromStep(el);
239
+ if (type && !this.registry.has(type.type)) {
240
+ this.registry.register(type);
241
+ added = true;
242
+ }
243
+ });
244
+ if (added) this.requestUpdate();
245
+ };
246
+
247
+ // --- Public API -------------------------------------------------------------
248
+
249
+ registerNodeType(type: FlowNodeType): this {
250
+ this.registry.register(type);
251
+ this.requestUpdate();
252
+ return this;
253
+ }
254
+
255
+ registerNodeTypes(types: FlowNodeType[]): this {
256
+ this.registry.registerAll(types);
257
+ this.requestUpdate();
258
+ return this;
259
+ }
260
+
261
+ getState(): FlowState {
262
+ return this._clone(this._state);
263
+ }
264
+
265
+ setState(next: FlowState) {
266
+ this._cancelUntangle();
267
+ this._state = next;
268
+ this._history = [];
269
+ this._redo = [];
270
+ this._selectedNodeId = null;
271
+ this._configRevision++;
272
+ }
273
+
274
+ get value(): string {
275
+ return JSON.stringify(this._state);
276
+ }
277
+
278
+ set value(json: string) {
279
+ try {
280
+ const parsed = JSON.parse(json) as Partial<FlowState>;
281
+ this.setState({
282
+ nodes: parsed.nodes ?? [],
283
+ connections: parsed.connections ?? [],
284
+ notes: parsed.notes ?? [],
285
+ });
286
+ } catch {
287
+ /* ignore malformed JSON */
288
+ }
289
+ }
290
+
291
+ undo = () => {
292
+ const prev = this._history.pop();
293
+ if (!prev) return;
294
+ this._cancelUntangle();
295
+ this._redo.push(this._clone(this._state));
296
+ this._state = prev;
297
+ this._configRevision++;
298
+ this._syncSelection();
299
+ this._commit();
300
+ };
301
+
302
+ redo = () => {
303
+ const next = this._redo.pop();
304
+ if (!next) return;
305
+ this._cancelUntangle();
306
+ this._history.push(this._clone(this._state));
307
+ this._state = next;
308
+ this._configRevision++;
309
+ this._syncSelection();
310
+ this._commit();
311
+ };
312
+
313
+ /**
314
+ * Auto-arrange the nodes into evenly spaced layers that follow the flow,
315
+ * animating them into place (wires and pills track them since everything is
316
+ * derived from the node coordinates). Undoable as a single step.
317
+ */
318
+ untangle = () => {
319
+ if (!this._state.nodes.length) return;
320
+ this._cancelUntangle();
321
+ this._pushHistory();
322
+ const positions = untangledPositions(this._state, t => this.registry.get(t));
323
+ const moves = this._state.nodes
324
+ .map(n => ({n, from: {x: n.x, y: n.y}, to: positions.get(n.id)}))
325
+ .filter((m): m is typeof m & { to: { x: number; y: number } } => !!m.to);
326
+
327
+ const settle = () => {
328
+ moves.forEach(({n, to}) => {
329
+ n.x = to.x;
330
+ n.y = to.y;
331
+ });
332
+ };
333
+ const finish = () => {
334
+ this._untangleSettle = null;
335
+ settle();
336
+ this._commit();
337
+ };
338
+ this._untangleSettle = settle;
339
+
340
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
341
+ finish();
342
+ return;
343
+ }
344
+
345
+ const DURATION = 500;
346
+ const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
347
+ const canvas = this.shadowRoot?.querySelector('zn-flow-canvas') as ZnFlowCanvas | null;
348
+ const t0 = performance.now();
349
+ const step = (now: number) => {
350
+ const t = Math.min(1, (now - t0) / DURATION);
351
+ if (t >= 1) {
352
+ this._untangleRaf = null;
353
+ finish();
354
+ return;
355
+ }
356
+ const e = ease(t);
357
+ moves.forEach(({n, from, to}) => {
358
+ n.x = Math.round(from.x + (to.x - from.x) * e);
359
+ n.y = Math.round(from.y + (to.y - from.y) * e);
360
+ });
361
+ canvas?.requestUpdate();
362
+ this._untangleRaf = requestAnimationFrame(step);
363
+ };
364
+ this._untangleRaf = requestAnimationFrame(step);
365
+ };
366
+
367
+ private _cancelUntangle() {
368
+ if (this._untangleRaf !== null) {
369
+ cancelAnimationFrame(this._untangleRaf);
370
+ this._untangleRaf = null;
371
+ }
372
+ // Never leave nodes mid-interpolation (off-grid) — snap them to their targets.
373
+ this._untangleSettle?.();
374
+ this._untangleSettle = null;
375
+ }
376
+
377
+ // --- State helpers ----------------------------------------------------------
378
+
379
+ private _clone(s: FlowState): FlowState {
380
+ return JSON.parse(JSON.stringify(s)) as FlowState;
381
+ }
382
+
383
+ private _pushHistory() {
384
+ this._history.push(this._clone(this._state));
385
+ if (this._history.length > HISTORY_LIMIT) this._history.shift();
386
+ this._redo = [];
387
+ }
388
+
389
+ private _commit = () => {
390
+ this._pruneConnections();
391
+ // New array refs so the canvas (which receives nodes/connections/notes by
392
+ // reference) re-renders on structural changes. Live drags mutate the node
393
+ // objects in place and the canvas self-updates; this keeps them in sync.
394
+ this._state = {
395
+ nodes: [...this._state.nodes],
396
+ connections: [...this._state.connections],
397
+ notes: [...this._state.notes],
398
+ };
399
+ this.emit('zn-flow-change', {detail: {state: this.getState()}});
400
+ };
401
+
402
+ /**
403
+ * Drop connections whose endpoints reference ports that no longer exist — e.g.
404
+ * after a user removes a node's output (branch) via its config.
405
+ */
406
+ private _pruneConnections() {
407
+ const byId = new Map(this._state.nodes.map(n => [n.id, n] as const));
408
+ this._state.connections = this._state.connections.filter(c => {
409
+ const from = byId.get(c.source.node);
410
+ const to = byId.get(c.target.node);
411
+ if (!from || !to) return false;
412
+ const hasSource = nodeOutputs(from, this.registry.get(from.type)).some(p => p.id === c.source.port);
413
+ const hasTarget = nodeInputs(to, this.registry.get(to.type)).some(p => p.id === c.target.port);
414
+ return hasSource && hasTarget;
415
+ });
416
+ }
417
+
418
+ private _syncSelection() {
419
+ if (this._selectedNodeId && !this._state.nodes.some(n => n.id === this._selectedNodeId)) {
420
+ this._selectedNodeId = null;
421
+ }
422
+ if (this._selectedBranch && !this._branchSelection()) {
423
+ this._selectedBranch = null;
424
+ }
425
+ }
426
+
427
+ /** The node + output port of the branch open in the editor, if both still exist. */
428
+ private _branchSelection(): { node: FlowNodeInstance; port: FlowPort } | null {
429
+ if (!this._selectedBranch) return null;
430
+ const node = this._state.nodes.find(n => n.id === this._selectedBranch!.nodeId);
431
+ if (!node) return null;
432
+ const port = nodeOutputs(node, this.registry.get(node.type)).find(p => p.id === this._selectedBranch!.port);
433
+ return port ? {node, port} : null;
434
+ }
435
+
436
+ private _id(prefix: string): string {
437
+ return `${prefix}-${Date.now().toString(36)}-${++this._seq}`;
438
+ }
439
+
440
+ // --- Node operations --------------------------------------------------------
441
+
442
+ /**
443
+ * Snap to the grid and, if another node's footprint (card or branch pills)
444
+ * occupies the spot, walk outward in grid-step rings to the nearest free one.
445
+ */
446
+ private _freePosition(x: number, y: number, excludeId?: string): { x: number; y: number } {
447
+ const collides = (px: number, py: number) =>
448
+ this._state.nodes.some(n =>
449
+ n.id !== excludeId
450
+ && cardCollides({x: px, y: py}, n, t => this.registry.get(t), this._state.nodes, this._state.connections)
451
+ );
452
+ x = snapToGrid(x);
453
+ y = snapToGrid(y);
454
+ if (!collides(x, y)) return {x, y};
455
+ const step = GRID_SIZE * 2;
456
+ for (let ring = 1; ring < 40; ring++) {
457
+ for (let dy = -ring; dy <= ring; dy++) {
458
+ for (let dx = -ring; dx <= ring; dx++) {
459
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue;
460
+ if (!collides(x + dx * step, y + dy * step)) {
461
+ return {x: x + dx * step, y: y + dy * step};
462
+ }
463
+ }
464
+ }
465
+ }
466
+ return {x, y};
467
+ }
468
+
469
+ private _addNode(typeKey: string, x: number, y: number) {
470
+ const type = this.registry.get(typeKey);
471
+ if (!type) return;
472
+ this._pushHistory();
473
+ const pos = this._freePosition(x, y);
474
+ const node: FlowNodeInstance = {
475
+ id: this._id('node'),
476
+ type: typeKey,
477
+ x: pos.x,
478
+ y: pos.y,
479
+ data: {...(type.defaultData ?? {})},
480
+ };
481
+ this._state.nodes.push(node);
482
+ this._select(node.id);
483
+ this._commit();
484
+ }
485
+
486
+ private _deleteNode(id: string) {
487
+ this._pushHistory();
488
+ this._state.nodes = this._state.nodes.filter(n => n.id !== id);
489
+ this._state.connections = this._state.connections.filter(
490
+ c => c.source.node !== id && c.target.node !== id
491
+ );
492
+ if (this._selectedNodeId === id) this._select(null);
493
+ this._commit();
494
+ }
495
+
496
+ private _duplicateNode(id: string) {
497
+ const node = this._state.nodes.find(n => n.id === id);
498
+ if (!node) return;
499
+ this._pushHistory();
500
+ const pos = this._freePosition(node.x + GRID_SIZE * 2, node.y + GRID_SIZE * 2);
501
+ const copy: FlowNodeInstance = {
502
+ ...this._clone({nodes: [node], connections: [], notes: []}).nodes[0],
503
+ id: this._id('node'),
504
+ x: pos.x,
505
+ y: pos.y,
506
+ };
507
+ this._state.nodes.push(copy);
508
+ this._select(copy.id);
509
+ this._commit();
510
+ }
511
+
512
+ /** Canvas position for a node newly placed off a source node's output. */
513
+ private _positionBelowOutput(source: FlowNodeInstance, port: string): { x: number; y: number } {
514
+ const outputs = nodeOutputs(source, this.registry.get(source.type));
515
+ const idx = Math.max(outputs.findIndex(o => o.id === port), 0);
516
+ const anchor = portAnchor(source, 'out', idx, outputs.length);
517
+ // A full layer below the source (same rhythm as untangle) — clears the bus,
518
+ // the branch pill, and leaves wire room, so the child lands in a straight
519
+ // line under the branch instead of being shoved sideways by collision.
520
+ return {x: Math.round(anchor.x - NODE_WIDTH / 2), y: source.y + LAYOUT_V_GAP};
521
+ }
522
+
523
+ /**
524
+ * Resolve an output port id on a node: the "new branch" sentinel materialises a
525
+ * fresh, labelled output port (per-instance), so it exists before connecting.
526
+ */
527
+ private _ensureOutput(node: FlowNodeInstance, port: string): string {
528
+ if (port !== NEW_OUTPUT_PORT) return port;
529
+ const outputs = nodeOutputs(node, this.registry.get(node.type)).map(p => ({...p}));
530
+ const id = this._id('branch');
531
+ outputs.push({id, label: 'Branch'});
532
+ node.outputs = outputs;
533
+ return id;
534
+ }
535
+
536
+ /**
537
+ * Every connected output is a configurable branch — default its name so the
538
+ * pill renders, however the connection was made (arrow, drop, or move).
539
+ */
540
+ private _ensureBranchLabel(node: FlowNodeInstance, port: string) {
541
+ const outputs = nodeOutputs(node, this.registry.get(node.type)).map(p => ({...p}));
542
+ const outPort = outputs.find(p => p.id === port);
543
+ if (outPort && !outPort.label) {
544
+ outPort.label = 'Branch';
545
+ node.outputs = outputs;
546
+ }
547
+ }
548
+
549
+ /** Create a node and attach it to an open output slot of an existing node. */
550
+ private _addNodeAtOutput(sourceId: string, port: string, typeKey: string) {
551
+ const source = this._state.nodes.find(n => n.id === sourceId);
552
+ const type = this.registry.get(typeKey);
553
+ if (!source || !type) return;
554
+ const inPort = typeInputs(type)[0]?.id;
555
+ if (!inPort) return; // an entrypoint takes no inputs, so it can't be attached
556
+ this._pushHistory();
557
+ port = this._ensureOutput(source, port);
558
+ this._ensureBranchLabel(source, port);
559
+ const below = this._positionBelowOutput(source, port);
560
+ const pos = this._freePosition(below.x, below.y);
561
+ const node: FlowNodeInstance = {
562
+ id: this._id('node'),
563
+ type: typeKey,
564
+ x: pos.x,
565
+ y: pos.y,
566
+ data: {...(type.defaultData ?? {})},
567
+ };
568
+ const connection: FlowConnection = {
569
+ id: this._id('conn'),
570
+ source: {node: sourceId, port},
571
+ target: {node: node.id, port: inPort},
572
+ };
573
+ this._state.nodes.push(node);
574
+ this._state.connections.push(connection);
575
+ this.emit('zn-flow-connect', {detail: {connection}});
576
+ this._select(node.id);
577
+ this._commit();
578
+ }
579
+
580
+ /**
581
+ * Wire an open output to an existing node — targets its first input. Fan-in
582
+ * and loops (a branch pointing back to an earlier step) are both allowed;
583
+ * only wiring a node directly to itself is refused.
584
+ */
585
+ private _linkNodeAtOutput(sourceId: string, port: string, targetId: string) {
586
+ const source = this._state.nodes.find(n => n.id === sourceId);
587
+ const target = this._state.nodes.find(n => n.id === targetId);
588
+ if (!source || !target || sourceId === targetId) return;
589
+ const inPort = firstInputId(target, this.registry.get(target.type));
590
+ if (inPort === null) return;
591
+ this._pushHistory();
592
+ port = this._ensureOutput(source, port);
593
+ this._ensureBranchLabel(source, port);
594
+ const connection: FlowConnection = {
595
+ id: this._id('conn'),
596
+ source: {node: sourceId, port},
597
+ target: {node: targetId, port: inPort},
598
+ };
599
+ this._state.connections.push(connection);
600
+ this.emit('zn-flow-connect', {detail: {connection}});
601
+ this._select(null);
602
+ this._selectedBranch = {nodeId: sourceId, port};
603
+ this._commit();
604
+ }
605
+
606
+ /** Insert a new node in the middle of an existing connection (split the wire). */
607
+ private _insertNodeOnWire(connectionId: string, typeKey: string) {
608
+ const conn = this._state.connections.find(c => c.id === connectionId);
609
+ const type = this.registry.get(typeKey);
610
+ if (!conn || !type) return;
611
+ const from = this._state.nodes.find(n => n.id === conn.source.node);
612
+ const to = this._state.nodes.find(n => n.id === conn.target.node);
613
+ if (!from || !to) return;
614
+
615
+ const inPort = typeInputs(type)[0]?.id;
616
+ if (!inPort) return; // an entrypoint can't be inserted mid-wire
617
+ this._pushHistory();
618
+ // The inserted node rejoins the original target via its own branch — make
619
+ // sure its first output exists and is named so the pill renders.
620
+ const outputs = typeOutputs(type).map(p => ({...p}));
621
+ if (!outputs.length) outputs.push({id: this._id('branch')});
622
+ if (!outputs[0].label) outputs[0].label = 'Branch';
623
+ // Slot in aligned under the source's branch, a full layer down. Push the
624
+ // original target (with its subtree) down first to make the room, so the
625
+ // insert reads as a clean vertical chain rather than a squeeze.
626
+ const below = this._positionBelowOutput(from, conn.source.port);
627
+ const needY = below.y + LAYOUT_V_GAP;
628
+ if (to.y < needY) {
629
+ const delta = needY - to.y;
630
+ const shifted = descendantIds(this._state, to.id);
631
+ shifted.add(to.id);
632
+ this._state.nodes.forEach(n => {
633
+ if (shifted.has(n.id)) n.y += delta;
634
+ });
635
+ }
636
+ const pos = this._freePosition(below.x, below.y);
637
+ const node: FlowNodeInstance = {
638
+ id: this._id('node'),
639
+ type: typeKey,
640
+ x: pos.x,
641
+ y: pos.y,
642
+ outputs,
643
+ data: {...(type.defaultData ?? {})},
644
+ };
645
+ // Re-wire: source -> new, new -> original target (using the new node's first output).
646
+ const originalTargetPort = conn.target.port;
647
+ conn.target = {node: node.id, port: inPort};
648
+ const downstream: FlowConnection = {
649
+ id: this._id('conn'),
650
+ source: {node: node.id, port: outputs[0].id},
651
+ target: {node: to.id, port: originalTargetPort},
652
+ };
653
+ this._state.nodes.push(node);
654
+ this._state.connections.push(downstream);
655
+ this.emit('zn-flow-connect', {detail: {connection: downstream}});
656
+ this._select(node.id);
657
+ this._commit();
658
+ }
659
+
660
+ private _select(id: string | null) {
661
+ this._selectedBranch = null;
662
+ if (this._selectedNodeId === id) return;
663
+ this._selectedNodeId = id;
664
+ this.emit('zn-flow-selection-change', {detail: {nodeId: id}});
665
+ }
666
+
667
+ private _onBranchPick = (e: CustomEvent<{ nodeId: string; port: string }>) => {
668
+ this._select(null);
669
+ this._selectedBranch = {nodeId: e.detail.nodeId, port: e.detail.port};
670
+ };
671
+
672
+ /** Remove an output branch and its wire. Undoable. */
673
+ private _onBranchDelete = (e: CustomEvent<{ nodeId: string; port: string }>) => {
674
+ const node = this._state.nodes.find(n => n.id === e.detail.nodeId);
675
+ if (!node) return;
676
+ this._pushHistory();
677
+ const outputs = nodeOutputs(node, this.registry.get(node.type)).filter(p => p.id !== e.detail.port);
678
+ // Deleting the last branch leaves a plain open output, so the node stays extensible.
679
+ node.outputs = outputs.length ? outputs : [{...DEFAULT_OUTPUT}];
680
+ // Sever the wire explicitly — the fallback output can share the deleted
681
+ // port's id, in which case pruning alone would keep the connection alive.
682
+ this._state.connections = this._state.connections.filter(
683
+ c => !(c.source.node === node.id && c.source.port === e.detail.port)
684
+ );
685
+ if (this._selectedBranch?.nodeId === e.detail.nodeId && this._selectedBranch.port === e.detail.port) {
686
+ this._selectedBranch = null;
687
+ }
688
+ this._commit();
689
+ };
690
+
691
+ // --- Event handlers ---------------------------------------------------------
692
+
693
+ private _onSelect = (e: CustomEvent<{ nodeId: string | null }>) => {
694
+ // A click on empty canvas cancels an in-progress move.
695
+ if (e.detail.nodeId === null && this._movingNodeId) {
696
+ this._movingNodeId = null;
697
+ return;
698
+ }
699
+ this._select(e.detail.nodeId);
700
+ };
701
+
702
+ private _onNodeAction = (e: CustomEvent<{ nodeId: string; action: string }>) => {
703
+ const {nodeId, action} = e.detail;
704
+ if (action === 'delete') this._deleteNode(nodeId);
705
+ else if (action === 'duplicate') this._duplicateNode(nodeId);
706
+ else if (action === 'move') this._movingNodeId = nodeId;
707
+ };
708
+
709
+ private _onInteractionStart = () => {
710
+ this._pushHistory();
711
+ };
712
+
713
+ private _onOutputAssign = (e: CustomEvent<{ nodeId: string; port: string; type: string }>) => {
714
+ this._addNodeAtOutput(e.detail.nodeId, e.detail.port, e.detail.type);
715
+ };
716
+
717
+ /** A stray branch was attached to an existing node (fan-in). */
718
+ private _onLinkAssign = (e: CustomEvent<{ nodeId: string; port: string; targetId: string }>) => {
719
+ this._linkNodeAtOutput(e.detail.nodeId, e.detail.port, e.detail.targetId);
720
+ };
721
+
722
+ private _onWirePick = (e: CustomEvent<{ connectionId: string; clientX: number; clientY: number }>) => {
723
+ this._picker = {
724
+ x: e.detail.clientX,
725
+ y: e.detail.clientY,
726
+ target: {kind: 'wire', connectionId: e.detail.connectionId}
727
+ };
728
+ };
729
+
730
+ private _onWireAssign = (e: CustomEvent<{ connectionId: string; type: string }>) => {
731
+ this._insertNodeOnWire(e.detail.connectionId, e.detail.type);
732
+ };
733
+
734
+ /** Re-attach the node being moved to the chosen open output slot. */
735
+ private _onOutputMoveTarget = (e: CustomEvent<{ nodeId: string; port: string }>) => {
736
+ const movingId = this._movingNodeId;
737
+ this._movingNodeId = null;
738
+ if (!movingId) return;
739
+ const slotOwner = e.detail.nodeId;
740
+ if (slotOwner === movingId) return;
741
+
742
+ const owner = this._state.nodes.find(n => n.id === slotOwner);
743
+ const moving = this._state.nodes.find(n => n.id === movingId);
744
+ if (!owner || !moving) return;
745
+ const inPort = firstInputId(moving, this.registry.get(moving.type));
746
+ if (inPort === null) return;
747
+
748
+ this._pushHistory();
749
+ const outPort = this._ensureOutput(owner, e.detail.port);
750
+ this._ensureBranchLabel(owner, outPort);
751
+ // Detach the moving node's current incoming connections, then attach to the new slot.
752
+ this._state.connections = this._state.connections.filter(c => c.target.node !== movingId);
753
+ const connection: FlowConnection = {
754
+ id: this._id('conn'),
755
+ source: {node: slotOwner, port: outPort},
756
+ target: {node: movingId, port: inPort},
757
+ };
758
+ this._state.connections.push(connection);
759
+ // Reposition the moved node below its new slot so it visually lands there.
760
+ const below = this._positionBelowOutput(owner, outPort);
761
+ const pos = this._freePosition(below.x, below.y, movingId);
762
+ moving.x = pos.x;
763
+ moving.y = pos.y;
764
+ this.emit('zn-flow-connect', {detail: {connection}});
765
+ this._commit();
766
+ };
767
+
768
+ private _onAddNote = () => {
769
+ this._pushHistory();
770
+ this._state.notes.push({id: this._id('note'), x: 80, y: 80, text: ''});
771
+ this._commit();
772
+ };
773
+
774
+ private _onNoteChange = (e: CustomEvent<{ noteId: string; text: string }>) => {
775
+ const note = this._state.notes.find(n => n.id === e.detail.noteId);
776
+ if (!note) return;
777
+ note.text = e.detail.text;
778
+ this._commit();
779
+ };
780
+
781
+ private _onNoteDelete = (e: CustomEvent<{ noteId: string }>) => {
782
+ this._pushHistory();
783
+ this._state.notes = this._state.notes.filter(n => n.id !== e.detail.noteId);
784
+ this._commit();
785
+ };
786
+
787
+ // --- Step drag/drop ------------------------------------------------------
788
+
789
+ private _onDragStart(e: DragEvent, typeKey: string) {
790
+ if (!e.dataTransfer) return;
791
+ // dataTransfer payload isn't readable during `dragover`, so expose the type
792
+ // separately for the canvas to render a meaningful drop preview.
793
+ this._draggingType = typeKey;
794
+ e.dataTransfer.setData(TYPE_MIME, typeKey);
795
+ // 'copyMove' so the drop is accepted by both the canvas (move) and "+" targets (copy).
796
+ e.dataTransfer.effectAllowed = 'copyMove';
797
+ // Hide the native drag image — the canvas renders an in-canvas drop preview instead.
798
+ e.dataTransfer.setDragImage(emptyDragImage(), 0, 0);
799
+ }
800
+
801
+ private _onDragEnd = () => {
802
+ this._draggingType = null;
803
+ };
804
+
805
+ // Slotted <zn-flow-step>s report their type so the canvas can preview the drop.
806
+ private _onStepDrag = (e: CustomEvent<{ type: string }>) => {
807
+ this._draggingType = e.detail.type;
808
+ };
809
+
810
+ private _onCanvasDragOver = (e: DragEvent) => {
811
+ // Allow the drop; the canvas's own dragover handler decides the cursor
812
+ // (copy over a "+" slot, move elsewhere), so don't override dropEffect here.
813
+ e.preventDefault();
814
+ };
815
+
816
+ private _onCanvasDrop = (e: DragEvent) => {
817
+ const typeKey = e.dataTransfer?.getData(TYPE_MIME);
818
+ if (!typeKey) return;
819
+ e.preventDefault();
820
+ const canvas = this.shadowRoot?.querySelector('zn-flow-canvas') as ZnFlowCanvas | null;
821
+ const pt = canvas?.screenToCanvas(e.clientX, e.clientY) ?? {x: e.clientX, y: e.clientY};
822
+ this._addNode(typeKey, pt.x - NODE_WIDTH / 2, pt.y - NODE_HEIGHT / 2);
823
+ };
824
+
825
+ // --- Rendering --------------------------------------------------------------
826
+
827
+ private _renderSteps() {
828
+ return html`
829
+ <aside part="steps" class="steps">
830
+ <div class="title-block">
831
+ <div class="heading">${this.heading || 'Flow Builder'}</div>
832
+ ${this.subheading ? html`
833
+ <div class="subheading">${this.subheading}</div>` : ''}
834
+ </div>
835
+
836
+ ${this._renderStepsContent()}
837
+
838
+ <!-- Node types are declared by <zn-flow-step> children (hidden); the steps panel
839
+ above is rendered from them, tabbed by group (groups with no steps get no tab). -->
840
+ <slot class="declarations" @slotchange="${this._registerSlottedTypes}"></slot>
841
+ </aside>
842
+ `;
843
+ }
844
+
845
+ private _hintFor(group: FlowGroup): string {
846
+ if (group === 'entrypoint') return this.entrypointsHint;
847
+ if (group === 'trigger') return this.triggersHint;
848
+ if (group === 'action') return this.actionsHint;
849
+ return this.rulesHint;
850
+ }
851
+
852
+ private _renderStep(type: FlowNodeType) {
853
+ return html`
854
+ <div
855
+ class="step"
856
+ draggable="true"
857
+ @dragstart="${(e: DragEvent) => this._onDragStart(e, type.type)}"
858
+ @dragend="${this._onDragEnd}"
859
+ >
860
+ <span class="step__icon" style="--node-accent:${type.color ?? 'rgb(var(--zn-color-primary))'}">
861
+ <zn-icon src="${type.icon ?? 'circle'}" library="${ifDefined(type.iconLibrary)}" size="16"></zn-icon>
862
+ </span>
863
+ <span class="step__label">${type.label}</span>
864
+ </div>
865
+ `;
866
+ }
867
+
868
+ private _renderStepsContent() {
869
+ // Only groups with registered types get a tab.
870
+ const tabs = TABS.filter(tab => this.registry.byGroup(tab.group).length > 0);
871
+ const activeGroup = tabs.find(t => t.group === this._activeGroup)?.group ?? tabs[0]?.group;
872
+
873
+ return html`
874
+ <div class="steps-content">
875
+ <zn-input
876
+ class="search"
877
+ placeholder="Search by step name"
878
+ clearable
879
+ .value="${this._search}"
880
+ @zn-input="${(e: Event) => (this._search = String((e.target as ZnInput).value ?? ''))}"
881
+ @input="${(e: Event) => (this._search = String((e.target as ZnInput).value ?? ''))}"
882
+ ></zn-input>
883
+
884
+ ${tabs.length === 0
885
+ ? html`<p class="steps-empty">No steps registered.</p>`
886
+ : html`
887
+ <zn-tabs flush>
888
+ <zn-navbar slot="top">
889
+ ${tabs.map((tab, i) => html`
890
+ <li tab="${i === 0 ? '' : tab.group}" @click="${() => (this._activeGroup = tab.group)}">
891
+ ${tab.label}
892
+ </li>`)}
893
+ </zn-navbar>
894
+ ${tabs.map((tab, i) => this._renderStepsPanel(tab.group, i === 0 ? '' : tab.group, tab.group === activeGroup))}
895
+ </zn-tabs>`}
896
+ </div>
897
+ `;
898
+ }
899
+
900
+ private _renderStepsPanel(group: FlowGroup, panelId: string, active: boolean) {
901
+ const term = active ? this._search.trim().toLowerCase() : '';
902
+ const categories = this.registry.categories(group);
903
+ const hint = this._hintFor(group);
904
+
905
+ return html`
906
+ <div id="${panelId}" class="steps-panel">
907
+ ${hint ? html`<p class="steps-hint">${hint}</p>` : ''}
908
+ <div class="steps-scroll">
909
+ ${Array.from(categories.entries()).map(([name, types]) => {
910
+ const items = types.filter(t => !term || t.label.toLowerCase().includes(term));
911
+ if (!items.length) return '';
912
+ const rows = items.map(type => this._renderStep(type));
913
+ return name
914
+ ? html`
915
+ <zn-flow-step-group caption="${name}">${rows}</zn-flow-step-group>`
916
+ : html`
917
+ <div class="steps-uncategorized">${rows}</div>`;
918
+ })}
919
+ </div>
920
+ </div>
921
+ `;
922
+ }
923
+
924
+ // The selected node's configuration, shown in the right panel.
925
+ private _renderInspector(node: FlowNodeInstance) {
926
+ const type = this.registry.get(node.type);
927
+ const renderConfig = type?.renderConfig;
928
+ const update = (data: Record<string, unknown>) => {
929
+ node.data = {...node.data, ...data};
930
+ this._commit();
931
+ };
932
+ // The guard keeps the consumer's config DOM in place across value-only
933
+ // re-renders (so live-typing inputs keep focus); it rebuilds when the node
934
+ // changes, the state is replaced, or a branch is added / removed.
935
+ const configKey = [node.id, this._configRevision, nodeOutputs(node, type).length];
936
+
937
+ return html`
938
+ <aside part="inspector" class="inspector">
939
+ <div class="inspector-head">
940
+ <span
941
+ class="inspector-head__icon"
942
+ style="--node-accent:${type?.color ?? 'rgb(var(--zn-color-primary))'}"
943
+ >
944
+ <zn-icon src="${type?.icon ?? 'circle'}" library="${ifDefined(type?.iconLibrary)}" size="18"></zn-icon>
945
+ </span>
946
+ <div class="inspector-head__text">
947
+ <div class="inspector-head__title">${node.label ?? type?.label ?? node.type}</div>
948
+ <div class="inspector-head__type">${type?.label ?? node.type}</div>
949
+ </div>
950
+ <button class="inspector-close" title="Close" @click="${() => this._select(null)}">
951
+ <zn-icon src="x@lu" size="18"></zn-icon>
952
+ </button>
953
+ </div>
954
+
955
+ <div class="inspector-body">
956
+ ${renderConfig
957
+ ? guard(configKey, () => renderConfig(node, update))
958
+ : html`
959
+ <zn-input
960
+ label="Label"
961
+ .value="${node.label ?? ''}"
962
+ @input="${(e: Event) => {
963
+ node.label = String((e.target as ZnInput).value ?? '');
964
+ this._commit();
965
+ }}"
966
+ ></zn-input>
967
+ <p class="inspector-hint">This step type has no custom configuration.</p>
968
+ `}
969
+ </div>
970
+ </aside>
971
+ `;
972
+ }
973
+
974
+ /** Replace one of the node's output ports (per-instance override), keeping its id. */
975
+ private _updateBranch(node: FlowNodeInstance, portId: string, patch: Partial<FlowPort>) {
976
+ const type = this.registry.get(node.type);
977
+ node.outputs = nodeOutputs(node, type).map(p => (p.id === portId ? {...p, ...patch, id: p.id} : p));
978
+ this._commit();
979
+ }
980
+
981
+ // The branch editor: rename an output branch and configure its conditions.
982
+ private _renderBranchEditor(node: FlowNodeInstance, port: FlowPort) {
983
+ const type = this.registry.get(node.type);
984
+ const renderBranchConfig = type?.renderBranchConfig;
985
+ const update = (patch: Partial<FlowPort>) => this._updateBranch(node, port.id, patch);
986
+ // Stable while the branch name is live-typed; rebuilds (with the fresh port
987
+ // object) when the branch, its condition data, or the state changes.
988
+ const configKey = [node.id, port.id, this._configRevision, JSON.stringify(port.data ?? null)];
989
+ // A loop branch's editor matches its amber pill and wire, and says so.
990
+ const conn = this._state.connections.find(c => c.source.node === node.id && c.source.port === port.id);
991
+ const isLoop = !!conn && loopConnections(this._state.nodes, this._state.connections).has(conn);
992
+ const accent = isLoop ? 'rgb(var(--zn-color-warning))' : type?.color ?? 'rgb(var(--zn-color-primary))';
993
+ const loopTarget = isLoop ? this._state.nodes.find(n => n.id === conn!.target.node) : undefined;
994
+ const loopTargetLabel = loopTarget
995
+ ? loopTarget.label ?? this.registry.get(loopTarget.type)?.label ?? loopTarget.type
996
+ : '';
997
+
998
+ return html`
999
+ <aside part="inspector" class="inspector">
1000
+ <div class="inspector-head">
1001
+ <span
1002
+ class="inspector-head__icon"
1003
+ style="--node-accent:${accent}"
1004
+ >
1005
+ <zn-icon src="git-branch@lu" size="18"></zn-icon>
1006
+ </span>
1007
+ <div class="inspector-head__text">
1008
+ <div class="inspector-head__title-row">
1009
+ <span class="inspector-head__title">${port.label ?? port.id}</span>
1010
+ ${isLoop
1011
+ ? html`<span class="inspector-loop-tag"><zn-icon src="repeat@lu" size="12"></zn-icon>Loop</span>`
1012
+ : ''}
1013
+ </div>
1014
+ <div class="inspector-head__type">
1015
+ ${isLoop
1016
+ ? `Branch of ${node.label ?? type?.label ?? node.type} — loops back to ${loopTargetLabel}`
1017
+ : `Branch of ${node.label ?? type?.label ?? node.type}`}
1018
+ </div>
1019
+ </div>
1020
+ <button class="inspector-close" title="Close" @click="${() => (this._selectedBranch = null)}">
1021
+ <zn-icon src="x@lu" size="18"></zn-icon>
1022
+ </button>
1023
+ </div>
1024
+
1025
+ <div class="inspector-body">
1026
+ <zn-input
1027
+ label="Branch name"
1028
+ .value="${port.label ?? ''}"
1029
+ @zn-input="${(e: Event) => update({label: String((e.target as ZnInput).value ?? '')})}"
1030
+ @input="${(e: Event) => update({label: String((e.target as ZnInput).value ?? '')})}"
1031
+ ></zn-input>
1032
+ ${renderBranchConfig
1033
+ ? guard(configKey, () => renderBranchConfig(node, port, update))
1034
+ : html`<p class="inspector-hint">This step type has no branch conditions.</p>`}
1035
+ </div>
1036
+ </aside>
1037
+ `;
1038
+ }
1039
+
1040
+ // The right panel: branch editor or node inspector when something is selected,
1041
+ // otherwise the sidebar (status / errors / version history).
1042
+ private _renderRightPanel() {
1043
+ const branch = this._branchSelection();
1044
+ if (branch) return this._renderBranchEditor(branch.node, branch.port);
1045
+ const selected = this._state.nodes.find(n => n.id === this._selectedNodeId);
1046
+ return selected ? this._renderInspector(selected) : this._renderSidebar();
1047
+ }
1048
+
1049
+ // Full-width action bar above the panels; only shown when actions are slotted.
1050
+ private _renderHeader() {
1051
+ const hasHeader = this._hasSlot.test('header-left') || this._hasSlot.test('header-right');
1052
+ return html`
1053
+ <header part="header" class="header" ?hidden="${!hasHeader}">
1054
+ <div class="header__group">
1055
+ <slot name="header-left"></slot>
1056
+ </div>
1057
+ <div class="header__group">
1058
+ <slot name="header-right"></slot>
1059
+ </div>
1060
+ </header>
1061
+ `;
1062
+ }
1063
+
1064
+ // The right panel: a slot for version history / status, defaulting to a
1065
+ // configuration-errors summary derived from `errorNodes`.
1066
+ private _renderSidebar() {
1067
+ const errors = this.errorNodes
1068
+ .map(id => this._state.nodes.find(n => n.id === id))
1069
+ .filter((n): n is FlowNodeInstance => !!n);
1070
+
1071
+ return html`
1072
+ <aside part="sidebar" class="sidebar">
1073
+ <div class="sidebar-section">
1074
+ <div class="sidebar-section__head">
1075
+ <span>Configuration Errors</span>
1076
+ <span class="sidebar-count">${errors.length}</span>
1077
+ </div>
1078
+ ${errors.length === 0
1079
+ ? html`<p class="sidebar-empty">No configuration errors.</p>`
1080
+ : errors.map(n => {
1081
+ const type = this.registry.get(n.type);
1082
+ return html`
1083
+ <button class="sidebar-error" @click="${() => this._select(n.id)}">
1084
+ <zn-icon src="triangle-alert@lu" size="16"></zn-icon>
1085
+ <span>${n.label ?? type?.label ?? n.type}</span>
1086
+ </button>
1087
+ `;
1088
+ })}
1089
+ </div>
1090
+ <slot name="sidebar"></slot>
1091
+ </aside>
1092
+ `;
1093
+ }
1094
+
1095
+ private _renderPicker() {
1096
+ if (!this._picker) return '';
1097
+ const {x, y} = this._picker;
1098
+ const groups = TABS.map(tab => ({tab, types: this.registry.byGroup(tab.group)})).filter(g => g.types.length);
1099
+
1100
+ return html`
1101
+ <div class="picker-backdrop" @pointerdown="${() => (this._picker = null)}"></div>
1102
+ <div class="picker" style="left:${x}px;top:${y}px">
1103
+ ${groups.length === 0
1104
+ ? html`<p class="picker-empty">No steps registered.</p>`
1105
+ : groups.map(
1106
+ g => html`
1107
+ <div class="picker-group">${g.tab.label}</div>
1108
+ ${g.types.map(
1109
+ type => html`
1110
+ <button class="picker-item" @click="${() => this._pickType(type.type)}">
1111
+ <span
1112
+ class="picker-item__icon"
1113
+ style="--node-accent:${type.color ?? 'rgb(var(--zn-color-primary))'}"
1114
+ >
1115
+ <zn-icon src="${type.icon ?? 'circle'}" library="${ifDefined(type.iconLibrary)}"
1116
+ size="16"></zn-icon>
1117
+ </span>
1118
+ <span>${type.label}</span>
1119
+ </button>
1120
+ `
1121
+ )}
1122
+ `
1123
+ )}
1124
+ </div>
1125
+ `;
1126
+ }
1127
+
1128
+ private _pickType(typeKey: string) {
1129
+ const picker = this._picker;
1130
+ if (!picker) return;
1131
+ this._picker = null;
1132
+ this._insertNodeOnWire(picker.target.connectionId, typeKey);
1133
+ }
1134
+
1135
+ render() {
1136
+ return html`
1137
+ <div part="base" class="builder">
1138
+ ${this._renderHeader()}
1139
+ ${this._renderSteps()}
1140
+ <div
1141
+ class="canvas-cell"
1142
+ @dragover="${this._onCanvasDragOver}"
1143
+ @drop="${this._onCanvasDrop}"
1144
+ >
1145
+ <zn-flow-canvas
1146
+ .nodes="${this._state.nodes}"
1147
+ .connections="${this._state.connections}"
1148
+ .notes="${this._state.notes}"
1149
+ .registry="${this.registry}"
1150
+ .errorNodes="${new Set(this.errorNodes)}"
1151
+ selected-node="${this._selectedNodeId ?? ''}"
1152
+ moving-node="${this._movingNodeId ?? ''}"
1153
+ drag-type="${this._draggingType ?? ''}"
1154
+ selected-branch="${this._selectedBranch ? `${this._selectedBranch.nodeId}:${this._selectedBranch.port}` : ''}"
1155
+ ></zn-flow-canvas>
1156
+ ${this._movingNodeId
1157
+ ? html`
1158
+ <div class="move-banner">
1159
+ <zn-icon src="move@lu" size="16"></zn-icon>
1160
+ <span>Pick a <strong>+</strong> slot to move this step, or press Esc.</span>
1161
+ <button @click="${() => (this._movingNodeId = null)}">Cancel</button>
1162
+ </div>
1163
+ `
1164
+ : ''}
1165
+ ${this._renderPicker()}
1166
+ </div>
1167
+ ${this._renderRightPanel()}
1168
+ </div>
1169
+ `;
1170
+ }
1171
+ }