@kubex/zinc 1.1.28 → 1.1.29

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 (46) hide show
  1. package/custom-elements-manifest.config.js +2 -2
  2. package/dist/custom-elements.json +1483 -17
  3. package/dist/vscode.html-custom-data.json +40 -1
  4. package/dist/web-types.json +124 -3
  5. package/dist/zn.d.ts +670 -14
  6. package/dist/zn.min.js +1305 -925
  7. package/docs/pages/components/flow-builder-troubleshooter-demo.njk +106 -78
  8. package/docs/pages/components/flow-builder.md +422 -66
  9. package/docs/pages/components/page-builder.md +167 -0
  10. package/package.json +1 -1
  11. package/src/components/datepicker/datepicker.scss +0 -10
  12. package/src/components/flow-builder/flow-builder.component.ts +377 -16
  13. package/src/components/flow-builder/flow-builder.scss +398 -250
  14. package/src/components/flow-builder/flow-builder.test.ts +241 -4
  15. package/src/components/flow-builder/flow-layout.ts +42 -17
  16. package/src/components/flow-builder/flow.types.ts +168 -43
  17. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.component.ts +300 -0
  18. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.scss +222 -0
  19. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.test.ts +125 -0
  20. package/src/components/flow-builder/modules/flow-branch-conditions/index.ts +12 -0
  21. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +184 -26
  22. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +170 -120
  23. package/src/components/flow-builder/modules/flow-node/flow-node.scss +42 -42
  24. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -1
  25. package/src/components/flow-builder/modules/flow-step/flow-step.scss +25 -17
  26. package/src/components/icon-picker/icon-picker.component.ts +20 -37
  27. package/src/components/icon-picker/icon-picker.scss +5 -45
  28. package/src/components/page-builder/index.ts +14 -0
  29. package/src/components/page-builder/modules/page-palette-item/index.ts +12 -0
  30. package/src/components/page-builder/modules/page-palette-item/page-palette-item.component.ts +71 -0
  31. package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +70 -0
  32. package/src/components/page-builder/modules/page-palette-item/page-palette-item.test.ts +20 -0
  33. package/src/components/page-builder/modules/page-section-card/index.ts +12 -0
  34. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +93 -0
  35. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +92 -0
  36. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +50 -0
  37. package/src/components/page-builder/page-builder.component.ts +1053 -0
  38. package/src/components/page-builder/page-builder.scss +494 -0
  39. package/src/components/page-builder/page-builder.test.ts +464 -0
  40. package/src/components/page-builder/page-registry.ts +48 -0
  41. package/src/components/page-builder/page.types.ts +75 -0
  42. package/src/events/events.ts +2 -0
  43. package/src/events/zn-page-change.ts +9 -0
  44. package/src/events/zn-page-selection-change.ts +7 -0
  45. package/src/zinc.ts +4 -0
  46. package/web-test-runner.config.js +6 -1
@@ -3,6 +3,7 @@ import {guard} from 'lit/directives/guard.js';
3
3
  import {ifDefined} from 'lit/directives/if-defined.js';
4
4
  import {property, state} from 'lit/decorators.js';
5
5
  import ZincElement from '../../internal/zinc-element';
6
+ import ZnFlowBranchConditions from './modules/flow-branch-conditions';
6
7
  import ZnFlowCanvas from './modules/flow-canvas';
7
8
  import ZnFlowStepGroup from './modules/flow-step-group';
8
9
  import ZnIcon from '../icon';
@@ -11,6 +12,8 @@ import ZnNavbar from '../navbar';
11
12
  import ZnTabs from '../tabs';
12
13
 
13
14
  import {
15
+ branchConditions,
16
+ branchDropXs,
14
17
  cardCollides,
15
18
  DEFAULT_OUTPUT,
16
19
  descendantIds,
@@ -18,7 +21,11 @@ import {
18
21
  emptyFlowState,
19
22
  firstInputId,
20
23
  FLOW_TYPE_MIME,
24
+ type FlowBranchConditions,
25
+ type FlowBranchFilter,
21
26
  type FlowConnection,
27
+ type FlowFilterField,
28
+ type FlowFilterOption,
22
29
  type FlowGroup,
23
30
  type FlowNodeInstance,
24
31
  type FlowNodeType,
@@ -31,7 +38,7 @@ import {
31
38
  NODE_WIDTH,
32
39
  nodeInputs,
33
40
  nodeOutputs,
34
- portAnchor,
41
+ pillsCollide,
35
42
  snapToGrid,
36
43
  typeInputs,
37
44
  typeOutputs,
@@ -45,6 +52,21 @@ import styles from './flow-builder.scss';
45
52
  const HISTORY_LIMIT = 50;
46
53
  const TYPE_MIME = FLOW_TYPE_MIME;
47
54
 
55
+ const AUTO_SAVE_DEFAULT_MINUTES = 5;
56
+ const AUTO_SAVE_TTL_MS = 24 * 60 * 60 * 1000;
57
+
58
+ /** Compact relative time for the auto-save status ("just now", "3m ago"). */
59
+ function timeAgo(ms: number): string {
60
+ const s = Math.floor(ms / 1000);
61
+ if (s < 10) return 'just now';
62
+ if (s < 60) return `${s}s ago`;
63
+ const m = Math.floor(s / 60);
64
+ if (m < 60) return `${m}m ago`;
65
+ const h = Math.floor(m / 60);
66
+ if (h < 24) return `${h}h ago`;
67
+ return `${Math.floor(h / 24)}d ago`;
68
+ }
69
+
48
70
  const TABS: { group: FlowGroup; label: string }[] = [
49
71
  {group: 'entrypoint', label: 'Entrypoint'},
50
72
  {group: 'trigger', label: 'Triggers'},
@@ -64,6 +86,7 @@ interface PickerTarget { kind: 'wire'; connectionId: string }
64
86
  * @dependency zn-input
65
87
  * @dependency zn-tabs
66
88
  * @dependency zn-navbar
89
+ * @dependency zn-flow-branch-conditions
67
90
  * @dependency zn-flow-canvas
68
91
  * @dependency zn-flow-node
69
92
  *
@@ -72,7 +95,10 @@ interface PickerTarget { kind: 'wire'; connectionId: string }
72
95
  * @event zn-flow-connect - Emitted when a connection is created. `event.detail.connection`.
73
96
  *
74
97
  * @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.
98
+ * step into the right tab and collapsible grouping of the rendered panel. A step may nest
99
+ * `<zn-flow-filter>` declarations (each holding `<zn-flow-filter-field>`s, whose operator /
100
+ * option choices are nested `<zn-flow-operator>` / `<zn-flow-option>` elements) — or set a
101
+ * `branch-filters` JSON attribute — to drive the built-in branch conditions editor.
76
102
  * @slot header-left - Actions shown on the left of the header bar (e.g. Close / Undo All Changes).
77
103
  * @slot header-right - Actions shown on the right of the header bar (e.g. Apply Changes).
78
104
  * @slot sidebar - Extra right-panel content (status, version history), below the configuration errors.
@@ -90,6 +116,7 @@ export default class ZnFlowBuilder extends ZincElement {
90
116
  'zn-input': ZnInput,
91
117
  'zn-tabs': ZnTabs,
92
118
  'zn-navbar': ZnNavbar,
119
+ 'zn-flow-branch-conditions': ZnFlowBranchConditions,
93
120
  'zn-flow-canvas': ZnFlowCanvas,
94
121
  'zn-flow-step-group': ZnFlowStepGroup,
95
122
  };
@@ -103,6 +130,23 @@ export default class ZnFlowBuilder extends ZincElement {
103
130
  /** Node ids flagged as having configuration errors (drives the red node styling). */
104
131
  @property({attribute: false}) errorNodes: string[] = [];
105
132
 
133
+ /**
134
+ * Auto-save the flow to localStorage (1-day TTL). Omit to disable. A bare
135
+ * `auto-save` saves every 5 minutes; a numeric value sets the interval in
136
+ * minutes (`auto-save="5"`). Restore with `restoreAutoSave()`.
137
+ */
138
+ @property({
139
+ attribute: 'auto-save',
140
+ converter: {
141
+ fromAttribute: (value: string | null) => {
142
+ if (value === null) return null;
143
+ const minutes = parseFloat(value);
144
+ return Number.isFinite(minutes) && minutes > 0 ? minutes : AUTO_SAVE_DEFAULT_MINUTES;
145
+ },
146
+ toAttribute: (value: number | null) => (value === null ? null : String(value)),
147
+ },
148
+ }) autoSave: number | null = null;
149
+
106
150
  /** Optional hint shown beneath each steps-panel tab. */
107
151
  @property({attribute: 'entrypoints-hint'}) entrypointsHint = '';
108
152
  @property({attribute: 'triggers-hint'}) triggersHint = '';
@@ -120,6 +164,9 @@ export default class ZnFlowBuilder extends ZincElement {
120
164
  @state() private _activeGroup: FlowGroup | null = null;
121
165
 
122
166
  private readonly _hasSlot = new HasSlotController(this, 'header-left', 'header-right');
167
+ /** Side panels tucked away via their edge chevrons. */
168
+ @state() private _stepsCollapsed = false;
169
+ @state() private _sideCollapsed = false;
123
170
  /** The node being relocated via the MOVE menu action, if any. */
124
171
  @state() private _movingNodeId: string | null = null;
125
172
  /** The "+" picker popover target (an open output, or a wire to insert into), if open. */
@@ -175,9 +222,120 @@ export default class ZnFlowBuilder extends ZincElement {
175
222
  this._listeners.forEach(([name, fn]) => this.removeEventListener(name, fn));
176
223
  document.removeEventListener('keydown', this._onKeyDown);
177
224
  this._cancelUntangle();
225
+ this._stopAutoSave();
226
+ if (this._justSavedTimer !== null) {
227
+ clearTimeout(this._justSavedTimer);
228
+ this._justSavedTimer = null;
229
+ }
178
230
  super.disconnectedCallback();
179
231
  }
180
232
 
233
+ // --- Auto-save --------------------------------------------------------------
234
+
235
+ private _autoSaveTimer: number | null = null;
236
+ private _statusTimer: number | null = null;
237
+ private _justSavedTimer: number | null = null;
238
+ /** Guards the restore prompt from re-triggering on restoreAutoSave's own setState. */
239
+ private _restoring = false;
240
+
241
+ /** Epoch of the newest auto-save (also picked up from storage on start). */
242
+ @state() private _lastSavedAt: number | null = null;
243
+ /** Briefly true right after a save — flashes "Auto-saved" in the status pill. */
244
+ @state() private _justSaved = false;
245
+ /** Re-render clock for the "last saved Xm ago" label. */
246
+ @state() private _statusNow = Date.now();
247
+ /** A fresh auto-save differing from the loaded flow — offer to restore it. */
248
+ @state() private _restorePrompt: { savedAt: number } | null = null;
249
+
250
+ /** localStorage key for this builder's auto-saves — its id, else its heading. */
251
+ private get _autoSaveKey(): string {
252
+ return `zn-flow-builder:${this.id || this.heading || 'flow'}`;
253
+ }
254
+
255
+ // The "Auto-saved" flash timeout is deliberately not cleared here — it always
256
+ // runs out 2.5s after the last save, even if the schedule changes meanwhile.
257
+ private _stopAutoSave() {
258
+ if (this._autoSaveTimer !== null) {
259
+ clearInterval(this._autoSaveTimer);
260
+ this._autoSaveTimer = null;
261
+ }
262
+ if (this._statusTimer !== null) {
263
+ clearInterval(this._statusTimer);
264
+ this._statusTimer = null;
265
+ }
266
+ }
267
+
268
+ private _restartAutoSave() {
269
+ this._stopAutoSave();
270
+ if (this.autoSave === null) return;
271
+ // Housekeeping: drop an expired auto-save, and carry its timestamp into the
272
+ // status pill when one survives — "last saved" outlives a reload.
273
+ const saved = this._readAutoSave();
274
+ if (saved) this._lastSavedAt = saved.savedAt;
275
+ this._autoSaveTimer = window.setInterval(this._autoSaveTick, this.autoSave * 60_000);
276
+ this._statusTimer = window.setInterval(() => (this._statusNow = Date.now()), 30_000);
277
+ }
278
+
279
+ /** An empty canvas is never saved — it would clobber a stored flow with nothing. */
280
+ private _autoSaveTick = () => {
281
+ // While a restore is being offered, the stored draft is the user's only
282
+ // copy — never overwrite it until they've decided.
283
+ if (this._restorePrompt) return;
284
+ const state = this.getState();
285
+ if (!state.nodes.length && !state.connections.length && !state.notes.length) return;
286
+ try {
287
+ localStorage.setItem(this._autoSaveKey, JSON.stringify({savedAt: Date.now(), state}));
288
+ this._lastSavedAt = Date.now();
289
+ this._justSaved = true;
290
+ if (this._justSavedTimer !== null) clearTimeout(this._justSavedTimer);
291
+ this._justSavedTimer = window.setTimeout(() => (this._justSaved = false), 2500);
292
+ } catch {
293
+ /* storage unavailable / full */
294
+ }
295
+ };
296
+
297
+ /** The stored auto-save, purging it when past its TTL (or unreadable). */
298
+ private _readAutoSave(): { savedAt: number; state: FlowState } | null {
299
+ try {
300
+ const raw = localStorage.getItem(this._autoSaveKey);
301
+ if (!raw) return null;
302
+ const saved = JSON.parse(raw) as { savedAt: number; state: FlowState };
303
+ if (!saved.state || Date.now() - saved.savedAt > AUTO_SAVE_TTL_MS) {
304
+ localStorage.removeItem(this._autoSaveKey);
305
+ return null;
306
+ }
307
+ return saved;
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+
313
+ /** Load the auto-saved flow, if one exists within the 1-day TTL. */
314
+ restoreAutoSave(): boolean {
315
+ const saved = this._readAutoSave();
316
+ if (!saved) return false;
317
+ this._restoring = true;
318
+ try {
319
+ this.setState(saved.state);
320
+ } finally {
321
+ this._restoring = false;
322
+ }
323
+ this._restorePrompt = null;
324
+ return true;
325
+ }
326
+
327
+ /**
328
+ * A flow was just loaded — when a fresh auto-save differs from it, ask the
329
+ * user whether to pick up their draft instead.
330
+ */
331
+ private _offerRestoreIfNewer() {
332
+ if (this.autoSave === null || this._restoring) return;
333
+ const saved = this._readAutoSave();
334
+ this._restorePrompt = saved && JSON.stringify(saved.state) !== JSON.stringify(this._state)
335
+ ? {savedAt: saved.savedAt}
336
+ : null;
337
+ }
338
+
181
339
  private _onKeyDown = (e: KeyboardEvent) => {
182
340
  if (e.key === 'Escape' && (this._movingNodeId || this._picker || this._selectedBranch)) {
183
341
  this._movingNodeId = null;
@@ -190,6 +348,9 @@ export default class ZnFlowBuilder extends ZincElement {
190
348
  if (changed.has('nodeTypes') && this.nodeTypes?.length) {
191
349
  this.registry.registerAll(this.nodeTypes);
192
350
  }
351
+ if (changed.has('autoSave')) {
352
+ this._restartAutoSave();
353
+ }
193
354
  super.willUpdate(changed);
194
355
  }
195
356
 
@@ -214,12 +375,116 @@ export default class ZnFlowBuilder extends ZincElement {
214
375
  }
215
376
  }
216
377
 
378
+ /** Parse an operator / option list: a JSON array (strings or `{value,label}`) or comma-separated values. */
379
+ private static _parseFilterOptions(attr: string | null): FlowFilterOption[] | undefined {
380
+ if (attr === null) return undefined;
381
+ const trimmed = attr.trim();
382
+ if (trimmed === '') return undefined;
383
+ if (trimmed.startsWith('[')) {
384
+ try {
385
+ const parsed = JSON.parse(trimmed) as unknown;
386
+ if (!Array.isArray(parsed)) return undefined;
387
+ return parsed.map(o => (typeof o === 'string' ? {value: o} : (o as FlowFilterOption)));
388
+ } catch {
389
+ return undefined;
390
+ }
391
+ }
392
+ return trimmed.split(',').map(s => s.trim()).filter(Boolean).map(value => ({value}));
393
+ }
394
+
395
+ /**
396
+ * Option list declared as child elements — the tidy form. The element's text
397
+ * is the label; a `value` attribute overrides the stored value:
398
+ * `<zn-flow-operator value="gte">at least</zn-flow-operator>`.
399
+ */
400
+ private static _nestedFilterOptions(el: Element, tag: string): FlowFilterOption[] | undefined {
401
+ const els = Array.from(el.querySelectorAll(`:scope > ${tag}`));
402
+ if (!els.length) return undefined;
403
+ return els.map(o => {
404
+ const text = o.textContent?.trim() ?? '';
405
+ const value = o.getAttribute('value');
406
+ return value !== null && text ? {value, label: text} : {value: value ?? text};
407
+ });
408
+ }
409
+
410
+ private static _filterFieldFromEl(el: Element): FlowFilterField | null {
411
+ const id = el.getAttribute('id');
412
+ if (!id) return null;
413
+ const value = el.getAttribute('value');
414
+ const type = el.getAttribute('type') as FlowFilterField['type'] | null;
415
+ return {
416
+ id,
417
+ label: el.getAttribute('label') ?? undefined,
418
+ type: type ?? undefined,
419
+ operators: ZnFlowBuilder._nestedFilterOptions(el, 'zn-flow-operator')
420
+ ?? ZnFlowBuilder._parseFilterOptions(el.getAttribute('operators')),
421
+ options: ZnFlowBuilder._nestedFilterOptions(el, 'zn-flow-option')
422
+ ?? ZnFlowBuilder._parseFilterOptions(el.getAttribute('options')),
423
+ units: ZnFlowBuilder._nestedFilterOptions(el, 'zn-flow-unit')
424
+ ?? ZnFlowBuilder._parseFilterOptions(el.getAttribute('units')),
425
+ suffix: el.getAttribute('suffix') ?? undefined,
426
+ placeholder: el.getAttribute('placeholder') ?? undefined,
427
+ value: value === null ? undefined : type === 'number' ? Number(value) : value,
428
+ };
429
+ }
430
+
431
+ /**
432
+ * A step's branch filters: the `branch-filters` JSON attribute, or nested
433
+ * `<zn-flow-filter>` declarations each holding `<zn-flow-filter-field>`s.
434
+ */
435
+ private static _parseBranchFilters(el: Element): FlowBranchFilter[] | undefined {
436
+ // Option lists in the JSON may use the string shorthand — normalise to objects.
437
+ const norm = (opts?: (string | FlowFilterOption)[]) =>
438
+ opts?.map(o => (typeof o === 'string' ? {value: o} : o));
439
+ const attr = el.getAttribute('branch-filters');
440
+ if (attr) {
441
+ try {
442
+ const parsed = JSON.parse(attr) as unknown;
443
+ if (Array.isArray(parsed)) {
444
+ return (parsed as FlowBranchFilter[]).map(f => ({
445
+ ...f,
446
+ fields: (f.fields ?? []).map(field => ({
447
+ ...field,
448
+ ...(field.operators ? {operators: norm(field.operators)} : {}),
449
+ ...(field.options ? {options: norm(field.options)} : {}),
450
+ ...(field.units ? {units: norm(field.units)} : {}),
451
+ })),
452
+ }));
453
+ }
454
+ } catch {
455
+ /* fall through to nested declarations */
456
+ }
457
+ }
458
+ const filters = Array.from(el.querySelectorAll(':scope > zn-flow-filter'))
459
+ .map((f): FlowBranchFilter | null => {
460
+ const id = f.getAttribute('id') ?? f.getAttribute('label');
461
+ if (!id) return null;
462
+ return {
463
+ id,
464
+ label: f.getAttribute('label') ?? id,
465
+ description: f.getAttribute('description') ?? undefined,
466
+ fields: Array.from(f.querySelectorAll(':scope > zn-flow-filter-field'))
467
+ .map(field => ZnFlowBuilder._filterFieldFromEl(field))
468
+ .filter((field): field is FlowFilterField => !!field),
469
+ };
470
+ })
471
+ .filter((f): f is FlowBranchFilter => !!f);
472
+ return filters.length ? filters : undefined;
473
+ }
474
+
217
475
  private _typeFromStep(el: Element): FlowNodeType | null {
218
476
  const type = el.getAttribute('type');
219
477
  if (!type) return null;
478
+ // The label falls back to the step's own text only — not the text of
479
+ // nested <zn-flow-filter> declarations.
480
+ const ownText = Array.from(el.childNodes)
481
+ .filter(n => n.nodeType === Node.TEXT_NODE)
482
+ .map(n => n.textContent ?? '')
483
+ .join('')
484
+ .trim();
220
485
  return {
221
486
  type,
222
- label: el.getAttribute('label') ?? el.textContent?.trim() ?? type,
487
+ label: el.getAttribute('label') ?? (ownText || type),
223
488
  group: (el.getAttribute('group') as FlowGroup) ?? 'action',
224
489
  category: el.getAttribute('category') ?? undefined,
225
490
  icon: el.getAttribute('icon') ?? undefined,
@@ -228,6 +493,7 @@ export default class ZnFlowBuilder extends ZincElement {
228
493
  description: el.getAttribute('description') ?? undefined,
229
494
  inputs: ZnFlowBuilder._parsePorts(el.getAttribute('inputs')),
230
495
  outputs: ZnFlowBuilder._parsePorts(el.getAttribute('outputs')),
496
+ branchFilters: ZnFlowBuilder._parseBranchFilters(el),
231
497
  };
232
498
  }
233
499
 
@@ -269,6 +535,7 @@ export default class ZnFlowBuilder extends ZincElement {
269
535
  this._redo = [];
270
536
  this._selectedNodeId = null;
271
537
  this._configRevision++;
538
+ this._offerRestoreIfNewer();
272
539
  }
273
540
 
274
541
  get value(): string {
@@ -288,6 +555,15 @@ export default class ZnFlowBuilder extends ZincElement {
288
555
  }
289
556
  }
290
557
 
558
+ /**
559
+ * The full flow state — nodes with their positions, connections, branch
560
+ * data, and notes — ready for persisting. Lets `JSON.stringify(builder)`
561
+ * serialize the flow directly, e.g. as a POST body.
562
+ */
563
+ toJSON(): FlowState {
564
+ return this.getState();
565
+ }
566
+
291
567
  undo = () => {
292
568
  const prev = this._history.pop();
293
569
  if (!prev) return;
@@ -396,6 +672,8 @@ export default class ZnFlowBuilder extends ZincElement {
396
672
  connections: [...this._state.connections],
397
673
  notes: [...this._state.notes],
398
674
  };
675
+ // Editing is choosing the loaded flow — stop offering the auto-saved draft.
676
+ this._restorePrompt = null;
399
677
  this.emit('zn-flow-change', {detail: {state: this.getState()}});
400
678
  };
401
679
 
@@ -444,11 +722,27 @@ export default class ZnFlowBuilder extends ZincElement {
444
722
  * occupies the spot, walk outward in grid-step rings to the nearest free one.
445
723
  */
446
724
  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
- );
725
+ const parents = new Set(
726
+ this._state.connections
727
+ .filter(c => excludeId && c.target.node === excludeId && c.source.node !== excludeId)
728
+ .map(c => c.source.node)
729
+ );
730
+ const collides = (px: number, py: number) => {
731
+ // Test with the excluded node AT the candidate spot — its parents' pills
732
+ // centre between the two, so they shift along with the move and must
733
+ // land clear of everything as well.
734
+ const nodes = excludeId
735
+ ? this._state.nodes.map(n => (n.id === excludeId ? {...n, x: px, y: py} : n))
736
+ : this._state.nodes;
737
+ const typeOf = (t: string) => this.registry.get(t);
738
+ if (nodes.some(n => n.id !== excludeId && cardCollides({x: px, y: py}, n, typeOf, nodes, this._state.connections))) {
739
+ return true;
740
+ }
741
+ return [...parents].some(pid => {
742
+ const parent = nodes.find(n => n.id === pid);
743
+ return !!parent && pillsCollide(parent, typeOf, nodes, this._state.connections);
744
+ });
745
+ };
452
746
  x = snapToGrid(x);
453
747
  y = snapToGrid(y);
454
748
  if (!collides(x, y)) return {x, y};
@@ -513,11 +807,11 @@ export default class ZnFlowBuilder extends ZincElement {
513
807
  private _positionBelowOutput(source: FlowNodeInstance, port: string): { x: number; y: number } {
514
808
  const outputs = nodeOutputs(source, this.registry.get(source.type));
515
809
  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
810
+ // Centred under the branch drop (where the pill hangs), a full layer below
811
+ // the source (same rhythm as untangle) — the child lands in a straight
519
812
  // 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};
813
+ const x = branchDropXs(source, t => this.registry.get(t))[idx];
814
+ return {x: Math.round(x - NODE_WIDTH / 2), y: source.y + LAYOUT_V_GAP};
521
815
  }
522
816
 
523
817
  /**
@@ -659,6 +953,8 @@ export default class ZnFlowBuilder extends ZincElement {
659
953
 
660
954
  private _select(id: string | null) {
661
955
  this._selectedBranch = null;
956
+ // A selection needs the inspector — bring the panel back if it's tucked away.
957
+ if (id) this._sideCollapsed = false;
662
958
  if (this._selectedNodeId === id) return;
663
959
  this._selectedNodeId = id;
664
960
  this.emit('zn-flow-selection-change', {detail: {nodeId: id}});
@@ -666,6 +962,7 @@ export default class ZnFlowBuilder extends ZincElement {
666
962
 
667
963
  private _onBranchPick = (e: CustomEvent<{ nodeId: string; port: string }>) => {
668
964
  this._select(null);
965
+ this._sideCollapsed = false;
669
966
  this._selectedBranch = {nodeId: e.detail.nodeId, port: e.detail.port};
670
967
  };
671
968
 
@@ -931,8 +1228,9 @@ export default class ZnFlowBuilder extends ZincElement {
931
1228
  };
932
1229
  // The guard keeps the consumer's config DOM in place across value-only
933
1230
  // 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];
1231
+ // changes, the state is replaced, or a branch is added / removed /
1232
+ // replaced (ids, not count — a swap like delete-last keeps the count).
1233
+ const configKey = [node.id, this._configRevision, nodeOutputs(node, type).map(p => p.id).join('|')];
936
1234
 
937
1235
  return html`
938
1236
  <aside part="inspector" class="inspector">
@@ -978,6 +1276,13 @@ export default class ZnFlowBuilder extends ZincElement {
978
1276
  this._commit();
979
1277
  }
980
1278
 
1279
+ /** Persist the built-in conditions editor's draft onto the branch and close it. Undoable. */
1280
+ private _saveBranchConditions(node: FlowNodeInstance, port: FlowPort, conditions: FlowBranchConditions) {
1281
+ this._pushHistory();
1282
+ this._updateBranch(node, port.id, {data: {...port.data, conditions}});
1283
+ this._selectedBranch = null;
1284
+ }
1285
+
981
1286
  // The branch editor: rename an output branch and configure its conditions.
982
1287
  private _renderBranchEditor(node: FlowNodeInstance, port: FlowPort) {
983
1288
  const type = this.registry.get(node.type);
@@ -1031,7 +1336,16 @@ export default class ZnFlowBuilder extends ZincElement {
1031
1336
  ></zn-input>
1032
1337
  ${renderBranchConfig
1033
1338
  ? guard(configKey, () => renderBranchConfig(node, port, update))
1034
- : html`<p class="inspector-hint">This step type has no branch conditions.</p>`}
1339
+ : type?.branchFilters?.length
1340
+ ? html`
1341
+ <zn-flow-branch-conditions
1342
+ .filters="${type.branchFilters}"
1343
+ .value="${branchConditions(port)}"
1344
+ @flow-conditions-save="${(e: CustomEvent<{ conditions: FlowBranchConditions }>) =>
1345
+ this._saveBranchConditions(node, port, e.detail.conditions)}"
1346
+ @flow-conditions-cancel="${() => (this._selectedBranch = null)}"
1347
+ ></zn-flow-branch-conditions>`
1348
+ : html`<p class="inspector-hint">This step type has no branch conditions.</p>`}
1035
1349
  </div>
1036
1350
  </aside>
1037
1351
  `;
@@ -1092,6 +1406,34 @@ export default class ZnFlowBuilder extends ZincElement {
1092
1406
  `;
1093
1407
  }
1094
1408
 
1409
+ // Bottom-left of the canvas: flashes as each auto-save lands, otherwise
1410
+ // shows how long ago the last one happened.
1411
+ private _renderAutoSaveStatus() {
1412
+ if (this.autoSave === null || (!this._justSaved && this._lastSavedAt === null)) return '';
1413
+ const label = this._justSaved
1414
+ ? 'Auto-saved'
1415
+ : `Last saved ${timeAgo(Math.max(0, this._statusNow - (this._lastSavedAt ?? 0)))}`;
1416
+ return html`
1417
+ <div class="save-status ${this._justSaved ? 'save-status--saved' : ''}">
1418
+ <zn-icon src="${this._justSaved ? 'check@lu' : 'history@lu'}" size="14"></zn-icon>
1419
+ <span>${label}</span>
1420
+ </div>
1421
+ `;
1422
+ }
1423
+
1424
+ // Offered when a loaded flow differs from a fresh auto-saved draft.
1425
+ private _renderRestorePrompt() {
1426
+ if (!this._restorePrompt) return '';
1427
+ return html`
1428
+ <div class="restore-banner">
1429
+ <zn-icon src="history@lu" size="16"></zn-icon>
1430
+ <span>An auto-saved draft from ${timeAgo(Date.now() - this._restorePrompt.savedAt)} differs from this flow.</span>
1431
+ <button class="restore-banner__restore" @click="${() => this.restoreAutoSave()}">Restore</button>
1432
+ <button class="restore-banner__dismiss" @click="${() => (this._restorePrompt = null)}">Dismiss</button>
1433
+ </div>
1434
+ `;
1435
+ }
1436
+
1095
1437
  private _renderPicker() {
1096
1438
  if (!this._picker) return '';
1097
1439
  const {x, y} = this._picker;
@@ -1134,7 +1476,10 @@ export default class ZnFlowBuilder extends ZincElement {
1134
1476
 
1135
1477
  render() {
1136
1478
  return html`
1137
- <div part="base" class="builder">
1479
+ <div
1480
+ part="base"
1481
+ class="builder ${this._stepsCollapsed ? 'builder--steps-collapsed' : ''} ${this._sideCollapsed ? 'builder--side-collapsed' : ''}"
1482
+ >
1138
1483
  ${this._renderHeader()}
1139
1484
  ${this._renderSteps()}
1140
1485
  <div
@@ -1142,6 +1487,20 @@ export default class ZnFlowBuilder extends ZincElement {
1142
1487
  @dragover="${this._onCanvasDragOver}"
1143
1488
  @drop="${this._onCanvasDrop}"
1144
1489
  >
1490
+ <button
1491
+ class="panel-toggle panel-toggle--left ${this._stepsCollapsed ? 'panel-toggle--tucked' : ''}"
1492
+ title="${this._stepsCollapsed ? 'Show steps panel' : 'Hide steps panel'}"
1493
+ @click="${() => (this._stepsCollapsed = !this._stepsCollapsed)}"
1494
+ >
1495
+ <zn-icon src="${this._stepsCollapsed ? 'chevron-right@lu' : 'chevron-left@lu'}" size="16"></zn-icon>
1496
+ </button>
1497
+ <button
1498
+ class="panel-toggle panel-toggle--right ${this._sideCollapsed ? 'panel-toggle--tucked' : ''}"
1499
+ title="${this._sideCollapsed ? 'Show panel' : 'Hide panel'}"
1500
+ @click="${() => (this._sideCollapsed = !this._sideCollapsed)}"
1501
+ >
1502
+ <zn-icon src="${this._sideCollapsed ? 'chevron-left@lu' : 'chevron-right@lu'}" size="16"></zn-icon>
1503
+ </button>
1145
1504
  <zn-flow-canvas
1146
1505
  .nodes="${this._state.nodes}"
1147
1506
  .connections="${this._state.connections}"
@@ -1162,6 +1521,8 @@ export default class ZnFlowBuilder extends ZincElement {
1162
1521
  </div>
1163
1522
  `
1164
1523
  : ''}
1524
+ ${this._renderAutoSaveStatus()}
1525
+ ${this._renderRestorePrompt()}
1165
1526
  ${this._renderPicker()}
1166
1527
  </div>
1167
1528
  ${this._renderRightPanel()}