@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
@@ -2,6 +2,20 @@ import dagre from "@dagrejs/dagre";
2
2
  import { Position } from "@xyflow/react";
3
3
  import type { Edge, Node } from "@xyflow/react";
4
4
 
5
+ /** The graphlib graph `dagre.graphlib.Graph` produces — dagre exports no standalone type for it. */
6
+ type DagreGraph = InstanceType<typeof dagre.graphlib.Graph>;
7
+
8
+ /**
9
+ * The fields dagre writes back onto a node label during `layout()`. `rank` is
10
+ * absent from dagre's own `.d.ts` (it is documented output, not declared
11
+ * output), so it is narrowed here rather than asserted at the call site.
12
+ */
13
+ interface DagreNodeLabel {
14
+ x: number;
15
+ y: number;
16
+ rank?: number;
17
+ }
18
+
5
19
  /** Direction dagre lays the graph out in — top-to-bottom, left-to-right, etc. */
6
20
  export type FlowLayoutDirection = "TB" | "LR" | "BT" | "RL";
7
21
 
@@ -35,6 +49,22 @@ export interface FlowLayoutOptions {
35
49
  export interface FlowLayoutResult<NodeType extends Node = Node, EdgeType extends Edge = Edge> {
36
50
  nodes: NodeType[];
37
51
  edges: EdgeType[];
52
+ /**
53
+ * Ids of edges that run **against** the layout direction — a rework / retry
54
+ * loop in a process graph. dagre breaks cycles by reversing such edges
55
+ * internally and never surfaces which ones it reversed, so this is derived
56
+ * from the ranks dagre stamps on the laid-out graph: an edge whose source
57
+ * ranks at or after its target went backwards. Render these with
58
+ * `FlowWeightedEdge`'s `variant="back"`.
59
+ */
60
+ backEdges: string[];
61
+ /**
62
+ * Ids of edges whose `source === target`. dagre does not lay out self-loops,
63
+ * so they are withheld from the graph entirely (never `setEdge`-ed) and are
64
+ * returned unchanged in `edges` — they take part in no rank computation and
65
+ * cannot distort the layout. Render these with `FlowSelfLoopEdge`.
66
+ */
67
+ selfLoops: string[];
38
68
  }
39
69
 
40
70
  /** Fallback size used when a node hasn't been measured yet (React Flow's own default node width). */
@@ -58,6 +88,11 @@ function nodeSize(node: Node): { width: number; height: number } {
58
88
  * `node.height`, then a sensible default. Node identity and `data` are left
59
89
  * untouched — only `position` changes.
60
90
  *
91
+ * Also reports the graph's two structural signals — `backEdges` (edges that run
92
+ * against the layout direction) and `selfLoops` (`source === target`). Both are
93
+ * additive fields on the result; a caller that only destructures
94
+ * `{ nodes, edges }` is unaffected.
95
+ *
61
96
  * Pair with `useFlowLayout` to apply the result to a live canvas.
62
97
  */
63
98
  export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
@@ -76,12 +111,22 @@ export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge =
76
111
  graph.setNode(node.id, { width, height });
77
112
  }
78
113
 
114
+ // Self-loops are withheld from dagre entirely: dagre does not lay them out,
115
+ // and feeding them in only perturbs the ranks of a graph they say nothing
116
+ // about. They are re-attached untouched in the returned `edges`.
117
+ const selfLoops: string[] = [];
79
118
  for (const edge of edges) {
119
+ if (edge.source === edge.target) {
120
+ selfLoops.push(edge.id);
121
+ continue;
122
+ }
80
123
  graph.setEdge(edge.source, edge.target);
81
124
  }
82
125
 
83
126
  dagre.layout(graph);
84
127
 
128
+ const backEdges = collectBackEdges(graph, edges);
129
+
85
130
  const handles = HANDLE_BY_DIRECTION[direction];
86
131
  const layoutedNodes = nodes.map((node) => {
87
132
  const dagreNode = graph.node(node.id);
@@ -100,5 +145,36 @@ export function layoutFlow<NodeType extends Node = Node, EdgeType extends Edge =
100
145
  };
101
146
  });
102
147
 
103
- return { nodes: layoutedNodes, edges };
148
+ return { nodes: layoutedNodes, edges, backEdges, selfLoops };
149
+ }
150
+
151
+ /**
152
+ * Which edges dagre had to run backwards, derived from the ranks it stamps on
153
+ * the laid-out graph (`graph.node(id).rank`).
154
+ *
155
+ * The obvious alternative — reading `graph.edge(e).reversed` after dagre's
156
+ * `acyclic.run` — is **not usable on the pinned `@dagrejs/dagre` 3.0.0**:
157
+ * cycle breaking happens on an internal copy of the graph, and the caller's
158
+ * graph carries no `reversed` flag once `dagre.layout()` returns (verified
159
+ * against the installed version — every edge label is bare `{ points }`).
160
+ * `rank` *is* on the public graph, and is direction-independent: it counts up
161
+ * along the flow for every `rankdir`, so `rank(source) >= rank(target)` means
162
+ * "this edge does not advance the process" in TB, BT, LR and RL alike.
163
+ *
164
+ * `>=` rather than `>` on purpose: a same-rank edge between two siblings is
165
+ * not forward progress either, and dagre would have had to reverse or flatten
166
+ * it. Self-loops never reach here — they are filtered out before layout.
167
+ * An edge naming a node that isn't in the graph has no ranks to compare and is
168
+ * left out rather than guessed at.
169
+ */
170
+ function collectBackEdges(graph: DagreGraph, edges: Edge[]): string[] {
171
+ const backEdges: string[] = [];
172
+ for (const edge of edges) {
173
+ if (edge.source === edge.target) continue;
174
+ const sourceRank = (graph.node(edge.source) as DagreNodeLabel | undefined)?.rank;
175
+ const targetRank = (graph.node(edge.target) as DagreNodeLabel | undefined)?.rank;
176
+ if (typeof sourceRank !== "number" || typeof targetRank !== "number") continue;
177
+ if (sourceRank >= targetRank) backEdges.push(edge.id);
178
+ }
179
+ return backEdges;
104
180
  }
@@ -36,7 +36,13 @@ function distance(p: { x: number; y: number }, q: { x: number; y: number } = { x
36
36
  describe("layoutGraph", () => {
37
37
  describe("empty / disconnected input", () => {
38
38
  it("returns [] for an empty graph, for every algorithm", () => {
39
- for (const algorithm of ["concentric", "force", "layered-lr", "grid"] as const) {
39
+ for (const algorithm of [
40
+ "concentric",
41
+ "force",
42
+ "layered-lr",
43
+ "layered-tb",
44
+ "grid",
45
+ ] as const) {
40
46
  expect(layoutGraph([], [], { algorithm })).toEqual([]);
41
47
  }
42
48
  });
@@ -45,7 +51,13 @@ describe("layoutGraph", () => {
45
51
  const nodes = ["a", "b", "c", "isolated1", "isolated2"].map(node);
46
52
  const edges = [edge("a", "b"), edge("b", "c")];
47
53
 
48
- for (const algorithm of ["concentric", "force", "layered-lr", "grid"] as const) {
54
+ for (const algorithm of [
55
+ "concentric",
56
+ "force",
57
+ "layered-lr",
58
+ "layered-tb",
59
+ "grid",
60
+ ] as const) {
49
61
  const laidOut = layoutGraph(nodes, edges, { algorithm, iterations: 20 });
50
62
  for (const n of laidOut) {
51
63
  expect(Number.isFinite(n.position.x)).toBe(true);
@@ -254,4 +266,73 @@ describe("layoutGraph", () => {
254
266
  expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
255
267
  });
256
268
  });
269
+ describe("layered-tb", () => {
270
+ it("delegates to layoutFlow direction=TB, ordering a chain top-to-bottom", () => {
271
+ const { nodes, edges } = chainGraph();
272
+ const laidOut = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
273
+ const [a, b, c] = laidOut as [Node, Node, Node];
274
+ expect(a.position.y).toBeLessThan(b.position.y);
275
+ expect(b.position.y).toBeLessThan(c.position.y);
276
+ });
277
+
278
+ it("stamps bottom-out / top-in handle sides (not just position)", () => {
279
+ const { nodes, edges } = chainGraph();
280
+ const [a] = layoutGraph(nodes, edges, { algorithm: "layered-tb" }) as [Node];
281
+ expect(a.sourcePosition).toBe("bottom");
282
+ expect(a.targetPosition).toBe("top");
283
+ });
284
+
285
+ it("maps spacing.y onto rank spacing (the axis TB ranks along)", () => {
286
+ const { nodes, edges } = chainGraph();
287
+ const tight = layoutGraph(nodes, edges, {
288
+ algorithm: "layered-tb",
289
+ spacing: { x: 10, y: 10 },
290
+ });
291
+ const wide = layoutGraph(nodes, edges, {
292
+ algorithm: "layered-tb",
293
+ spacing: { x: 10, y: 500 },
294
+ });
295
+ const tightGap = tight[1]!.position.y - tight[0]!.position.y;
296
+ const wideGap = wide[1]!.position.y - wide[0]!.position.y;
297
+ expect(wideGap).toBeGreaterThan(tightGap);
298
+ });
299
+
300
+ it("respects a custom nodeSize without permanently resizing the nodes", () => {
301
+ const nodes: Node[] = [
302
+ { id: "a", type: "brand", position: { x: 0, y: 0 }, data: {} },
303
+ { id: "b", type: "brand", position: { x: 0, y: 0 }, data: {} },
304
+ ];
305
+ const laidOut = layoutGraph(nodes, [edge("a", "b")], {
306
+ algorithm: "layered-tb",
307
+ nodeSize: () => ({ width: 200, height: 400 }),
308
+ });
309
+ expect(laidOut[1]!.position.y - laidOut[0]!.position.y).toBeGreaterThanOrEqual(400 + 72 - 1);
310
+ expect(laidOut[0]).not.toHaveProperty("measured");
311
+ });
312
+
313
+ it("is the same layout as layered-lr with the axes swapped", () => {
314
+ // A fork exercises BOTH axes: a rank progression and a within-rank stack.
315
+ // Square nodes + isotropic spacing make the two layouts exact mirrors, so
316
+ // the assertion is "x and y trade places", not "the numbers look similar".
317
+ const nodes = ["a", "b", "c"].map(node);
318
+ const edges = [edge("a", "b"), edge("a", "c")];
319
+ const options = {
320
+ spacing: { x: 150, y: 150 },
321
+ nodeSize: () => ({ width: 100, height: 100 }),
322
+ };
323
+ const lr = layoutGraph(nodes, edges, { ...options, algorithm: "layered-lr" });
324
+ const tb = layoutGraph(nodes, edges, { ...options, algorithm: "layered-tb" });
325
+
326
+ expect(tb.map((n) => n.position)).toEqual(
327
+ lr.map((n) => ({ x: n.position.y, y: n.position.x })),
328
+ );
329
+ });
330
+
331
+ it("is deterministic", () => {
332
+ const { nodes, edges } = chainGraph();
333
+ const first = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
334
+ const second = layoutGraph(nodes, edges, { algorithm: "layered-tb" });
335
+ expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position));
336
+ });
337
+ });
257
338
  });
@@ -8,12 +8,12 @@ import {
8
8
  type SimulationNodeDatum,
9
9
  } from "d3-force";
10
10
  import type { Edge, Node } from "@xyflow/react";
11
- import { HANDLE_BY_DIRECTION, layoutFlow } from "./flow-layout";
11
+ import { HANDLE_BY_DIRECTION, layoutFlow, type FlowLayoutDirection } from "./flow-layout";
12
12
 
13
13
  /** Which generic graph-geometry algorithm `layoutGraph` should run. */
14
- export type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "grid";
14
+ export type LayoutAlgorithm = "concentric" | "force" | "layered-lr" | "layered-tb" | "grid";
15
15
 
16
- /** Horizontal/vertical gap used by the `"grid"` and `"layered-lr"` algorithms. */
16
+ /** Horizontal/vertical gap used by the `"grid"` and `"layered-*"` algorithms. */
17
17
  export interface LayoutSpacing {
18
18
  x: number;
19
19
  y: number;
@@ -31,7 +31,7 @@ export interface LayoutOptions {
31
31
  ringRadius?: number;
32
32
  /** `"force"` only — number of synchronous simulation ticks to run. @default 300 */
33
33
  iterations?: number;
34
- /** `"grid"` / `"layered-lr"` — gap between nodes. @default {x:200,y:120} for grid. */
34
+ /** `"grid"` / `"layered-lr"` / `"layered-tb"` — gap between nodes. @default {x:200,y:120} for grid. */
35
35
  spacing?: LayoutSpacing;
36
36
  /** Resolve a node's size by id. Falls back to `measured`/`width`/`height`/a sensible default. */
37
37
  nodeSize?: (id: string) => { width: number; height: number };
@@ -66,7 +66,10 @@ function resolveNodeSize(
66
66
  * Node identity and `data` are left untouched — only `position` changes.
67
67
  * Pair with `useAutoLayout` for a memoized hook form.
68
68
  *
69
- * - `"layered-lr"` delegates to the dagre-powered `layoutFlow` (direction `"LR"`).
69
+ * - `"layered-lr"` / `"layered-tb"` delegate to the dagre-powered `layoutFlow`
70
+ * (direction `"LR"` / `"TB"`). `spacing.x` is always the horizontal gap and
71
+ * `spacing.y` the vertical one, so the two differ only in which axis carries
72
+ * the ranks.
70
73
  * - `"concentric"` places `centerId` (or the highest-degree node) at the
71
74
  * origin, with BFS shells at `ring × ringRadius`; disconnected nodes land in
72
75
  * one extra outer ring so positions are never `NaN`.
@@ -83,7 +86,9 @@ export function layoutGraph<NodeType extends Node = Node, EdgeType extends Edge
83
86
 
84
87
  switch (options.algorithm) {
85
88
  case "layered-lr":
86
- return layoutLayeredLr(nodes, edges, options);
89
+ return layoutLayered(nodes, edges, options, "LR");
90
+ case "layered-tb":
91
+ return layoutLayered(nodes, edges, options, "TB");
87
92
  case "concentric":
88
93
  return layoutConcentric(nodes, edges, options);
89
94
  case "force":
@@ -97,10 +102,11 @@ export function layoutGraph<NodeType extends Node = Node, EdgeType extends Edge
97
102
  }
98
103
  }
99
104
 
100
- function layoutLayeredLr<NodeType extends Node, EdgeType extends Edge>(
105
+ function layoutLayered<NodeType extends Node, EdgeType extends Edge>(
101
106
  nodes: NodeType[],
102
107
  edges: EdgeType[],
103
108
  options: LayoutOptions,
109
+ direction: FlowLayoutDirection,
104
110
  ): NodeType[] {
105
111
  const { spacing, nodeSize } = options;
106
112
 
@@ -114,17 +120,19 @@ function layoutLayeredLr<NodeType extends Node, EdgeType extends Edge>(
114
120
  })
115
121
  : nodes;
116
122
 
123
+ // Ranks progress along the layout axis and nodes stack across it, so the two
124
+ // directions map the SAME `spacing.x`/`spacing.y` onto dagre's rank/node sep
125
+ // with the axes swapped: LR ranks horizontally (x), TB ranks vertically (y).
126
+ const horizontalRanks = direction === "LR" || direction === "RL";
117
127
  const { nodes: laidOut } = layoutFlow(sizedNodes, edges, {
118
- direction: "LR",
119
- // For an LR flow, ranks progress horizontally (x) and nodes within a rank
120
- // stack vertically (y) map spacing accordingly onto dagre's rank/node sep.
121
- nodeSpacing: spacing?.y,
122
- rankSpacing: spacing?.x,
128
+ direction,
129
+ nodeSpacing: horizontalRanks ? spacing?.y : spacing?.x,
130
+ rankSpacing: horizontalRanks ? spacing?.x : spacing?.y,
123
131
  });
124
132
 
125
- // Carry the LR handle sides (right-out / left-in) that layoutFlow stamped on,
126
- // not just the position — otherwise the layout flips positions but leaves the
127
- // anchors on top/bottom.
133
+ // Carry the handle sides layoutFlow stamped on (LR right-out/left-in, TB
134
+ // bottom-out/top-in), not just the position — otherwise the layout moves the
135
+ // nodes but leaves the anchors on the wrong sides.
128
136
  return nodes.map((node, i) => ({
129
137
  ...node,
130
138
  sourcePosition: laidOut[i]!.sourcePosition,
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import "@xyflow/react/dist/style.css";
3
+ import { expect, waitFor } from "storybook/test";
3
4
  import { CanvasShell } from "../canvas-shell";
4
5
  import { FlowNode, FLOW_ALL_SIDE_HANDLES, type BrandFlowNode } from "../flow-node";
5
6
  import { FlowSmartEdge } from "../flow-smart-edge";
@@ -71,4 +72,106 @@ export const Default: Story = {
71
72
  </CanvasShell>
72
73
  </div>
73
74
  ),
75
+ /**
76
+ * The minimap must actually DRAW the nodes — this story rendered a blank white panel
77
+ * for as long as it has existed, and nothing caught it: React Flow's `<MiniMap>` reads
78
+ * each node's dimensions off the object the CONSUMER passed (`internals.userNode`), and
79
+ * bails out per node when they are absent. On a controlled canvas with no
80
+ * `onNodesChange` applying React Flow's own `dimensions` changes back, they always were.
81
+ * `CanvasShell` now merges the measurements in (`useMeasuredNodes`), so the count below
82
+ * is the honest proof — asserting the `<svg>` merely exists passes on the broken state.
83
+ *
84
+ * The count alone is not the whole issue (#363): its second hypothesis was a
85
+ * `nodeColor`/`nodeStrokeColor` token resolving to nothing, which paints the same
86
+ * rects INVISIBLY rather than omitting them — a count-only assertion would pass on
87
+ * that failure too. So this also resolves the rect's actual painted fill against the
88
+ * panel's actual painted background and demands real contrast, at the FILL-rung floor
89
+ * this repo already holds status marks to (`styling-and-tokens.md`). Same
90
+ * canvas-readback pattern `FlowNode`'s `FocusIndicator` / `FlowWeightedEdge`'s
91
+ * `KeyboardFocus` locks use to turn a CSS colour string (`oklch()` included) into a
92
+ * real measurement instead of an assumption.
93
+ */
94
+ play: async ({ canvasElement }) => {
95
+ let minimap!: HTMLElement;
96
+ await waitFor(() => {
97
+ const el = canvasElement.querySelector<HTMLElement>(".react-flow__minimap");
98
+ expect(el).toBeTruthy();
99
+ expect(el!.querySelectorAll(".react-flow__minimap-node")).toHaveLength(nodes.length);
100
+ // The viewport mask is derived from the transform, not from node geometry — it
101
+ // never broke, but the fix for this issue touches nothing about it either, so
102
+ // this pins it as unchanged rather than leaving it unasserted.
103
+ expect(el!.querySelector(".react-flow__minimap-mask")).toBeTruthy();
104
+ minimap = el!;
105
+ });
106
+
107
+ // `getImageData()` reports straight (non-premultiplied) RGBA — the alpha channel is
108
+ // real, but reading r/g/b alone discards it: a fully transparent fill (alpha 0) still
109
+ // returns SOME rgb triple (typically 0,0,0), which can measure as opaque black and pass
110
+ // contrast against a light panel while nothing is actually painted. Compositing the
111
+ // fill ON TOP OF the real panel background first — the same source-over the browser
112
+ // performs when it paints the rect — means a zero-alpha fill reads back AS the panel
113
+ // background, so it can never clear the threshold below (#409 review).
114
+ const toSrgbOverBackground = (colour: string, background: string): [number, number, number] => {
115
+ const surface = document.createElement("canvas");
116
+ surface.width = 1;
117
+ surface.height = 1;
118
+ const ctx = surface.getContext("2d")!;
119
+ ctx.fillStyle = background;
120
+ ctx.fillRect(0, 0, 1, 1);
121
+ ctx.fillStyle = colour;
122
+ ctx.fillRect(0, 0, 1, 1);
123
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
124
+ return [r! / 255, g! / 255, b! / 255];
125
+ };
126
+ const luminance = ([r, g, b]: [number, number, number]) => {
127
+ const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
128
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
129
+ };
130
+ const contrastOverBackground = (fill: string, background: string) => {
131
+ const [hi, lo] = [
132
+ luminance(toSrgbOverBackground(fill, background)),
133
+ luminance(toSrgbOverBackground(background, background)),
134
+ ].sort((x, y) => y - x);
135
+ return (hi! + 0.05) / (lo! + 0.05);
136
+ };
137
+
138
+ // Measure the SETTLED paint, not the frame the theme switched in. The preview's
139
+ // `ThemeBoundary` writes `data-theme` in an effect, after the canvas has already
140
+ // rendered once under the `:root` (light) fallback. The storybook Vitest project runs
141
+ // with `reducedMotion: "reduce"`, and the tokens' reduced-motion backstop clamps
142
+ // `transition-duration` to 0.01ms on EVERY element — whose `transition-property`
143
+ // defaults to `all` — so the light→dark swap becomes a one-frame colour transition.
144
+ // Read synchronously, `getComputedStyle` still returns the transition's START value
145
+ // (the light panel's white) while the node rects, mounted after the switch, are
146
+ // already dark: a false 2.47:1 in the dark theme only. Awaiting the running
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
+ );
155
+ const panelBackground = getComputedStyle(minimap).backgroundColor;
156
+
157
+ // The whole point of this helper is that a blank minimap must FAIL the check — lock
158
+ // that directly: a fully transparent fill composites to the panel background itself,
159
+ // so it can never satisfy the ≥3 contrast assertion below (would incorrectly read as
160
+ // 21:1 "black on white" if alpha were discarded, per the #409 review finding).
161
+ expect(
162
+ contrastOverBackground("rgba(0, 0, 0, 0)", panelBackground),
163
+ "a fully transparent fill must not be able to satisfy the contrast check",
164
+ ).toBeLessThan(3);
165
+
166
+ const rects = minimap.querySelectorAll<SVGRectElement>(".react-flow__minimap-node");
167
+ for (const rect of rects) {
168
+ expect(rect.width.baseVal.value, "minimap node rect has zero width").toBeGreaterThan(0);
169
+ expect(rect.height.baseVal.value, "minimap node rect has zero height").toBeGreaterThan(0);
170
+ const fill = getComputedStyle(rect).fill;
171
+ expect(
172
+ contrastOverBackground(fill, panelBackground),
173
+ `minimap node fill (${fill}) is not distinguishable from the panel background (${panelBackground})`,
174
+ ).toBeGreaterThanOrEqual(3);
175
+ }
176
+ },
74
177
  };
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import "@xyflow/react/dist/style.css";
3
3
  import { type Edge } from "@xyflow/react";
4
+ import { expect, userEvent, waitFor } from "storybook/test";
4
5
  import { CanvasShell } from "../canvas-shell";
5
6
  import { FlowNode, type BrandFlowNode } from "./flow-node";
6
7
 
@@ -141,3 +142,153 @@ export const Connected: Story = {
141
142
  );
142
143
  },
143
144
  };
145
+
146
+ /**
147
+ * Regression lock for #312 — `FlowNode` was keyboard-focusable and painted NO
148
+ * focus indicator at all. React Flow puts `tabIndex`/`:focus-visible` on its
149
+ * own wrapper (`.react-flow__node`), one level above the `<div>` this
150
+ * component renders, so the lock reaches the real tab stop by keyboard alone
151
+ * (no synthetic `.focus()`) and reads RESOLVED computed styles on the
152
+ * component's own div — not just a class string — because a previous defect
153
+ * on this exact surface measured 0 changed pixels out of 540,000 while the
154
+ * class list looked correct.
155
+ *
156
+ * Two nodes: a plain one (proves focus alone paints an indicator) and a
157
+ * pre-selected one (proves the NEW focus outline is a distinct, additional
158
+ * layer over the EXISTING `selected` ring — the two never collapse into one
159
+ * ring, and `selected` on its own never gains the outline).
160
+ */
161
+ export const FocusIndicator: Story = {
162
+ render: () => {
163
+ const nodes: BrandFlowNode[] = [
164
+ {
165
+ id: "plain",
166
+ type: "brand",
167
+ position: { x: 40, y: 40 },
168
+ data: { title: "Plain node" },
169
+ },
170
+ {
171
+ id: "chosen",
172
+ type: "brand",
173
+ position: { x: 320, y: 40 },
174
+ selected: true,
175
+ data: { kind: "Output", title: "Selected node", tone: "success" },
176
+ },
177
+ ];
178
+ return (
179
+ <div className="h-[220px]">
180
+ <CanvasShell nodes={nodes} edges={[]} nodeTypes={nodeTypes} />
181
+ </div>
182
+ );
183
+ },
184
+ play: async ({ canvasElement }) => {
185
+ // Resolve ANY CSS colour string down to sRGB so a contrast ratio is a
186
+ // measurement, not an assumption — same helper `FlowWeightedEdge`'s
187
+ // `KeyboardFocus` lock uses for the edge half of this same fix family.
188
+ const toSrgb = (colour: string): [number, number, number] => {
189
+ const surface = document.createElement("canvas");
190
+ surface.width = 1;
191
+ surface.height = 1;
192
+ const ctx = surface.getContext("2d")!;
193
+ ctx.fillStyle = colour;
194
+ ctx.fillRect(0, 0, 1, 1);
195
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
196
+ return [r! / 255, g! / 255, b! / 255];
197
+ };
198
+ const luminance = ([r, g, b]: [number, number, number]) => {
199
+ const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
200
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
201
+ };
202
+ const contrast = (a: string, b: string) => {
203
+ const [hi, lo] = [luminance(toSrgb(a)), luminance(toSrgb(b))].sort((x, y) => y - x);
204
+ return (hi! + 0.05) / (lo! + 0.05);
205
+ };
206
+
207
+ let plainWrapper!: HTMLElement;
208
+ let plainDiv!: HTMLElement;
209
+ let chosenWrapper!: HTMLElement;
210
+ let chosenDiv!: HTMLElement;
211
+ await waitFor(() => {
212
+ const pw = canvasElement.querySelector<HTMLElement>('[data-testid="rf__node-plain"]');
213
+ const cw = canvasElement.querySelector<HTMLElement>('[data-testid="rf__node-chosen"]');
214
+ expect(pw).not.toBe(null);
215
+ expect(cw).not.toBe(null);
216
+ plainWrapper = pw!;
217
+ chosenWrapper = cw!;
218
+ plainDiv = pw!.querySelector<HTMLElement>("[data-tone]")!;
219
+ chosenDiv = cw!.querySelector<HTMLElement>("[data-tone]")!;
220
+ expect(plainDiv).not.toBe(null);
221
+ expect(chosenDiv).not.toBe(null);
222
+ });
223
+
224
+ // Resting: NEITHER node paints the focus outline. The pre-selected node
225
+ // already shows its selection ring (driven by the `selected` prop, not a
226
+ // CSS state) — that ring is untouched by this fix and must stay exactly
227
+ // as it was.
228
+ const restingPlainShadow = getComputedStyle(plainDiv).boxShadow;
229
+ const restingChosenShadow = getComputedStyle(chosenDiv).boxShadow;
230
+ await expect(getComputedStyle(plainDiv).outlineStyle).toBe("none");
231
+ await expect(getComputedStyle(chosenDiv).outlineStyle).toBe("none");
232
+ await expect(restingChosenShadow).not.toBe("none");
233
+
234
+ // Reach the plain node by keyboard alone — real tab order, no synthetic .focus().
235
+ let guard = 0;
236
+ while (document.activeElement !== plainWrapper && guard < 40) {
237
+ await userEvent.tab();
238
+ guard += 1;
239
+ }
240
+ await expect(document.activeElement).toBe(plainWrapper);
241
+
242
+ // Focused, unselected: a RESOLVED computed value changed, not just a
243
+ // class string.
244
+ await waitFor(() => {
245
+ expect(getComputedStyle(plainDiv).outlineStyle).toBe("solid");
246
+ });
247
+ await expect(getComputedStyle(plainDiv).outlineWidth).toBe("1px");
248
+ await expect(getComputedStyle(plainDiv).boxShadow).not.toBe(restingPlainShadow);
249
+
250
+ // The compound indicator clears WCAG 1.4.11 (3:1) against the node's own
251
+ // ground — via AT LEAST ONE of its two layers. Per ADR 0027 Amendment 2
252
+ // the bar is `max(contrast(--ring, S), contrast(--ring-contour, S)) >= 3:1`:
253
+ // in `dark`, `--ring-contour` is a deliberate no-op aliased to
254
+ // `--background` because the ring layer alone already clears the bar, so
255
+ // checking the contour alone (rather than the max of both layers) would
256
+ // fail here even though the indicator is genuinely visible. This is a
257
+ // rendered-surface re-check of the guarantee `themes-contrast.test.ts`'s
258
+ // `INDICATOR_SURFACES` already locks at the token level for
259
+ // `--flow-node`/`--canvas` — not a new bar.
260
+ const nodeGround = getComputedStyle(plainDiv).backgroundColor;
261
+ const contourInk = getComputedStyle(plainDiv).outlineColor;
262
+ const ringInk = getComputedStyle(plainDiv).getPropertyValue("--ring").trim();
263
+ const contourRatio = contrast(contourInk, nodeGround);
264
+ const ringRatio = contrast(ringInk, nodeGround);
265
+ const bestRatio = Math.max(contourRatio, ringRatio);
266
+ await expect(
267
+ bestRatio,
268
+ `focus indicator vs node ground: contour ${contourInk} = ${contourRatio.toFixed(2)}:1, ring ${ringInk} = ${ringRatio.toFixed(2)}:1`,
269
+ ).toBeGreaterThanOrEqual(3);
270
+
271
+ // Continue tabbing to the pre-selected node.
272
+ guard = 0;
273
+ while (document.activeElement !== chosenWrapper && guard < 40) {
274
+ await userEvent.tab();
275
+ guard += 1;
276
+ }
277
+ await expect(document.activeElement).toBe(chosenWrapper);
278
+
279
+ // Selected AND focused: the outline is the ADDITIONAL, distinguishing
280
+ // layer — the shared ring layer resolves to the exact same box-shadow the
281
+ // selection alone already painted, so "selected" never silently gains a
282
+ // second, indistinguishable ring; only the new outline signals focus.
283
+ await waitFor(() => {
284
+ expect(getComputedStyle(chosenDiv).outlineStyle).toBe("solid");
285
+ });
286
+ await expect(getComputedStyle(chosenDiv).boxShadow).toBe(restingChosenShadow);
287
+
288
+ // Blur restores the resting state.
289
+ await userEvent.tab();
290
+ await waitFor(() => {
291
+ expect(getComputedStyle(chosenDiv).outlineStyle).toBe("none");
292
+ });
293
+ },
294
+ };