@elabs-ai/components-flow 4.2.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/README.md +2 -1
  2. package/dist/canvas-shell/canvas-shell.js +13 -0
  3. package/dist/canvas-shell/canvas-shell.js.map +1 -0
  4. package/dist/canvas-shell/clamped-fit-offset.js +8 -0
  5. package/dist/canvas-shell/clamped-fit-offset.js.map +1 -0
  6. package/dist/canvas-shell/index.js +14 -0
  7. package/dist/canvas-shell/index.js.map +1 -0
  8. package/dist/canvas-shell/use-measured-nodes.js +8 -0
  9. package/dist/canvas-shell/use-measured-nodes.js.map +1 -0
  10. package/dist/chunk-3BTU2VWZ.js +42 -0
  11. package/dist/chunk-3BTU2VWZ.js.map +1 -0
  12. package/dist/chunk-3LK6OOXE.js +2 -0
  13. package/dist/chunk-3LK6OOXE.js.map +1 -0
  14. package/dist/chunk-3M3FQAD4.js +105 -0
  15. package/dist/chunk-3M3FQAD4.js.map +1 -0
  16. package/dist/chunk-3Y5TCGC3.js +2 -0
  17. package/dist/chunk-3Y5TCGC3.js.map +1 -0
  18. package/dist/chunk-4VYHZM3S.js +108 -0
  19. package/dist/chunk-4VYHZM3S.js.map +1 -0
  20. package/dist/chunk-5TVVM4GR.js +45 -0
  21. package/dist/chunk-5TVVM4GR.js.map +1 -0
  22. package/dist/chunk-5XT73MQQ.js +2 -0
  23. package/dist/chunk-5XT73MQQ.js.map +1 -0
  24. package/dist/chunk-6LQD5Y5R.js +24 -0
  25. package/dist/chunk-6LQD5Y5R.js.map +1 -0
  26. package/dist/chunk-6SR26QY2.js +18 -0
  27. package/dist/chunk-6SR26QY2.js.map +1 -0
  28. package/dist/chunk-75JJK6BH.js +157 -0
  29. package/dist/chunk-75JJK6BH.js.map +1 -0
  30. package/dist/chunk-A66JBW4X.js +2 -0
  31. package/dist/chunk-A66JBW4X.js.map +1 -0
  32. package/dist/chunk-A7AFM3LI.js +72 -0
  33. package/dist/chunk-A7AFM3LI.js.map +1 -0
  34. package/dist/chunk-ADHLQF56.js +2 -0
  35. package/dist/chunk-ADHLQF56.js.map +1 -0
  36. package/dist/chunk-ATO4LXJF.js +75 -0
  37. package/dist/chunk-ATO4LXJF.js.map +1 -0
  38. package/dist/chunk-ATXVXTMZ.js +227 -0
  39. package/dist/chunk-ATXVXTMZ.js.map +1 -0
  40. package/dist/chunk-B6IUPZU7.js +2 -0
  41. package/dist/chunk-B6IUPZU7.js.map +1 -0
  42. package/dist/chunk-BJ7UMCD3.js +53 -0
  43. package/dist/chunk-BJ7UMCD3.js.map +1 -0
  44. package/dist/chunk-BWGYV4JZ.js +2 -0
  45. package/dist/chunk-BWGYV4JZ.js.map +1 -0
  46. package/dist/chunk-CWNO63LQ.js +109 -0
  47. package/dist/chunk-CWNO63LQ.js.map +1 -0
  48. package/dist/chunk-DHPUXKCZ.js +76 -0
  49. package/dist/chunk-DHPUXKCZ.js.map +1 -0
  50. package/dist/chunk-DNX7G3KA.js +185 -0
  51. package/dist/chunk-DNX7G3KA.js.map +1 -0
  52. package/dist/chunk-DNZRAJAQ.js +41 -0
  53. package/dist/chunk-DNZRAJAQ.js.map +1 -0
  54. package/dist/chunk-ELCLRVD4.js +2 -0
  55. package/dist/chunk-ELCLRVD4.js.map +1 -0
  56. package/dist/chunk-ELOADZSX.js +2 -0
  57. package/dist/chunk-ELOADZSX.js.map +1 -0
  58. package/dist/chunk-EUNV2MR5.js +21 -0
  59. package/dist/chunk-EUNV2MR5.js.map +1 -0
  60. package/dist/chunk-FAFQ5KLB.js +43 -0
  61. package/dist/chunk-FAFQ5KLB.js.map +1 -0
  62. package/dist/chunk-FIRO6Y3R.js +128 -0
  63. package/dist/chunk-FIRO6Y3R.js.map +1 -0
  64. package/dist/chunk-GE73WQUK.js +2 -0
  65. package/dist/chunk-GE73WQUK.js.map +1 -0
  66. package/dist/chunk-GIARAOU6.js +39 -0
  67. package/dist/chunk-GIARAOU6.js.map +1 -0
  68. package/dist/chunk-HTLTCCFA.js +73 -0
  69. package/dist/chunk-HTLTCCFA.js.map +1 -0
  70. package/dist/chunk-I53W3CFN.js +56 -0
  71. package/dist/chunk-I53W3CFN.js.map +1 -0
  72. package/dist/chunk-IJVFPUXO.js +2 -0
  73. package/dist/chunk-IJVFPUXO.js.map +1 -0
  74. package/dist/chunk-JUPMOISE.js +51 -0
  75. package/dist/chunk-JUPMOISE.js.map +1 -0
  76. package/dist/chunk-K5RPSVO6.js +167 -0
  77. package/dist/chunk-K5RPSVO6.js.map +1 -0
  78. package/dist/chunk-K6U27YJ6.js +78 -0
  79. package/dist/chunk-K6U27YJ6.js.map +1 -0
  80. package/dist/chunk-KDVMGMAK.js +2 -0
  81. package/dist/chunk-KDVMGMAK.js.map +1 -0
  82. package/dist/chunk-LQOZCOK3.js +74 -0
  83. package/dist/chunk-LQOZCOK3.js.map +1 -0
  84. package/dist/chunk-NAQWNX2A.js +102 -0
  85. package/dist/chunk-NAQWNX2A.js.map +1 -0
  86. package/dist/chunk-NVJVWGUS.js +152 -0
  87. package/dist/chunk-NVJVWGUS.js.map +1 -0
  88. package/dist/chunk-O2F3R3PV.js +2 -0
  89. package/dist/chunk-O2F3R3PV.js.map +1 -0
  90. package/dist/chunk-OMFQHMA6.js +2 -0
  91. package/dist/chunk-OMFQHMA6.js.map +1 -0
  92. package/dist/chunk-PE6JXVOX.js +2 -0
  93. package/dist/chunk-PE6JXVOX.js.map +1 -0
  94. package/dist/chunk-ROJEIIA4.js +52 -0
  95. package/dist/chunk-ROJEIIA4.js.map +1 -0
  96. package/dist/chunk-ROYAQKJA.js +9 -0
  97. package/dist/chunk-ROYAQKJA.js.map +1 -0
  98. package/dist/chunk-SHCZK4DO.js +45 -0
  99. package/dist/chunk-SHCZK4DO.js.map +1 -0
  100. package/dist/chunk-SXXJV6YJ.js +79 -0
  101. package/dist/chunk-SXXJV6YJ.js.map +1 -0
  102. package/dist/chunk-T7S7NJFD.js +287 -0
  103. package/dist/chunk-T7S7NJFD.js.map +1 -0
  104. package/dist/chunk-TEZ3OXAY.js +63 -0
  105. package/dist/chunk-TEZ3OXAY.js.map +1 -0
  106. package/dist/chunk-TGWPM2CG.js +199 -0
  107. package/dist/chunk-TGWPM2CG.js.map +1 -0
  108. package/dist/chunk-TSB6RE7L.js +2 -0
  109. package/dist/chunk-TSB6RE7L.js.map +1 -0
  110. package/dist/chunk-UPV2MT4O.js +2 -0
  111. package/dist/chunk-UPV2MT4O.js.map +1 -0
  112. package/dist/chunk-UZJZY4A5.js +2 -0
  113. package/dist/chunk-UZJZY4A5.js.map +1 -0
  114. package/dist/chunk-W3TQMUGX.js +2 -0
  115. package/dist/chunk-W3TQMUGX.js.map +1 -0
  116. package/dist/chunk-WN46RIOC.js +29 -0
  117. package/dist/chunk-WN46RIOC.js.map +1 -0
  118. package/dist/chunk-XZFE2YYH.js +70 -0
  119. package/dist/chunk-XZFE2YYH.js.map +1 -0
  120. package/dist/chunk-YG3KXTZW.js +2 -0
  121. package/dist/chunk-YG3KXTZW.js.map +1 -0
  122. package/dist/chunk-ZATVF42E.js +65 -0
  123. package/dist/chunk-ZATVF42E.js.map +1 -0
  124. package/dist/chunk-ZXZTLQIX.js +19 -0
  125. package/dist/chunk-ZXZTLQIX.js.map +1 -0
  126. package/dist/flow-button-edge/flow-button-edge.js +10 -0
  127. package/dist/flow-button-edge/flow-button-edge.js.map +1 -0
  128. package/dist/flow-button-edge/index.js +11 -0
  129. package/dist/flow-button-edge/index.js.map +1 -0
  130. package/dist/flow-edge/flow-edge.js +10 -0
  131. package/dist/flow-edge/flow-edge.js.map +1 -0
  132. package/dist/flow-edge/index.js +11 -0
  133. package/dist/flow-edge/index.js.map +1 -0
  134. package/dist/flow-edge-path/flow-edge-path.js +12 -0
  135. package/dist/flow-edge-path/flow-edge-path.js.map +1 -0
  136. package/dist/flow-edge-path/index.js +13 -0
  137. package/dist/flow-edge-path/index.js.map +1 -0
  138. package/dist/flow-edge-tokens/edge-path-sampler.js +10 -0
  139. package/dist/flow-edge-tokens/edge-path-sampler.js.map +1 -0
  140. package/dist/flow-edge-tokens/flow-edge-tokens.js +12 -0
  141. package/dist/flow-edge-tokens/flow-edge-tokens.js.map +1 -0
  142. package/dist/flow-edge-tokens/index.js +17 -0
  143. package/dist/flow-edge-tokens/index.js.map +1 -0
  144. package/dist/flow-floating-edge/floating-edge-geometry.js +8 -0
  145. package/dist/flow-floating-edge/floating-edge-geometry.js.map +1 -0
  146. package/dist/flow-floating-edge/flow-floating-edge.js +11 -0
  147. package/dist/flow-floating-edge/flow-floating-edge.js.map +1 -0
  148. package/dist/flow-floating-edge/index.js +15 -0
  149. package/dist/flow-floating-edge/index.js.map +1 -0
  150. package/dist/flow-group-node/flow-group-node.js +12 -0
  151. package/dist/flow-group-node/flow-group-node.js.map +1 -0
  152. package/dist/flow-group-node/index.js +13 -0
  153. package/dist/flow-group-node/index.js.map +1 -0
  154. package/dist/flow-handle/flow-handle-anchor.js +8 -0
  155. package/dist/flow-handle/flow-handle-anchor.js.map +1 -0
  156. package/dist/flow-handle/index.js +9 -0
  157. package/dist/flow-handle/index.js.map +1 -0
  158. package/dist/flow-layout/backbone.js +8 -0
  159. package/dist/flow-layout/backbone.js.map +1 -0
  160. package/dist/flow-layout/flow-layout.js +10 -0
  161. package/dist/flow-layout/flow-layout.js.map +1 -0
  162. package/dist/flow-layout/index.js +21 -0
  163. package/dist/flow-layout/index.js.map +1 -0
  164. package/dist/flow-layout/layout-flow-elk.js +9 -0
  165. package/dist/flow-layout/layout-flow-elk.js.map +1 -0
  166. package/dist/flow-layout/layout-graph.js +9 -0
  167. package/dist/flow-layout/layout-graph.js.map +1 -0
  168. package/dist/flow-layout/use-auto-layout.js +10 -0
  169. package/dist/flow-layout/use-auto-layout.js.map +1 -0
  170. package/dist/flow-layout/use-flow-layout.js +9 -0
  171. package/dist/flow-layout/use-flow-layout.js.map +1 -0
  172. package/dist/flow-mini-map/flow-mini-map.js +8 -0
  173. package/dist/flow-mini-map/flow-mini-map.js.map +1 -0
  174. package/dist/flow-mini-map/index.js +9 -0
  175. package/dist/flow-mini-map/index.js.map +1 -0
  176. package/dist/flow-node/flow-node.js +11 -0
  177. package/dist/flow-node/flow-node.js.map +1 -0
  178. package/dist/flow-node/index.js +12 -0
  179. package/dist/flow-node/index.js.map +1 -0
  180. package/dist/flow-placeholder-node/flow-placeholder-node.js +9 -0
  181. package/dist/flow-placeholder-node/flow-placeholder-node.js.map +1 -0
  182. package/dist/flow-placeholder-node/index.js +10 -0
  183. package/dist/flow-placeholder-node/index.js.map +1 -0
  184. package/dist/flow-self-loop-edge/flow-self-loop-edge.js +20 -0
  185. package/dist/flow-self-loop-edge/flow-self-loop-edge.js.map +1 -0
  186. package/dist/flow-self-loop-edge/index.js +28 -0
  187. package/dist/flow-self-loop-edge/index.js.map +1 -0
  188. package/dist/flow-self-loop-edge/self-loop-geometry.js +12 -0
  189. package/dist/flow-self-loop-edge/self-loop-geometry.js.map +1 -0
  190. package/dist/flow-smart-edge/flow-smart-edge.js +11 -0
  191. package/dist/flow-smart-edge/flow-smart-edge.js.map +1 -0
  192. package/dist/flow-smart-edge/index.js +25 -0
  193. package/dist/flow-smart-edge/index.js.map +1 -0
  194. package/dist/flow-smart-edge/smart-edge-geometry.js +18 -0
  195. package/dist/flow-smart-edge/smart-edge-geometry.js.map +1 -0
  196. package/dist/flow-weighted-edge/back-edge-geometry.js +8 -0
  197. package/dist/flow-weighted-edge/back-edge-geometry.js.map +1 -0
  198. package/dist/flow-weighted-edge/edge-aria.js +10 -0
  199. package/dist/flow-weighted-edge/edge-aria.js.map +1 -0
  200. package/dist/flow-weighted-edge/edge-label-pill.js +8 -0
  201. package/dist/flow-weighted-edge/edge-label-pill.js.map +1 -0
  202. package/dist/flow-weighted-edge/flow-weighted-edge.js +16 -0
  203. package/dist/flow-weighted-edge/flow-weighted-edge.js.map +1 -0
  204. package/dist/flow-weighted-edge/index.js +34 -0
  205. package/dist/flow-weighted-edge/index.js.map +1 -0
  206. package/dist/flow-weighted-edge/weight-scale.js +10 -0
  207. package/dist/flow-weighted-edge/weight-scale.js.map +1 -0
  208. package/dist/helper-lines/get-helper-lines.js +8 -0
  209. package/dist/helper-lines/get-helper-lines.js.map +1 -0
  210. package/dist/helper-lines/helper-lines.js +9 -0
  211. package/dist/helper-lines/helper-lines.js.map +1 -0
  212. package/dist/helper-lines/index.js +17 -0
  213. package/dist/helper-lines/index.js.map +1 -0
  214. package/dist/helper-lines/use-helper-lines.js +10 -0
  215. package/dist/helper-lines/use-helper-lines.js.map +1 -0
  216. package/dist/index.d.ts +237 -2
  217. package/dist/index.js +186 -2308
  218. package/dist/index.js.map +1 -1
  219. package/dist/inspector-panel/index.js +9 -0
  220. package/dist/inspector-panel/index.js.map +1 -0
  221. package/dist/inspector-panel/inspector-panel.js +8 -0
  222. package/dist/inspector-panel/inspector-panel.js.map +1 -0
  223. package/dist/legend/index.js +10 -0
  224. package/dist/legend/index.js.map +1 -0
  225. package/dist/legend/legend.js +9 -0
  226. package/dist/legend/legend.js.map +1 -0
  227. package/dist/use-flow-groups/group-operations.js +20 -0
  228. package/dist/use-flow-groups/group-operations.js.map +1 -0
  229. package/dist/use-flow-groups/index.js +25 -0
  230. package/dist/use-flow-groups/index.js.map +1 -0
  231. package/dist/use-flow-groups/use-flow-groups.js +9 -0
  232. package/dist/use-flow-groups/use-flow-groups.js.map +1 -0
  233. package/dist/zoom-controls/index.js +9 -0
  234. package/dist/zoom-controls/index.js.map +1 -0
  235. package/dist/zoom-controls/zoom-controls.js +8 -0
  236. package/dist/zoom-controls/zoom-controls.js.map +1 -0
  237. package/package.json +13 -6
  238. package/src/canvas-shell/canvas-shell.stories.tsx +1 -0
  239. package/src/canvas-shell/canvas-shell.tsx +51 -4
  240. package/src/canvas-shell/clamped-fit-offset.test.ts +25 -0
  241. package/src/canvas-shell/clamped-fit-offset.ts +30 -0
  242. package/src/custom-nodes/custom-nodes.stories.tsx +719 -0
  243. package/src/flow-edge-tokens/edge-path-sampler.test.ts +108 -0
  244. package/src/flow-edge-tokens/edge-path-sampler.ts +362 -0
  245. package/src/flow-edge-tokens/flow-edge-tokens.stories.tsx +312 -0
  246. package/src/flow-edge-tokens/flow-edge-tokens.test.tsx +115 -0
  247. package/src/flow-edge-tokens/flow-edge-tokens.tsx +94 -0
  248. package/src/flow-edge-tokens/index.ts +12 -0
  249. package/src/flow-layout/backbone.test.ts +88 -0
  250. package/src/flow-layout/backbone.ts +58 -0
  251. package/src/flow-layout/layout-flow-elk.stories.tsx +154 -0
  252. package/src/flow-layout/layout-flow-elk.test.ts +152 -0
  253. package/src/flow-layout/layout-flow-elk.ts +399 -0
  254. package/src/flow-mini-map/flow-mini-map.stories.tsx +10 -7
  255. package/src/flow-node/flow-node.stories.tsx +40 -1
  256. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +38 -0
  257. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +7 -0
  258. package/src/flow-weighted-edge/edge-label-pill.test.tsx +20 -0
  259. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +1 -0
  260. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +40 -0
  261. package/src/flow-weighted-edge/flow-weighted-edge.tsx +7 -0
  262. package/src/index.ts +31 -0
  263. package/src/templates-flow-workspace.stories.tsx +7 -2
@@ -0,0 +1,719 @@
1
+ /**
2
+ * Custom nodes — how to write your OWN node type on the branded canvas.
3
+ *
4
+ * Every node below is a plain React component registered in `nodeTypes`, built from the
5
+ * engine parts this package re-exports (`Handle`, `NodeToolbar`, `NodeResizer` — import them
6
+ * from `@elabs-ai/components-flow`, no direct engine dependency needed),
7
+ * `@elabs-ai/components-ui` primitives and the three conventions `FlowNode` itself follows.
8
+ * Copy the one closest to what you need.
9
+ *
10
+ * The three conventions (see `nodeCardClassName` / `handleClassName` below):
11
+ *
12
+ * 1. The painted card IS the node box. Handles live inside it, so their dots sit
13
+ * on its border. Extra rows go INSIDE the card, never beside it.
14
+ * 2. Every `<Handle>` carries `FLOW_HANDLE_ANCHOR_CLASS` — a connector dot is a
15
+ * measurement anchor and must never be mid-transition when React Flow reads it.
16
+ * 3. React Flow focuses ITS wrapper, not your div, so the focus indicator is the
17
+ * proxied flavour: `[[data-id]:focus-visible_&]:focus-ring-static`. `selected`
18
+ * paints a ring of its own; the two never merge.
19
+ *
20
+ * Controls inside a node (inputs, switches, buttons) carry React Flow's `nodrag`
21
+ * class so a click or a text selection does not start a node drag.
22
+ */
23
+ import type { Meta, StoryObj } from "@storybook/react-vite";
24
+ import "@xyflow/react/dist/style.css";
25
+ import { type ReactNode } from "react";
26
+ import {
27
+ Handle,
28
+ NodeResizer,
29
+ NodeToolbar,
30
+ Position,
31
+ useReactFlow,
32
+ type Edge,
33
+ type Node,
34
+ type NodeProps,
35
+ } from "../index";
36
+ import {
37
+ Braces,
38
+ Copy,
39
+ FileSpreadsheet,
40
+ Filter,
41
+ Mail,
42
+ Play,
43
+ StickyNote,
44
+ Trash2,
45
+ Webhook,
46
+ } from "lucide-react";
47
+ import {
48
+ Badge,
49
+ Button,
50
+ IconButton,
51
+ Input,
52
+ Label,
53
+ Meter,
54
+ StatusBadge,
55
+ Switch,
56
+ cn,
57
+ } from "@elabs-ai/components-ui";
58
+ import { CanvasShell } from "../canvas-shell";
59
+ import { FlowEdge } from "../flow-edge";
60
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle";
61
+ import { FlowNode, type BrandFlowNode } from "../flow-node";
62
+
63
+ /* -------------------------------------------------------------------------- */
64
+ /* The shared conventions, written once. */
65
+ /* -------------------------------------------------------------------------- */
66
+
67
+ /** Convention 1 + 3: the card is the node box, and it paints the proxied focus ring. */
68
+ const nodeCardClassName = (selected: boolean | undefined, className?: string) =>
69
+ cn(
70
+ "rounded-lg border border-border bg-flow-node text-flow-node-foreground shadow-sm",
71
+ "transition-[box-shadow,border-color] duration-fast ease-standard",
72
+ selected && "ring-2 ring-ring",
73
+ "[[data-id]:focus-visible_&]:focus-ring-static",
74
+ className,
75
+ );
76
+
77
+ /** Convention 2: the anchor class goes last on every handle. */
78
+ const handleClassName = `!size-2 !border-2 !border-flow-edge !bg-flow-node ${FLOW_HANDLE_ANCHOR_CLASS}`;
79
+
80
+ const edgeTypes = { brand: FlowEdge };
81
+ /** Fit at reading size: a two-node story should not be blown up to React Flow’s max zoom. */
82
+ const fitViewOptions = { maxZoom: 1, padding: 0.2 };
83
+
84
+ function Stage({ height = 320, children }: { height?: number; children: ReactNode }) {
85
+ return <div style={{ height }}>{children}</div>;
86
+ }
87
+
88
+ /* -------------------------------------------------------------------------- */
89
+ /* 1. Sectioned card — header, body, footer. */
90
+ /* -------------------------------------------------------------------------- */
91
+
92
+ type SectionedNode = Node<
93
+ { title: string; kind: string; icon: ReactNode; lines: [string, string][]; footer: string },
94
+ "sectioned"
95
+ >;
96
+
97
+ function SectionedCardNode({ data, selected }: NodeProps<SectionedNode>) {
98
+ return (
99
+ <div className={nodeCardClassName(selected, "w-60")} data-slot="sectioned-node">
100
+ <Handle className={handleClassName} position={Position.Left} type="target" />
101
+ <Handle className={handleClassName} position={Position.Right} type="source" />
102
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2">
103
+ <span aria-hidden="true" className="text-muted-foreground [&_svg]:size-4">
104
+ {data.icon}
105
+ </span>
106
+ <div className="min-w-0 flex-1">
107
+ <div className="text-eyebrow text-muted-foreground">{data.kind}</div>
108
+ <div className="truncate text-body font-medium">{data.title}</div>
109
+ </div>
110
+ </div>
111
+ <dl className="flex flex-col gap-1 px-3 py-2 text-caption">
112
+ {data.lines.map(([term, value]) => (
113
+ <div className="flex justify-between gap-3" key={term}>
114
+ <dt className="text-muted-foreground">{term}</dt>
115
+ <dd className="truncate font-medium tabular-nums">{value}</dd>
116
+ </div>
117
+ ))}
118
+ </dl>
119
+ <div className="border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
120
+ {data.footer}
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /* -------------------------------------------------------------------------- */
127
+ /* 2. Status node — an execution state with a second channel (glyph + word). */
128
+ /* -------------------------------------------------------------------------- */
129
+
130
+ type StepStatus = "pending" | "running" | "complete" | "failed";
131
+ type StatusNodeType = Node<{ title: string; detail: string; status: StepStatus }, "status">;
132
+
133
+ const statusBorder: Record<StepStatus, string> = {
134
+ pending: "border-border",
135
+ running: "border-info",
136
+ complete: "border-success",
137
+ failed: "border-destructive",
138
+ };
139
+
140
+ function StatusNode({ data, selected }: NodeProps<StatusNodeType>) {
141
+ return (
142
+ <div
143
+ className={nodeCardClassName(selected, cn("w-64 px-3 py-2", statusBorder[data.status]))}
144
+ data-slot="status-node"
145
+ data-status={data.status}
146
+ >
147
+ <Handle className={handleClassName} position={Position.Left} type="target" />
148
+ <Handle className={handleClassName} position={Position.Right} type="source" />
149
+ <div className="flex items-start justify-between gap-2">
150
+ <div className="min-w-0">
151
+ <div className="truncate text-body font-medium">{data.title}</div>
152
+ <div className="truncate text-caption text-muted-foreground">{data.detail}</div>
153
+ </div>
154
+ {/* StatusBadge pairs every tone with its own glyph, so the state survives greyscale. */}
155
+ <StatusBadge size="sm" status={data.status} />
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ /* -------------------------------------------------------------------------- */
162
+ /* 3. Labelled ports — one handle per row, each with a stable id. */
163
+ /* -------------------------------------------------------------------------- */
164
+
165
+ type PortsNodeType = Node<{ title: string; inputs: string[]; outputs: string[] }, "ports">;
166
+
167
+ function PortRow({ id, label, side }: { id: string; label: string; side: "in" | "out" }) {
168
+ return (
169
+ // `relative` makes THIS row the handle's positioning box, so the dot sits at
170
+ // the row's mid-line on the card's edge instead of the card's mid-line.
171
+ <li
172
+ className={cn("relative px-3 py-1 text-caption", side === "out" ? "text-end" : "text-start")}
173
+ >
174
+ <Handle
175
+ className={handleClassName}
176
+ id={id}
177
+ position={side === "in" ? Position.Left : Position.Right}
178
+ type={side === "in" ? "target" : "source"}
179
+ />
180
+ <span className="font-mono">{label}</span>
181
+ </li>
182
+ );
183
+ }
184
+
185
+ function PortsNode({ data, selected }: NodeProps<PortsNodeType>) {
186
+ return (
187
+ <div className={nodeCardClassName(selected, "w-56")} data-slot="ports-node">
188
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2">
189
+ <Braces aria-hidden="true" className="size-4 text-muted-foreground" />
190
+ <span className="truncate text-body font-medium">{data.title}</span>
191
+ </div>
192
+ <div className="grid grid-cols-2 py-1">
193
+ <ul aria-label="Inputs">
194
+ {data.inputs.map((port) => (
195
+ <PortRow id={`in:${port}`} key={port} label={port} side="in" />
196
+ ))}
197
+ </ul>
198
+ <ul aria-label="Outputs">
199
+ {data.outputs.map((port) => (
200
+ <PortRow id={`out:${port}`} key={port} label={port} side="out" />
201
+ ))}
202
+ </ul>
203
+ </div>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ /* -------------------------------------------------------------------------- */
209
+ /* 4. Node toolbar — actions that appear with the selection. */
210
+ /* -------------------------------------------------------------------------- */
211
+
212
+ type ToolbarNodeType = Node<{ title: string; subtitle: string }, "toolbar">;
213
+
214
+ function ToolbarNode({ id, data, selected }: NodeProps<ToolbarNodeType>) {
215
+ const { deleteElements, getNode, addNodes } = useReactFlow();
216
+ const duplicate = () => {
217
+ const self = getNode(id);
218
+ if (!self) return;
219
+ addNodes({
220
+ ...self,
221
+ id: `${id}-copy-${Date.now()}`,
222
+ position: { x: self.position.x + 32, y: self.position.y + 72 },
223
+ selected: false,
224
+ });
225
+ };
226
+ return (
227
+ <div className={nodeCardClassName(selected, "w-52 px-3 py-2")} data-slot="toolbar-node">
228
+ {/* Visible while the node is selected; React Flow portals it above the node. */}
229
+ <NodeToolbar
230
+ className="nodrag flex items-center gap-0.5 rounded-md bg-surface-elevated p-0.5 shadow-ring-sm"
231
+ position={Position.Top}
232
+ >
233
+ <IconButton icon={<Play aria-hidden="true" />} label="Run from here" size="icon-sm" />
234
+ <IconButton
235
+ icon={<Copy aria-hidden="true" />}
236
+ label="Duplicate"
237
+ onClick={duplicate}
238
+ size="icon-sm"
239
+ />
240
+ <IconButton
241
+ icon={<Trash2 aria-hidden="true" />}
242
+ label="Delete"
243
+ onClick={() => void deleteElements({ nodes: [{ id }] })}
244
+ size="icon-sm"
245
+ />
246
+ </NodeToolbar>
247
+ <Handle className={handleClassName} position={Position.Left} type="target" />
248
+ <Handle className={handleClassName} position={Position.Right} type="source" />
249
+ <div className="truncate text-body font-medium">{data.title}</div>
250
+ <div className="truncate text-caption text-muted-foreground">{data.subtitle}</div>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ /* -------------------------------------------------------------------------- */
256
+ /* 5. Editable fields — controls inside a node write back to its data. */
257
+ /* -------------------------------------------------------------------------- */
258
+
259
+ type FilterNodeType = Node<{ field: string; threshold: number; enabled: boolean }, "filter">;
260
+
261
+ function FilterNode({ id, data, selected }: NodeProps<FilterNodeType>) {
262
+ const { updateNodeData } = useReactFlow();
263
+ return (
264
+ <div className={nodeCardClassName(selected, "w-64")} data-slot="filter-node">
265
+ <Handle className={handleClassName} position={Position.Left} type="target" />
266
+ <Handle className={handleClassName} position={Position.Right} type="source" />
267
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2">
268
+ <Filter aria-hidden="true" className="size-4 text-muted-foreground" />
269
+ <span className="flex-1 truncate text-body font-medium">Filter rows</span>
270
+ <Switch
271
+ aria-label="Filter enabled"
272
+ checked={data.enabled}
273
+ className="nodrag"
274
+ onCheckedChange={(enabled) => updateNodeData(id, { enabled })}
275
+ />
276
+ </div>
277
+ {/* `nodrag` on the fields: typing and selecting text must not drag the node. */}
278
+ <div className="nodrag flex flex-col gap-2 px-3 py-2">
279
+ <div className="flex flex-col gap-1">
280
+ <Label className="text-meta text-muted-foreground" htmlFor={`${id}-field`}>
281
+ Column
282
+ </Label>
283
+ <Input
284
+ id={`${id}-field`}
285
+ onChange={(event) => updateNodeData(id, { field: event.target.value })}
286
+ value={data.field}
287
+ />
288
+ </div>
289
+ <div className="flex flex-col gap-1">
290
+ <Label className="text-meta text-muted-foreground" htmlFor={`${id}-threshold`}>
291
+ Keep rows above
292
+ </Label>
293
+ <Input
294
+ id={`${id}-threshold`}
295
+ inputMode="numeric"
296
+ onChange={(event) => updateNodeData(id, { threshold: Number(event.target.value) || 0 })}
297
+ type="number"
298
+ value={data.threshold}
299
+ />
300
+ </div>
301
+ </div>
302
+ <div className="border-t border-border px-3 py-1.5 font-mono text-meta text-muted-foreground">
303
+ {data.enabled ? `${data.field || "…"} > ${data.threshold}` : "passes every row"}
304
+ </div>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ /* -------------------------------------------------------------------------- */
310
+ /* 6. Annotation — a note that is not part of the graph. */
311
+ /* -------------------------------------------------------------------------- */
312
+
313
+ type NoteNodeType = Node<{ text: string }, "note">;
314
+
315
+ function NoteNode({ data, selected }: NodeProps<NoteNodeType>) {
316
+ return (
317
+ // No handles: a note cannot be wired. It still takes selection and focus.
318
+ <div
319
+ className={cn(
320
+ "flex w-56 gap-2 rounded-md border border-dashed border-border-strong bg-surface-muted px-3 py-2",
321
+ "text-caption text-muted-foreground",
322
+ selected && "ring-2 ring-ring",
323
+ "[[data-id]:focus-visible_&]:focus-ring-static",
324
+ )}
325
+ data-slot="note-node"
326
+ >
327
+ <StickyNote aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
328
+ <p>{data.text}</p>
329
+ </div>
330
+ );
331
+ }
332
+
333
+ /* -------------------------------------------------------------------------- */
334
+ /* 7. Resizable — the node takes whatever box the reader drags it to. */
335
+ /* -------------------------------------------------------------------------- */
336
+
337
+ type ResizableNodeType = Node<{ title: string; body: string }, "resizable">;
338
+
339
+ function ResizableNode({ data, selected }: NodeProps<ResizableNodeType>) {
340
+ return (
341
+ <div
342
+ className={nodeCardClassName(selected, "flex h-full w-full flex-col overflow-hidden")}
343
+ data-slot="resizable-node"
344
+ >
345
+ <NodeResizer
346
+ handleClassName="!size-2 !rounded-sm !border-ring !bg-flow-node"
347
+ isVisible={selected}
348
+ lineClassName="!border-ring"
349
+ minHeight={96}
350
+ minWidth={176}
351
+ />
352
+ <Handle className={handleClassName} position={Position.Left} type="target" />
353
+ <Handle className={handleClassName} position={Position.Right} type="source" />
354
+ <div className="border-b border-border px-3 py-2 text-body font-medium">{data.title}</div>
355
+ <p className="min-h-0 flex-1 overflow-auto px-3 py-2 text-caption text-muted-foreground">
356
+ {data.body}
357
+ </p>
358
+ </div>
359
+ );
360
+ }
361
+
362
+ /* -------------------------------------------------------------------------- */
363
+ /* Stories */
364
+ /* -------------------------------------------------------------------------- */
365
+
366
+ const meta = {
367
+ title: "Flow/Custom Nodes",
368
+ tags: ["autodocs"],
369
+ parameters: {
370
+ layout: "fullscreen",
371
+ docs: {
372
+ description: {
373
+ component:
374
+ "How to write your own node type on the branded canvas, with nothing but today’s exports. Each story is one self-contained node component registered in `nodeTypes`. They all follow the three conventions `FlowNode` follows: the painted card is the node box and the handles sit inside it; every `<Handle>` carries `FLOW_HANDLE_ANCHOR_CLASS`; and the keyboard focus indicator is the proxied `[[data-id]:focus-visible_&]:focus-ring-static`, separate from the `selected` ring. Controls inside a node carry React Flow’s `nodrag` class. When `FlowNode` plus its `footer` slot is enough, prefer it — see the last story.",
375
+ },
376
+ },
377
+ },
378
+ } satisfies Meta;
379
+ export default meta;
380
+ type Story = StoryObj<typeof meta>;
381
+
382
+ /** A card with a header, a definition list and a footer — the base shape most custom nodes start from. */
383
+ export const SectionedCard: Story = {
384
+ render: () => {
385
+ const nodes: SectionedNode[] = [
386
+ {
387
+ id: "inbox",
388
+ type: "sectioned",
389
+ position: { x: 0, y: 0 },
390
+ data: {
391
+ kind: "Trigger",
392
+ title: "Shared AP inbox",
393
+ icon: <Mail />,
394
+ lines: [
395
+ ["Mailbox", "ap@acme.example"],
396
+ ["Polls every", "5 min"],
397
+ ["Last 24 h", "212 messages"],
398
+ ],
399
+ footer: "Attachments: PDF, XML",
400
+ },
401
+ },
402
+ {
403
+ id: "sheet",
404
+ type: "sectioned",
405
+ position: { x: 340, y: 0 },
406
+ data: {
407
+ kind: "Output",
408
+ title: "Accruals workbook",
409
+ icon: <FileSpreadsheet />,
410
+ lines: [
411
+ ["Sheet", "2026-Q3"],
412
+ ["Mode", "Append rows"],
413
+ ["Owner", "Finance ops"],
414
+ ],
415
+ footer: "Writes 1 row per invoice",
416
+ },
417
+ },
418
+ ];
419
+ const edges: Edge[] = [{ id: "e", source: "inbox", target: "sheet", type: "brand" }];
420
+ return (
421
+ <Stage>
422
+ <CanvasShell
423
+ fitViewOptions={fitViewOptions}
424
+ edgeTypes={edgeTypes}
425
+ edges={edges}
426
+ nodeTypes={{ sectioned: SectionedCardNode }}
427
+ nodes={nodes}
428
+ />
429
+ </Stage>
430
+ );
431
+ },
432
+ };
433
+
434
+ /** One node per execution state. The border tints, and `StatusBadge` repeats the state as a glyph and a word. */
435
+ export const StatusStates: Story = {
436
+ render: () => {
437
+ const steps: [string, string, StepStatus][] = [
438
+ ["Fetch orders", "1,204 rows · 0.8 s", "complete"],
439
+ ["Match payments", "Row 640 of 1,204", "running"],
440
+ ["Post to ledger", "Waiting for match", "pending"],
441
+ ["Notify treasury", "SMTP timeout", "failed"],
442
+ ];
443
+ const nodes: StatusNodeType[] = steps.map(([title, detail, status], index) => ({
444
+ id: `s${index}`,
445
+ type: "status",
446
+ position: { x: index * 310, y: index % 2 === 0 ? 0 : 70 },
447
+ data: { title, detail, status },
448
+ }));
449
+ const edges: Edge[] = steps.slice(1).map((_, index) => ({
450
+ id: `e${index}`,
451
+ source: `s${index}`,
452
+ target: `s${index + 1}`,
453
+ type: "brand",
454
+ }));
455
+ return (
456
+ <Stage height={280}>
457
+ <CanvasShell
458
+ fitViewOptions={fitViewOptions}
459
+ edgeTypes={edgeTypes}
460
+ edges={edges}
461
+ nodeTypes={{ status: StatusNode }}
462
+ nodes={nodes}
463
+ />
464
+ </Stage>
465
+ );
466
+ },
467
+ };
468
+
469
+ /** A handle per row, each with its own id, so an edge names the exact port it uses (`sourceHandle` / `targetHandle`). */
470
+ export const LabelledPorts: Story = {
471
+ render: () => {
472
+ const nodes: PortsNodeType[] = [
473
+ {
474
+ id: "parse",
475
+ type: "ports",
476
+ position: { x: 0, y: 20 },
477
+ data: { title: "Parse invoice", inputs: ["document"], outputs: ["header", "lines", "tax"] },
478
+ },
479
+ {
480
+ id: "match",
481
+ type: "ports",
482
+ position: { x: 340, y: 0 },
483
+ data: {
484
+ title: "Three-way match",
485
+ inputs: ["header", "lines", "purchase_order"],
486
+ outputs: ["matched", "exceptions"],
487
+ },
488
+ },
489
+ ];
490
+ const edges: Edge[] = [
491
+ {
492
+ id: "e-header",
493
+ source: "parse",
494
+ sourceHandle: "out:header",
495
+ target: "match",
496
+ targetHandle: "in:header",
497
+ type: "brand",
498
+ },
499
+ {
500
+ id: "e-lines",
501
+ source: "parse",
502
+ sourceHandle: "out:lines",
503
+ target: "match",
504
+ targetHandle: "in:lines",
505
+ type: "brand",
506
+ },
507
+ ];
508
+ return (
509
+ <Stage>
510
+ <CanvasShell
511
+ fitViewOptions={fitViewOptions}
512
+ edgeTypes={edgeTypes}
513
+ edges={edges}
514
+ nodeTypes={{ ports: PortsNode }}
515
+ nodes={nodes}
516
+ />
517
+ </Stage>
518
+ );
519
+ },
520
+ };
521
+
522
+ /** Select a node (click, or Tab then Enter) and its actions appear above it. Duplicate and Delete are wired to the flow. */
523
+ export const WithToolbar: Story = {
524
+ render: () => {
525
+ const nodes: ToolbarNodeType[] = [
526
+ {
527
+ id: "hook",
528
+ type: "toolbar",
529
+ position: { x: 0, y: 60 },
530
+ selected: true,
531
+ data: { title: "Order webhook", subtitle: "POST /hooks/orders" },
532
+ },
533
+ {
534
+ id: "enrich",
535
+ type: "toolbar",
536
+ position: { x: 300, y: 60 },
537
+ data: { title: "Enrich customer", subtitle: "CRM lookup by email" },
538
+ },
539
+ ];
540
+ const edges: Edge[] = [{ id: "e", source: "hook", target: "enrich", type: "brand" }];
541
+ return (
542
+ <Stage>
543
+ <CanvasShell
544
+ fitViewOptions={fitViewOptions}
545
+ defaultEdges={edges}
546
+ defaultNodes={nodes}
547
+ edgeTypes={edgeTypes}
548
+ nodeTypes={{ toolbar: ToolbarNode }}
549
+ />
550
+ </Stage>
551
+ );
552
+ },
553
+ };
554
+
555
+ /** Inputs and a switch inside the node write to its `data` through `updateNodeData`; the footer reads it back. */
556
+ export const EditableFields: Story = {
557
+ render: () => {
558
+ const nodes: (FilterNodeType | BrandFlowNode)[] = [
559
+ {
560
+ id: "source",
561
+ type: "brand",
562
+ position: { x: 0, y: 70 },
563
+ data: { kind: "Source", title: "Open invoices", icon: <Webhook /> },
564
+ sourcePosition: Position.Right,
565
+ targetPosition: Position.Left,
566
+ },
567
+ {
568
+ id: "filter",
569
+ type: "filter",
570
+ position: { x: 280, y: 0 },
571
+ data: { field: "amount_eur", threshold: 5000, enabled: true },
572
+ },
573
+ ];
574
+ const edges: Edge[] = [{ id: "e", source: "source", target: "filter", type: "brand" }];
575
+ return (
576
+ <Stage height={340}>
577
+ <CanvasShell
578
+ fitViewOptions={fitViewOptions}
579
+ defaultEdges={edges}
580
+ defaultNodes={nodes}
581
+ edgeTypes={edgeTypes}
582
+ nodeTypes={{ brand: FlowNode, filter: FilterNode }}
583
+ />
584
+ </Stage>
585
+ );
586
+ },
587
+ };
588
+
589
+ /** A note has no handles, so it cannot be wired into the graph — it explains the graph. */
590
+ export const Annotation: Story = {
591
+ render: () => {
592
+ const nodes: (NoteNodeType | BrandFlowNode)[] = [
593
+ {
594
+ id: "approve",
595
+ type: "brand",
596
+ position: { x: 0, y: 0 },
597
+ data: { kind: "Approval", title: "Controller sign-off", tone: "warning" },
598
+ },
599
+ {
600
+ id: "note",
601
+ type: "note",
602
+ position: { x: 230, y: -6 },
603
+ data: {
604
+ text: "Required above €5,000 since the 2026 audit. Do not remove without Finance.",
605
+ },
606
+ },
607
+ ];
608
+ return (
609
+ <Stage height={240}>
610
+ <CanvasShell
611
+ fitViewOptions={fitViewOptions}
612
+ edges={[]}
613
+ nodeTypes={{ brand: FlowNode, note: NoteNode }}
614
+ nodes={nodes}
615
+ />
616
+ </Stage>
617
+ );
618
+ },
619
+ };
620
+
621
+ /** Select the node and drag a corner. The card fills the box React Flow gives it (`h-full w-full`). */
622
+ export const Resizable: Story = {
623
+ render: () => {
624
+ const nodes: ResizableNodeType[] = [
625
+ {
626
+ id: "prompt",
627
+ type: "resizable",
628
+ position: { x: 0, y: 0 },
629
+ width: 280,
630
+ height: 140,
631
+ selected: true,
632
+ data: {
633
+ title: "Reply guidelines",
634
+ body: "Answer in the customer’s language. Quote the order number. Never promise a delivery date the carrier has not confirmed.",
635
+ },
636
+ },
637
+ ];
638
+ return (
639
+ <Stage>
640
+ <CanvasShell
641
+ fitViewOptions={fitViewOptions}
642
+ defaultEdges={[]}
643
+ defaultNodes={nodes}
644
+ nodeTypes={{ resizable: ResizableNode }}
645
+ />
646
+ </Stage>
647
+ );
648
+ },
649
+ };
650
+
651
+ /**
652
+ * Before writing a node at all: `FlowNode` takes a `footer` — a meter, a chip row, a button —
653
+ * and keeps the handles, tones and focus ring for free.
654
+ */
655
+ export const FooterSlotFirst: Story = {
656
+ render: () => {
657
+ const nodes: BrandFlowNode[] = [
658
+ {
659
+ id: "queue",
660
+ type: "brand",
661
+ position: { x: 0, y: 0 },
662
+ data: {
663
+ kind: "Queue",
664
+ title: "Exceptions",
665
+ subtitle: "38 of 50 slots used",
666
+ tone: "warning",
667
+ footer: (
668
+ <Meter
669
+ aria-label="Exceptions queue: 38 of 50 slots used"
670
+ max={50}
671
+ size="xs"
672
+ value={38}
673
+ />
674
+ ),
675
+ },
676
+ },
677
+ {
678
+ id: "tags",
679
+ type: "brand",
680
+ position: { x: 260, y: 0 },
681
+ data: {
682
+ kind: "Classifier",
683
+ title: "Route by topic",
684
+ footer: (
685
+ <div className="flex flex-wrap gap-1">
686
+ <Badge variant="secondary">billing</Badge>
687
+ <Badge variant="secondary">delivery</Badge>
688
+ <Badge variant="secondary">returns</Badge>
689
+ </div>
690
+ ),
691
+ },
692
+ },
693
+ {
694
+ id: "manual",
695
+ type: "brand",
696
+ position: { x: 520, y: 0 },
697
+ data: {
698
+ kind: "Manual step",
699
+ title: "Review sample",
700
+ footer: (
701
+ <Button className="nodrag w-full" size="sm" variant="secondary">
702
+ Open 12 items
703
+ </Button>
704
+ ),
705
+ },
706
+ },
707
+ ];
708
+ return (
709
+ <Stage height={260}>
710
+ <CanvasShell
711
+ fitViewOptions={fitViewOptions}
712
+ edges={[]}
713
+ nodeTypes={{ brand: FlowNode }}
714
+ nodes={nodes}
715
+ />
716
+ </Stage>
717
+ );
718
+ },
719
+ };