@elabs-ai/components-flow 4.2.0 → 5.0.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 +236 -1
  217. package/dist/index.js +167 -2311
  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 +718 -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 +13 -0
  263. package/src/templates-flow-workspace.stories.tsx +7 -2
@@ -0,0 +1,399 @@
1
+ import type { Edge, Node, XYPosition } from "@xyflow/react";
2
+ import type { FlowElkBackbone } from "./backbone";
3
+ import {
4
+ HANDLE_BY_DIRECTION,
5
+ layoutFlow,
6
+ type FlowLayoutDirection,
7
+ type FlowLayoutResult,
8
+ } from "./flow-layout";
9
+
10
+ /**
11
+ * elkjs layout adapter — RM-067.
12
+ *
13
+ * dagre has no self-loop layout, no edge routing and weak compound nodes. ELK (Eclipse
14
+ * Layout Kernel, EPL-2.0) has all three, and it is large — so it is an OPTIONAL peer of this
15
+ * package, reached ONLY through `import()` inside {@link layoutFlowElk}'s body. A consumer
16
+ * that never calls it never downloads it, and a consumer that calls it without installing
17
+ * elkjs gets `layoutFlow`'s dagre answer plus a development-only console warning, never a
18
+ * crash.
19
+ *
20
+ * The default engine runs inline on the calling thread (elkjs's own in-thread worker
21
+ * stand-in). The package deliberately ships NO worker entry: a `new Worker(new URL(…,
22
+ * import.meta.url))` pointing at a file the tsup build does not emit breaks some consumer
23
+ * bundlers at build time. A host that wants the layout off the main thread supplies
24
+ * `loadEngine` with its own worker, e.g.
25
+ *
26
+ * ```ts
27
+ * loadEngine: async () => {
28
+ * const { default: ELK } = await import("elkjs/lib/elk-api.js");
29
+ * return new ELK({ workerUrl: new URL("elkjs/lib/elk-worker.min.js", import.meta.url).href });
30
+ * }
31
+ * ```
32
+ *
33
+ * No remote origin is involved either way.
34
+ */
35
+
36
+ /** Options for {@link layoutFlowElk}. */
37
+ export interface FlowLayoutElkOptions {
38
+ /** Layout direction. @default "TB" */
39
+ direction?: FlowLayoutDirection;
40
+ /** How ELK routes edges. @default "splines" */
41
+ edgeRouting?: "orthogonal" | "splines";
42
+ /** Route back-edges as ELK feedback edges (around the graph, not through it). @default true */
43
+ feedbackEdges?: boolean;
44
+ /** Gap between nodes in the same layer. @default 48 */
45
+ nodeSpacing?: number;
46
+ /** Gap between layers. @default 72 */
47
+ rankSpacing?: number;
48
+ /**
49
+ * Compound nodes. Each `id` names a node already in `nodes` (typically a `FlowGroupNode`);
50
+ * its `children` are laid out inside it and come back with `parentId` set, positioned
51
+ * relative to the group, and the group comes back sized to fit them.
52
+ */
53
+ groups?: { id: string; children: string[] }[];
54
+ /** A pinned straight path — see `pinBackbone`. */
55
+ backbone?: FlowElkBackbone;
56
+ /**
57
+ * Supply the engine yourself: a host with its own worker URL, or a test. Rejecting (for
58
+ * example because elkjs is not installed) falls back to dagre exactly like the default
59
+ * loader does.
60
+ */
61
+ loadEngine?: () => Promise<FlowElkEngine>;
62
+ }
63
+
64
+ /** A graph in ELK's JSON format — the minimal shape this adapter reads and writes. */
65
+ export interface FlowElkGraph {
66
+ id: string;
67
+ x?: number;
68
+ y?: number;
69
+ width?: number;
70
+ height?: number;
71
+ layoutOptions?: Record<string, string>;
72
+ children?: FlowElkGraph[];
73
+ edges?: {
74
+ id: string;
75
+ sources: string[];
76
+ targets: string[];
77
+ layoutOptions?: Record<string, string>;
78
+ }[];
79
+ }
80
+
81
+ /** A running ELK engine. `new ELK()` from elkjs satisfies it. */
82
+ export interface FlowElkEngine {
83
+ layout(graph: FlowElkGraph): Promise<FlowElkGraph>;
84
+ }
85
+
86
+ /** What {@link layoutFlowElk} answers — `layoutFlow`'s result plus which engine produced it. */
87
+ export interface FlowLayoutElkResult<
88
+ NodeType extends Node = Node,
89
+ EdgeType extends Edge = Edge,
90
+ > extends FlowLayoutResult<NodeType, EdgeType> {
91
+ /** `"elk"`, or `"dagre"` when elkjs could not be loaded or failed and dagre answered. */
92
+ engine: "elk" | "dagre";
93
+ }
94
+
95
+ const ELK_DIRECTION: Record<FlowLayoutDirection, string> = {
96
+ TB: "DOWN",
97
+ BT: "UP",
98
+ LR: "RIGHT",
99
+ RL: "LEFT",
100
+ };
101
+
102
+ /** Room above a group's children for its header, matching `groupNodes`' own offset. */
103
+ const GROUP_PADDING = "[top=60,left=16,bottom=16,right=16]";
104
+
105
+ /**
106
+ * Straightness priority for backbone edges, against the default `0` of every other edge —
107
+ * enough for network-simplex placement to favour them in every tie it breaks.
108
+ */
109
+ const BACKBONE_STRAIGHTNESS = "100";
110
+
111
+ const DEFAULT_NODE_WIDTH = 172;
112
+ const DEFAULT_NODE_HEIGHT = 40;
113
+
114
+ function nodeSize(node: Node): { width: number; height: number } {
115
+ return {
116
+ width: node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH,
117
+ height: node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT,
118
+ };
119
+ }
120
+
121
+ /** CommonJS interop: elkjs ships CJS, which bundlers and Node wrap in `default` differently. */
122
+ function interopDefault<T>(mod: unknown): T {
123
+ let value = mod as { default?: unknown };
124
+ while (value && typeof value === "object" && "default" in value && value.default) {
125
+ value = value.default as { default?: unknown };
126
+ }
127
+ return value as T;
128
+ }
129
+
130
+ type ElkConstructor = new (args: { workerFactory: (url?: string) => unknown }) => FlowElkEngine;
131
+
132
+ type FakeWorkerConstructor = new (url?: string) => unknown;
133
+
134
+ /** The default engine: elkjs run inline on the calling thread (see the module docblock). */
135
+ async function loadDefaultEngine(): Promise<FlowElkEngine> {
136
+ const ELK = interopDefault<ElkConstructor>(await import("elkjs/lib/elk-api.js"));
137
+ // `elk-worker.min.js` exports `{ default: FakeWorker, Worker: FakeWorker }` outside a worker.
138
+ // @ts-expect-error -- elkjs ships no declaration for the minified worker; the shape is narrowed below.
139
+ const mod = (await import("elkjs/lib/elk-worker.min.js")) as {
140
+ Worker?: FakeWorkerConstructor;
141
+ default?: FakeWorkerConstructor | { Worker?: FakeWorkerConstructor };
142
+ };
143
+ const FakeWorker =
144
+ mod.Worker ?? (typeof mod.default === "function" ? mod.default : mod.default?.Worker);
145
+ if (FakeWorker === undefined) throw new Error("elkjs inline worker not found");
146
+ return new ELK({ workerFactory: (url) => new FakeWorker(url) });
147
+ }
148
+
149
+ function warnFallback(error: unknown): void {
150
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
151
+ console.warn(
152
+ "[@elabs-ai/components-flow] layoutFlowElk could not run elkjs (is the optional peer " +
153
+ "`elkjs` installed?) — falling back to the dagre layout.",
154
+ error,
155
+ );
156
+ }
157
+
158
+ /** Build ELK's hierarchical graph, nesting grouped nodes under their group. */
159
+ function toElkGraph(nodes: Node[], edges: Edge[], options: FlowLayoutElkOptions): FlowElkGraph {
160
+ const {
161
+ direction = "TB",
162
+ edgeRouting = "splines",
163
+ feedbackEdges = true,
164
+ nodeSpacing = 48,
165
+ rankSpacing = 72,
166
+ groups = [],
167
+ backbone,
168
+ } = options;
169
+
170
+ const ids = new Set(nodes.map((n) => n.id));
171
+ const straight = new Set(backbone?.edgeIds ?? []);
172
+ const parentOf = new Map<string, string>();
173
+ const groupIds = new Set<string>();
174
+ for (const group of groups) {
175
+ if (!ids.has(group.id)) continue;
176
+ groupIds.add(group.id);
177
+ for (const child of group.children) {
178
+ if (ids.has(child) && child !== group.id) parentOf.set(child, group.id);
179
+ }
180
+ }
181
+
182
+ const elkNodes = new Map<string, FlowElkGraph>();
183
+ for (const node of nodes) {
184
+ const isGroup = groupIds.has(node.id);
185
+ elkNodes.set(node.id, {
186
+ id: node.id,
187
+ // A group is sized by ELK from its children; a leaf keeps its measured size.
188
+ ...(isGroup ? { layoutOptions: { "elk.padding": GROUP_PADDING } } : nodeSize(node)),
189
+ });
190
+ }
191
+
192
+ const root: FlowElkGraph = {
193
+ id: "__flow_elk_root__",
194
+ layoutOptions: {
195
+ "elk.algorithm": "layered",
196
+ "elk.direction": ELK_DIRECTION[direction],
197
+ "elk.edgeRouting": edgeRouting === "orthogonal" ? "ORTHOGONAL" : "SPLINES",
198
+ "elk.spacing.nodeNode": String(nodeSpacing),
199
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(rankSpacing),
200
+ "elk.layered.feedbackEdges": String(feedbackEdges),
201
+ // Break cycles by input order, as a reader would: the edge pointing back to an
202
+ // EARLIER node is the reversed one — the same edge dagre reports.
203
+ "elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
204
+ "elk.hierarchyHandling": groupIds.size > 0 ? "INCLUDE_CHILDREN" : "INHERIT",
205
+ ...(backbone && backbone.nodeIds.length > 0
206
+ ? { "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX" }
207
+ : {}),
208
+ },
209
+ children: [],
210
+ edges: edges
211
+ .filter((edge) => ids.has(edge.source) && ids.has(edge.target))
212
+ .map((edge) => ({
213
+ id: edge.id,
214
+ sources: [edge.source],
215
+ targets: [edge.target],
216
+ ...(straight.has(edge.id)
217
+ ? { layoutOptions: { "elk.layered.priority.straightness": BACKBONE_STRAIGHTNESS } }
218
+ : {}),
219
+ })),
220
+ };
221
+
222
+ for (const node of nodes) {
223
+ const elkNode = elkNodes.get(node.id)!;
224
+ const parent = resolveParent(node.id, parentOf);
225
+ const container = parent === undefined ? root : elkNodes.get(parent)!;
226
+ (container.children ??= []).push(elkNode);
227
+ }
228
+ return root;
229
+ }
230
+
231
+ /** A node's group, ignoring any parent chain that would loop back onto itself. */
232
+ function resolveParent(id: string, parentOf: Map<string, string>): string | undefined {
233
+ const parent = parentOf.get(id);
234
+ if (parent === undefined) return undefined;
235
+ const seen = new Set([id]);
236
+ let cursor: string | undefined = parent;
237
+ while (cursor !== undefined) {
238
+ if (seen.has(cursor)) return undefined;
239
+ seen.add(cursor);
240
+ cursor = parentOf.get(cursor);
241
+ }
242
+ return parent;
243
+ }
244
+
245
+ interface PlacedNode {
246
+ relative: XYPosition;
247
+ absolute: XYPosition;
248
+ width?: number;
249
+ height?: number;
250
+ parentId?: string;
251
+ }
252
+
253
+ function collectPlacements(
254
+ graph: FlowElkGraph,
255
+ origin: XYPosition,
256
+ parentId: string | undefined,
257
+ out: Map<string, PlacedNode>,
258
+ ): void {
259
+ for (const child of graph.children ?? []) {
260
+ const relative = { x: child.x ?? 0, y: child.y ?? 0 };
261
+ const absolute = { x: origin.x + relative.x, y: origin.y + relative.y };
262
+ out.set(child.id, {
263
+ relative,
264
+ absolute,
265
+ width: child.width,
266
+ height: child.height,
267
+ ...(parentId === undefined ? {} : { parentId }),
268
+ });
269
+ if (child.children && child.children.length > 0) {
270
+ collectPlacements(child, absolute, child.id, out);
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Put the backbone's nodes on one line by translating each of their LAYERS along the cross
277
+ * axis. ELK's straightness priority gets close but does not guarantee a line; shifting a
278
+ * whole layer does, and cannot overlap two nodes of that layer. Only top-level nodes are
279
+ * moved — a node inside a group is positioned relative to it.
280
+ */
281
+ function straightenBackbone(
282
+ placements: Map<string, PlacedNode>,
283
+ nodeIds: readonly string[],
284
+ direction: FlowLayoutDirection,
285
+ ): void {
286
+ const along = direction === "LR" || direction === "RL" ? "x" : "y";
287
+ const cross = along === "x" ? "y" : "x";
288
+ const extent = (p: PlacedNode, axis: "x" | "y") => (axis === "x" ? p.width : p.height) ?? 0;
289
+ const centerOf = (p: PlacedNode) => p.absolute[cross] + extent(p, cross) / 2;
290
+
291
+ const pinned = nodeIds
292
+ .map((id) => placements.get(id))
293
+ .filter((p): p is PlacedNode => p !== undefined && p.parentId === undefined);
294
+ const first = pinned[0];
295
+ if (first === undefined) return;
296
+ const line = centerOf(first);
297
+
298
+ const topLevel = [...placements.values()].filter((p) => p.parentId === undefined);
299
+ const moved = new Set<PlacedNode>();
300
+ for (const anchor of pinned) {
301
+ const delta = line - centerOf(anchor);
302
+ if (delta === 0) continue;
303
+ const start = anchor.absolute[along];
304
+ const end = start + extent(anchor, along);
305
+ for (const p of topLevel) {
306
+ if (moved.has(p)) continue;
307
+ const pStart = p.absolute[along];
308
+ if (pStart >= end || pStart + extent(p, along) <= start) continue;
309
+ p.absolute = { ...p.absolute, [cross]: p.absolute[cross] + delta };
310
+ p.relative = { ...p.relative, [cross]: p.relative[cross] + delta };
311
+ moved.add(p);
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Lay a graph out with ELK's layered algorithm — `layoutFlow`'s async sibling.
318
+ *
319
+ * Same result shape as `layoutFlow` (`nodes`, `edges`, `backEdges`, `selfLoops`), so a
320
+ * caller can swap engines without a different consumption path; `engine` says which one
321
+ * actually answered. Self-loops are laid out by ELK (it routes them around their node) and
322
+ * still reported in `selfLoops`; `backEdges` are the edges whose source sits at or after
323
+ * their target along the layout direction.
324
+ *
325
+ * elkjs is imported lazily inside this function. If it is not installed, or ELK throws,
326
+ * the result is `layoutFlow`'s dagre layout with `engine: "dagre"`.
327
+ */
328
+ export async function layoutFlowElk<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
329
+ nodes: NodeType[],
330
+ edges: EdgeType[],
331
+ options: FlowLayoutElkOptions = {},
332
+ ): Promise<FlowLayoutElkResult<NodeType, EdgeType>> {
333
+ const direction = options.direction ?? "TB";
334
+
335
+ let laidOut: FlowElkGraph;
336
+ try {
337
+ const engine = await (options.loadEngine ?? loadDefaultEngine)();
338
+ laidOut = await engine.layout(toElkGraph(nodes, edges, options));
339
+ } catch (error) {
340
+ warnFallback(error);
341
+ return {
342
+ ...layoutFlow(nodes, edges, {
343
+ direction,
344
+ ...(options.nodeSpacing === undefined ? {} : { nodeSpacing: options.nodeSpacing }),
345
+ ...(options.rankSpacing === undefined ? {} : { rankSpacing: options.rankSpacing }),
346
+ }),
347
+ engine: "dagre",
348
+ };
349
+ }
350
+
351
+ const placements = new Map<string, PlacedNode>();
352
+ collectPlacements(laidOut, { x: 0, y: 0 }, undefined, placements);
353
+ if (options.backbone) straightenBackbone(placements, options.backbone.nodeIds, direction);
354
+
355
+ const handles = HANDLE_BY_DIRECTION[direction];
356
+ const groupIds = new Set((options.groups ?? []).map((group) => group.id));
357
+ const layoutedNodes = nodes.map((node) => {
358
+ const placed = placements.get(node.id);
359
+ if (!placed) return node;
360
+ const next: NodeType = {
361
+ ...node,
362
+ sourcePosition: handles.source,
363
+ targetPosition: handles.target,
364
+ position: placed.relative,
365
+ };
366
+ if (placed.parentId !== undefined) {
367
+ next.parentId = placed.parentId;
368
+ next.extent = "parent";
369
+ }
370
+ if (groupIds.has(node.id) && placed.width !== undefined && placed.height !== undefined) {
371
+ next.width = placed.width;
372
+ next.height = placed.height;
373
+ }
374
+ return next;
375
+ });
376
+
377
+ const selfLoops: string[] = [];
378
+ const backEdges: string[] = [];
379
+ const axis = direction === "LR" || direction === "RL" ? "x" : "y";
380
+ const sign = direction === "BT" || direction === "RL" ? -1 : 1;
381
+ const center = (id: string): number | undefined => {
382
+ const placed = placements.get(id);
383
+ if (!placed) return undefined;
384
+ const size = axis === "x" ? (placed.width ?? 0) : (placed.height ?? 0);
385
+ return sign * (placed.absolute[axis] + size / 2);
386
+ };
387
+ for (const edge of edges) {
388
+ if (edge.source === edge.target) {
389
+ selfLoops.push(edge.id);
390
+ continue;
391
+ }
392
+ const source = center(edge.source);
393
+ const target = center(edge.target);
394
+ if (source === undefined || target === undefined) continue;
395
+ if (source >= target) backEdges.push(edge.id);
396
+ }
397
+
398
+ return { nodes: layoutedNodes, edges, backEdges, selfLoops, engine: "elk" };
399
+ }
@@ -145,13 +145,16 @@ export const Default: Story = {
145
145
  // (the light panel's white) while the node rects, mounted after the switch, are
146
146
  // already dark: a false 2.47:1 in the dark theme only. Awaiting the running
147
147
  // transitions on the panel and its rects reads what the browser actually paints.
148
- await Promise.all(
149
- minimap.getAnimations({ subtree: true }).map((animation) =>
150
- // A transition superseded by another change rejects `finished`; the settled
151
- // value is still what the readback below measures.
152
- animation.finished.catch(() => undefined),
153
- ),
154
- );
148
+ // Transitions keep starting after the first batch settles: a child's inherited
149
+ // colour starts its own once the parent's has ended, and a superseded one rejects
150
+ // `finished` while its replacement is still running. So keep waiting
151
+ // until none is left; awaiting only the first batch still read the white panel
152
+ // once on CI.
153
+ for (;;) {
154
+ const running = minimap.getAnimations({ subtree: true });
155
+ if (running.length === 0) break;
156
+ await Promise.allSettled(running.map((animation) => animation.finished));
157
+ }
155
158
  const panelBackground = getComputedStyle(minimap).backgroundColor;
156
159
 
157
160
  // The whole point of this helper is that a blank minimap must FAIL the check — lock
@@ -204,6 +204,43 @@ export const FocusIndicator: Story = {
204
204
  return (hi! + 0.05) / (lo! + 0.05);
205
205
  };
206
206
 
207
+ // Since ADR 0027 Amendment 2 (commit 779c040b), the shared `focus-ring*`
208
+ // composite always paints its ring as three `box-shadow` layers: a
209
+ // `var(--background)`-coloured "gap" segment and an inset echo segment
210
+ // that both have zero offset/blur/spread by construction at this node's
211
+ // default geometry (they exist for OTHER call sites with a non-zero
212
+ // `--focus-ring-offset`), plus the one visible ring segment in between.
213
+ // A zero-geometry layer paints nothing, so comparing the raw `boxShadow`
214
+ // string byte-for-byte (its old shape, before Amendment 2, had no such
215
+ // padding layers) now reports a difference where nothing actually
216
+ // renders differently. Splitting into layers and keeping only the ones
217
+ // with a non-zero length lets the assertion below compare what is
218
+ // genuinely PAINTED — the resting selection ring plus the node's
219
+ // elevation shadow — while staying just as strict: drop the ring
220
+ // entirely, or change its colour/width, and the visible layers stop
221
+ // matching.
222
+ const splitShadowLayers = (shadowValue: string): string[] => {
223
+ const layers: string[] = [];
224
+ let depth = 0;
225
+ let current = "";
226
+ for (const char of shadowValue) {
227
+ if (char === "(") depth += 1;
228
+ if (char === ")") depth -= 1;
229
+ if (char === "," && depth === 0) {
230
+ layers.push(current.trim());
231
+ current = "";
232
+ } else {
233
+ current += char;
234
+ }
235
+ }
236
+ if (current.trim()) layers.push(current.trim());
237
+ return layers;
238
+ };
239
+ const isVisibleShadowLayer = (layer: string): boolean =>
240
+ [...layer.matchAll(/(-?[\d.]+)px/g)].some((match) => Number(match[1]) !== 0);
241
+ const visibleShadow = (shadowValue: string): string =>
242
+ splitShadowLayers(shadowValue).filter(isVisibleShadowLayer).join(", ");
243
+
207
244
  let plainWrapper!: HTMLElement;
208
245
  let plainDiv!: HTMLElement;
209
246
  let chosenWrapper!: HTMLElement;
@@ -283,7 +320,9 @@ export const FocusIndicator: Story = {
283
320
  await waitFor(() => {
284
321
  expect(getComputedStyle(chosenDiv).outlineStyle).toBe("solid");
285
322
  });
286
- await expect(getComputedStyle(chosenDiv).boxShadow).toBe(restingChosenShadow);
323
+ await expect(visibleShadow(getComputedStyle(chosenDiv).boxShadow)).toBe(
324
+ visibleShadow(restingChosenShadow),
325
+ );
287
326
 
288
327
  // Blur restores the resting state.
289
328
  await userEvent.tab();
@@ -194,3 +194,41 @@ describe("FlowSelfLoopEdge", () => {
194
194
  expect(pill.parentElement!.style.transform).toContain(`translate(${labelX}px, ${labelY}px)`);
195
195
  });
196
196
  });
197
+
198
+ // RM-065 prerequisite — `data.tokens` draws `FlowEdgeTokens` on the loop's own arc.
199
+ describe("FlowSelfLoopEdge data.tokens", () => {
200
+ it("renders exactly the same markup with an empty list as with the field absent", () => {
201
+ internalNodeBox.current = measuredNode();
202
+ const absent = render(<FlowSelfLoopEdge {...makeEdgeProps()} />);
203
+ const absentHtml = absent.container.innerHTML;
204
+ expect(absent.container.querySelector('[data-slot="flow-edge-tokens"]')).toBeNull();
205
+ absent.unmount();
206
+ const empty = render(<FlowSelfLoopEdge {...makeEdgeProps({ data: { tokens: [] } })} />);
207
+ expect(empty.container.innerHTML).toBe(absentHtml);
208
+ });
209
+
210
+ it("starts and ends its tokens on the arc's own endpoints, outside the named graphic", () => {
211
+ internalNodeBox.current = measuredNode();
212
+ const { container } = render(
213
+ <FlowSelfLoopEdge
214
+ {...makeEdgeProps({
215
+ data: {
216
+ tokens: [
217
+ { id: "start", progress: 0 },
218
+ { id: "end", progress: 1 },
219
+ ],
220
+ },
221
+ })}
222
+ />,
223
+ );
224
+ const [start, end] = Array.from(
225
+ container.querySelectorAll<SVGCircleElement>('[data-slot="flow-edge-tokens-token"]'),
226
+ );
227
+ const arc = fixtureArc().path;
228
+ const [sx, sy] = arc.slice(2).split(" ")[0]!.split(",").map(Number);
229
+ expect(start!.style.transform).toBe(`translate(${sx}px, ${sy}px)`);
230
+ const [ex, ey] = arc.trim().split(" ").at(-1)!.split(",").map(Number);
231
+ expect(end!.style.transform).toBe(`translate(${ex}px, ${ey}px)`);
232
+ expect(screen.getByRole("img").querySelector('[data-slot="flow-edge-tokens"]')).toBeNull();
233
+ });
234
+ });
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import { useEdges, useInternalNode, type Edge, type EdgeProps } from "@xyflow/react";
3
3
  import { FlowEdgePath } from "../flow-edge-path";
4
+ import { FlowEdgeTokens, type FlowEdgeToken } from "../flow-edge-tokens";
4
5
  import {
5
6
  computeEdgeWeightScale,
6
7
  DEFAULT_EDGE_WIDTH_RANGE,
@@ -37,6 +38,11 @@ export interface FlowSelfLoopEdgeData extends Record<string, unknown> {
37
38
  * own root button from outside this component, without a new semantic prop here.
38
39
  */
39
40
  labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
41
+ /**
42
+ * Markers travelling along the loop's computed arc (see `FlowEdgeTokens`). The parent
43
+ * drives each token's `progress`; omitted or empty renders exactly the edge without them.
44
+ */
45
+ tokens?: readonly FlowEdgeToken[];
40
46
  }
41
47
 
42
48
  export type BrandFlowSelfLoopEdge = Edge<FlowSelfLoopEdgeData, "self-loop">;
@@ -159,6 +165,7 @@ export function FlowSelfLoopEdge({
159
165
  style={{ fill: "none", ...style }}
160
166
  />
161
167
  </g>
168
+ {data?.tokens?.length ? <FlowEdgeTokens path={path} tokens={data.tokens} /> : null}
162
169
  <EdgeLabelPill
163
170
  label={data?.label}
164
171
  secondaryLabel={data?.secondaryLabel}
@@ -62,4 +62,24 @@ describe("EdgeLabelPill", () => {
62
62
  render(<EdgeLabelPill x={0} y={0} label="128×" data-selection="excluded" />);
63
63
  expect(screen.getByRole("button")).toHaveAttribute("data-selection", "excluded");
64
64
  });
65
+
66
+ // #354 — a caller-supplied `aria-label` (the seam #351 built) must win over the pill's
67
+ // own computed `[label, secondaryLabel].join(" · ")` string, since a composing package
68
+ // (e.g. `@elabs-ai/components-process`'s `ProcessTransitionEdge`) uses it to replace a
69
+ // bare printed number with a full, disambiguating sentence naming both endpoints.
70
+ it("lets a caller-supplied aria-label win over the computed label · secondaryLabel name", () => {
71
+ render(
72
+ <EdgeLabelPill
73
+ x={0}
74
+ y={0}
75
+ label="213"
76
+ aria-label="Transition from Check Credit to Approve Order, Transitions 213"
77
+ />,
78
+ );
79
+ expect(
80
+ screen.getByRole("button", {
81
+ name: "Transition from Check Credit to Approve Order, Transitions 213",
82
+ }),
83
+ ).toBeInTheDocument();
84
+ });
65
85
  });
@@ -406,6 +406,7 @@ export const TenEdgesMixed: Story = {
406
406
  * add" for the full trap. Do not "simplify" `nameOf` away.
407
407
  */
408
408
  export const NamingContractEdges: Story = {
409
+ name: "Edge names come from node titles, never ids",
409
410
  render: function NamingContractEdgesStory() {
410
411
  const nodes: BrandFlowNode[] = [
411
412
  { id: "n1", type: "brand", position: { x: 0, y: 0 }, data: { kind: "Step", title: "Start" } },
@@ -403,3 +403,43 @@ describe("edge focus contour contrast (#286)", () => {
403
403
  },
404
404
  );
405
405
  });
406
+
407
+ // RM-065 prerequisite — `data.tokens` draws `FlowEdgeTokens` on the edge's own path.
408
+ describe("FlowWeightedEdge data.tokens", () => {
409
+ it("renders exactly the same markup with an empty list as with the field absent", () => {
410
+ edgesBox.current = [{ id: "test-edge", data: { weight: 4, label: "4×" } }];
411
+ const absent = render(
412
+ <FlowWeightedEdge {...makeEdgeProps({ data: { weight: 4, label: "4×" } })} />,
413
+ );
414
+ const absentHtml = absent.container.innerHTML;
415
+ expect(absent.container.querySelector('[data-slot="flow-edge-tokens"]')).toBeNull();
416
+ absent.unmount();
417
+ const empty = render(
418
+ <FlowWeightedEdge {...makeEdgeProps({ data: { weight: 4, label: "4×", tokens: [] } })} />,
419
+ );
420
+ expect(empty.container.innerHTML).toBe(absentHtml);
421
+ });
422
+
423
+ it("draws one token per entry along the SAME path the edge draws", () => {
424
+ edgesBox.current = [{ id: "test-edge", data: {} }];
425
+ // The mocked getSmoothStepPath returns a straight `M0,0 L100,100`.
426
+ const { container } = render(
427
+ <FlowWeightedEdge
428
+ {...makeEdgeProps({
429
+ data: {
430
+ path: "smoothstep",
431
+ tokens: [
432
+ { id: "c1", progress: 0 },
433
+ { id: "c2", progress: 0.5 },
434
+ ],
435
+ },
436
+ })}
437
+ />,
438
+ );
439
+ const tokens = container.querySelectorAll<SVGCircleElement>(
440
+ '[data-slot="flow-edge-tokens-token"]',
441
+ );
442
+ expect(tokens).toHaveLength(2);
443
+ expect(tokens[1]!.style.transform).toBe("translate(50px, 50px)");
444
+ });
445
+ });
@@ -11,6 +11,7 @@ import {
11
11
  } from "@xyflow/react";
12
12
  import { resolveTokenColor } from "@elabs-ai/components-tokens";
13
13
  import { FlowEdgePath } from "../flow-edge-path";
14
+ import { FlowEdgeTokens, type FlowEdgeToken } from "../flow-edge-tokens";
14
15
  import { EdgeLabelPill, type EdgeLabelPillProps } from "./edge-label-pill";
15
16
  import { backEdgeDetour, type BackEdgeNodeRect } from "./back-edge-geometry";
16
17
  import {
@@ -63,6 +64,11 @@ export interface FlowWeightedEdgeData extends Record<string, unknown> {
63
64
  * own root button from outside this component, without a new semantic prop here.
64
65
  */
65
66
  labelProps?: Omit<EdgeLabelPillProps, "label" | "secondaryLabel" | "x" | "y" | "selected">;
67
+ /**
68
+ * Markers travelling along this edge's computed path (see `FlowEdgeTokens`). The parent
69
+ * drives each token's `progress`; omitted or empty renders exactly the edge without them.
70
+ */
71
+ tokens?: readonly FlowEdgeToken[];
66
72
  }
67
73
 
68
74
  export type BrandFlowWeightedEdge = Edge<FlowWeightedEdgeData, "weighted">;
@@ -295,6 +301,7 @@ export function FlowWeightedEdge({
295
301
  ) : (
296
302
  edge
297
303
  )}
304
+ {data?.tokens?.length ? <FlowEdgeTokens path={edgePath} tokens={data.tokens} /> : null}
298
305
  <EdgeLabelPill
299
306
  label={data?.label}
300
307
  secondaryLabel={data?.secondaryLabel}
package/src/index.ts CHANGED
@@ -44,3 +44,16 @@ export * from "./flow-weighted-edge";
44
44
 
45
45
  // FlowSelfLoopEdge — RM-044
46
46
  export * from "./flow-self-loop-edge";
47
+
48
+ // FlowEdgeTokens — RM-065 prerequisite
49
+ export * from "./flow-edge-tokens";
50
+
51
+ // elkjs adapter — RM-067
52
+ export {
53
+ layoutFlowElk,
54
+ type FlowElkEngine,
55
+ type FlowElkGraph,
56
+ type FlowLayoutElkOptions,
57
+ type FlowLayoutElkResult,
58
+ } from "./flow-layout/layout-flow-elk";
59
+ export { pinBackbone, type FlowElkBackbone } from "./flow-layout/backbone";