@elabs-ai/components-flow 4.0.0 → 4.2.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 (68) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +738 -20
  3. package/dist/index.js +985 -182
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -7
  6. package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
  7. package/src/__contract__/legend.contract.test.tsx +49 -0
  8. package/src/canvas-shell/canvas-shell.tsx +116 -1
  9. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  10. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  11. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  12. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  13. package/src/flow-edge/flow-edge.tsx +10 -3
  14. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  15. package/src/flow-edge-path/index.ts +1 -0
  16. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  17. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  18. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  19. package/src/flow-group-node/flow-group-node.tsx +24 -8
  20. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  21. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  22. package/src/flow-handle/index.ts +1 -0
  23. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  24. package/src/flow-layout/flow-layout.test.tsx +91 -0
  25. package/src/flow-layout/flow-layout.ts +77 -1
  26. package/src/flow-layout/layout-graph.test.ts +83 -2
  27. package/src/flow-layout/layout-graph.ts +23 -15
  28. package/src/flow-mini-map/flow-mini-map.stories.tsx +103 -0
  29. package/src/flow-node/flow-node.stories.tsx +151 -0
  30. package/src/flow-node/flow-node.tsx +56 -1
  31. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  33. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  34. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  35. package/src/flow-self-loop-edge/index.ts +13 -0
  36. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  37. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  38. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  39. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  40. package/src/flow-smart-edge/index.ts +5 -1
  41. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  42. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  43. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  44. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  45. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  46. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  47. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  48. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  50. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  51. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  52. package/src/flow-weighted-edge/index.ts +18 -0
  53. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  54. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  55. package/src/index.ts +9 -0
  56. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  57. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  58. package/src/inspector-panel/inspector-panel.tsx +22 -9
  59. package/src/legend/index.ts +7 -1
  60. package/src/legend/legend.stories.tsx +126 -0
  61. package/src/legend/legend.test.tsx +180 -0
  62. package/src/legend/legend.tsx +222 -3
  63. package/src/templates-flow-workspace.stories.tsx +1 -1
  64. package/src/testing/canvas-framing.test.ts +107 -0
  65. package/src/testing/canvas-framing.ts +396 -0
  66. package/src/testing/edge-anchors.ts +107 -0
  67. package/src/testing/index.ts +36 -0
  68. package/src/zoom-controls/zoom-controls.tsx +1 -1
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Story-test helpers for the invariants a CANVAS owes its reader, as distinct
3
+ * from the per-edge invariant in `./edge-anchors`.
4
+ *
5
+ * All four are things a green typecheck cannot see and jsdom cannot measure —
6
+ * they are properties of the rendered, laid-out, zoomed picture:
7
+ *
8
+ * 1. **Everything is in frame.** A canvas that opens with half its graph below
9
+ * the fold has failed before the reader touches it. Measured on the process
10
+ * map: `fitView` fired once, before the layout hook had positions, and then
11
+ * never again — 5 of 11 activities opened off-screen.
12
+ * 2. **Connectors sit on the card.** React Flow lays a `<Handle>` out against
13
+ * the nearest positioned ancestor, which is NOT necessarily the painted card:
14
+ * a sibling row rendered beside the card pushes every dot off it by that
15
+ * row's own height (measured: 24 px). The wrapper still looks correct, so
16
+ * this must be measured against the card's own box.
17
+ * 3. **Labels do not cover nodes.** Edge label pills are portalled into
18
+ * `.react-flow__edgelabel-renderer`, so they contribute nothing to dagre's
19
+ * routing and nothing to React Flow's fitted bounds — a rank gap tuned for
20
+ * bare arrows is exactly one pill too narrow, and nothing in the layout
21
+ * math notices.
22
+ * 4. **The overview thumbnail is drawn.** `<MiniMap>` bails out per node in
23
+ * `nodeHasDimensions(internals.userNode)`, so a controlled canvas whose
24
+ * consumer never writes measurements back renders a blank white panel with
25
+ * a correct viewBox — no error, no warning, nothing to typecheck.
26
+ *
27
+ * Every helper returns report LINES rather than a boolean, so a failing
28
+ * assertion prints the offending distances: the numbers are what say whether an
29
+ * anchor drifted a pixel or half a node.
30
+ *
31
+ * Not part of the published surface — see `./index`.
32
+ */
33
+
34
+ /** The zoom factor React Flow has applied to the viewport, or `1` if it is not rendered. */
35
+ export function viewportZoom(canvasElement: HTMLElement): number {
36
+ const viewport = canvasElement.querySelector<HTMLElement>(".react-flow__viewport");
37
+ if (!viewport) return 1;
38
+ const { a } = new DOMMatrixReadOnly(getComputedStyle(viewport).transform);
39
+ return Number.isFinite(a) && a > 0 ? a : 1;
40
+ }
41
+
42
+ /**
43
+ * One line per node that is not COMPLETELY inside the canvas pane.
44
+ *
45
+ * `tolerance` is a sub-pixel allowance: `fitView`'s padding maths and the
46
+ * browser's own rounding routinely leave an edge a fraction of a pixel proud of
47
+ * the pane, which no reader can see.
48
+ */
49
+ export function nodesOutsidePane(canvasElement: HTMLElement, tolerance = 1): string[] {
50
+ const pane = canvasElement.querySelector<HTMLElement>(".react-flow__pane");
51
+ if (!pane) return ["no .react-flow__pane rendered"];
52
+ const bounds = pane.getBoundingClientRect();
53
+ const misses: string[] = [];
54
+ for (const node of canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")) {
55
+ const rect = node.getBoundingClientRect();
56
+ const overflow = Math.max(
57
+ bounds.left - rect.left,
58
+ bounds.top - rect.top,
59
+ rect.right - bounds.right,
60
+ rect.bottom - bounds.bottom,
61
+ );
62
+ if (overflow > tolerance) {
63
+ const id = node.getAttribute("data-id") ?? "?";
64
+ misses.push(`node "${id}" is ${overflow.toFixed(1)}px outside the pane`);
65
+ }
66
+ }
67
+ return misses;
68
+ }
69
+
70
+ /**
71
+ * One line per handle dot whose centre sits off the border of its own node's
72
+ * painted card, in FLOW px (screen distance divided by the zoom) so the number
73
+ * means the same at any zoom.
74
+ *
75
+ * `cardSelector` selects the painted card inside each `.react-flow__node`
76
+ * wrapper — `[data-slot="flow-node"]` for a canvas built on `FlowNode`. A node
77
+ * whose wrapper contains no such card is skipped rather than reported: a canvas
78
+ * may legitimately mix node types.
79
+ */
80
+ export function handlesOffCard(
81
+ canvasElement: HTMLElement,
82
+ cardSelector = '[data-slot="flow-node"]',
83
+ tolerance = 1,
84
+ ): string[] {
85
+ const zoom = viewportZoom(canvasElement);
86
+ const misses: string[] = [];
87
+ for (const node of canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")) {
88
+ const card = node.querySelector<HTMLElement>(cardSelector);
89
+ if (!card) continue;
90
+ const box = card.getBoundingClientRect();
91
+ for (const handle of node.querySelectorAll<HTMLElement>(".react-flow__handle")) {
92
+ const rect = handle.getBoundingClientRect();
93
+ const x = rect.left + rect.width / 2;
94
+ const y = rect.top + rect.height / 2;
95
+ // Distance from the point to the card RECTANGLE (0 when the point is inside it).
96
+ const dx = Math.max(box.left - x, 0, x - box.right);
97
+ const dy = Math.max(box.top - y, 0, y - box.bottom);
98
+ const gap = Math.hypot(dx, dy) / zoom;
99
+ if (gap > tolerance) {
100
+ const id = node.getAttribute("data-id") ?? "?";
101
+ const side = handle.getAttribute("data-handlepos") ?? "?";
102
+ misses.push(`handle ${side} on "${id}" is ${gap.toFixed(1)}px off its card`);
103
+ }
104
+ }
105
+ }
106
+ return misses;
107
+ }
108
+
109
+ /**
110
+ * One line per portalled edge label that overlaps a node card.
111
+ *
112
+ * Rectangle overlap, not centre distance: a pill that clips a card's corner by
113
+ * two pixels is still a number printed on top of a name, which is the thing a
114
+ * reader complains about.
115
+ */
116
+ export function labelsOverNodes(
117
+ canvasElement: HTMLElement,
118
+ cardSelector = '[data-slot="flow-node"]',
119
+ ): string[] {
120
+ const labels = [
121
+ ...canvasElement.querySelectorAll<HTMLElement>(".react-flow__edgelabel-renderer > *"),
122
+ ];
123
+ const cards = [...canvasElement.querySelectorAll<HTMLElement>(cardSelector)];
124
+ const hits: string[] = [];
125
+ for (const label of labels) {
126
+ const a = label.getBoundingClientRect();
127
+ if (a.width === 0 || a.height === 0) continue;
128
+ for (const card of cards) {
129
+ const b = card.getBoundingClientRect();
130
+ if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) {
131
+ const id = card.closest(".react-flow__node")?.getAttribute("data-id") ?? "?";
132
+ hits.push(`label "${(label.textContent ?? "").trim()}" covers node "${id}"`);
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ return hits;
138
+ }
139
+
140
+ /**
141
+ * The framing invariant for a canvas that is allowed to open ZOOMED IN.
142
+ *
143
+ * `nodesOutsidePane` alone says "everything fits", which is the right bar only while the
144
+ * whole graph is meant to fit. A canvas with a legibility floor on its opening fit gives
145
+ * that up on purpose: a process wider than the pane opens readable and partial rather
146
+ * than complete and unreadable. The bar that replaces it is a disjunction —
147
+ *
148
+ * 1. everything is in frame; **or**
149
+ * 2. the fit was CLAMPED by the floor (the zoom is sitting on it), and the beginning of
150
+ * the process — the first node along each overflowing axis — is fully in frame.
151
+ *
152
+ * Both halves are falsifiable, which is the point of writing it this way. Lose the fit
153
+ * and nodes go out of frame at a zoom BELOW the floor, failing (1) and (2). Lose the
154
+ * anchoring and the clamped fit centres again, so the first node drops off the top or the
155
+ * start edge, failing (2). "Partial" never becomes an excuse for "wrong".
156
+ *
157
+ * Pass `legibleZoom: 0` (the default) to get the plain everything-fits bar back.
158
+ */
159
+ export function framingMisses(
160
+ canvasElement: HTMLElement,
161
+ { legibleZoom = 0, tolerance = 1 }: { legibleZoom?: number; tolerance?: number } = {},
162
+ ): string[] {
163
+ const outside = nodesOutsidePane(canvasElement, tolerance);
164
+ if (outside.length === 0) return [];
165
+ if (legibleZoom <= 0) return outside;
166
+
167
+ const zoom = viewportZoom(canvasElement);
168
+ if (zoom < legibleZoom - 1e-3) {
169
+ return [
170
+ `${outside.length} node(s) out of frame at zoom ${zoom.toFixed(3)}, below the legible ` +
171
+ `floor of ${legibleZoom} — the fit was not clamped, it simply missed`,
172
+ ...outside,
173
+ ];
174
+ }
175
+
176
+ const pane = canvasElement.querySelector<HTMLElement>(".react-flow__pane");
177
+ if (!pane) return ["no .react-flow__pane rendered"];
178
+ const bounds = pane.getBoundingClientRect();
179
+ const nodes = [...canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")].map(
180
+ (element) => ({
181
+ id: element.getAttribute("data-id") ?? "?",
182
+ rect: element.getBoundingClientRect(),
183
+ }),
184
+ );
185
+ if (nodes.length === 0) return ["no nodes rendered"];
186
+
187
+ const spanX =
188
+ Math.max(...nodes.map((n) => n.rect.right)) - Math.min(...nodes.map((n) => n.rect.left));
189
+ const spanY =
190
+ Math.max(...nodes.map((n) => n.rect.bottom)) - Math.min(...nodes.map((n) => n.rect.top));
191
+ const inFrame = (r: DOMRect) =>
192
+ r.left >= bounds.left - tolerance &&
193
+ r.top >= bounds.top - tolerance &&
194
+ r.right <= bounds.right + tolerance &&
195
+ r.bottom <= bounds.bottom + tolerance;
196
+
197
+ const misses: string[] = [];
198
+ // Per axis, and only where the content actually outruns the pane: a tall graph in a
199
+ // wide pane is pinned to the top and stays horizontally centred, so demanding a
200
+ // left-edge anchor there would fail a canvas that is framed exactly right.
201
+ if (spanX > bounds.width + tolerance) {
202
+ const first = nodes.reduce((a, b) => (b.rect.left < a.rect.left ? b : a));
203
+ if (!inFrame(first.rect)) {
204
+ misses.push(
205
+ `the graph starts at node "${first.id}", which the clamped fit left out of frame`,
206
+ );
207
+ }
208
+ }
209
+ if (spanY > bounds.height + tolerance) {
210
+ const first = nodes.reduce((a, b) => (b.rect.top < a.rect.top ? b : a));
211
+ if (!inFrame(first.rect)) {
212
+ misses.push(
213
+ `the graph starts at node "${first.id}", which the clamped fit left out of frame`,
214
+ );
215
+ }
216
+ }
217
+ return misses;
218
+ }
219
+
220
+ /** How many node rectangles the overview thumbnail has actually drawn. */
221
+ export function miniMapNodeCount(canvasElement: HTMLElement): number {
222
+ const miniMap = canvasElement.querySelector(".react-flow__minimap");
223
+ if (!miniMap) return -1;
224
+ return miniMap.querySelectorAll(".react-flow__minimap-node").length;
225
+ }
226
+
227
+ /** One node's laid-out position, read from React Flow's own inline transform. */
228
+ export interface NodePlacement {
229
+ id: string;
230
+ x: number;
231
+ y: number;
232
+ }
233
+
234
+ /**
235
+ * Every node's laid-out position, parsed out of `style.transform`.
236
+ *
237
+ * The inline transform, not `getBoundingClientRect()`: React Flow writes
238
+ * `node.position` there un-animated, so it reads the final coordinate on the
239
+ * first commit that has it — whatever frame a CSS entry transition happens to
240
+ * be on, and whichever way the viewport has since been panned.
241
+ */
242
+ export function nodePlacements(canvasElement: HTMLElement): NodePlacement[] {
243
+ const out: NodePlacement[] = [];
244
+ for (const node of canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")) {
245
+ const match = /translate\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px\s*\)/.exec(node.style.transform);
246
+ if (!match) continue;
247
+ out.push({ id: node.getAttribute("data-id") ?? "?", x: Number(match[1]), y: Number(match[2]) });
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /**
253
+ * Node placements plus the viewport zoom, as one comparable string.
254
+ *
255
+ * Carries BOTH coordinate systems on purpose. `nodePlacements` reads the inline
256
+ * `transform` React Flow writes, which jumps to its final value the instant the layout
257
+ * runs — so a canvas that ANIMATES its nodes into place looks settled to it while the
258
+ * cards are still sliding, and every measurement taken then is of a picture the reader
259
+ * never sees. The rendered rect is what actually moves during the transition, so
260
+ * including it is what makes `waitForSettledCanvas` wait for the animation as well as
261
+ * for the layout. Rounded to 0.1 px: sub-pixel jitter is not motion.
262
+ */
263
+ export function canvasSignature(canvasElement: HTMLElement): string {
264
+ const placements = nodePlacements(canvasElement)
265
+ .map((p) => `${p.id}@${p.x},${p.y}`)
266
+ .join("|");
267
+ const rendered = [...canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")]
268
+ .map((node) => {
269
+ const { left, top, width, height } = node.getBoundingClientRect();
270
+ const at = (n: number) => n.toFixed(1);
271
+ return `${node.getAttribute("data-id") ?? "?"}@${at(left)},${at(top)},${at(width)}x${at(height)}`;
272
+ })
273
+ .join("|");
274
+ return `${viewportZoom(canvasElement).toFixed(4)} ${placements} ${rendered}`;
275
+ }
276
+
277
+ /**
278
+ * Every animation or CSS transition still RUNNING inside the canvas that is
279
+ * going to stop on its own.
280
+ *
281
+ * Infinite ones are skipped on purpose: a shimmer or a pulsing skeleton never
282
+ * reaches `finished`, and waiting for it would turn "settled" into "timed out".
283
+ * What this is for is the finite kind that MOVES the picture — above all the
284
+ * node-position transition a canvas puts on React Flow's own node elements
285
+ * (`PROCESS_MAP_NODE_MOTION_CLASS` is one), which is exactly the thing a
286
+ * measurement must not land in the middle of.
287
+ */
288
+ function unfinishedAnimations(canvasElement: HTMLElement): Animation[] {
289
+ return canvasElement.getAnimations({ subtree: true }).filter((animation) => {
290
+ if (animation.playState !== "running") return false;
291
+ const iterations = animation.effect?.getTiming().iterations ?? 1;
292
+ return Number.isFinite(iterations);
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Resolve after the browser has PRODUCED a frame, or `false` once `deadline` passes.
298
+ *
299
+ * A bare `setTimeout` is not a frame: timers fire several times per frame, and a
300
+ * loaded machine can go many timer ticks without the renderer advancing anything.
301
+ * The pair is deliberate — the timer paces the poll, the frame is what guarantees
302
+ * the picture had a chance to change between two reads.
303
+ */
304
+ async function nextFrame(interval: number, deadline: number): Promise<boolean> {
305
+ await new Promise((resolve) => setTimeout(resolve, interval));
306
+ return new Promise<boolean>((resolve) => {
307
+ const timer = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
308
+ requestAnimationFrame(() => {
309
+ clearTimeout(timer);
310
+ resolve(true);
311
+ });
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Resolve once the canvas has finished laying itself out AND finished moving, or throw.
317
+ *
318
+ * **Every framing assertion needs this, and none of them can detect its
319
+ * absence.** A canvas that has mounted but not yet run its layout has all its
320
+ * nodes stacked on the model's mount position — which is fully inside the pane,
321
+ * has every handle on its own card, and has every edge terminating on a dot,
322
+ * because there is nowhere else for any of it to be. So a play function that
323
+ * measures too early does not fail; it PASSES, on a picture the reader never
324
+ * sees. That is not hypothetical: it is how this helper came to exist.
325
+ *
326
+ * Three conditions, and the first is what excludes that trap:
327
+ *
328
+ * 1. **Distinct positions.** A layout pass gives every node its own coordinate;
329
+ * before it runs they share one, so a duplicate means it has not landed.
330
+ * 2. **Nothing is still animating.** A canvas that animates node deltas moves its
331
+ * node elements for the whole of `duration-base` AFTER the coordinates are
332
+ * final, while React Flow draws every edge at the FINAL coordinate straight
333
+ * away — so mid-flight, edges genuinely end in mid-air, by as much as 142 px
334
+ * (measured on the process map). Any finite animation still running is
335
+ * awaited, rather than inferred from a rect that happens to look still.
336
+ * 3. **Stable across two consecutive polls, one produced FRAME apart** —
337
+ * positions AND zoom, so a mid-relayout frame or an in-flight re-fit is not
338
+ * mistaken for the result.
339
+ *
340
+ * The frame in (3) is the load-bearing word, and the reason this helper was
341
+ * rewritten. A CSS transition advances on the frame clock: two reads taken inside
342
+ * ONE frame return the identical rect no matter how fast the picture is actually
343
+ * moving. Polling on a bare timer, a machine slow enough to render at 5 fps
344
+ * therefore gets two identical polls mid-animation and declares a moving canvas
345
+ * settled — which is how CI, and only CI, saw `endpointsOffHandles` report nine
346
+ * edges hanging off their dots on a canvas that is correct a fifth of a second
347
+ * later. Pacing the poll with `requestAnimationFrame` means a starved renderer
348
+ * makes this helper WAIT rather than lie; the assertions it guards are unchanged,
349
+ * and still fail if the picture settles wrong.
350
+ *
351
+ * A settled canvas satisfies all three on the second poll, so the cost is one
352
+ * `interval` plus one frame, not the timeout.
353
+ */
354
+ export async function waitForSettledCanvas(
355
+ canvasElement: HTMLElement,
356
+ { timeout = 15_000, interval = 100 }: { timeout?: number; interval?: number } = {},
357
+ ): Promise<void> {
358
+ const deadline = Date.now() + timeout;
359
+ let previous: string | null = null;
360
+ let reason = "no nodes rendered";
361
+ for (;;) {
362
+ const placements = nodePlacements(canvasElement);
363
+ const signature = canvasSignature(canvasElement);
364
+ const distinct = new Set(placements.map((p) => `${p.x},${p.y}`)).size;
365
+ const animating = unfinishedAnimations(canvasElement);
366
+ if (placements.length === 0) {
367
+ reason = "no nodes rendered";
368
+ } else if (distinct !== placements.length) {
369
+ reason = `${placements.length - distinct} node(s) share a position — layout has not run`;
370
+ } else if (animating.length > 0) {
371
+ reason = `${animating.length} animation(s) still running`;
372
+ } else if (signature !== previous) {
373
+ reason = "positions or zoom still changing";
374
+ } else {
375
+ return;
376
+ }
377
+ // A running animation is waited OUT, not polled around: `finished` resolves on the
378
+ // frame the movement actually ends, and rejects only when it is cancelled, which is
379
+ // itself a change worth re-reading after.
380
+ if (animating.length > 0) {
381
+ await Promise.race([
382
+ Promise.allSettled(animating.map((animation) => animation.finished)),
383
+ new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))),
384
+ ]);
385
+ }
386
+ previous = signature;
387
+ if (Date.now() >= deadline) {
388
+ throw new Error(`canvas did not settle within ${timeout}ms: ${reason}`);
389
+ }
390
+ if (!(await nextFrame(interval, deadline))) {
391
+ throw new Error(
392
+ `canvas did not settle within ${timeout}ms: the renderer produced no frame (${reason})`,
393
+ );
394
+ }
395
+ }
396
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Story-test helpers for the invariant every brand edge owes its canvas: an
3
+ * edge path must **terminate on a handle dot**, not on a bare stretch of node
4
+ * border.
5
+ *
6
+ * Why this lives in a real browser and not in a unit test: React Flow derives
7
+ * edge endpoints from `handleBounds`, which it fills in from measured DOM boxes.
8
+ * jsdom measures nothing, so `handleBounds` is empty there and any assertion
9
+ * about where an edge lands is vacuous. These helpers therefore read both sides
10
+ * in SCREEN coordinates — the handle via `getBoundingClientRect()`, the path via
11
+ * its own `getScreenCTM()` so the viewport's pan/zoom transform is accounted for
12
+ * — and compare them.
13
+ *
14
+ * Not exported from the package barrel: this is test scaffolding for
15
+ * `*.stories.tsx` play functions, not part of the public surface.
16
+ */
17
+
18
+ /** A point in screen (viewport) coordinates. */
19
+ export interface ScreenPoint {
20
+ x: number;
21
+ y: number;
22
+ }
23
+
24
+ /** A rendered handle dot: its screen-space centre and radius. */
25
+ export interface HandleDot extends ScreenPoint {
26
+ /** Half the dot's larger rendered dimension, in screen px. */
27
+ radius: number;
28
+ }
29
+
30
+ /** Every handle dot React Flow has painted inside `canvasElement`. */
31
+ export function handleDots(canvasElement: HTMLElement): HandleDot[] {
32
+ return Array.from(canvasElement.querySelectorAll<HTMLElement>(".react-flow__handle")).map(
33
+ (handle) => {
34
+ const rect = handle.getBoundingClientRect();
35
+ return {
36
+ x: rect.left + rect.width / 2,
37
+ y: rect.top + rect.height / 2,
38
+ radius: Math.max(rect.width, rect.height) / 2,
39
+ };
40
+ },
41
+ );
42
+ }
43
+
44
+ /** The screen-space start and end points of an edge path element. */
45
+ export function pathEndpoints(path: SVGPathElement): [ScreenPoint, ScreenPoint] {
46
+ const ctm = path.getScreenCTM();
47
+ const svg = path.ownerSVGElement;
48
+ if (!ctm || !svg) throw new Error("edge path is not rendered inside a positioned <svg>");
49
+ const at = (length: number): ScreenPoint => {
50
+ const local = path.getPointAtLength(length);
51
+ const point = svg.createSVGPoint();
52
+ point.x = local.x;
53
+ point.y = local.y;
54
+ const screen = point.matrixTransform(ctm);
55
+ return { x: screen.x, y: screen.y };
56
+ };
57
+ return [at(0), at(path.getTotalLength())];
58
+ }
59
+
60
+ /** Edge paths of one brand edge type, selected by the `data-slot` it carries. */
61
+ export function edgePaths(canvasElement: HTMLElement, slot: string): SVGPathElement[] {
62
+ return Array.from(canvasElement.querySelectorAll<SVGPathElement>(`path[data-slot="${slot}"]`));
63
+ }
64
+
65
+ /** How far an endpoint sits OUTSIDE the nearest dot, in screen px (0 = on or within it). */
66
+ function overshootOfNearestDot(point: ScreenPoint, dots: HandleDot[]): number {
67
+ let worst = Infinity;
68
+ for (const dot of dots) {
69
+ const gap = Math.hypot(point.x - dot.x, point.y - dot.y) - dot.radius;
70
+ if (gap < worst) worst = gap;
71
+ }
72
+ return worst;
73
+ }
74
+
75
+ /**
76
+ * One report line per endpoint that misses every handle dot, naming the edge
77
+ * and by how much. An empty array is the passing state, and a failing
78
+ * assertion prints the offending distances rather than just a boolean — the
79
+ * numbers are what tell you whether an anchor drifted a pixel or half a node.
80
+ *
81
+ * `tolerance` is a sub-pixel rounding allowance on top of the dot's own radius,
82
+ * because an endpoint landing anywhere ON the dot reads as connected: React
83
+ * Flow's native anchors sit on the dot's outer rim, `FlowSmartEdge`'s on its
84
+ * centre.
85
+ */
86
+ export function endpointsOffHandles(
87
+ canvasElement: HTMLElement,
88
+ slot: string,
89
+ tolerance = 1,
90
+ ): string[] {
91
+ const dots = handleDots(canvasElement);
92
+ if (!dots.length) return [`${slot}: no handle dots rendered`];
93
+ const misses: string[] = [];
94
+ for (const [index, path] of edgePaths(canvasElement, slot).entries()) {
95
+ const [start, end] = pathEndpoints(path);
96
+ for (const [label, point] of [
97
+ ["start", start],
98
+ ["end", end],
99
+ ] as const) {
100
+ const overshoot = overshootOfNearestDot(point, dots);
101
+ if (overshoot > tolerance) {
102
+ misses.push(`${slot}[${index}].${label} is ${overshoot.toFixed(1)}px off the nearest dot`);
103
+ }
104
+ }
105
+ }
106
+ return misses;
107
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Browser-only measurement helpers for `*.stories.tsx` play functions.
3
+ *
4
+ * Reachable across the workspace as `@elabs-ai/components-flow/test`, because
5
+ * the composing packages owe the SAME canvas invariants and re-deriving this
6
+ * DOM arithmetic per package is how two canvases end up disagreeing about what
7
+ * "connected" means.
8
+ *
9
+ * **Deliberately source-only.** The `./test` key exists in `package.json`'s
10
+ * `exports` but NOT in `publishConfig.exports` and NOT as a `tsup` entry, so it
11
+ * resolves for workspace consumers and does not exist in a published tarball:
12
+ * this is test scaffolding, not public API, and it must not turn up in a
13
+ * consumer's autocomplete. The `/test` suffix is also what keeps it out of
14
+ * `brand-ui.manifest.json` (`readSubpathBarrels` skips any subpath ending in
15
+ * `/test`), so no agent can mistake a measurement helper for a component.
16
+ */
17
+ export {
18
+ edgePaths,
19
+ endpointsOffHandles,
20
+ handleDots,
21
+ pathEndpoints,
22
+ type HandleDot,
23
+ type ScreenPoint,
24
+ } from "./edge-anchors";
25
+ export {
26
+ canvasSignature,
27
+ framingMisses,
28
+ handlesOffCard,
29
+ labelsOverNodes,
30
+ miniMapNodeCount,
31
+ nodePlacements,
32
+ nodesOutsidePane,
33
+ viewportZoom,
34
+ waitForSettledCanvas,
35
+ type NodePlacement,
36
+ } from "./canvas-framing";
@@ -20,7 +20,7 @@ function ControlButton({
20
20
  type="button"
21
21
  aria-label={label}
22
22
  onClick={onClick}
23
- className="flex size-8 items-center justify-center text-foreground transition-colors duration-fast hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4"
23
+ className="flex size-8 items-center justify-center text-foreground transition-colors duration-fast hover:bg-surface-muted focus-ring [&_svg]:size-4"
24
24
  >
25
25
  {children}
26
26
  </button>