@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,405 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // @xyflow/react requires real layout/measurement — mock the engine and assert
5
+ // the brand component's own output. Real rendering + a11y are covered by
6
+ // Storybook interaction tests.
7
+ //
8
+ // `vi.mock`'s factory is hoisted above every import (and above ordinary
9
+ // top-level `const`s) — `vi.hoisted` is the escape hatch so the mock fns
10
+ // themselves survive the hoist without a TDZ ReferenceError.
11
+ const { getBezierPathMock, getSmoothStepPathMock, edgesBox, nodesBox } = vi.hoisted(() => {
12
+ return {
13
+ edgesBox: { current: [] as unknown[] },
14
+ nodesBox: { current: [] as unknown[] },
15
+ getBezierPathMock: vi.fn(
16
+ ({
17
+ sourceX,
18
+ sourceY,
19
+ targetX,
20
+ targetY,
21
+ }: {
22
+ sourceX: number;
23
+ sourceY: number;
24
+ targetX: number;
25
+ targetY: number;
26
+ }) => [
27
+ `M${sourceX},${sourceY} C${targetX},${targetY}`,
28
+ (sourceX + targetX) / 2,
29
+ (sourceY + targetY) / 2,
30
+ ],
31
+ ),
32
+ getSmoothStepPathMock: vi.fn(
33
+ ({
34
+ sourceX,
35
+ sourceY,
36
+ targetX,
37
+ targetY,
38
+ }: {
39
+ sourceX: number;
40
+ sourceY: number;
41
+ targetX: number;
42
+ targetY: number;
43
+ }) => [
44
+ `M${sourceX},${sourceY} L${targetX},${targetY}`,
45
+ (sourceX + targetX) / 2,
46
+ (sourceY + targetY) / 2,
47
+ ],
48
+ ),
49
+ };
50
+ });
51
+
52
+ vi.mock("@xyflow/react", () => {
53
+ // 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
54
+ const React = require("react");
55
+ return {
56
+ BaseEdge: ({
57
+ id,
58
+ path,
59
+ style,
60
+ className,
61
+ markerStart: _markerStart,
62
+ interactionWidth: _interactionWidth,
63
+ ...rest
64
+ }: {
65
+ id: string;
66
+ path: string;
67
+ style?: React.CSSProperties;
68
+ className?: string;
69
+ markerStart?: string;
70
+ interactionWidth?: number;
71
+ [key: string]: unknown;
72
+ }) =>
73
+ // `...rest` forwards data-* passthrough (e.g. `data-weight`/`data-value`,
74
+ // #285) onto the mocked path, exactly as `FlowEdgePath`'s own `...props`
75
+ // spread does onto the real `BaseEdge`.
76
+ React.createElement("svg", { "data-testid": "base-edge" }, [
77
+ React.createElement("path", { key: "p", d: path, id, style, className, ...rest }),
78
+ ]),
79
+ // Real EdgeLabelRenderer portals into a fixed container; a passthrough is
80
+ // enough here since we only assert the brand component's own output.
81
+ EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children,
82
+ getBezierPath: getBezierPathMock,
83
+ getSmoothStepPath: getSmoothStepPathMock,
84
+ useEdges: () => edgesBox.current,
85
+ // The component reads nodes through `useStore(selector)` rather than `useNodes()`, so
86
+ // that a FORWARD edge subscribes to nothing that changes when a node moves. The mock
87
+ // hands the selector the one slice it reads, which keeps the back-edge tests below
88
+ // exercising the real selector rather than a stand-in for it.
89
+ useStore: (selector: (state: { nodes: unknown[] }) => unknown) =>
90
+ selector({ nodes: nodesBox.current }),
91
+ Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
92
+ };
93
+ });
94
+
95
+ import { oklchToHex } from "@elabs-ai/components-tokens";
96
+ import { FlowWeightedEdge, type BrandFlowWeightedEdge } from "./flow-weighted-edge";
97
+ import type { EdgeProps } from "@xyflow/react";
98
+
99
+ afterEach(() => {
100
+ cleanup();
101
+ edgesBox.current = [];
102
+ nodesBox.current = [];
103
+ getBezierPathMock.mockClear();
104
+ getSmoothStepPathMock.mockClear();
105
+ });
106
+
107
+ /** Minimal EdgeProps factory for FlowWeightedEdge. */
108
+ function makeEdgeProps(
109
+ overrides: Partial<EdgeProps<BrandFlowWeightedEdge>> = {},
110
+ ): EdgeProps<BrandFlowWeightedEdge> {
111
+ return {
112
+ id: "test-edge",
113
+ type: "weighted",
114
+ source: "node-a",
115
+ target: "node-b",
116
+ sourceX: 0,
117
+ sourceY: 0,
118
+ targetX: 100,
119
+ targetY: 100,
120
+ sourcePosition: "bottom" as EdgeProps["sourcePosition"],
121
+ targetPosition: "top" as EdgeProps["targetPosition"],
122
+ selected: false,
123
+ animated: false,
124
+ data: {},
125
+ ...overrides,
126
+ };
127
+ }
128
+
129
+ describe("FlowWeightedEdge", () => {
130
+ it("renders a BaseEdge element", () => {
131
+ render(<FlowWeightedEdge {...makeEdgeProps()} />);
132
+ expect(screen.getByTestId("base-edge")).toBeInTheDocument();
133
+ });
134
+
135
+ it("renders at the fixed 1.5px floor when data.weight is absent (unchanged from FlowEdge)", () => {
136
+ edgesBox.current = [{ id: "test-edge", data: {} }];
137
+ render(<FlowWeightedEdge {...makeEdgeProps()} />);
138
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
139
+ expect(path.style.strokeWidth).toBe("1.5");
140
+ expect(path.style.stroke).toBe("var(--flow-edge)");
141
+ });
142
+
143
+ it("scales strokeWidth into [1.5, 8] against sibling edges from the same scaleGroup", () => {
144
+ edgesBox.current = [
145
+ { id: "test-edge", data: { weight: 1 } },
146
+ { id: "sibling", data: { weight: 10 } },
147
+ ];
148
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { weight: 1 } })} />);
149
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
150
+ expect(path.style.strokeWidth).toBe("1.5");
151
+ });
152
+
153
+ it("renders an EdgeLabelPill when data.label is set", () => {
154
+ edgesBox.current = [{ id: "test-edge", data: {} }];
155
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { label: "128×" } })} />);
156
+ expect(screen.getByRole("button", { name: "128×" })).toBeInTheDocument();
157
+ });
158
+
159
+ it("renders no EdgeLabelPill when neither label is set", () => {
160
+ edgesBox.current = [{ id: "test-edge", data: {} }];
161
+ render(<FlowWeightedEdge {...makeEdgeProps()} />);
162
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
163
+ });
164
+
165
+ // #285 — the raw data.weight/data.value (not the scaled stroke width/colour)
166
+ // land on the path as data attributes: a stable selector for tests/consumers,
167
+ // independent of the accessible-name seam in `edge-aria.ts`.
168
+ it("stamps data-weight on the path from data.weight", () => {
169
+ edgesBox.current = [{ id: "test-edge", data: { weight: 7 } }];
170
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { weight: 7 } })} />);
171
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
172
+ expect(path.getAttribute("data-weight")).toBe("7");
173
+ });
174
+
175
+ it("stamps data-value on the path from data.value", () => {
176
+ edgesBox.current = [{ id: "test-edge", data: {} }];
177
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { value: 5, valueDomain: [0, 10] } })} />);
178
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
179
+ expect(path.getAttribute("data-value")).toBe("5");
180
+ });
181
+
182
+ it("omits data-weight/data-value entirely when neither is set", () => {
183
+ edgesBox.current = [{ id: "test-edge", data: {} }];
184
+ render(<FlowWeightedEdge {...makeEdgeProps()} />);
185
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
186
+ expect(path.hasAttribute("data-weight")).toBe(false);
187
+ expect(path.hasAttribute("data-value")).toBe(false);
188
+ });
189
+
190
+ // #286 — an edge is a real tab stop, so it must show a focus indicator with
191
+ // NO `selected` state and no consumer-supplied `onEdgesChange`. These lock
192
+ // the structure; `KeyboardFocus` in the stories locks the rendered result
193
+ // (resolved computed values in a real browser, both themes).
194
+ it("draws the compound focus indicator with no `selected` state (#286)", () => {
195
+ edgesBox.current = [{ id: "test-edge", data: { weight: 5 } }];
196
+ const { container } = render(
197
+ <FlowWeightedEdge {...makeEdgeProps({ selected: false, data: { weight: 5 } })} />,
198
+ );
199
+ const contour = container.querySelector<SVGPathElement>(
200
+ '[data-slot="flow-edge-focus-contour"]',
201
+ );
202
+ const ring = container.querySelector<SVGPathElement>('[data-slot="flow-edge-focus-ring"]');
203
+ expect(contour).not.toBeNull();
204
+ expect(ring).not.toBeNull();
205
+
206
+ const edgeWidth = parseFloat(
207
+ screen.getByTestId("base-edge").querySelector("path")!.style.strokeWidth,
208
+ );
209
+ const contourWidth = parseFloat(contour!.getAttribute("stroke-width")!);
210
+ const ringWidth = parseFloat(ring!.getAttribute("stroke-width")!);
211
+ // Neutral contour outside the --ring band, both outside the edge itself.
212
+ expect(contourWidth).toBeGreaterThan(ringWidth);
213
+ expect(ringWidth).toBeGreaterThan(edgeWidth);
214
+
215
+ // Same geometry as the edge — a halo, not a second shape.
216
+ expect(contour!.getAttribute("d")).toBe(ring!.getAttribute("d"));
217
+
218
+ // Hidden until the ancestor g.react-flow__edge matches :focus-visible. The
219
+ // pattern is asserted rather than the literal class string so this file does
220
+ // not itself become a Tailwind candidate.
221
+ for (const layer of [contour!, ring!]) {
222
+ const cls = layer.getAttribute("class") ?? "";
223
+ expect(cls).toContain("opacity-0");
224
+ expect(cls).toMatch(/react-flow.+edge:focus-visible.+opacity-100/);
225
+ expect(cls).toContain("pointer-events-none");
226
+ }
227
+ });
228
+
229
+ it("uses --ring and a wider stroke when selected", () => {
230
+ edgesBox.current = [{ id: "test-edge", data: { weight: 5 } }];
231
+ render(<FlowWeightedEdge {...makeEdgeProps({ selected: true, data: { weight: 5 } })} />);
232
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
233
+ expect(path.style.stroke).toBe("var(--ring)");
234
+ });
235
+
236
+ it("colours the stroke when value + valueDomain are set (not the plain --flow-edge token)", () => {
237
+ edgesBox.current = [{ id: "test-edge", data: {} }];
238
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { value: 5, valueDomain: [0, 10] } })} />);
239
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
240
+ expect(path.style.stroke).not.toBe("var(--flow-edge)");
241
+ expect(path.style.stroke).toMatch(/^#[0-9a-f]{6}$/i);
242
+ });
243
+
244
+ it("uses getSmoothStepPath when data.path is 'smoothstep'", () => {
245
+ edgesBox.current = [{ id: "test-edge", data: {} }];
246
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { path: "smoothstep" } })} />);
247
+ expect(getSmoothStepPathMock).toHaveBeenCalled();
248
+ expect(getBezierPathMock).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it("uses getBezierPath by default", () => {
252
+ edgesBox.current = [{ id: "test-edge", data: {} }];
253
+ render(<FlowWeightedEdge {...makeEdgeProps()} />);
254
+ expect(getBezierPathMock).toHaveBeenCalled();
255
+ expect(getSmoothStepPathMock).not.toHaveBeenCalled();
256
+ });
257
+
258
+ // The return leg of a back edge is placed past the cards it crosses, NOT at the
259
+ // handles' midpoint: at the midpoint it runs behind the very nodes it connects, and
260
+ // since edges paint under nodes the reader sees two dashed stubs and no loop.
261
+ it("routes a back edge's return leg clear of every card in the band", () => {
262
+ edgesBox.current = [{ id: "test-edge", data: {} }];
263
+ nodesBox.current = [
264
+ { id: "node-a", position: { x: 0, y: 0 }, measured: { width: 176, height: 83 } },
265
+ { id: "node-b", position: { x: 220, y: 200 }, measured: { width: 176, height: 83 } },
266
+ // A third card on the same rank, connected to neither end of this edge.
267
+ { id: "node-c", position: { x: 440, y: 200 }, measured: { width: 176, height: 83 } },
268
+ ];
269
+ render(
270
+ <FlowWeightedEdge
271
+ {...makeEdgeProps({ sourceY: 283, targetY: 200, data: { variant: "back" } })}
272
+ />,
273
+ );
274
+ // 440 + 176 (the far side of the outermost card) + 40 (clearance).
275
+ expect(getSmoothStepPathMock).toHaveBeenCalledWith(expect.objectContaining({ centerX: 656 }));
276
+ });
277
+
278
+ it("leaves a back edge on React Flow's own midpoint while nothing is measured", () => {
279
+ edgesBox.current = [{ id: "test-edge", data: {} }];
280
+ nodesBox.current = [];
281
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { variant: "back" } })} />);
282
+ const args = getSmoothStepPathMock.mock.calls[0]![0] as Record<string, unknown>;
283
+ expect(args).not.toHaveProperty("centerX");
284
+ expect(args).not.toHaveProperty("centerY");
285
+ });
286
+ });
287
+
288
+ // #282 — the SSR fallback hexes (used whenever `--flow-edge-weak`/
289
+ // `--flow-edge-strong` can't be resolved from a live stylesheet — true SSR,
290
+ // or, as here, jsdom with no themes.css custom properties set, which
291
+ // `resolveTokenColor` treats identically via its `if (!raw) return fallback`
292
+ // branch) must clear WCAG 1.4.11's 3:1 non-text bar against BOTH reference
293
+ // themes' `--canvas` — not just the theme they happen to approximate. A pure
294
+ // SSR render can't know which theme will apply, so a single hex pair has to
295
+ // be safe under either one.
296
+ //
297
+ // The oklch→sRGB→luminance math below is a MINIMAL, self-contained
298
+ // reimplementation of `packages/tokens/src/color-contrast.ts` — that module
299
+ // isn't part of `@elabs-ai/components-tokens`'s public barrel (only
300
+ // `oklchToHex`/`resolveTokenColor` are), so this package can't reach it
301
+ // without a relative cross-package import. Keep this in sync with
302
+ // `color-contrast.ts` if that math ever changes; `themes-contrast.test.ts`
303
+ // is the source of truth for the underlying token values.
304
+ describe("FlowWeightedEdge SSR fallback contrast (#282)", () => {
305
+ function hexToSrgb01(hex: string): [number, number, number] {
306
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex)!;
307
+ return [parseInt(m[1]!, 16) / 255, parseInt(m[2]!, 16) / 255, parseInt(m[3]!, 16) / 255];
308
+ }
309
+
310
+ function relativeLuminance([r, g, b]: [number, number, number]): number {
311
+ const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
312
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
313
+ }
314
+
315
+ function contrastHexVsHex(a: string, b: string): number {
316
+ const la = relativeLuminance(hexToSrgb01(a));
317
+ const lb = relativeLuminance(hexToSrgb01(b));
318
+ const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
319
+ return (hi + 0.05) / (lo + 0.05);
320
+ }
321
+
322
+ // `--canvas` in each reference theme (packages/tokens/src/themes/light.css,
323
+ // themes/dark.css), pre-converted to hex via the same oklch math — kept as
324
+ // hex here rather than re-deriving oklch→sRGB locally a second time.
325
+ const LIGHT_CANVAS_HEX = "#f4f5f6"; // oklch(0.97 0.002 257)
326
+ const DARK_CANVAS_HEX = "#0e1217"; // oklch(0.18 0.012 257)
327
+ const AA_NONTEXT = 3;
328
+
329
+ function strokeAt(value: number, valueDomain: [number, number]): string {
330
+ edgesBox.current = [{ id: "test-edge", data: {} }];
331
+ render(<FlowWeightedEdge {...makeEdgeProps({ data: { value, valueDomain } })} />);
332
+ const path = screen.getByTestId("base-edge").querySelector("path")!;
333
+ const stroke = path.style.stroke;
334
+ cleanup();
335
+ return stroke;
336
+ }
337
+
338
+ it("FALLBACK_WEAK (t=0) clears 3:1 on light's and dark's --canvas", () => {
339
+ const stroke = strokeAt(0, [0, 10]);
340
+ expect(stroke).toMatch(/^#[0-9a-f]{6}$/i);
341
+ const vsLight = contrastHexVsHex(stroke, LIGHT_CANVAS_HEX);
342
+ const vsDark = contrastHexVsHex(stroke, DARK_CANVAS_HEX);
343
+ expect(
344
+ vsLight,
345
+ `FALLBACK_WEAK ${stroke} vs light --canvas = ${vsLight.toFixed(2)}`,
346
+ ).toBeGreaterThanOrEqual(AA_NONTEXT);
347
+ expect(
348
+ vsDark,
349
+ `FALLBACK_WEAK ${stroke} vs dark --canvas = ${vsDark.toFixed(2)}`,
350
+ ).toBeGreaterThanOrEqual(AA_NONTEXT);
351
+ });
352
+
353
+ it("FALLBACK_STRONG (t=1) clears 3:1 on light's and dark's --canvas", () => {
354
+ const stroke = strokeAt(10, [0, 10]);
355
+ expect(stroke).toMatch(/^#[0-9a-f]{6}$/i);
356
+ const vsLight = contrastHexVsHex(stroke, LIGHT_CANVAS_HEX);
357
+ const vsDark = contrastHexVsHex(stroke, DARK_CANVAS_HEX);
358
+ expect(
359
+ vsLight,
360
+ `FALLBACK_STRONG ${stroke} vs light --canvas = ${vsLight.toFixed(2)}`,
361
+ ).toBeGreaterThanOrEqual(AA_NONTEXT);
362
+ expect(
363
+ vsDark,
364
+ `FALLBACK_STRONG ${stroke} vs dark --canvas = ${vsDark.toFixed(2)}`,
365
+ ).toBeGreaterThanOrEqual(AA_NONTEXT);
366
+ });
367
+ });
368
+
369
+ // #286 — the focus indicator's neutral contour is what carries WCAG 1.4.11's
370
+ // 3:1 non-text bar, because `--ring` alone measures 1.30:1 against `--canvas`
371
+ // in the `light` reference theme. That makes `--foreground` vs `--canvas` a
372
+ // load-bearing token pairing for the flow package, and nothing else gates it:
373
+ // `--canvas` is not one of the five MARK_SURFACES in
374
+ // packages/tokens/src/themes-contrast.test.ts. Values are the literals in
375
+ // packages/tokens/src/themes/{light,dark}.css.
376
+ describe("edge focus contour contrast (#286)", () => {
377
+ const THEMES = [
378
+ { name: "light", foreground: "oklch(0.3 0.021 257)", canvas: "oklch(0.97 0.002 257)" },
379
+ { name: "dark", foreground: "oklch(0.95 0.004 257)", canvas: "oklch(0.18 0.012 257)" },
380
+ ] as const;
381
+
382
+ function srgb01(hex: string): [number, number, number] {
383
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex)!;
384
+ return [parseInt(m[1]!, 16) / 255, parseInt(m[2]!, 16) / 255, parseInt(m[3]!, 16) / 255];
385
+ }
386
+ function luminance([r, g, b]: [number, number, number]): number {
387
+ const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
388
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
389
+ }
390
+
391
+ it.each(THEMES)(
392
+ "--foreground clears 3:1 against --canvas in $name",
393
+ ({ name, foreground, canvas }) => {
394
+ const [la, lb] = [
395
+ luminance(srgb01(oklchToHex(foreground)!)),
396
+ luminance(srgb01(oklchToHex(canvas)!)),
397
+ ].sort((x, y) => y - x);
398
+ const ratio = (la! + 0.05) / (lb! + 0.05);
399
+ expect(
400
+ ratio,
401
+ `--foreground vs --canvas in ${name} = ${ratio.toFixed(2)}:1`,
402
+ ).toBeGreaterThanOrEqual(3);
403
+ },
404
+ );
405
+ });