@kubex/zinc 1.1.28 → 1.1.31

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 (52) hide show
  1. package/custom-elements-manifest.config.js +2 -2
  2. package/dist/custom-elements.json +1910 -362
  3. package/dist/vscode.html-custom-data.json +55 -14
  4. package/dist/web-types.json +185 -32
  5. package/dist/zn.d.ts +684 -16
  6. package/dist/zn.min.js +1325 -929
  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/docs/pages/components/settings-container.md +37 -2
  11. package/package.json +1 -1
  12. package/src/components/datepicker/datepicker.scss +0 -10
  13. package/src/components/flow-builder/flow-builder.component.ts +377 -16
  14. package/src/components/flow-builder/flow-builder.scss +398 -250
  15. package/src/components/flow-builder/flow-builder.test.ts +241 -4
  16. package/src/components/flow-builder/flow-layout.ts +42 -17
  17. package/src/components/flow-builder/flow.types.ts +168 -43
  18. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.component.ts +300 -0
  19. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.scss +222 -0
  20. package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.test.ts +125 -0
  21. package/src/components/flow-builder/modules/flow-branch-conditions/index.ts +12 -0
  22. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +184 -26
  23. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +170 -120
  24. package/src/components/flow-builder/modules/flow-node/flow-node.scss +42 -42
  25. package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -1
  26. package/src/components/flow-builder/modules/flow-step/flow-step.scss +25 -17
  27. package/src/components/header/header.scss +3 -1
  28. package/src/components/icon-picker/icon-picker.component.ts +20 -37
  29. package/src/components/icon-picker/icon-picker.scss +5 -45
  30. package/src/components/page-builder/index.ts +14 -0
  31. package/src/components/page-builder/modules/page-palette-item/index.ts +12 -0
  32. package/src/components/page-builder/modules/page-palette-item/page-palette-item.component.ts +71 -0
  33. package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +70 -0
  34. package/src/components/page-builder/modules/page-palette-item/page-palette-item.test.ts +20 -0
  35. package/src/components/page-builder/modules/page-section-card/index.ts +12 -0
  36. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +93 -0
  37. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +92 -0
  38. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +50 -0
  39. package/src/components/page-builder/page-builder.component.ts +1053 -0
  40. package/src/components/page-builder/page-builder.scss +494 -0
  41. package/src/components/page-builder/page-builder.test.ts +464 -0
  42. package/src/components/page-builder/page-registry.ts +48 -0
  43. package/src/components/page-builder/page.types.ts +75 -0
  44. package/src/components/panel/panel.scss +1 -0
  45. package/src/components/settings-container/settings-container.component.ts +74 -9
  46. package/src/components/settings-container/settings-container.scss +75 -0
  47. package/src/components/settings-container/settings-container.test.ts +35 -0
  48. package/src/events/events.ts +2 -0
  49. package/src/events/zn-page-change.ts +9 -0
  50. package/src/events/zn-page-selection-change.ts +7 -0
  51. package/src/zinc.ts +4 -0
  52. package/web-test-runner.config.js +6 -1
@@ -15,7 +15,8 @@ The Flow Builder is a three-panel editor for visual automations, with an optiona
15
15
  on a node to start a **stray branch** — the wire follows your cursor until you click a node (or its output
16
16
  port) to attach it. Fan-in is allowed, and so are **loops** — a branch may point back to an earlier step (e.g.
17
17
  an answer that restarts the questioning); only wiring a node directly to itself is refused. Cancel by clicking
18
- empty canvas, pressing Esc, or leaving the window. A connected output port spawns a brand-new branch on click, so extra arrows never require
18
+ empty canvas, pressing Esc, or leaving the window. A connected output port spawns a brand-new branch on click, so
19
+ extra arrows never require
19
20
  config forms. Open outputs draw nothing while idle — their **`+`** targets appear only while dragging a step in
20
21
  or moving one. Every existing wire has a **`+`** at its midpoint to insert a step between two nodes. New
21
22
  steps are created by dragging them from the panel onto the canvas or a `+`. Reposition nodes by dragging, and add
@@ -25,8 +26,13 @@ The Flow Builder is a three-panel editor for visual automations, with an optiona
25
26
  - **Right panel** — the sidebar by default: a slot (`slot="sidebar"`) for status / version history, with a built-in
26
27
  Configuration Errors summary derived from `errorNodes`. Selecting a node slides that step's configuration in from
27
28
  the right (driven by the node type's `renderConfig`); clicking a **branch pill** on a wire slides in the branch
28
- editor instead — rename the branch and configure the filters/conditions for taking that path (the node type's
29
- `renderBranchConfig`, persisted on the output port's `data`).
29
+ editor instead — rename the branch and configure the filters/conditions for taking that path. Node types that
30
+ declare **`branchFilters`** (in markup or JS) get the built-in conditions editor — pick filters from a searchable
31
+ list and combine them into AND groups joined by OR, saved onto the output port's `data.conditions`. A custom
32
+ `renderBranchConfig` takes precedence when supplied.
33
+
34
+ Both side panels can be **tucked away** with the chevrons on the canvas edges, giving the canvas the full
35
+ width; selecting a node or branch brings the right panel back automatically.
30
36
 
31
37
  A node's **`…`** menu offers Delete, Duplicate, and **Move** — Move detaches the step (with its subtree) so you can
32
38
  re-attach it to a different `+` slot.
@@ -170,62 +176,145 @@ component generic and lets it be extended for any future workflow without changi
170
176
  // edits node.outputs (add / rename / remove branches). The canvas re-renders the
171
177
  // outputs and the builder prunes connections to any removed branch.
172
178
  const splitType = {
173
- type: 'split',
174
- label: 'Conditional Split',
175
- group: 'rule',
176
- category: 'Logic',
177
- icon: 'split@lu',
178
- color: 'rgb(105, 54, 245)',
179
- outputs: [{id: 'true', label: 'TRUE'}, {id: 'false', label: 'FALSE'}],
180
- renderConfig: (node, update) =>
181
- {
182
- const branches = node.outputs ?? [{id: 'true', label: 'TRUE'}, {id: 'false', label: 'FALSE'}];
183
- const setBranches = (next) =>
184
- {
185
- node.outputs = next;
186
- update({});
187
- };
188
-
189
- const wrap = document.createElement('div');
190
- wrap.style.cssText = 'display:flex;flex-direction:column;gap:8px';
191
-
192
- const heading = document.createElement('p');
193
- heading.textContent = 'Branches';
194
- heading.style.cssText = 'margin:0;font-weight:600;font-size:0.8125rem';
195
- wrap.appendChild(heading);
196
-
197
- branches.forEach((branch, i) =>
198
- {
199
- const row = document.createElement('div');
200
- row.style.cssText = 'display:flex;gap:6px;align-items:center';
201
-
202
- const input = document.createElement('zn-input');
203
- input.setAttribute('size', 'small');
204
- input.value = branch.label ?? branch.id;
205
- input.style.flex = '1';
206
- input.addEventListener('change', () =>
207
- setBranches(branches.map((b, j) => (j === i ? {...b, label: input.value} : b))));
208
-
209
- const remove = document.createElement('zn-button');
210
- remove.setAttribute('icon', 'x@lu');
211
- remove.setAttribute('icon-button', 'small');
212
- remove.setAttribute('plain', '');
213
- remove.addEventListener('click', () => setBranches(branches.filter((_, j) => j !== i)));
214
-
215
- row.append(input, remove);
216
- wrap.appendChild(row);
217
- });
218
-
219
- const add = document.createElement('zn-button');
220
- add.setAttribute('size', 'small');
221
- add.setAttribute('icon', 'plus@lu');
222
- add.textContent = 'Add branch';
223
- add.addEventListener('click', () =>
224
- setBranches([...branches, {id: 'branch-' + (branches.length + 1), label: 'New branch'}]));
225
- wrap.appendChild(add);
226
-
227
- return wrap;
228
- }
179
+ type: 'split',
180
+ label: 'Conditional Split',
181
+ group: 'rule',
182
+ category: 'Logic',
183
+ icon: 'split@lu',
184
+ color: 'rgb(105, 54, 245)',
185
+ outputs: [{id: 'true', label: 'TRUE'}, {id: 'false', label: 'FALSE'}],
186
+ // Clicking one of this step's branch pills opens the built-in conditions
187
+ // editor, offering these filters (AND/OR groups saved on the port's data).
188
+ branchFilters: [
189
+ {
190
+ id: 'engagement',
191
+ label: 'Email engagement',
192
+ fields: [
193
+ {
194
+ id: 'count',
195
+ type: 'number',
196
+ suffix: 'time(s)',
197
+ value: 2,
198
+ operators: [
199
+ {value: 'at-least', label: 'at least'},
200
+ {value: 'at-most', label: 'at most'},
201
+ {value: 'exactly', label: 'exactly'}
202
+ ]
203
+ },
204
+ {
205
+ id: 'window',
206
+ type: 'number',
207
+ value: 6,
208
+ operators: [
209
+ {value: 'in-last', label: 'in the last'},
210
+ {value: 'before-last', label: 'before the last'}
211
+ ],
212
+ units: [
213
+ {value: 'days', label: 'day(s)'},
214
+ {value: 'weeks', label: 'week(s)'},
215
+ {value: 'months', label: 'month(s)'}
216
+ ]
217
+ }
218
+ ]
219
+ },
220
+ {
221
+ id: 'lost-reason',
222
+ label: 'Lost reason',
223
+ fields: [
224
+ {
225
+ id: 'reason',
226
+ operators: [
227
+ {value: 'eq', label: 'Is equal to'},
228
+ {value: 'neq', label: 'Is not equal to'}
229
+ ],
230
+ options: [
231
+ {value: 'price', label: 'Price'},
232
+ {value: 'competitor', label: 'Competitor'},
233
+ {value: 'no-need', label: 'No need'},
234
+ {value: 'other', label: 'Other'}
235
+ ]
236
+ }
237
+ ]
238
+ },
239
+ {
240
+ id: 'arr',
241
+ label: 'Annual recurring revenue',
242
+ fields: [
243
+ {
244
+ id: 'amount',
245
+ type: 'number',
246
+ suffix: 'USD',
247
+ operators: [
248
+ {value: 'gte', label: 'is at least'},
249
+ {value: 'lte', label: 'is at most'}
250
+ ]
251
+ }
252
+ ]
253
+ }
254
+ ],
255
+ renderConfig: (node, update) =>
256
+ {
257
+ const branches = node.outputs ?? [{id: 'true', label: 'TRUE'}, {id: 'false', label: 'FALSE'}];
258
+ const setBranches = (next) =>
259
+ {
260
+ node.outputs = next;
261
+ update({});
262
+ };
263
+
264
+ const wrap = document.createElement('div');
265
+ wrap.style.cssText = 'display:flex;flex-direction:column;gap:8px';
266
+
267
+ // Branches are created on the canvas; the manager only shows once the
268
+ // node has real ones — connected, or named and waiting to be attached.
269
+ const el = document.getElementById('flow-demo');
270
+ const connected = new Set(
271
+ el.getState().connections.filter(c => c.source.node === node.id).map(c => c.source.port));
272
+ if(!branches.some(b => b.label || connected.has(b.id))) return wrap;
273
+
274
+ const heading = document.createElement('p');
275
+ heading.textContent = 'Branches';
276
+ heading.style.cssText = 'margin:0;font-weight:600;font-size:0.8125rem';
277
+ wrap.appendChild(heading);
278
+
279
+ branches.forEach((branch, i) =>
280
+ {
281
+ const row = document.createElement('div');
282
+ row.style.cssText = 'display:flex;gap:6px;align-items:center';
283
+
284
+ const input = document.createElement('zn-input');
285
+ input.setAttribute('size', 'small');
286
+ input.value = branch.label ?? branch.id;
287
+ input.style.flex = '1';
288
+ input.addEventListener('change', () =>
289
+ setBranches(branches.map((b, j) => (j === i ? {...b, label: input.value} : b))));
290
+ row.appendChild(input);
291
+
292
+ const remove = document.createElement('zn-button');
293
+ remove.setAttribute('icon', 'x@lu');
294
+ remove.setAttribute('icon-button', 'small');
295
+ remove.setAttribute('plain', '');
296
+ remove.addEventListener('click', () =>
297
+ {
298
+ const next = branches.filter((_, j) => j !== i);
299
+ // Deleting the last branch leaves a plain open output (a fresh id, so
300
+ // the removed branch's wire is pruned) — the node stays attachable.
301
+ setBranches(next.length ? next : [{id: 'branch-' + Date.now().toString(36)}]);
302
+ });
303
+ row.appendChild(remove);
304
+
305
+ wrap.appendChild(row);
306
+ });
307
+
308
+ const add = document.createElement('zn-button');
309
+ add.setAttribute('size', 'small');
310
+ add.setAttribute('icon', 'plus@lu');
311
+ add.textContent = 'Add branch';
312
+ add.addEventListener('click', () =>
313
+ setBranches([...branches, {id: 'branch-' + (branches.length + 1), label: 'New branch'}]));
314
+ wrap.appendChild(add);
315
+
316
+ return wrap;
317
+ }
229
318
  };
230
319
 
231
320
  customElements.whenDefined('zn-flow-builder').then(() =>
@@ -273,20 +362,205 @@ they're declarations that drive the rendered panel):
273
362
 
274
363
  Set an optional per-tab hint with the `triggers-hint` / `actions-hint` / `rules-hint` attributes on the
275
364
  builder. (A node's inspector `renderConfig` can't be expressed in markup — supply it via
276
- `registerNodeTypes()` for the same `type` id if you need a custom config panel.)
365
+ `registerNodeTypes()` for the same `type` id if you need a custom config panel. Branch conditions *can*
366
+ be expressed in markup — see the next section.)
277
367
 
278
368
  ```html
279
369
 
280
370
  <zn-flow-builder triggers-hint="Choose a trigger and drag it to the canvas.">
281
371
  <zn-flow-step type="webhook" group="trigger" category="Events"
282
- icon="webhook@lu" color="rgb(236,68,91)" inputs=""
283
- description="User hits webhook XYZ">Cancellation Requested</zn-flow-step>
372
+ icon="webhook@lu" color="rgb(236,68,91)" inputs=""
373
+ description="User hits webhook XYZ">Cancellation Requested
374
+ </zn-flow-step>
284
375
 
285
376
  <zn-flow-step type="split" group="rule" category="Logic" icon="split@lu"
286
- outputs='[{"id":"true","label":"TRUE"},{"id":"false","label":"FALSE"}]'>Conditional Split</zn-flow-step>
377
+ outputs='[{"id":"true","label":"TRUE"},{"id":"false","label":"FALSE"}]'>Conditional Split
378
+ </zn-flow-step>
287
379
  </zn-flow-builder>
288
380
  ```
289
381
 
382
+ ### Branch conditions in markup
383
+
384
+ Clicking a **branch pill** opens the branch editor. For step types that declare **branch filters**, it
385
+ renders the built-in conditions editor: a searchable filter picker, then the picked filters combined
386
+ into **AND groups joined by OR** — with Save / Cancel, so edits only apply (undoably) on Save.
387
+
388
+ Declare the filters when instantiating the builder by nesting `<zn-flow-filter>` elements inside a
389
+ `<zn-flow-step>` (use the `label` attribute for the step's name when nesting). Each filter holds one
390
+ `<zn-flow-filter-field>` per control row, and each field declares its choices as nested
391
+ `<zn-flow-operator>` / `<zn-flow-option>` elements — the element text is the label, with an optional
392
+ `value` attribute for the stored value.
393
+
394
+ Try it: click the **High intent** or **Everyone else** pill to open the conditions editor.
395
+
396
+ ```html:preview
397
+
398
+ <div style="height: 640px;">
399
+ <zn-flow-builder id="branch-filter-demo" heading="Win-back Campaign" subheading="Branch filters demo"
400
+ rules-hint="Rules branch the flow based on conditions configured on each branch pill.">
401
+
402
+ <zn-flow-step type="cancel-requested" group="entrypoint" category="Events"
403
+ icon="webhook@lu" color="rgb(236, 68, 91)" inputs="">Cancellation Requested
404
+ </zn-flow-step>
405
+
406
+ <zn-flow-step type="split" group="rule" category="Logic"
407
+ icon="split@lu" color="rgb(105, 54, 245)" label="Conditional Split"
408
+ outputs='[{"id":"a","label":"Branch A"},{"id":"b","label":"Branch B"}]'>
409
+
410
+ <zn-flow-filter id="engagement" label="Email engagement"
411
+ description="How often the contact opened a campaign email">
412
+ <zn-flow-filter-field id="count" type="number" suffix="time(s)" value="2">
413
+ <zn-flow-operator value="gte">at least</zn-flow-operator>
414
+ <zn-flow-operator value="lte">at most</zn-flow-operator>
415
+ <zn-flow-operator value="eq">exactly</zn-flow-operator>
416
+ </zn-flow-filter-field>
417
+ <!-- Three adjustable controls: operator, amount, and time-frame unit. -->
418
+ <zn-flow-filter-field id="window" type="number" value="6">
419
+ <zn-flow-operator value="within">in the last</zn-flow-operator>
420
+ <zn-flow-operator value="before">before the last</zn-flow-operator>
421
+ <zn-flow-unit value="days">day(s)</zn-flow-unit>
422
+ <zn-flow-unit value="weeks">week(s)</zn-flow-unit>
423
+ <zn-flow-unit value="months">month(s)</zn-flow-unit>
424
+ </zn-flow-filter-field>
425
+ </zn-flow-filter>
426
+
427
+ <zn-flow-filter id="lost-reason" label="Lost reason"
428
+ description="Why the customer said they cancelled">
429
+ <zn-flow-filter-field id="reason">
430
+ <zn-flow-operator value="eq">Is equal to</zn-flow-operator>
431
+ <zn-flow-operator value="neq">Is not equal to</zn-flow-operator>
432
+ <zn-flow-option value="price">Price</zn-flow-option>
433
+ <zn-flow-option value="competitor">Competitor</zn-flow-option>
434
+ <zn-flow-option value="no-need">No need</zn-flow-option>
435
+ <zn-flow-option value="other">Other</zn-flow-option>
436
+ </zn-flow-filter-field>
437
+ </zn-flow-filter>
438
+
439
+ <zn-flow-filter id="plan" label="Subscription plan">
440
+ <zn-flow-filter-field id="name">
441
+ <zn-flow-operator value="is">is</zn-flow-operator>
442
+ <zn-flow-operator value="is-not">is not</zn-flow-operator>
443
+ <zn-flow-option value="starter">Starter</zn-flow-option>
444
+ <zn-flow-option value="growth">Growth</zn-flow-option>
445
+ <zn-flow-option value="enterprise">Enterprise</zn-flow-option>
446
+ </zn-flow-filter-field>
447
+ </zn-flow-filter>
448
+
449
+ <!-- Well-known operator keys don't need text — gte/lte get default labels. -->
450
+ <zn-flow-filter id="arr" label="Annual recurring revenue">
451
+ <zn-flow-filter-field id="amount" type="number" suffix="USD" placeholder="0">
452
+ <zn-flow-operator value="gte"></zn-flow-operator>
453
+ <zn-flow-operator value="lte"></zn-flow-operator>
454
+ </zn-flow-filter-field>
455
+ </zn-flow-filter>
456
+
457
+ <zn-flow-filter id="last-order" label="Last order"
458
+ description="Time since the contact last placed an order">
459
+ <zn-flow-filter-field id="days" type="number" suffix="day(s) ago" value="30">
460
+ <zn-flow-operator value="within">within the last</zn-flow-operator>
461
+ <zn-flow-operator value="over">more than</zn-flow-operator>
462
+ </zn-flow-filter-field>
463
+ </zn-flow-filter>
464
+
465
+ <zn-flow-filter id="tag" label="Contact tag">
466
+ <zn-flow-filter-field id="name" type="text" placeholder="e.g. vip">
467
+ <zn-flow-operator value="has">contains</zn-flow-operator>
468
+ <zn-flow-operator value="not-has">does not contain</zn-flow-operator>
469
+ </zn-flow-filter-field>
470
+ </zn-flow-filter>
471
+ </zn-flow-step>
472
+
473
+ <zn-flow-step type="send-offer" group="action" category="Email"
474
+ icon="mail@lu" color="rgb(43, 192, 145)">Send Win-back Offer
475
+ </zn-flow-step>
476
+ <zn-flow-step type="notify-csm" group="action" category="Support"
477
+ icon="user@lu" color="rgb(43, 192, 145)">Notify Account Manager
478
+ </zn-flow-step>
479
+ </zn-flow-builder>
480
+ </div>
481
+
482
+ <script type="module">
483
+ customElements.whenDefined('zn-flow-builder').then(() =>
484
+ {
485
+ const el = document.getElementById('branch-filter-demo');
486
+ el.setState({
487
+ nodes: [
488
+ {id: 'n1', type: 'cancel-requested', x: 300, y: 40, data: {}},
489
+ {
490
+ id: 'n2', type: 'split', x: 300, y: 280, data: {subtitle: 'Qualify the save attempt'},
491
+ // The branch pills carry their saved conditions on the port's data.
492
+ outputs: [
493
+ {
494
+ id: 'a', label: 'High intent',
495
+ data: {
496
+ conditions: [
497
+ [
498
+ {filter: 'engagement', values: {count: {operator: 'gte', value: 2}, window: {operator: 'within', value: 6, unit: 'months'}}},
499
+ {filter: 'lost-reason', values: {reason: {operator: 'eq', value: 'price'}}}
500
+ ],
501
+ [
502
+ {filter: 'arr', values: {amount: {operator: 'gte', value: 5000}}}
503
+ ]
504
+ ]
505
+ }
506
+ },
507
+ {id: 'b', label: 'Everyone else'}
508
+ ]
509
+ },
510
+ {id: 'n3', type: 'send-offer', x: 100, y: 580, data: {subtitle: '20% off for 3 months'}},
511
+ {id: 'n4', type: 'notify-csm', x: 500, y: 580, data: {subtitle: 'Manual follow-up'}}
512
+ ],
513
+ connections: [
514
+ {id: 'c1', source: {node: 'n1', port: 'out'}, target: {node: 'n2', port: 'in'}},
515
+ {id: 'c2', source: {node: 'n2', port: 'a'}, target: {node: 'n3', port: 'in'}},
516
+ {id: 'c3', source: {node: 'n2', port: 'b'}, target: {node: 'n4', port: 'in'}}
517
+ ],
518
+ notes: []
519
+ });
520
+ });
521
+ </script>
522
+ ```
523
+
524
+ `<zn-flow-filter>` attributes: `id` (required), `label`, `description` (shown under the label in the
525
+ picker). `<zn-flow-filter-field>` attributes:
526
+
527
+ - `id` (required) — key the field's operator/value are stored under.
528
+ - `type` — `select` | `number` | `text`. Defaults to `select` when options are given, else `text`.
529
+ - `suffix` — trailing unit text (e.g. `time(s)`) when the unit isn't adjustable.
530
+ - `label`, `placeholder`, `value` (the initial value when the filter is added).
531
+
532
+ Inside a field, `<zn-flow-operator>` elements declare the operator dropdown (shown before the value —
533
+ "at least", "is equal to"), `<zn-flow-option>` elements the value choices of a `select` field, and
534
+ `<zn-flow-unit>` elements an **adjustable unit dropdown** after the value (day(s) / week(s) / month(s)) —
535
+ so a row can have three adjustable controls: operator, amount, and time frame. The
536
+ element text is the display label; add `value="…"` to store a stable key instead of the text. Empty
537
+ operators with a **well-known key** get a default label — `<zn-flow-operator value="gte"></zn-flow-operator>`
538
+ renders as "Greater Than or Equal To". Recognised keys: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `is`,
539
+ `is-not`, `in`, `not-in`, `contains`, `not-contains`, `starts-with`, `ends-with`, `empty`, `not-empty`,
540
+ `within`, `before`, `between`, `matches`.
541
+
542
+ For programmatic generation the same lists also fit in `operators` / `options` **attributes**
543
+ (comma-separated values, or a JSON array of strings and/or `{"value","label"}` objects), a whole
544
+ catalog fits in a **`branch-filters` JSON attribute** on the step, and JS registration takes a
545
+ `branchFilters` array on the `FlowNodeType` — as in the full demo at the top of this page. A type
546
+ supplying its own `renderBranchConfig` overrides the built-in editor.
547
+
548
+ Saved conditions live on the output port as `port.data.conditions`, an array of OR-ed groups, each an
549
+ array of AND-ed conditions:
550
+
551
+ ```js
552
+ [
553
+ [ // group 1 (AND-ed)…
554
+ {filter: 'engagement',
555
+ values: {count: {operator: 'gte', value: 2}, window: {operator: 'within', value: 6, unit: 'months'}}
556
+ },
557
+ {filter: 'lost-reason', values: {reason: {operator: 'eq', value: 'price'}}}
558
+ ],
559
+ // …OR group 2
560
+ [{filter: 'arr', values: {amount: {operator: 'gte', value: 5000}}}]
561
+ ]
562
+ ```
563
+
290
564
  ### Registering custom node types
291
565
 
292
566
  Node types are the modular extension point. Instead of (or alongside) slotted steps, register an
@@ -338,9 +612,11 @@ the builder prunes any connection whose branch is removed. `node.inputs` works t
338
612
  ports then call `update({})` to commit:
339
613
 
340
614
  ```js
341
- renderConfig: (node, update) => {
615
+ renderConfig: (node, update) =>
616
+ {
342
617
  const branches = node.outputs ?? [{id: 'true', label: 'TRUE'}, {id: 'false', label: 'FALSE'}];
343
- const addBranch = () => {
618
+ const addBranch = () =>
619
+ {
344
620
  node.outputs = [...branches, {id: crypto.randomUUID(), label: 'New branch'}];
345
621
  update({}); // commit — re-renders the outputs, prunes dropped branches' connections
346
622
  };
@@ -371,7 +647,87 @@ builder.addEventListener('zn-flow-connect', e =>
371
647
  console.log('new connection', e.detail.connection);
372
648
  });
373
649
 
374
- // Persist / restore:
650
+ // Persist / restore — `value` is the full FlowState, node positions included:
375
651
  localStorage.setItem('flow', builder.value);
376
652
  builder.value = localStorage.getItem('flow');
653
+
654
+ // Saving to a server: the builder serialises directly (toJSON returns the
655
+ // FlowState), so the POST body is one expression either way:
656
+ await fetch('/api/flows/123', {
657
+ method: 'POST',
658
+ headers: {'Content-Type': 'application/json'},
659
+ body: JSON.stringify(builder), // === builder.value
660
+ });
661
+ ```
662
+
663
+ ### Auto-save
664
+
665
+ Add the `auto-save` attribute to periodically snapshot the flow (positions included) into
666
+ localStorage, keyed by the builder's `id` (falling back to its `heading`), with a **1-day TTL**:
667
+
668
+ ```html
669
+
670
+ <zn-flow-builder id="my-flow" auto-save></zn-flow-builder> <!-- every 5 minutes -->
671
+ <zn-flow-builder id="my-flow" auto-save="2"></zn-flow-builder> <!-- every 2 minutes -->
672
+ ```
673
+
674
+ Without the attribute nothing is saved. An empty canvas is never written (so a fresh session can't
675
+ clobber a stored flow), and expired snapshots are purged when encountered.
676
+
677
+ While auto-save is on, a **status pill** in the canvas's bottom-left flashes "Auto-saved" as each
678
+ snapshot lands and otherwise shows how long ago the last one happened ("Last saved 3m ago"). And when
679
+ a flow is loaded (`setState` / `value`) that **differs from a fresh auto-saved draft**, a banner offers
680
+ to restore the draft — editing the loaded flow or dismissing the banner keeps what was loaded. Restoring
681
+ can also be done programmatically:
682
+
683
+ ```js
684
+ const builder = document.querySelector('zn-flow-builder');
685
+ if(builder.restoreAutoSave())
686
+ {
687
+ console.log('Restored an auto-saved draft'); // false when none exists / it expired
688
+ }
689
+ ```
690
+
691
+ Try it below — the demo saves every **6 seconds** (`auto-save="0.1"`; real flows would use minutes).
692
+ Drag a node and watch the pill flash **Auto-saved**. Then **reload the page**: the demo always loads
693
+ its original flow, so if your draft differs a banner offers to restore it.
694
+
695
+ ```html:preview
696
+
697
+ <div style="height: 480px;">
698
+ <zn-flow-builder id="autosave-demo" auto-save="0.1"
699
+ heading="Auto-save" subheading="Saves every 6s in this demo">
700
+
701
+ <zn-flow-step type="webhook" group="entrypoint" category="Events"
702
+ icon="webhook@lu" color="rgb(236, 68, 91)" inputs="">Order Placed
703
+ </zn-flow-step>
704
+ <zn-flow-step type="send-email" group="action" category="Email"
705
+ icon="mail@lu" color="rgb(43, 192, 145)">Send Confirmation
706
+ </zn-flow-step>
707
+ <zn-flow-step type="notify" group="action" category="Support"
708
+ icon="user@lu" color="rgb(43, 192, 145)">Notify Team
709
+ </zn-flow-step>
710
+ </zn-flow-builder>
711
+ </div>
712
+
713
+ <script type="module">
714
+ customElements.whenDefined('zn-flow-builder').then(() =>
715
+ {
716
+ const el = document.getElementById('autosave-demo');
717
+ // Always load the same baseline — after editing and reloading, the
718
+ // auto-saved draft differs from it and the restore banner appears.
719
+ el.setState({
720
+ nodes: [
721
+ {id: 'n1', type: 'webhook', x: 300, y: 40, data: {subtitle: 'Webhook fires'}},
722
+ {id: 'n2', type: 'send-email', x: 300, y: 260, data: {subtitle: 'Order confirmation'}},
723
+ {id: 'n3', type: 'notify', x: 300, y: 480, data: {subtitle: 'Ping #orders'}}
724
+ ],
725
+ connections: [
726
+ {id: 'c1', source: {node: 'n1', port: 'out'}, target: {node: 'n2', port: 'in'}},
727
+ {id: 'c2', source: {node: 'n2', port: 'out'}, target: {node: 'n3', port: 'in'}}
728
+ ],
729
+ notes: []
730
+ });
731
+ });
732
+ </script>
377
733
  ```