@elabs-ai/components-flow 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +8 -8
  2. package/dist/index.d.ts +734 -20
  3. package/dist/index.js +976 -178
  4. package/dist/index.js.map +1 -1
  5. package/package.json +6 -6
  6. package/src/canvas-shell/canvas-shell.tsx +116 -1
  7. package/src/canvas-shell/use-measured-nodes.ts +101 -0
  8. package/src/flow-button-edge/flow-button-edge.stories.tsx +13 -0
  9. package/src/flow-button-edge/flow-button-edge.tsx +8 -10
  10. package/src/flow-edge/flow-edge.stories.tsx +20 -0
  11. package/src/flow-edge/flow-edge.tsx +10 -3
  12. package/src/flow-edge-path/flow-edge-path.tsx +149 -0
  13. package/src/flow-edge-path/index.ts +1 -0
  14. package/src/flow-edge-path/no-raw-base-edge.test.ts +45 -0
  15. package/src/flow-floating-edge/flow-floating-edge.tsx +7 -3
  16. package/src/flow-group-node/flow-group-node.stories.tsx +1 -1
  17. package/src/flow-group-node/flow-group-node.tsx +24 -8
  18. package/src/flow-handle/flow-handle-anchor.test.tsx +97 -0
  19. package/src/flow-handle/flow-handle-anchor.ts +36 -0
  20. package/src/flow-handle/index.ts +1 -0
  21. package/src/flow-layout/flow-layout.stories.tsx +2 -2
  22. package/src/flow-layout/flow-layout.test.tsx +91 -0
  23. package/src/flow-layout/flow-layout.ts +77 -1
  24. package/src/flow-layout/layout-graph.test.ts +83 -2
  25. package/src/flow-layout/layout-graph.ts +23 -15
  26. package/src/flow-mini-map/flow-mini-map.stories.tsx +86 -0
  27. package/src/flow-node/flow-node.stories.tsx +151 -0
  28. package/src/flow-node/flow-node.tsx +56 -1
  29. package/src/flow-placeholder-node/flow-placeholder-node.tsx +5 -2
  30. package/src/flow-self-loop-edge/flow-self-loop-edge.stories.tsx +275 -0
  31. package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +196 -0
  32. package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +172 -0
  33. package/src/flow-self-loop-edge/index.ts +13 -0
  34. package/src/flow-self-loop-edge/self-loop-geometry.test.ts +128 -0
  35. package/src/flow-self-loop-edge/self-loop-geometry.ts +165 -0
  36. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +55 -8
  37. package/src/flow-smart-edge/flow-smart-edge.tsx +125 -35
  38. package/src/flow-smart-edge/index.ts +5 -1
  39. package/src/flow-smart-edge/smart-edge-geometry.test.ts +88 -47
  40. package/src/flow-smart-edge/smart-edge-geometry.ts +69 -33
  41. package/src/flow-weighted-edge/back-edge-geometry.test.ts +54 -0
  42. package/src/flow-weighted-edge/back-edge-geometry.ts +60 -0
  43. package/src/flow-weighted-edge/edge-aria.test.ts +108 -0
  44. package/src/flow-weighted-edge/edge-aria.ts +117 -0
  45. package/src/flow-weighted-edge/edge-label-pill.test.tsx +65 -0
  46. package/src/flow-weighted-edge/edge-label-pill.tsx +82 -0
  47. package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +691 -0
  48. package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +405 -0
  49. package/src/flow-weighted-edge/flow-weighted-edge.tsx +308 -0
  50. package/src/flow-weighted-edge/index.ts +18 -0
  51. package/src/flow-weighted-edge/weight-scale.test.ts +92 -0
  52. package/src/flow-weighted-edge/weight-scale.ts +86 -0
  53. package/src/index.ts +9 -0
  54. package/src/inspector-panel/inspector-panel.stories.tsx +1 -1
  55. package/src/inspector-panel/inspector-panel.test.tsx +20 -0
  56. package/src/inspector-panel/inspector-panel.tsx +13 -3
  57. package/src/legend/index.ts +7 -1
  58. package/src/legend/legend.stories.tsx +126 -0
  59. package/src/legend/legend.test.tsx +169 -0
  60. package/src/legend/legend.tsx +214 -3
  61. package/src/templates-flow-workspace.stories.tsx +1 -1
  62. package/src/testing/canvas-framing.test.ts +107 -0
  63. package/src/testing/canvas-framing.ts +396 -0
  64. package/src/testing/edge-anchors.ts +107 -0
  65. package/src/testing/index.ts +36 -0
  66. package/src/zoom-controls/zoom-controls.tsx +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
2
3
  import { Legend } from "./legend";
3
4
 
4
5
  const meta = {
@@ -10,6 +11,8 @@ const meta = {
10
11
  export default meta;
11
12
  type Story = StoryObj<typeof meta>;
12
13
 
14
+ // ── Categorical (variant omitted — unchanged since before "scale" existed) ──
15
+
13
16
  /** Default legend with a title and token-based color references. */
14
17
  export const Default: Story = {
15
18
  args: {
@@ -54,3 +57,126 @@ export const SingleItem: Story = {
54
57
  items: [{ label: "Active node", color: "var(--primary)" }],
55
58
  },
56
59
  };
60
+
61
+ // ── Continuous scale (`variant="scale"`) ─────────────────────────────────────
62
+
63
+ /**
64
+ * Width ramp, default `ticks="minmax"` — min/max sample strokes drawn at the
65
+ * exact widths `computeEdgeWeightScale` (RM-043) would assign those weights,
66
+ * so the legend can never drift from a real flow's weighted edges.
67
+ */
68
+ export const ScaleWidth: Story = {
69
+ args: {
70
+ variant: "scale",
71
+ kind: "width",
72
+ domain: [2, 48],
73
+ },
74
+ play: async ({ canvas }) => {
75
+ const group = await canvas.findByRole("group", {
76
+ name: "Edge width scale, 2 to 48, minimum to maximum",
77
+ });
78
+ await waitFor(async () => {
79
+ expect(await canvas.findByText("2")).toBeInTheDocument();
80
+ expect(await canvas.findByText("48")).toBeInTheDocument();
81
+ });
82
+
83
+ // One tab stop for the whole reading key, not one per tick.
84
+ group.focus();
85
+ await expect(group).toHaveFocus();
86
+ },
87
+ };
88
+
89
+ /** `ticks="minmedmax"` adds the domain midpoint as a third sample stroke. */
90
+ export const ScaleWidthMinMedMax: Story = {
91
+ args: {
92
+ variant: "scale",
93
+ kind: "width",
94
+ domain: [2, 48],
95
+ ticks: "minmedmax",
96
+ },
97
+ play: async ({ canvas }) => {
98
+ await waitFor(async () => {
99
+ expect(await canvas.findByText("2")).toBeInTheDocument();
100
+ expect(await canvas.findByText("25")).toBeInTheDocument();
101
+ expect(await canvas.findByText("48")).toBeInTheDocument();
102
+ });
103
+ },
104
+ };
105
+
106
+ /**
107
+ * Colour ramp — always a fixed 5-stop gradient built from
108
+ * `--flow-edge-weak`/`--flow-edge-strong`, labelled at every stop via
109
+ * `format`. Colour alone never carries the ordering (WCAG 1.4.1); the five
110
+ * numbered ticks are the required second channel.
111
+ */
112
+ export const ScaleColor: Story = {
113
+ args: {
114
+ variant: "scale",
115
+ kind: "color",
116
+ domain: [0, 100],
117
+ format: (value: number) => `${value}%`,
118
+ },
119
+ play: async ({ canvas }) => {
120
+ await canvas.findByRole("group", {
121
+ name: "Edge color scale, 0% to 100%, minimum to maximum",
122
+ });
123
+ await waitFor(() => {
124
+ expect(canvas.getByText("0%")).toBeInTheDocument();
125
+ expect(canvas.getByText("25%")).toBeInTheDocument();
126
+ expect(canvas.getByText("50%")).toBeInTheDocument();
127
+ expect(canvas.getByText("75%")).toBeInTheDocument();
128
+ expect(canvas.getByText("100%")).toBeInTheDocument();
129
+ });
130
+ },
131
+ };
132
+
133
+ /**
134
+ * Zero-width domain (`min === max`) — every sample collapses onto one value.
135
+ * Must render without crashing and without a duplicate React key warning.
136
+ */
137
+ export const ScaleWidthZeroDomain: Story = {
138
+ args: {
139
+ variant: "scale",
140
+ kind: "width",
141
+ domain: [10, 10],
142
+ ticks: "minmedmax",
143
+ },
144
+ play: async ({ canvas }) => {
145
+ await waitFor(async () => {
146
+ expect(await canvas.findAllByText("10")).toHaveLength(3);
147
+ });
148
+ },
149
+ };
150
+
151
+ /** Domain spanning several orders of magnitude. */
152
+ export const ScaleColorWideRange: Story = {
153
+ args: {
154
+ variant: "scale",
155
+ kind: "color",
156
+ domain: [1, 1_000_000],
157
+ },
158
+ play: async ({ canvas }) => {
159
+ await waitFor(async () => {
160
+ expect(await canvas.findByText("1")).toBeInTheDocument();
161
+ expect(await canvas.findByText("1,000,000")).toBeInTheDocument();
162
+ });
163
+ },
164
+ };
165
+
166
+ /** A verbose custom `format` must truncate rather than break the layout. */
167
+ export const ScaleWidthLongLabels: Story = {
168
+ args: {
169
+ variant: "scale",
170
+ kind: "width",
171
+ domain: [2, 48],
172
+ ticks: "minmedmax",
173
+ format: (value: number) => `${value.toFixed(2)} transactions processed per hour on average`,
174
+ },
175
+ play: async ({ canvas }) => {
176
+ await waitFor(async () => {
177
+ expect(
178
+ await canvas.findByText("2.00 transactions processed per hour on average"),
179
+ ).toBeInTheDocument();
180
+ });
181
+ },
182
+ };
@@ -1,5 +1,6 @@
1
1
  import { cleanup, render, screen } from "@testing-library/react";
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
+ import { computeEdgeWeightScale, type WeightedEdgeLike } from "../flow-weighted-edge/weight-scale";
3
4
  import { Legend } from "./legend";
4
5
 
5
6
  afterEach(cleanup);
@@ -43,4 +44,172 @@ describe("Legend", () => {
43
44
  const list = screen.queryByRole("list");
44
45
  expect(list).toBeInTheDocument();
45
46
  });
47
+
48
+ // Same cases as above, but explicitly passing `variant="categorical"` — the
49
+ // additive prop must be a true no-op, not merely an omission that happens
50
+ // to work.
51
+ it('renders identically with variant="categorical" made explicit', () => {
52
+ render(<Legend variant="categorical" items={items} title="Legend Title" />);
53
+ expect(screen.getByText("Legend Title")).toBeInTheDocument();
54
+ expect(screen.getByText("Source")).toBeInTheDocument();
55
+ expect(screen.getByText("Target")).toBeInTheDocument();
56
+ });
57
+ });
58
+
59
+ describe('Legend variant="scale", kind="width"', () => {
60
+ it("draws min/max sample strokes at exactly computeEdgeWeightScale's widths for that domain (#219)", () => {
61
+ const { container } = render(<Legend variant="scale" kind="width" domain={[2, 48]} />);
62
+ const expected = computeEdgeWeightScale([
63
+ { id: "0", data: { weight: 2 } },
64
+ { id: "1", data: { weight: 48 } },
65
+ ] satisfies WeightedEdgeLike[]);
66
+
67
+ const lines = container.querySelectorAll("line");
68
+ expect(lines).toHaveLength(2);
69
+ expect(Number(lines[0]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("0")!, 5);
70
+ expect(Number(lines[1]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("1")!, 5);
71
+ });
72
+
73
+ it('adds the domain midpoint as a third stroke under ticks="minmedmax", matching computeEdgeWeightScale for 2/25/48', () => {
74
+ const { container } = render(
75
+ <Legend variant="scale" kind="width" domain={[2, 48]} ticks="minmedmax" />,
76
+ );
77
+ const expected = computeEdgeWeightScale([
78
+ { id: "0", data: { weight: 2 } },
79
+ { id: "1", data: { weight: 25 } },
80
+ { id: "2", data: { weight: 48 } },
81
+ ] satisfies WeightedEdgeLike[]);
82
+
83
+ const lines = container.querySelectorAll("line");
84
+ expect(lines).toHaveLength(3);
85
+ expect(Number(lines[0]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("0")!, 5);
86
+ expect(Number(lines[1]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("1")!, 5);
87
+ expect(Number(lines[2]!.getAttribute("stroke-width"))).toBeCloseTo(expected.get("2")!, 5);
88
+ });
89
+
90
+ it("renders formatted min/max tick labels with tabular-nums", () => {
91
+ render(<Legend variant="scale" kind="width" domain={[2, 48]} />);
92
+ expect(screen.getByText("2")).toHaveClass("tabular-nums");
93
+ expect(screen.getByText("48")).toHaveClass("tabular-nums");
94
+ });
95
+
96
+ it("applies a caller-supplied format function to every tick", () => {
97
+ render(
98
+ <Legend
99
+ variant="scale"
100
+ kind="width"
101
+ domain={[2, 48]}
102
+ ticks="minmedmax"
103
+ format={(v) => `${v} evt/s`}
104
+ />,
105
+ );
106
+ expect(screen.getByText("2 evt/s")).toBeInTheDocument();
107
+ expect(screen.getByText("25 evt/s")).toBeInTheDocument();
108
+ expect(screen.getByText("48 evt/s")).toBeInTheDocument();
109
+ });
110
+
111
+ it("keeps the smallest tick's stroke at the readable floor (default 1.5px), never thinner", () => {
112
+ const { container } = render(<Legend variant="scale" kind="width" domain={[2, 1000]} />);
113
+ const lines = container.querySelectorAll("line");
114
+ const widths = Array.from(lines).map((l) => Number(l.getAttribute("stroke-width")));
115
+ expect(Math.min(...widths)).toBeCloseTo(1.5, 5);
116
+ });
117
+
118
+ it("handles a zero-width domain (min === max) without crashing, all samples at the midpoint width", () => {
119
+ const { container } = render(
120
+ <Legend variant="scale" kind="width" domain={[10, 10]} ticks="minmedmax" />,
121
+ );
122
+ const lines = container.querySelectorAll("line");
123
+ expect(lines).toHaveLength(3);
124
+ const widths = Array.from(lines).map((l) => Number(l.getAttribute("stroke-width")));
125
+ // computeEdgeWeightScale's own zero-variance behavior: the midpoint of
126
+ // the default [1.5, 8] output range.
127
+ for (const w of widths) expect(w).toBeCloseTo(4.75, 5);
128
+ expect(screen.getAllByText("10")).toHaveLength(3);
129
+ });
130
+
131
+ it("is a single keyboard tab stop with an aria-label summarizing the domain", () => {
132
+ render(<Legend variant="scale" kind="width" domain={[2, 48]} ticks="minmedmax" />);
133
+ const group = screen.getByRole("group", {
134
+ name: "Edge width scale, 2 to 48, minimum to maximum",
135
+ });
136
+ expect(group).toHaveAttribute("tabindex", "0");
137
+ // No descendant of the legend is itself a tab stop — one Tab reaches the
138
+ // whole reading key, not one per tick.
139
+ const innerTabbables = group.querySelectorAll(
140
+ "button, a[href], input, select, textarea, [tabindex]",
141
+ );
142
+ expect(innerTabbables).toHaveLength(0);
143
+ });
144
+
145
+ it("does not render the categorical <ul> item list", () => {
146
+ const { container } = render(<Legend variant="scale" kind="width" domain={[2, 48]} />);
147
+ expect(container.querySelector("ul")).toBeNull();
148
+ });
149
+ });
150
+
151
+ describe('Legend variant="scale", kind="color"', () => {
152
+ it("renders a 5-stop gradient using the flow-edge-weak/strong tokens", () => {
153
+ const { container } = render(<Legend variant="scale" kind="color" domain={[0, 100]} />);
154
+ const bar = container.querySelector("[aria-hidden='true'][style*='linear-gradient']");
155
+ expect(bar).not.toBeNull();
156
+ const backgroundImage = (bar as HTMLElement).style.backgroundImage;
157
+ expect(backgroundImage).toContain("var(--flow-edge-weak)");
158
+ expect(backgroundImage).toContain("var(--flow-edge-strong)");
159
+ // 5 explicit percentage stops: 0, 25, 50, 75, 100.
160
+ for (const pct of ["0%", "25%", "50%", "75%", "100%"]) {
161
+ expect(backgroundImage).toContain(pct);
162
+ }
163
+ });
164
+
165
+ it("labels every one of the 5 stops via the caller's format function — colour is never the only channel (#387-style, WCAG 1.4.1)", () => {
166
+ render(<Legend variant="scale" kind="color" domain={[0, 100]} format={(v) => `${v}%`} />);
167
+ for (const label of ["0%", "25%", "50%", "75%", "100%"]) {
168
+ expect(screen.getByText(label)).toBeInTheDocument();
169
+ }
170
+ });
171
+
172
+ it("uses the default locale number format when none is supplied", () => {
173
+ render(<Legend variant="scale" kind="color" domain={[0, 1000]} />);
174
+ expect(screen.getByText("0")).toBeInTheDocument();
175
+ expect(screen.getByText("250")).toBeInTheDocument();
176
+ expect(screen.getByText("500")).toBeInTheDocument();
177
+ expect(screen.getByText("750")).toBeInTheDocument();
178
+ expect(screen.getByText("1,000")).toBeInTheDocument();
179
+ });
180
+
181
+ it("handles a domain spanning several orders of magnitude", () => {
182
+ render(<Legend variant="scale" kind="color" domain={[1, 1_000_000]} />);
183
+ expect(screen.getByText("1")).toBeInTheDocument();
184
+ expect(screen.getByText("1,000,000")).toBeInTheDocument();
185
+ });
186
+
187
+ it("handles a zero-width domain (min === max) — all 5 stop labels equal, no crash", () => {
188
+ render(<Legend variant="scale" kind="color" domain={[10, 10]} />);
189
+ expect(screen.getAllByText("10")).toHaveLength(5);
190
+ });
191
+
192
+ it("truncates a very long formatted label instead of breaking layout", () => {
193
+ render(
194
+ <Legend
195
+ variant="scale"
196
+ kind="color"
197
+ domain={[0, 1]}
198
+ format={(v) => `${v} — a very long description of what this stop means in context`}
199
+ />,
200
+ );
201
+ const label = screen.getByText(
202
+ "0 — a very long description of what this stop means in context",
203
+ );
204
+ expect(label).toHaveClass("truncate");
205
+ expect(label).toHaveClass("min-w-0");
206
+ });
207
+
208
+ it("is a single keyboard tab stop with an aria-label summarizing the domain", () => {
209
+ render(<Legend variant="scale" kind="color" domain={[0, 100]} />);
210
+ const group = screen.getByRole("group", {
211
+ name: "Edge color scale, 0 to 100, minimum to maximum",
212
+ });
213
+ expect(group).toHaveAttribute("tabindex", "0");
214
+ });
46
215
  });
@@ -1,4 +1,5 @@
1
1
  import { cn } from "@elabs-ai/components-ui/lib/cn";
2
+ import { computeEdgeWeightScale, type WeightedEdgeLike } from "../flow-weighted-edge/weight-scale";
2
3
 
3
4
  export interface LegendItem {
4
5
  label: string;
@@ -6,14 +7,65 @@ export interface LegendItem {
6
7
  color: string;
7
8
  }
8
9
 
9
- export interface LegendProps {
10
+ /**
11
+ * Categorical legend props — a swatch-and-label list. This is the shape
12
+ * `Legend` renders when `variant` is omitted, byte-for-byte unchanged from
13
+ * before the `"scale"` variant existed.
14
+ */
15
+ export interface LegendCategoricalProps {
16
+ variant?: "categorical";
10
17
  items: LegendItem[];
11
18
  title?: string;
12
19
  className?: string;
13
20
  }
14
21
 
15
- /** Small legend mapping colors/types to labels for a canvas or chart. */
16
- export function Legend({ items, title, className }: LegendProps) {
22
+ /**
23
+ * Continuous scale legend a reading key that explains a WIDTH or COLOR
24
+ * encoding as a *range*, not a set of discrete categories (e.g.
25
+ * `FlowWeightedEdge`'s `data.weight` → stroke width, `data.value` → stroke
26
+ * color). `kind: "width"` reuses `computeEdgeWeightScale` — the exact pure
27
+ * scale `FlowWeightedEdge` calls — so the sample strokes drawn here can never
28
+ * drift from the widths a real flow's edges render for the same weights.
29
+ */
30
+ export interface LegendScaleProps {
31
+ variant: "scale";
32
+ /** Which continuous encoding this legend explains. */
33
+ kind: "width" | "color";
34
+ /** `[min, max]` of the underlying value the ramp represents. */
35
+ domain: [number, number];
36
+ /** Formats a domain value for display at a tick. @default `(v) => v.toLocaleString()` */
37
+ format?: (value: number) => string;
38
+ /**
39
+ * Sample count for `kind: "width"`: `"minmax"` draws a min/max pair of
40
+ * sample strokes, `"minmedmax"` adds the domain midpoint as a third
41
+ * sample. `kind: "color"` always renders a fixed 5-stop gradient
42
+ * regardless of this prop — five stops is what makes a 2-endpoint color
43
+ * ramp legible as an ordered scale (see the color-ramp a11y note below),
44
+ * independent of how many width samples are shown.
45
+ * @default "minmax"
46
+ */
47
+ ticks?: "minmax" | "minmedmax";
48
+ title?: string;
49
+ className?: string;
50
+ }
51
+
52
+ export type LegendProps = LegendCategoricalProps | LegendScaleProps;
53
+
54
+ const defaultFormat = (value: number): string => value.toLocaleString();
55
+
56
+ /**
57
+ * Small legend mapping colors/types to labels for a canvas or chart
58
+ * (`variant: "categorical"`, the default), or — via `variant="scale"` — a
59
+ * continuous width/color ramp with a domain, tick marks and formatting.
60
+ */
61
+ export function Legend(props: LegendProps) {
62
+ if (props.variant === "scale") {
63
+ return <LegendScale {...props} />;
64
+ }
65
+ return <LegendCategorical {...props} />;
66
+ }
67
+
68
+ function LegendCategorical({ items, title, className }: LegendCategoricalProps) {
17
69
  return (
18
70
  <div
19
71
  className={cn(
@@ -37,3 +89,162 @@ export function Legend({ items, title, className }: LegendProps) {
37
89
  </div>
38
90
  );
39
91
  }
92
+
93
+ /**
94
+ * Width-ramp sample: a tick value plus the stroke width `computeEdgeWeightScale`
95
+ * assigns it. The "edges" fed to the scale are exactly the requested tick
96
+ * values, so the group's own min/max always equal the domain — the returned
97
+ * widths are byte-identical to what `computeEdgeWeightScale` would compute for
98
+ * real edges carrying these same weights (no duplicated min-max math).
99
+ */
100
+ function computeWidthSamples(
101
+ domain: [number, number],
102
+ ticks: "minmax" | "minmedmax",
103
+ ): { value: number; width: number }[] {
104
+ const [min, max] = domain;
105
+ const values = ticks === "minmedmax" ? [min, (min + max) / 2, max] : [min, max];
106
+ const likeEdges: WeightedEdgeLike[] = values.map((weight, index) => ({
107
+ id: String(index),
108
+ data: { weight },
109
+ }));
110
+ const widths = computeEdgeWeightScale(likeEdges);
111
+ return values.map((value, index) => ({ value, width: widths.get(String(index)) ?? 0 }));
112
+ }
113
+
114
+ /** Fractions along the domain the color ramp samples — fixed at 5 stops (see `LegendScaleProps.ticks`). */
115
+ const COLOR_STOP_FRACTIONS = [0, 0.25, 0.5, 0.75, 1] as const;
116
+
117
+ function scaleKindLabel(kind: "width" | "color"): string {
118
+ return kind === "width" ? "width" : "color";
119
+ }
120
+
121
+ function LegendScale({
122
+ kind,
123
+ domain,
124
+ format = defaultFormat,
125
+ ticks = "minmax",
126
+ title,
127
+ className,
128
+ }: LegendScaleProps) {
129
+ const [min, max] = domain;
130
+ // e.g. "Edge width scale, 2 to 48, minimum to maximum" — a single accessible
131
+ // name for the whole reading key, since it is one keyboard tab stop, not one
132
+ // per tick.
133
+ const ariaLabel = `Edge ${scaleKindLabel(kind)} scale, ${format(min)} to ${format(max)}, minimum to maximum`;
134
+
135
+ return (
136
+ <div
137
+ aria-label={ariaLabel}
138
+ className={cn(
139
+ "flex flex-col gap-2 rounded-lg bg-surface-elevated/90 p-3 shadow-ring-sm backdrop-blur",
140
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
141
+ className,
142
+ )}
143
+ role="group"
144
+ tabIndex={0}
145
+ >
146
+ {title ? <div className="text-body font-medium text-foreground">{title}</div> : null}
147
+ {kind === "width" ? (
148
+ <LegendScaleWidth domain={domain} format={format} ticks={ticks} />
149
+ ) : (
150
+ <LegendScaleColor domain={domain} format={format} />
151
+ )}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ function LegendScaleWidth({
157
+ domain,
158
+ format,
159
+ ticks,
160
+ }: {
161
+ domain: [number, number];
162
+ format: (value: number) => string;
163
+ ticks: "minmax" | "minmedmax";
164
+ }) {
165
+ const samples = computeWidthSamples(domain, ticks);
166
+ return (
167
+ <div className="flex flex-col gap-2">
168
+ {samples.map((sample, index) => (
169
+ // Index disambiguates a zero-width domain, where every sample shares
170
+ // the same value (and therefore the same width).
171
+ <div key={`${index}-${sample.value}`} className="flex items-center gap-3">
172
+ {/* The thin end of the ramp (default floor 1.5px) is the weakest mark
173
+ in the system — `--flow-edge` is the same token a real, unweighted
174
+ edge draws, and `strokeLinecap="round"` keeps that floor width from
175
+ disappearing into a hairline. */}
176
+ <svg aria-hidden="true" className="shrink-0" height={16} overflow="visible" width={40}>
177
+ <line
178
+ stroke="var(--flow-edge)"
179
+ strokeLinecap="round"
180
+ strokeWidth={sample.width}
181
+ x1={2}
182
+ x2={38}
183
+ y1={8}
184
+ y2={8}
185
+ />
186
+ </svg>
187
+ <span className="min-w-0 truncate text-meta tabular-nums text-muted-foreground">
188
+ {format(sample.value)}
189
+ </span>
190
+ </div>
191
+ ))}
192
+ </div>
193
+ );
194
+ }
195
+
196
+ function LegendScaleColor({
197
+ domain,
198
+ format,
199
+ }: {
200
+ domain: [number, number];
201
+ format: (value: number) => string;
202
+ }) {
203
+ const [min, max] = domain;
204
+ const span = max - min;
205
+ const stops = COLOR_STOP_FRACTIONS.map((fraction) => ({
206
+ fraction,
207
+ value: min + fraction * span,
208
+ }));
209
+ // Explicit 5-stop `color-mix` gradient (interpolated `in oklch`, matching how
210
+ // the `--flow-edge-weak`/`--flow-edge-strong` tokens themselves are authored)
211
+ // rather than a bare 2-color `linear-gradient`, so the ramp's perceptual
212
+ // steps match the token authoring space instead of the browser's default
213
+ // sRGB gradient interpolation.
214
+ const gradient = `linear-gradient(to right, ${stops
215
+ .map(({ fraction }) => {
216
+ const percent = Math.round(fraction * 100);
217
+ return `color-mix(in oklch, var(--flow-edge-strong) ${percent}%, var(--flow-edge-weak)) ${percent}%`;
218
+ })
219
+ .join(", ")})`;
220
+
221
+ return (
222
+ <div className="flex flex-col gap-1.5">
223
+ {/*
224
+ WCAG 1.4.1 — this gradient alone cannot carry the ordering, so the
225
+ numbered ticks below are the required second channel, not decoration.
226
+ */}
227
+ <div
228
+ aria-hidden="true"
229
+ className="h-3 w-full rounded-full"
230
+ style={{ backgroundImage: gradient }}
231
+ />
232
+ <div className="flex justify-between gap-1">
233
+ {stops.map((stop, index) => (
234
+ <span
235
+ // Index disambiguates a zero-width domain, where every stop shares
236
+ // the same formatted value.
237
+ key={`${index}-${stop.value}`}
238
+ className={cn(
239
+ "min-w-0 truncate text-meta tabular-nums text-muted-foreground",
240
+ index === 0 && "text-start",
241
+ index === stops.length - 1 && "text-end",
242
+ )}
243
+ >
244
+ {format(stop.value)}
245
+ </span>
246
+ ))}
247
+ </div>
248
+ </div>
249
+ );
250
+ }
@@ -4,7 +4,7 @@
4
4
  * single source of truth: `pnpm gen:templates` derives the consumer template
5
5
  * source (`docs/playbooks/templates/flow-workspace.tsx`) from it.
6
6
  * Remember `import "@xyflow/react/dist/style.css"` is wired in Storybook preview.
7
- * Verify across all three themes with globals=theme:<slug>.
7
+ * Verify across every theme with globals=theme:<slug>.
8
8
  */
9
9
  import type { Meta, StoryObj } from "@storybook/react-vite";
10
10
  import { useState } from "react";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The one property of {@link waitForSettledCanvas} that is testable outside a browser:
3
+ * a canvas that is still MOVING is not settled, even when it measures as still.
4
+ *
5
+ * jsdom reports every box as 0×0, so the rect half of `canvasSignature` is constant here
6
+ * by construction — which is precisely the shape of the CI failure this locks. Two polls
7
+ * taken close enough together read the identical signature while the node-position
8
+ * transition is mid-flight (on a loaded runner, "close enough together" means inside one
9
+ * rendered frame), and the framing assertions then measure a picture in which React Flow
10
+ * has already drawn every edge at its final coordinate while the node elements, and with
11
+ * them the handle dots, are still sliding towards it — nine edges reported hanging up to
12
+ * 142 px off their dots on a canvas that is correct a fifth of a second later.
13
+ */
14
+ import { afterEach, describe, expect, it } from "vitest";
15
+ import { waitForSettledCanvas } from "./canvas-framing";
16
+
17
+ type AnimationCapableElement = { getAnimations: () => Animation[] };
18
+
19
+ /** A canvas whose nodes already hold distinct laid-out positions. */
20
+ function canvasWithLaidOutNodes(): HTMLElement {
21
+ const canvas = document.createElement("div");
22
+ for (const [id, x] of [
23
+ ["a", 0],
24
+ ["b", 240],
25
+ ] as const) {
26
+ const node = document.createElement("div");
27
+ node.className = "react-flow__node";
28
+ node.setAttribute("data-id", id);
29
+ node.style.transform = `translate(${x}px, 0px)`;
30
+ canvas.append(node);
31
+ }
32
+ document.body.append(canvas);
33
+ return canvas;
34
+ }
35
+
36
+ /** A finite, still-running animation whose end this test controls. */
37
+ function pendingAnimation(): { animation: Animation; finish: () => void } {
38
+ let finish = () => {};
39
+ const finished = new Promise<void>((resolve) => {
40
+ finish = () => resolve();
41
+ });
42
+ return {
43
+ animation: {
44
+ playState: "running",
45
+ effect: { getTiming: () => ({ iterations: 1 }) },
46
+ finished,
47
+ } as unknown as Animation,
48
+ finish,
49
+ };
50
+ }
51
+
52
+ function stubAnimations(read: () => Animation[]) {
53
+ (Element.prototype as unknown as AnimationCapableElement).getAnimations = read;
54
+ }
55
+
56
+ afterEach(() => {
57
+ delete (Element.prototype as unknown as Partial<AnimationCapableElement>).getAnimations;
58
+ document.body.innerHTML = "";
59
+ });
60
+
61
+ describe("waitForSettledCanvas", () => {
62
+ it("does not settle while a finite animation is still running", async () => {
63
+ const canvas = canvasWithLaidOutNodes();
64
+ const { animation, finish } = pendingAnimation();
65
+ let running: Animation[] = [animation];
66
+ stubAnimations(() => running);
67
+
68
+ let settled = false;
69
+ const wait = waitForSettledCanvas(canvas, { timeout: 4_000, interval: 10 }).then(() => {
70
+ settled = true;
71
+ });
72
+
73
+ // Long enough for many polls: under the old two-identical-polls rule this canvas —
74
+ // whose signature never changes in jsdom — settled on the second one.
75
+ await new Promise((resolve) => setTimeout(resolve, 150));
76
+ expect(settled).toBe(false);
77
+
78
+ running = [];
79
+ finish();
80
+ await wait;
81
+ expect(settled).toBe(true);
82
+ });
83
+
84
+ it("settles once nothing is animating", async () => {
85
+ const canvas = canvasWithLaidOutNodes();
86
+ stubAnimations(() => []);
87
+ await expect(
88
+ waitForSettledCanvas(canvas, { timeout: 4_000, interval: 10 }),
89
+ ).resolves.toBeUndefined();
90
+ });
91
+
92
+ it("throws, naming the reason, when the nodes never take distinct positions", async () => {
93
+ const canvas = document.createElement("div");
94
+ for (const id of ["a", "b"]) {
95
+ const node = document.createElement("div");
96
+ node.className = "react-flow__node";
97
+ node.setAttribute("data-id", id);
98
+ node.style.transform = "translate(0px, 0px)";
99
+ canvas.append(node);
100
+ }
101
+ document.body.append(canvas);
102
+ stubAnimations(() => []);
103
+ await expect(waitForSettledCanvas(canvas, { timeout: 120, interval: 10 })).rejects.toThrow(
104
+ /layout has not run/,
105
+ );
106
+ });
107
+ });