@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,149 @@
1
+ import type { CSSProperties, SVGProps } from "react";
2
+ import { BaseEdge } from "@xyflow/react";
3
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
4
+
5
+ /**
6
+ * Extra stroke width, in px, of the `--ring` band drawn around a focused edge.
7
+ * The band sits *outside* the edge's own stroke, so it is visible whatever the
8
+ * edge's resting width is.
9
+ */
10
+ export const FLOW_EDGE_FOCUS_RING_WIDTH = 3;
11
+
12
+ /**
13
+ * Extra stroke width, in px, of the neutral contour drawn outside the `--ring`
14
+ * band. This is the layer that carries the WCAG 1.4.11 bar: `--foreground`
15
+ * measures 12.50:1 against `--canvas` in `light` and 16.25:1 in `dark`, where
16
+ * `--ring` alone measures 1.30:1 in `light` (issue #286).
17
+ */
18
+ export const FLOW_EDGE_FOCUS_CONTOUR_WIDTH = 6;
19
+
20
+ export interface FlowEdgePathProps extends Omit<
21
+ SVGProps<SVGPathElement>,
22
+ "path" | "stroke" | "strokeWidth" | "style" | "ref"
23
+ > {
24
+ /** SVG path `d` for this edge, from `getBezierPath`/`getSmoothStepPath`/… */
25
+ path: string;
26
+ /** Resting stroke paint — a `var(--token)` reference or a resolved colour. */
27
+ stroke: string;
28
+ /** Resting stroke width in px. The focus layers are drawn wider than this. */
29
+ strokeWidth: number;
30
+ /** Dash pattern, applied to the edge AND to both focus layers so a dashed edge keeps its shape when focused. */
31
+ strokeDasharray?: string;
32
+ /** Stroke opacity for the edge itself. Never applied to the focus layers. */
33
+ strokeOpacity?: number;
34
+ /** React Flow marker url, e.g. from `EdgeProps.markerEnd`. */
35
+ markerEnd?: string;
36
+ /** React Flow marker url, e.g. from `EdgeProps.markerStart`. */
37
+ markerStart?: string;
38
+ /** Width of the invisible pointer-target path React Flow draws over the edge. */
39
+ interactionWidth?: number;
40
+ /** Merged onto the edge path, last — so a consumer's `style.stroke` still wins. */
41
+ style?: CSSProperties;
42
+ }
43
+
44
+ /**
45
+ * The edge path every brand edge type draws, with the keyboard focus indicator
46
+ * built in. **Use this instead of React Flow's `BaseEdge`** — a custom edge that
47
+ * reaches for `BaseEdge` directly ships with no focus indicator at all, which is
48
+ * the defect issue #286 records (and `no-raw-base-edge.test.ts` fails on).
49
+ *
50
+ * ## Why the indicator is drawn rather than restyled
51
+ *
52
+ * React Flow zeroes the native outline on a focused edge
53
+ * (`.react-flow__edge:focus-visible { outline: none }`) and substitutes a stroke
54
+ * recolour on `.react-flow__edge-path`. Every brand edge passes its stroke as an
55
+ * **inline style** on that exact path — `BaseEdge` spreads `style` onto it — and
56
+ * an inline declaration beats any stylesheet rule, so the substitute never
57
+ * painted. Focusing an edge changed nothing on screen: WCAG 2.4.7 failed on
58
+ * every edge of every canvas, in both themes.
59
+ *
60
+ * The obvious repair — move the stroke into a custom property so a stylesheet
61
+ * rule can reach it — does **not** work here, and that was measured rather than
62
+ * assumed: React Flow's own `.react-flow__edge-path { stroke: … }` ships
63
+ * **unlayered**, and unlayered CSS outranks anything in `@layer utilities`,
64
+ * where Tailwind puts every utility. A `stroke-[var(--flow-edge-stroke)]` class
65
+ * would therefore lose to React Flow's `#b1b1b7` default and repaint every edge
66
+ * in the library. `!important` and setting `--xy-edge-stroke-selected` were both
67
+ * rejected in the issue.
68
+ *
69
+ * So the indicator is drawn on two paths this component owns, under the edge and
70
+ * on the same geometry. Nothing can shadow them: not React Flow's stylesheet,
71
+ * not a consumer's inline `style.stroke`, not a future edge type's own painting.
72
+ * They are hidden (`opacity-0`) until the ancestor `g.react-flow__edge` matches
73
+ * `:focus-visible`, which is a plain descendant selector on classes only this
74
+ * component uses.
75
+ *
76
+ * ## Compound, because one colour is not enough
77
+ *
78
+ * The indicator is two layers, per the compound-indicator recipe in
79
+ * `.claude/rules/theming.md`: a neutral `--foreground` contour at
80
+ * `strokeWidth + 6`, and the `--ring` band at `strokeWidth + 3` over it. The
81
+ * contour is the layer that clears WCAG 1.4.11's 3:1 bar against `--canvas` in
82
+ * both reference themes (12.50:1 `light`, 16.25:1 `dark`); a bare `--ring`
83
+ * stroke would not (1.30:1 in `light`). It is opacity + stroke only — no
84
+ * shadow — so it survives `data-decoration="8|9|10"`, which goes shadowless.
85
+ */
86
+ export function FlowEdgePath({
87
+ path,
88
+ stroke,
89
+ strokeWidth,
90
+ strokeDasharray,
91
+ strokeOpacity,
92
+ markerEnd,
93
+ markerStart,
94
+ interactionWidth,
95
+ className,
96
+ style,
97
+ ...props
98
+ }: FlowEdgePathProps) {
99
+ // The focus layers repeat the edge's own dash pattern so a dashed edge (the
100
+ // `"back"` variant) does not gain a solid halo. `"none"` is explicit rather
101
+ // than omitted because React Flow's `.react-flow__edge.animated path` rule
102
+ // sets a dasharray on *every* path inside an animated edge, ours included.
103
+ const focusDash = strokeDasharray ?? "none";
104
+
105
+ return (
106
+ <>
107
+ {/* Outer neutral contour — the layer that carries the 3:1 bar. */}
108
+ <path
109
+ d={path}
110
+ fill="none"
111
+ aria-hidden="true"
112
+ data-slot="flow-edge-focus-contour"
113
+ className="pointer-events-none stroke-foreground opacity-0 [.react-flow\_\_edge:focus-visible_&]:opacity-100"
114
+ strokeWidth={strokeWidth + FLOW_EDGE_FOCUS_CONTOUR_WIDTH}
115
+ strokeDasharray={focusDash}
116
+ strokeLinecap="round"
117
+ />
118
+ {/* Inner --ring band, drawn over the contour and under the edge. */}
119
+ <path
120
+ d={path}
121
+ fill="none"
122
+ aria-hidden="true"
123
+ data-slot="flow-edge-focus-ring"
124
+ className="pointer-events-none stroke-ring opacity-0 [.react-flow\_\_edge:focus-visible_&]:opacity-100"
125
+ strokeWidth={strokeWidth + FLOW_EDGE_FOCUS_RING_WIDTH}
126
+ strokeDasharray={focusDash}
127
+ strokeLinecap="round"
128
+ />
129
+ <BaseEdge
130
+ {...props}
131
+ path={path}
132
+ markerEnd={markerEnd}
133
+ markerStart={markerStart}
134
+ interactionWidth={interactionWidth}
135
+ className={cn(
136
+ "transition-[stroke,stroke-width] duration-fast ease-standard motion-reduce:transition-none",
137
+ className,
138
+ )}
139
+ style={{
140
+ stroke,
141
+ strokeWidth,
142
+ ...(strokeDasharray ? { strokeDasharray } : null),
143
+ ...(strokeOpacity !== undefined ? { strokeOpacity } : null),
144
+ ...style,
145
+ }}
146
+ />
147
+ </>
148
+ );
149
+ }
@@ -0,0 +1 @@
1
+ export * from "./flow-edge-path";
@@ -0,0 +1,45 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ /**
6
+ * #286 tripwire. React Flow's `BaseEdge` ships with a focus indicator that any
7
+ * inline `stroke` silently disables — which is how every brand edge in this
8
+ * package came to have no keyboard focus indicator at all. `FlowEdgePath` is
9
+ * the repair, and this test is what stops the next edge type from reaching past
10
+ * it: a custom edge that imports `BaseEdge` directly is, by construction, an
11
+ * edge with no focus indicator.
12
+ *
13
+ * A component that genuinely needs the primitive can extend `FlowEdgePath`
14
+ * instead — that is the one place the import is allowed.
15
+ */
16
+ // The jsdom environment gives `import.meta.url` an http: scheme, so resolve
17
+ // from the Vitest root (this package) instead.
18
+ const SRC = join(process.cwd(), "src");
19
+ const ALLOWED = new Set(["flow-edge-path/flow-edge-path.tsx"]);
20
+
21
+ function walk(dir: string, prefix = ""): string[] {
22
+ return readdirSync(dir).flatMap((entry) => {
23
+ const abs = join(dir, entry);
24
+ const rel = prefix ? `${prefix}/${entry}` : entry;
25
+ if (statSync(abs).isDirectory()) return walk(abs, rel);
26
+ if (!/\.tsx?$/.test(entry) || /\.(test|stories)\.tsx?$/.test(entry)) return [];
27
+ return [rel];
28
+ });
29
+ }
30
+
31
+ describe("no edge type reaches past FlowEdgePath (#286)", () => {
32
+ it("imports BaseEdge from @xyflow/react in exactly one shipped module", () => {
33
+ const offenders = walk(SRC).filter((rel) => {
34
+ if (ALLOWED.has(rel)) return false;
35
+ const source = readFileSync(join(SRC, rel), "utf8");
36
+ // Only an IMPORT counts — prose mentioning the name in a doc comment does not.
37
+ return /import\s+[^;]*\bBaseEdge\b[^;]*from\s+["']@xyflow\/react["']/s.test(source);
38
+ });
39
+ expect(
40
+ offenders,
41
+ `These modules import React Flow's BaseEdge directly, so their edges have no keyboard ` +
42
+ `focus indicator (#286). Draw through FlowEdgePath instead: ${offenders.join(", ")}`,
43
+ ).toEqual([]);
44
+ });
45
+ });
@@ -1,4 +1,5 @@
1
- import { BaseEdge, getBezierPath, useInternalNode, type EdgeProps } from "@xyflow/react";
1
+ import { getBezierPath, useInternalNode, type EdgeProps } from "@xyflow/react";
2
+ import { FlowEdgePath } from "../flow-edge-path";
2
3
  import { getEdgeParams } from "./floating-edge-geometry";
3
4
 
4
5
  /** Optional per-edge `data` for {@link FlowFloatingEdge}. */
@@ -45,11 +46,14 @@ export function FlowFloatingEdge({ id, source, target, markerEnd, style, data }:
45
46
 
46
47
  return (
47
48
  <>
48
- <BaseEdge
49
+ <FlowEdgePath
49
50
  id={id}
50
51
  path={edgePath}
51
52
  markerEnd={markerEnd}
52
- style={{ stroke: "var(--flow-edge)", strokeWidth: 1.5, ...style }}
53
+ data-slot="flow-floating-edge"
54
+ stroke="var(--flow-edge)"
55
+ strokeWidth={1.5}
56
+ style={style}
53
57
  />
54
58
  {showAnchors ? (
55
59
  <g
@@ -11,7 +11,7 @@ import { FlowGroupNode } from "./flow-group-node";
11
11
  const nodeTypes = { brand: FlowNode, group: FlowGroupNode };
12
12
 
13
13
  const toolbarButton =
14
- "rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
14
+ "rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-ring disabled:opacity-50";
15
15
 
16
16
  const meta = {
17
17
  title: "Flow/FlowGroupNode",
@@ -3,6 +3,7 @@ import { Handle, NodeResizer, Position, useNodes, type Node, type NodeProps } fr
3
3
  import { ChevronDown, ChevronRight } from "lucide-react";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import { useFlowGroups } from "../use-flow-groups";
6
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "../flow-handle/flow-handle-anchor";
6
7
 
7
8
  /** Visual accent for a group container. */
8
9
  export type FlowGroupTone = "default" | "accent" | "success" | "warning" | "destructive";
@@ -29,18 +30,33 @@ export interface FlowGroupNodeData extends Record<string, unknown> {
29
30
 
30
31
  export type BrandFlowGroupNode = Node<FlowGroupNodeData, "group">;
31
32
 
32
- const toneAccent: Record<FlowGroupTone, string> = {
33
+ // #124 split this map in two: it used to serve both the icon slot (a MARK,
34
+ // >=3:1) and the child-count text badge (running text, >=4.5:1) from one set
35
+ // of values, which is exactly the fill-rung-as-text shape #124 fixes
36
+ // elsewhere. `accent` is unchanged (`#399` already put it on the ink rung for
37
+ // both slots — legal, since the ink rung clears the 3:1 mark bar trivially).
38
+
39
+ /** Icon-slot tone (the leading glyph next to the title) — the 3:1 mark rung. */
40
+ const toneMark: Record<FlowGroupTone, string> = {
33
41
  default: "text-muted-foreground",
34
- // #399 — the group LABEL is text on the canvas, so it takes the on-surface
35
- // `-text` rung. (The status rows below stay on their fill rungs; those are a
36
- // separate, still-open question, not part of #399's enumerated call sites.)
37
42
  accent: "text-primary-text",
38
43
  success: "text-success",
39
44
  warning: "text-warning",
40
45
  destructive: "text-destructive",
41
46
  };
42
47
 
43
- const handleClassName = "!size-2 !border-2 !border-flow-group-border !bg-flow-group";
48
+ /** Child-count badge tone running text, so it takes the >=4.5:1 ink rung. */
49
+ const toneInk: Record<FlowGroupTone, string> = {
50
+ default: "text-muted-foreground",
51
+ accent: "text-primary-text",
52
+ success: "text-success-text",
53
+ warning: "text-warning-text",
54
+ destructive: "text-destructive-text",
55
+ };
56
+
57
+ // `FLOW_HANDLE_ANCHOR_CLASS` last: a connector dot must never be in flight when React
58
+ // Flow measures it. See `flow-handle/flow-handle-anchor.ts`.
59
+ const handleClassName = `!size-2 !border-2 !border-flow-group-border !bg-flow-group ${FLOW_HANDLE_ANCHOR_CLASS}`;
44
60
 
45
61
  /**
46
62
  * Branded group container node. Register it as `nodeTypes={{ group: FlowGroupNode }}`
@@ -103,7 +119,7 @@ export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNo
103
119
  className={cn(
104
120
  "-ml-1 grid size-5 shrink-0 place-items-center rounded",
105
121
  "text-muted-foreground hover:bg-muted hover:text-foreground",
106
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
122
+ "focus-ring",
107
123
  )}
108
124
  >
109
125
  {collapsed ? (
@@ -114,7 +130,7 @@ export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNo
114
130
  </button>
115
131
 
116
132
  {data.icon ? (
117
- <span className={cn("[&_svg]:size-4", toneAccent[tone])}>{data.icon}</span>
133
+ <span className={cn("[&_svg]:size-4", toneMark[tone])}>{data.icon}</span>
118
134
  ) : null}
119
135
 
120
136
  <span className="min-w-0 flex-1 truncate text-body font-medium">{data.title}</span>
@@ -122,7 +138,7 @@ export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNo
122
138
  <span
123
139
  className={cn(
124
140
  "shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-meta font-medium tabular-nums",
125
- toneAccent[tone],
141
+ toneInk[tone],
126
142
  )}
127
143
  aria-label={`${childCount} ${childCount === 1 ? "node" : "nodes"}`}
128
144
  >
@@ -0,0 +1,97 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ /**
5
+ * The engine is mocked so the handle's CLASS LIST is observable in jsdom, which has no
6
+ * layout and therefore cannot reproduce the defect itself. The real lock on the picture
7
+ * is `Process/ProcessMap > Left To Right`, a Chromium story test that measures every
8
+ * edge endpoint against the dot it should terminate on; this file locks the mechanism
9
+ * that story exposed — a connector dot that carries no transition, so it is never in
10
+ * flight when React Flow measures `handleBounds`.
11
+ */
12
+ vi.mock("@xyflow/react", () => {
13
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- a vi.mock factory is hoisted above imports; a lazy require avoids the TDZ a top-level import would hit
14
+ const React = require("react");
15
+ return {
16
+ Handle: ({ type, className }: { type: string; className?: string }) =>
17
+ React.createElement("div", { "data-testid": `handle-${type}`, className }),
18
+ NodeResizer: () => React.createElement("div", { "data-testid": "node-resizer" }),
19
+ Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
20
+ useNodes: () => [] as unknown[],
21
+ useReactFlow: () => ({
22
+ getNodes: () => [],
23
+ getEdges: () => [],
24
+ setNodes: () => {},
25
+ setEdges: () => {},
26
+ }),
27
+ getNodesBounds: () => ({ x: 0, y: 0, width: 0, height: 0 }),
28
+ };
29
+ });
30
+
31
+ import type { Node, NodeProps } from "@xyflow/react";
32
+ import { FlowGroupNode, type BrandFlowGroupNode } from "../flow-group-node/flow-group-node";
33
+ import { FlowNode, type BrandFlowNode } from "../flow-node/flow-node";
34
+ import {
35
+ FlowPlaceholderNode,
36
+ type BrandFlowPlaceholderNode,
37
+ } from "../flow-placeholder-node/flow-placeholder-node";
38
+ import { FLOW_HANDLE_ANCHOR_CLASS } from "./flow-handle-anchor";
39
+
40
+ afterEach(cleanup);
41
+
42
+ function makeProps<NodeType extends Node>(id: string, data: NodeType["data"]): NodeProps<NodeType> {
43
+ return {
44
+ id,
45
+ data,
46
+ selected: false,
47
+ dragging: false,
48
+ zIndex: 0,
49
+ isConnectable: true,
50
+ draggable: true,
51
+ deletable: true,
52
+ selectable: true,
53
+ positionAbsoluteX: 0,
54
+ positionAbsoluteY: 0,
55
+ width: 160,
56
+ height: 48,
57
+ type: "brand",
58
+ } as NodeProps<NodeType>;
59
+ }
60
+
61
+ describe("FLOW_HANDLE_ANCHOR_CLASS", () => {
62
+ it("switches transitions off rather than shortening them", () => {
63
+ // A duration is not enough: the tokens reduced-motion backstop already forces
64
+ // `transition-duration: 0.01ms !important` on every element, and it is that
65
+ // one-frame transition — on `transition-property: all` — which the handle has to
66
+ // be out of. Only `transition-property: none` takes a dot out of it.
67
+ expect(FLOW_HANDLE_ANCHOR_CLASS).toBe("transition-none");
68
+ });
69
+
70
+ it.each([
71
+ [
72
+ "FlowNode",
73
+ () => render(<FlowNode {...makeProps<BrandFlowNode>("node", { title: "Node" })} />),
74
+ ],
75
+ [
76
+ "FlowGroupNode",
77
+ () =>
78
+ render(<FlowGroupNode {...makeProps<BrandFlowGroupNode>("group", { title: "Group" })} />),
79
+ ],
80
+ [
81
+ "FlowPlaceholderNode",
82
+ () =>
83
+ render(
84
+ <FlowPlaceholderNode
85
+ {...makeProps<BrandFlowPlaceholderNode>("placeholder", { label: "Add node" })}
86
+ />,
87
+ ),
88
+ ],
89
+ ])("%s renders every handle as a static anchor", (_name, renderNode) => {
90
+ renderNode();
91
+ const handles = screen.getAllByTestId(/^handle-/);
92
+ expect(handles.length).toBeGreaterThan(0);
93
+ for (const handle of handles) {
94
+ expect(handle.className.split(/\s+/)).toContain(FLOW_HANDLE_ANCHOR_CLASS);
95
+ }
96
+ });
97
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The class every React Flow `<Handle>` in this package carries — and the one a
3
+ * consumer's own custom node must carry too.
4
+ *
5
+ * A connector dot is a MEASUREMENT ANCHOR, not a moving part. React Flow measures
6
+ * `handleBounds` from the DOM exactly once per layout change (`updateNodeInternals`,
7
+ * fired from the effect that sees `sourcePosition`/`targetPosition` change) and then
8
+ * draws every edge endpoint from that stored number until something invalidates it.
9
+ * So a dot that is still ON ITS WAY to its new side when that measurement is taken is
10
+ * measured at the wrong place — permanently, because nothing measures again.
11
+ *
12
+ * That is not hypothetical, and it is not only about author-written transitions:
13
+ *
14
+ * - `@elabs-ai/components-tokens`' reduced-motion backstop (`themes.css`, MOTION GATE)
15
+ * sets `transition-duration: 0.01ms !important` on `*` so third-party engines that
16
+ * ignore the `--t-*` tokens (Monaco, `@xyflow/react`, Streamdown) cannot animate.
17
+ * `transition-property` is left at its initial value, `all` — so under
18
+ * `prefers-reduced-motion: reduce` that rule does not remove a transition from a
19
+ * handle, it CREATES one: every geometric property of every element becomes
20
+ * transitioned, for one frame.
21
+ * - One frame is all it takes. Measured on `ProcessMap direction="LR"`: at the commit
22
+ * that flips the handles from top/bottom to left/right, the dot's computed box was
23
+ * still the OLD one (`getAnimations()` on it returned live `left`+`top`+`transform`
24
+ * transitions), React Flow measured `{ x: 164, y: 45.5 }` where the settled DOM has
25
+ * `{ x: 172, y: 37.5 }`, and every edge on the map then terminated up to 67 px away
26
+ * from the dot it points at — for reduced-motion readers only, forever.
27
+ *
28
+ * `transition-property: none` is the fix at the right layer: it costs nothing under
29
+ * normal motion (no rule animates a handle there anyway) and it makes the reposition
30
+ * synchronous, so whenever React Flow measures, it measures the settled dot.
31
+ *
32
+ * The literal is written out here rather than assembled, because Tailwind extracts
33
+ * candidates from source TEXT: this file is scanned, so the utility is emitted, and
34
+ * components may interpolate the constant freely.
35
+ */
36
+ export const FLOW_HANDLE_ANCHOR_CLASS = "transition-none";
@@ -0,0 +1 @@
1
+ export { FLOW_HANDLE_ANCHOR_CLASS } from "./flow-handle-anchor";
@@ -65,7 +65,7 @@ function AutoLayoutButton({ direction }: { direction: FlowLayoutDirection }) {
65
65
  type="button"
66
66
  disabled={layouting}
67
67
  onClick={() => layout(direction)}
68
- className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
68
+ className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-ring disabled:opacity-50"
69
69
  >
70
70
  Auto layout ({direction === "TB" ? "top → bottom" : "left → right"})
71
71
  </button>
@@ -127,7 +127,7 @@ function AutoLayoutGraphButton({ algorithm }: { algorithm: LayoutAlgorithm }) {
127
127
  type="button"
128
128
  disabled={layouting}
129
129
  onClick={handleClick}
130
- className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
130
+ className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-ring disabled:opacity-50"
131
131
  >
132
132
  Auto layout ({ALGORITHM_LABEL[algorithm]})
133
133
  </button>
@@ -130,4 +130,95 @@ describe("layoutFlow", () => {
130
130
  const wideGap = wide.nodes[1]!.position.y - wide.nodes[0]!.position.y;
131
131
  expect(wideGap).toBeGreaterThan(tightGap);
132
132
  });
133
+ describe("back edges and self-loops (RM-044)", () => {
134
+ /** The canonical rework fixture: A → B → C, C loops back to B, and B repeats itself. */
135
+ function reworkGraph(): { nodes: Node[]; edges: Edge[] } {
136
+ return {
137
+ nodes: chainNodes(),
138
+ edges: [
139
+ { id: "e-ab", source: "a", target: "b" },
140
+ { id: "e-bc", source: "b", target: "c" },
141
+ { id: "e-cb", source: "c", target: "b" },
142
+ { id: "e-bb", source: "b", target: "b" },
143
+ ],
144
+ };
145
+ }
146
+
147
+ it("reports the reversed edge in backEdges and the self-loop in selfLoops", () => {
148
+ const { nodes, edges } = reworkGraph();
149
+ const result = layoutFlow(nodes, edges);
150
+
151
+ expect(result.backEdges).toEqual(["e-cb"]);
152
+ expect(result.selfLoops).toEqual(["e-bb"]);
153
+ // Both classifications are disjoint — a self-loop is never a back edge.
154
+ expect(result.backEdges).not.toContain("e-bb");
155
+ });
156
+
157
+ it("keeps the self-loop out of the rank computation", () => {
158
+ const { nodes, edges } = reworkGraph();
159
+ const withLoop = layoutFlow(nodes, edges);
160
+ const withoutLoop = layoutFlow(
161
+ nodes,
162
+ edges.filter((e) => e.id !== "e-bb"),
163
+ );
164
+
165
+ // Identical positions: the loop contributed nothing to the layout.
166
+ expect(withLoop.nodes.map((n) => n.position)).toEqual(
167
+ withoutLoop.nodes.map((n) => n.position),
168
+ );
169
+ });
170
+
171
+ it("returns every edge untouched, self-loops included", () => {
172
+ const { nodes, edges } = reworkGraph();
173
+ const result = layoutFlow(nodes, edges);
174
+ expect(result.edges).toBe(edges);
175
+ expect(result.edges.map((e) => e.id)).toContain("e-bb");
176
+ });
177
+
178
+ it("produces no NaN position on the rework fixture", () => {
179
+ const { nodes, edges } = reworkGraph();
180
+ for (const n of layoutFlow(nodes, edges).nodes) {
181
+ expect(Number.isFinite(n.position.x)).toBe(true);
182
+ expect(Number.isFinite(n.position.y)).toBe(true);
183
+ }
184
+ });
185
+
186
+ it("classifies the same edge as a back edge in every direction", () => {
187
+ const { nodes, edges } = reworkGraph();
188
+ for (const direction of ["TB", "BT", "LR", "RL"] as const) {
189
+ expect(layoutFlow(nodes, edges, { direction }).backEdges).toEqual(["e-cb"]);
190
+ }
191
+ });
192
+
193
+ it("reports nothing for an acyclic, loop-free graph", () => {
194
+ const result = layoutFlow(chainNodes(), chainEdges());
195
+ expect(result.backEdges).toEqual([]);
196
+ expect(result.selfLoops).toEqual([]);
197
+ });
198
+
199
+ it("counts a same-rank edge as a back edge — it does not advance the process", () => {
200
+ const nodes: Node[] = [
201
+ { id: "a", position: { x: 0, y: 0 }, data: {} },
202
+ { id: "b", position: { x: 0, y: 0 }, data: {} },
203
+ ];
204
+ // Two mutually-referencing nodes: dagre has to break the cycle somewhere.
205
+ const result = layoutFlow(nodes, [
206
+ { id: "e-ab", source: "a", target: "b" },
207
+ { id: "e-ba", source: "b", target: "a" },
208
+ ]);
209
+ expect(result.backEdges).toEqual(["e-ba"]);
210
+ });
211
+
212
+ it("pins node positions on the rework fixture (regression snapshot)", () => {
213
+ const { nodes, edges } = reworkGraph();
214
+ const positions = Object.fromEntries(
215
+ layoutFlow(nodes, edges).nodes.map((n) => [n.id, n.position]),
216
+ );
217
+ expect(positions).toEqual({
218
+ a: { x: 0, y: 0 },
219
+ b: { x: 0, y: 112 },
220
+ c: { x: 0, y: 224 },
221
+ });
222
+ });
223
+ });
133
224
  });