@elabs-ai/components-process 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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/core/index.d.ts +1029 -0
  4. package/dist/core/index.js +1553 -0
  5. package/dist/core/index.js.map +1 -0
  6. package/dist/core/process-worker.js +462 -0
  7. package/dist/core/process-worker.js.map +1 -0
  8. package/dist/index.d.ts +1153 -0
  9. package/dist/index.js +3146 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/test/index.d.ts +196 -0
  12. package/dist/test/index.js +527 -0
  13. package/dist/test/index.js.map +1 -0
  14. package/package.json +80 -0
  15. package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
  16. package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
  17. package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
  18. package/src/abstraction-controls/abstraction-controls.tsx +288 -0
  19. package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
  20. package/src/abstraction-controls/auto-abstraction.ts +128 -0
  21. package/src/abstraction-controls/index.ts +4 -0
  22. package/src/core/abstract-graph.test.ts +209 -0
  23. package/src/core/abstract-graph.ts +407 -0
  24. package/src/core/adapters/csv.test.ts +131 -0
  25. package/src/core/adapters/csv.ts +146 -0
  26. package/src/core/adapters/flat.test.ts +149 -0
  27. package/src/core/adapters/flat.ts +168 -0
  28. package/src/core/aggregate-performance.test.ts +208 -0
  29. package/src/core/aggregate-performance.ts +200 -0
  30. package/src/core/detect-rework.test.ts +134 -0
  31. package/src/core/detect-rework.ts +100 -0
  32. package/src/core/discover-graph.test.ts +378 -0
  33. package/src/core/discover-graph.ts +202 -0
  34. package/src/core/duration-stats.test.ts +116 -0
  35. package/src/core/duration-stats.ts +162 -0
  36. package/src/core/event-log.test.ts +224 -0
  37. package/src/core/event-log.ts +244 -0
  38. package/src/core/extract-variants.test.ts +126 -0
  39. package/src/core/extract-variants.ts +140 -0
  40. package/src/core/filter-log.test.ts +193 -0
  41. package/src/core/filter-log.ts +215 -0
  42. package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
  43. package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
  44. package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
  45. package/src/core/fixtures/order-to-cash-small.json +200 -0
  46. package/src/core/fixtures/synthetic-log.test.ts +109 -0
  47. package/src/core/fixtures/synthetic-log.ts +167 -0
  48. package/src/core/index.ts +118 -0
  49. package/src/core/reconcile-graph.test.ts +175 -0
  50. package/src/core/reconcile-graph.ts +107 -0
  51. package/src/core/scale.test.ts +80 -0
  52. package/src/core/scale.ts +100 -0
  53. package/src/core/types.ts +151 -0
  54. package/src/core/worker/create-process-worker.test.ts +255 -0
  55. package/src/core/worker/create-process-worker.ts +211 -0
  56. package/src/core/worker/process-worker.ts +80 -0
  57. package/src/index.ts +29 -0
  58. package/src/metric-layer-switch/index.ts +6 -0
  59. package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
  60. package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
  61. package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
  62. package/src/process-explorer.stories.tsx +392 -0
  63. package/src/process-kpi-strip/index.ts +6 -0
  64. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
  65. package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
  66. package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
  67. package/src/process-map/index.ts +13 -0
  68. package/src/process-map/map-model.test.ts +326 -0
  69. package/src/process-map/map-model.ts +873 -0
  70. package/src/process-map/process-activity-node.tsx +200 -0
  71. package/src/process-map/process-map-context.ts +71 -0
  72. package/src/process-map/process-map.stories.tsx +673 -0
  73. package/src/process-map/process-map.test.tsx +523 -0
  74. package/src/process-map/process-map.tsx +979 -0
  75. package/src/process-map/process-transition-edge.test.tsx +160 -0
  76. package/src/process-map/process-transition-edge.tsx +151 -0
  77. package/src/process-map/use-process-layout.test.tsx +265 -0
  78. package/src/process-map/use-process-layout.ts +315 -0
  79. package/src/test/contract.test.ts +99 -0
  80. package/src/test/contract.ts +118 -0
  81. package/src/test/doubles.test.tsx +51 -0
  82. package/src/test/doubles.tsx +82 -0
  83. package/src/test/index.ts +34 -0
  84. package/src/test/primitives.tsx +35 -0
  85. package/src/use-process-explorer/index.ts +8 -0
  86. package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
  87. package/src/use-process-explorer/use-process-explorer.ts +540 -0
@@ -0,0 +1,673 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { useState } from "react";
4
+ import { expect, userEvent, waitFor, within } from "storybook/test";
5
+ import {
6
+ endpointsOffHandles,
7
+ framingMisses,
8
+ handlesOffCard,
9
+ labelsOverNodes,
10
+ miniMapNodeCount,
11
+ waitForSettledCanvas,
12
+ } from "@elabs-ai/components-flow/test";
13
+ import { abstractGraph } from "../core/abstract-graph";
14
+ import { detectRework } from "../core/detect-rework";
15
+ import { discoverGraph } from "../core/discover-graph";
16
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
17
+ import type { ActivityStats, DurationStats, ProcessGraph, TransitionStats } from "../core/types";
18
+ import { useProcessExplorer } from "../use-process-explorer";
19
+ import { PROCESS_MAP_LEGIBLE_ZOOM, ProcessMap } from "./process-map";
20
+ import type { ProcessSelection } from "./map-model";
21
+
22
+ const log = generateSyntheticLog({ cases: 240, seed: 42 });
23
+ const graph = discoverGraph(log);
24
+ const rework = detectRework(log);
25
+
26
+ /**
27
+ * A 60-activity graph. The shipped synthetic log has eleven activities by design, so the
28
+ * scale story builds its own — a wide chain with skip paths and a few loops, which is the
29
+ * shape the "under 200 ms" layout budget is stated over.
30
+ */
31
+ function largeGraph(size = 60): ProcessGraph {
32
+ const zero: DurationStats = {
33
+ min: 0,
34
+ max: 0,
35
+ mean: 0,
36
+ median: 0,
37
+ p90: 0,
38
+ sum: 0,
39
+ trimmedMean: 0,
40
+ };
41
+ const ids = Array.from({ length: size }, (_, i) => `Step ${String(i + 1).padStart(2, "0")}`);
42
+ const activities: ActivityStats[] = ids.map((id, index) => ({
43
+ id,
44
+ label: id,
45
+ instances: size * 4 - index * 3,
46
+ cases: size - index,
47
+ isStart: index === 0,
48
+ isEnd: index === size - 1,
49
+ duration: { ...zero, median: (index + 1) * 900_000 },
50
+ }));
51
+ const transitions: TransitionStats[] = [];
52
+ const push = (source: string, target: string, count: number) => {
53
+ transitions.push({
54
+ source,
55
+ target,
56
+ count,
57
+ caseCount: Math.max(1, Math.round(count * 0.8)),
58
+ duration: { ...zero, median: count * 60_000 },
59
+ isSelfLoop: source === target,
60
+ isBackEdge: false,
61
+ });
62
+ };
63
+ for (let i = 0; i < ids.length - 1; i += 1) {
64
+ push(ids[i]!, ids[i + 1]!, size * 3 - i * 2);
65
+ if (i + 2 < ids.length) push(ids[i]!, ids[i + 2]!, Math.max(2, size - i));
66
+ if (i % 9 === 4) push(ids[i]!, ids[i]!, 6);
67
+ if (i % 13 === 7 && i > 3) push(ids[i]!, ids[i - 3]!, 5);
68
+ }
69
+ return {
70
+ activities,
71
+ transitions,
72
+ startActivities: { [ids[0]!]: size },
73
+ endActivities: { [ids[size - 1]!]: size },
74
+ totals: { cases: size, events: size * 6, variants: 9 },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * The four things this canvas has to get right about its own picture, asserted in a REAL
80
+ * browser because none of them is visible to a typecheck and none is measurable in jsdom
81
+ * (which reports every box as 0×0, so every one of these assertions would pass vacuously).
82
+ *
83
+ * Each line locks one defect that actually shipped on this map:
84
+ *
85
+ * 1. **Framing** — the map opened at React Flow's `minZoom` floor of `0.5` with five of
86
+ * eleven activities below the fold, because `fitView` fires once and fired before the
87
+ * layout hook had positions.
88
+ * 2. **Connectors** — every handle dot sat 24 px (top-to-bottom) to 69 px (left-to-right)
89
+ * off the card it belonged to: the metric meter was rendered as a SIBLING of the card,
90
+ * and React Flow lays handles out against the nearest positioned ancestor.
91
+ * 3. **Edges** — an edge path that stops short of a dot reads as a broken graph. Both edge
92
+ * types are checked, including the self-loop, which used to draw an unattached arc
93
+ * floating above the node.
94
+ * 4. **Labels** — an edge's metric pill is portalled out of the SVG, so it contributes
95
+ * nothing to the layout maths; three pills per direction printed on top of activity
96
+ * names before the within-rank gap was widened.
97
+ *
98
+ * `waitForSettledCanvas` first, and it is not optional politeness about timing: an
99
+ * unlaid-out canvas has every node stacked on one point, where all four assertions below
100
+ * pass for the wrong reason. Measured — this helper passed in 129 ms against a picture in
101
+ * which eleven activities sat on top of each other.
102
+ */
103
+ async function expectWellFramedCanvas(canvasElement: HTMLElement) {
104
+ await waitForSettledCanvas(canvasElement);
105
+
106
+ // 1. Everything in frame — or, once the graph outgrows the pane, the opening zoom
107
+ // sitting on the legibility floor with the START of the process in frame. The map
108
+ // is allowed to open partial; it is not allowed to open on the middle of nowhere.
109
+ expect(framingMisses(canvasElement, { legibleZoom: PROCESS_MAP_LEGIBLE_ZOOM })).toEqual([]);
110
+
111
+ // 2. Every connector dot on its own card's border.
112
+ expect(handlesOffCard(canvasElement)).toEqual([]);
113
+
114
+ // 3. Every edge — plain transition AND self-loop — terminating on a dot.
115
+ expect(endpointsOffHandles(canvasElement, "flow-weighted-edge")).toEqual([]);
116
+ expect(endpointsOffHandles(canvasElement, "flow-self-loop-edge")).toEqual([]);
117
+
118
+ // 4. No metric pill printed on top of an activity.
119
+ expect(labelsOverNodes(canvasElement)).toEqual([]);
120
+
121
+ // The overview thumbnail draws a rectangle per activity — it rendered blank while the
122
+ // measurements React Flow takes never reached the node objects the minimap reads.
123
+ expect(miniMapNodeCount(canvasElement)).toBe(
124
+ canvasElement.querySelectorAll(".react-flow__node").length,
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Locks #350: `ZoomControls` and `FlowMiniMap` used to both resolve to the React Flow
130
+ * `bottom-right` panel slot, which is absolutely positioned — the pannable/zoomable
131
+ * minimap `<svg>` painted over the zoom buttons and took every pointer event meant for
132
+ * them.
133
+ *
134
+ * The hit test is the user-visible property: whatever `document.elementFromPoint` returns
135
+ * at a button's own centre is what a real click actually lands on, so a synthetic
136
+ * `.click()` (which fires regardless of what is drawn on top) would pass right through
137
+ * this exact bug — the reason the issue explicitly rules that approach out. The rect
138
+ * check is kept alongside it because it explains a hit-test failure with two numbers
139
+ * instead of a screen coordinate, and it also catches the two panels drifting back
140
+ * together at a size where they happen not to share a centre point.
141
+ *
142
+ * Also checked: neither panel overlaps the top rail (legend + filter trigger), which sits
143
+ * in its own `pointer-events-none` wrapper at the top of the canvas.
144
+ */
145
+ function expectClickableZoomControls(canvasElement: HTMLElement) {
146
+ const rectsIntersect = (a: DOMRect, b: DOMRect) =>
147
+ a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
148
+
149
+ for (const label of ["Zoom in", "Zoom out", "Fit view"]) {
150
+ const button = within(canvasElement).getByRole("button", { name: label });
151
+ const rect = button.getBoundingClientRect();
152
+ const cx = rect.left + rect.width / 2;
153
+ const cy = rect.top + rect.height / 2;
154
+ const hit = document.elementFromPoint(cx, cy);
155
+ expect(hit, `expected "${label}" to be the topmost element at its own centre`).toBeTruthy();
156
+ expect(
157
+ hit === button || button.contains(hit),
158
+ `"${label}" is covered at its own centre by ${hit?.outerHTML ?? "nothing"}`,
159
+ ).toBe(true);
160
+ }
161
+
162
+ const zoomInButton = within(canvasElement).getByRole("button", { name: "Zoom in" });
163
+ const zoomPanel = zoomInButton.closest<HTMLElement>(".react-flow__panel");
164
+ const miniMap = canvasElement.querySelector<HTMLElement>(".react-flow__minimap");
165
+ const topRail = canvasElement.querySelector<HTMLElement>('[data-slot="process-map-top-rail"]');
166
+ expect(zoomPanel, "no .react-flow__panel around the zoom controls").toBeTruthy();
167
+
168
+ if (miniMap) {
169
+ expect(
170
+ rectsIntersect(zoomPanel!.getBoundingClientRect(), miniMap.getBoundingClientRect()),
171
+ "ZoomControls and FlowMiniMap bounding boxes intersect",
172
+ ).toBe(false);
173
+ }
174
+
175
+ if (topRail) {
176
+ const railRect = topRail.getBoundingClientRect();
177
+ expect(
178
+ rectsIntersect(zoomPanel!.getBoundingClientRect(), railRect),
179
+ "ZoomControls overlaps the top rail (legend + filter trigger)",
180
+ ).toBe(false);
181
+ if (miniMap) {
182
+ expect(
183
+ rectsIntersect(miniMap.getBoundingClientRect(), railRect),
184
+ "FlowMiniMap overlaps the top rail (legend + filter trigger)",
185
+ ).toBe(false);
186
+ }
187
+ }
188
+ }
189
+
190
+ const meta = {
191
+ title: "Process/ProcessMap",
192
+ component: ProcessMap,
193
+ tags: ["autodocs"],
194
+ parameters: {
195
+ layout: "fullscreen",
196
+ docs: {
197
+ description: {
198
+ component:
199
+ "The directly-follows map a process-mining session opens on: activities as " +
200
+ "nodes, directly-follows relations as edges, both painted from one metric " +
201
+ "choice. It is a composition of `@elabs-ai/components-flow` — `CanvasShell`, " +
202
+ "`layoutFlow`, `ZoomControls`, `FlowMiniMap` and a continuous `Legend` — over " +
203
+ "the framework-free graph from `@elabs-ai/components-process/core`. " +
204
+ "Every reading reaches the viewer through at least two channels: an edge " +
205
+ "carries its metric as stroke WIDTH and as a printed label pill before it " +
206
+ "carries it as hue, and a node prints its value above a meter bar whose LENGTH " +
207
+ "encodes the same number. Self-loops are a closed arc, back-edges are dashed, " +
208
+ "and a filter-excluded element is dimmed — never removed, never " +
209
+ "`aria-disabled` — so nothing on this canvas is legible only in colour, and " +
210
+ "clicking a dimmed element is how a reader filters it back in. `tableView` " +
211
+ "renders the identical numbers as two tables, sharing one formatter with the " +
212
+ "canvas so the two can never disagree.",
213
+ },
214
+ },
215
+ },
216
+ decorators: [
217
+ (Story) => (
218
+ <div className="h-[36rem] w-full bg-background">
219
+ <Story />
220
+ </div>
221
+ ),
222
+ ],
223
+ } satisfies Meta<typeof ProcessMap>;
224
+ export default meta;
225
+ type Story = StoryObj<typeof meta>;
226
+
227
+ /**
228
+ * The default reading: cases per activity, transitions per edge. Edge width and the
229
+ * printed pill both scale with the count; the node's meter bar and printed value both
230
+ * scale with its case count.
231
+ */
232
+ export const Frequency: Story = {
233
+ args: {
234
+ graph,
235
+ metric: { node: "absolute_case", edge: "absolute" },
236
+ },
237
+ play: async ({ canvasElement }) => {
238
+ const canvas = within(canvasElement);
239
+ await expectWellFramedCanvas(canvasElement);
240
+ expectClickableZoomControls(canvasElement);
241
+
242
+ // Click-to-select: a node picks itself, and the rest of the graph reads as excluded.
243
+ const nodes = canvasElement.querySelectorAll<HTMLElement>(
244
+ '[data-slot="process-activity-node"]',
245
+ );
246
+ await userEvent.click(nodes[0]!);
247
+ await waitFor(() =>
248
+ expect(canvasElement.querySelector('[data-selection="selected"]')).toBeTruthy(),
249
+ );
250
+ await waitFor(() =>
251
+ expect(canvasElement.querySelector('[data-selection="excluded"]')).toBeTruthy(),
252
+ );
253
+
254
+ // The legend names the edge metric, so a width is never an unlabelled width.
255
+ await expect(canvas.getByText("Transitions")).toBeInTheDocument();
256
+ },
257
+ };
258
+
259
+ /**
260
+ * The same graph read as time: median activity duration on the nodes, median flow time on
261
+ * the edges. The value is always printed — a duration ramp is never asked to carry the
262
+ * reading on its own.
263
+ */
264
+ export const Performance: Story = {
265
+ args: {
266
+ graph,
267
+ metric: { node: "median", edge: "median" },
268
+ },
269
+ };
270
+
271
+ /**
272
+ * Rework tallies from `detectRework`, badged on the activities that repeat. The badge is a
273
+ * count and an `sr-only` sentence, not a colour.
274
+ */
275
+ export const Rework: Story = {
276
+ args: {
277
+ graph,
278
+ metric: { node: "absolute", edge: "absolute", secondary: "median" },
279
+ rework,
280
+ },
281
+ };
282
+
283
+ /** Half the activities and a third of the paths kept — `/core`'s `abstractGraph`. */
284
+ export const Abstracted: Story = {
285
+ args: {
286
+ graph: abstractGraph(graph, { activities: 0.5, paths: 0.35 }),
287
+ metric: { node: "absolute_case", edge: "absolute" },
288
+ },
289
+ };
290
+
291
+ /**
292
+ * A controlled selection, plus the keyboard path, asserted by a REAL `Tab` walk rather
293
+ * than a synthetic `focus()` call: `Tab` reaches every activity in layout order, `Enter`
294
+ * selects the one it is standing on, `F` opens the filter-intent menu for it, and
295
+ * `Escape` puts focus back on the activity instead of on the Filter button.
296
+ *
297
+ * Wired through `useProcessExplorer` (RM-052 round 2, #227) rather than a bare `useState`,
298
+ * so `onFilterIntent` is the hook's real `applyIntent` — choosing an intent from the menu
299
+ * this story already opens does not shrink the canvas. The tail of the play function
300
+ * proves it: applying a real filter dims the activities it drops but leaves every one of
301
+ * them in the DOM (Invariant F).
302
+ */
303
+ export const Selection: Story = {
304
+ args: {
305
+ graph,
306
+ metric: { node: "absolute_case", edge: "absolute" },
307
+ },
308
+ render: function SelectionStory(args) {
309
+ const explorer = useProcessExplorer(log);
310
+ const [selection, setSelection] = useState<ProcessSelection | null>({
311
+ kind: "activity",
312
+ id: graph.activities[0]!.id,
313
+ });
314
+ return (
315
+ <ProcessMap
316
+ {...args}
317
+ graph={explorer.graph}
318
+ selection={selection}
319
+ onSelect={setSelection}
320
+ selectionStates={explorer.selectionStates}
321
+ onFilterIntent={explorer.applyIntent}
322
+ />
323
+ );
324
+ },
325
+ play: async ({ canvasElement }) => {
326
+ await waitFor(() =>
327
+ expect(canvasElement.querySelector('[data-selection="selected"]')).toBeTruthy(),
328
+ );
329
+ // The selection lands before the layout does, so waiting on it is not enough: this
330
+ // snapshot of DOM order is only meaningful once the map has been laid out, because
331
+ // `applyPositions` re-sorts the nodes into reading order as part of that pass. Taken a
332
+ // frame early it captures the model's own alphabetical order instead, and the tab-order
333
+ // assertion below then compares a real traversal against a picture that never rendered.
334
+ await waitForSettledCanvas(canvasElement);
335
+
336
+ const wrappers = [...canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")];
337
+ expect(wrappers.length).toBeGreaterThan(1);
338
+
339
+ // ── DOM order IS layout order ────────────────────────────────────────────────────
340
+ // Read each node's own `translate(Xpx, Ypx)` out of `wrapper.style.transform` —
341
+ // React Flow's authoritative, un-animated position (written straight from
342
+ // `node.position` — see `applyPositions`, `process-map.tsx`) — rather than calling
343
+ // `getBoundingClientRect()`. A measured rectangle is wrong here for TWO independent
344
+ // reasons, not one: (1) focusing a node pans the viewport (`autoPanOnNodeFocus`),
345
+ // which this still runs before, and (2) every node also carries the map's own 260ms
346
+ // entry transition, sliding it in from the model's mount position `{x:0, y:0}`
347
+ // (`PROCESS_MAP_NODE_MOTION_CLASS`, `use-process-layout.ts`) — so a rectangle sampled
348
+ // this early can be a mid-flight animation frame rather than the laid-out position
349
+ // (#365). The inline `transform` is a value a CSS transition never touches, so it
350
+ // reads the final, laid-out coordinate on the very first commit that has it, whatever
351
+ // frame the animation itself is on. The map is laid out `TB`, so reading order is
352
+ // top-to-bottom and then left-to-right — exactly what `applyPositions` sorts by, and
353
+ // therefore what the tab order below inherits.
354
+ const positions = wrappers.map((wrapper) => {
355
+ const match = /translate\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px\s*\)/.exec(
356
+ wrapper.style.transform,
357
+ );
358
+ expect(
359
+ match,
360
+ `expected a translate() transform on node "${wrapper.dataset.id}"`,
361
+ ).toBeTruthy();
362
+ return { x: Number(match![1]), y: Number(match![2]) };
363
+ });
364
+ for (let i = 1; i < positions.length; i += 1) {
365
+ const previous = positions[i - 1]!;
366
+ const current = positions[i]!;
367
+ // dagre gives an entire rank the SAME y — an exact tie, not a 1px tolerance, because
368
+ // this is the laid-out coordinate rather than a painted, sub-pixel rectangle.
369
+ const sameRow = previous.y === current.y;
370
+ expect({
371
+ index: i,
372
+ y: [previous.y, current.y],
373
+ x: [previous.x, current.x],
374
+ ordered: sameRow ? previous.x <= current.x : previous.y < current.y,
375
+ }).toMatchObject({ ordered: true });
376
+ }
377
+
378
+ // ── A real Tab walk ─────────────────────────────────────────────────────────────
379
+ // From the top of the document, so this measures the traversal a keyboard user
380
+ // actually performs. A synthetic `.focus()` would prove the element is focusable and
381
+ // nothing about reachability or order.
382
+ (document.activeElement as HTMLElement | null)?.blur();
383
+ const visited: HTMLElement[] = [];
384
+ const visitedNodes: HTMLElement[] = [];
385
+ for (let i = 0; i < 80 && visitedNodes.length < wrappers.length; i += 1) {
386
+ await userEvent.tab();
387
+ const active = document.activeElement as HTMLElement | null;
388
+ if (!active || active === document.body) break;
389
+ visited.push(active);
390
+ if (active.classList.contains("react-flow__node")) visitedNodes.push(active);
391
+ }
392
+
393
+ // Every activity is reached, and in the same order the DOM (and so the layout) has
394
+ // them — this is the acceptance criterion, asserted against the real traversal.
395
+ expect(visitedNodes.map((node) => node.dataset.id)).toEqual(
396
+ wrappers.map((wrapper) => wrapper.dataset.id),
397
+ );
398
+
399
+ // An arrow is ONE tab stop (its label pill), never two: the edge `<g>` itself is not
400
+ // focusable, so the activities are not buried behind a second stop per edge.
401
+ expect(visited.filter((element) => element.classList.contains("react-flow__edge"))).toEqual([]);
402
+ const pills = canvasElement.querySelectorAll('[data-slot="edge-label-pill"]');
403
+ expect(visited.indexOf(visitedNodes[0]!)).toBe(pills.length);
404
+
405
+ // ── Enter selects, F opens the menu, Escape comes back ───────────────────────────
406
+ const standingOn = visitedNodes.at(-1)!;
407
+ expect(document.activeElement).toBe(standingOn);
408
+ await userEvent.keyboard("{Enter}");
409
+ await waitFor(() =>
410
+ expect(
411
+ standingOn
412
+ .querySelector('[data-slot="process-activity-node"]')
413
+ ?.getAttribute("data-selection"),
414
+ ).toBe("selected"),
415
+ );
416
+
417
+ await userEvent.keyboard("f");
418
+ const menu = await waitFor(() => {
419
+ const found = document.querySelector<HTMLElement>('[role="menu"]');
420
+ expect(found).toBeTruthy();
421
+ return found!;
422
+ });
423
+ expect(within(menu).getAllByRole("menuitem")).toHaveLength(4);
424
+
425
+ // Escape returns focus to the activity the user was standing on — not to the Filter
426
+ // trigger, which is Radix's default and would drop a keyboard user out of the graph.
427
+ await userEvent.keyboard("{Escape}");
428
+ await waitFor(() => expect(document.activeElement).toBe(standingOn));
429
+
430
+ // ── The same two keys, on an ARROW ───────────────────────────────────────────────
431
+ // An edge's label pill is portalled out of the edge's own `<g>` by
432
+ // `EdgeLabelRenderer`, so it has no `[data-id]` ancestor to read; the map recovers the
433
+ // edge id by catching the key on the edge component itself, where portal events bubble
434
+ // in the React tree. A transition offers BOTH endpoints, so eight menu items rather
435
+ // than a node's four is the observable proof the right target was resolved.
436
+ const shapes = [
437
+ ...canvasElement.querySelectorAll<SVGGElement>('[data-slot="process-transition-edge"]'),
438
+ ];
439
+ const forward = shapes.findIndex((shape) => shape.dataset.shape !== "self-loop");
440
+ expect(forward).toBeGreaterThanOrEqual(0);
441
+
442
+ (document.activeElement as HTMLElement | null)?.blur();
443
+ for (let i = 0; i <= forward; i += 1) await userEvent.tab();
444
+ const pill = document.activeElement as HTMLElement;
445
+ expect(pill).toHaveAttribute("data-slot", "edge-label-pill");
446
+
447
+ const pillIndex = [
448
+ ...canvasElement.querySelectorAll<HTMLElement>('[data-slot="edge-label-pill"]'),
449
+ ].indexOf(pill);
450
+
451
+ await userEvent.keyboard("f");
452
+ const edgeMenu = await waitFor(() => {
453
+ const found = document.querySelector<HTMLElement>('[role="menu"]');
454
+ expect(found).toBeTruthy();
455
+ return found!;
456
+ });
457
+ expect(within(edgeMenu).getAllByRole("menuitem")).toHaveLength(8);
458
+
459
+ // Escape comes back to the ARROW too. Compared by position rather than by node
460
+ // identity: React Flow re-creates every portalled label pill on a re-render, so the
461
+ // button the user was standing on is a different DOM node by the time the menu closes
462
+ // — which is exactly what `focusRestorer` exists to survive.
463
+ await userEvent.keyboard("{Escape}");
464
+ await waitFor(() => {
465
+ const active = document.activeElement as HTMLElement;
466
+ expect(active.dataset.slot).toBe("edge-label-pill");
467
+ expect(
468
+ [...canvasElement.querySelectorAll<HTMLElement>('[data-slot="edge-label-pill"]')].indexOf(
469
+ active,
470
+ ),
471
+ ).toBe(pillIndex);
472
+ });
473
+
474
+ // ── Filtering re-inks, it never removes (Invariant F, RM-052 round 2, #227) ──────
475
+ // `onFilterIntent` is wired straight into `useProcessExplorer`'s own `applyIntent` —
476
+ // choosing a real intent from the menu this story already knows how to open must dim
477
+ // the activities it drops, never delete them from the canvas.
478
+ //
479
+ // Non-vacuity fix (RM-052 round 3, #227, G6): `standingOn` was selected via `Enter`
480
+ // earlier in this same play function and stays selected here, so
481
+ // `resolveSelectionState`'s neighbourhood rule (an activity outside the selection's
482
+ // one-hop neighbourhood reads "excluded" once something else is "selected") already
483
+ // paints excluded nodes BEFORE the filter click below ever runs — a bare
484
+ // `toBeTruthy()` on "some excluded node exists" would pass even if the filter click
485
+ // changed nothing. Capture the excluded count before the click and require it to
486
+ // strictly grow, so the assertion actually depends on the filter's effect rather than
487
+ // on the pre-existing selection.
488
+ //
489
+ // Menu item [1] ("Keep cases without" <activity>), not [0] ("Keep cases containing"
490
+ // <activity>) — and NOT `standingOn` (RM-052 round 4, #227, H2, correcting round 3's
491
+ // G6 comment). The `f` press right above this block fires while focus sits on the
492
+ // edge's label pill (restored by the Escape at line ~319), so `targetOfEvent` resolves
493
+ // a TRANSITION, and `menuActivities` returns `[edge.source, edge.target]` for one —
494
+ // this is the 8-item EDGE menu, not the 4-item activity menu `standingOn` would open.
495
+ // Menu item [1] belongs to the FIRST group, i.e. `edge.source`: measured directly by
496
+ // dumping the open menu in a real browser, that edge's source is common to (or the
497
+ // only start activity of) effectively every case, so "containing" it is a no-op filter
498
+ // — confirmed by running this fixture with item [0] and observing the excluded count
499
+ // stay flat at 29 before and after the click, i.e. exactly the false pass this lock
500
+ // exists to prevent. "without" instead drops the cases through `edge.source`, so other
501
+ // activities lose their statistics and the excluded count measurably grows (29 → 35).
502
+ const totalNodesBeforeFilter = wrappers.length;
503
+ const excludedCountBeforeFilter = canvasElement.querySelectorAll(
504
+ '[data-selection="excluded"]',
505
+ ).length;
506
+ // #375: the live region's text is captured BEFORE the click too, so the assertion
507
+ // below proves the SAME click that re-inks the canvas also moves the announcement —
508
+ // not merely that some non-empty sentence happens to be present at rest.
509
+ const statusBeforeFilter = canvasElement.querySelector('[role="status"]')!.textContent;
510
+ await userEvent.keyboard("f");
511
+ const filterMenu = await waitFor(() => {
512
+ const found = document.querySelector<HTMLElement>('[role="menu"]');
513
+ expect(found).toBeTruthy();
514
+ return found!;
515
+ });
516
+ await userEvent.click(within(filterMenu).getAllByRole("menuitem")[1]!);
517
+
518
+ await waitFor(() =>
519
+ expect(canvasElement.querySelectorAll('[data-selection="excluded"]').length).toBeGreaterThan(
520
+ excludedCountBeforeFilter,
521
+ ),
522
+ );
523
+ expect(canvasElement.querySelectorAll('[data-slot="process-activity-node"]').length).toBe(
524
+ totalNodesBeforeFilter,
525
+ );
526
+
527
+ // ── The live region announces what the click just did (#375) ────────────────────
528
+ // Filtering re-inks the canvas, but nothing narrated it outside the DOM — this is
529
+ // the exact gap #375 closes: the SAME real filter click above must also move the
530
+ // one `role="status"` region's text off its pre-click content.
531
+ const statusAfterFilter = canvasElement.querySelector('[role="status"]')!.textContent;
532
+ expect(statusAfterFilter).not.toBe(statusBeforeFilter);
533
+ expect(statusAfterFilter).not.toMatch(/^0 of \d+ activities? excluded/);
534
+ },
535
+ };
536
+
537
+ /**
538
+ * Left-to-right layout. The same cached model; only the layout key changes.
539
+ *
540
+ * It repeats the whole framing check rather than trusting the top-to-bottom pass, because
541
+ * the two directions put the handles on different sides of the card and every one of the
542
+ * defects {@link expectWellFramedCanvas} locks behaved DIFFERENTLY here: the connector
543
+ * drift measured 69 px against top-to-bottom's 24 px, and it was the direction FLIP that
544
+ * triggered it — the node's motion transition was reaching React Flow's handle elements as
545
+ * well as the node body, so a dot animated to its new side while React Flow measured it.
546
+ */
547
+ export const LeftToRight: Story = {
548
+ args: {
549
+ graph,
550
+ metric: { node: "absolute_case", edge: "absolute" },
551
+ direction: "LR",
552
+ },
553
+ play: async ({ canvasElement }) => {
554
+ await expectWellFramedCanvas(canvasElement);
555
+ },
556
+ };
557
+
558
+ /**
559
+ * The accessible twin required for a graph that cannot be read as a picture. It renders
560
+ * from the same model as the canvas, so the numbers are identical by construction.
561
+ */
562
+ export const TableView: Story = {
563
+ args: {
564
+ graph,
565
+ metric: { node: "absolute_case", edge: "absolute", secondary: "median" },
566
+ tableView: true,
567
+ rework,
568
+ },
569
+ decorators: [
570
+ (Story) => (
571
+ <div className="h-[36rem] w-full overflow-auto bg-background p-6">
572
+ <Story />
573
+ </div>
574
+ ),
575
+ ],
576
+ play: async ({ canvasElement }) => {
577
+ const canvas = within(canvasElement);
578
+ const activities = await canvas.findByRole("table", { name: /Activities/ });
579
+ await expect(within(activities).getAllByRole("row").length).toBeGreaterThan(1);
580
+ },
581
+ };
582
+
583
+ /**
584
+ * The accessible twin WITH an active selection AND an active filter exclusion (#373) — the
585
+ * cell of the story matrix that was missing before this fix. No shipped story combined
586
+ * `tableView` with a real `selection`/`selectionStates`, so the twin's state gap never
587
+ * reached the blocking interaction + axe job or autodocs. Locks that a selected row and an
588
+ * excluded row are each named for their real, perceivable state.
589
+ */
590
+ export const TableViewSelection: Story = {
591
+ args: {
592
+ graph,
593
+ metric: { node: "absolute_case", edge: "absolute", secondary: "median" },
594
+ tableView: true,
595
+ rework,
596
+ selection: { kind: "activity", id: graph.activities[0]!.id },
597
+ selectionStates: {
598
+ activities: { [graph.activities[graph.activities.length - 1]!.id]: "excluded" },
599
+ },
600
+ },
601
+ decorators: [
602
+ (Story) => (
603
+ <div className="h-[36rem] w-full overflow-auto bg-background p-6">
604
+ <Story />
605
+ </div>
606
+ ),
607
+ ],
608
+ play: async ({ canvasElement }) => {
609
+ const canvas = within(canvasElement);
610
+ const activities = await canvas.findByRole("table", { name: /Activities/ });
611
+ const selectedRow = within(activities).getByRole("row", {
612
+ name: new RegExp(graph.activities[0]!.id),
613
+ });
614
+ const excludedRow = within(activities).getByRole("row", {
615
+ name: new RegExp(graph.activities[graph.activities.length - 1]!.id),
616
+ });
617
+ await expect(selectedRow).toHaveAccessibleName(/Selected/);
618
+ await expect(excludedRow).toHaveAccessibleName(/Excluded/);
619
+ // The two rows' accessible names actually differ — not merely their `data-*`
620
+ // attributes, which the pre-fix markup already carried correctly.
621
+ await expect(selectedRow).not.toHaveAccessibleName(excludedRow.textContent ?? "");
622
+ },
623
+ };
624
+
625
+ /** No graph yet — a whole-region loading panel, not an empty canvas. */
626
+ export const Loading: Story = {
627
+ args: {
628
+ graph,
629
+ metric: { node: "absolute_case", edge: "absolute" },
630
+ loading: true,
631
+ },
632
+ };
633
+
634
+ /** An abstraction that hid everything, or a log with no events. */
635
+ export const Empty: Story = {
636
+ args: {
637
+ graph: {
638
+ activities: [],
639
+ transitions: [],
640
+ startActivities: {},
641
+ endActivities: {},
642
+ totals: { cases: 0, events: 0, variants: 0 },
643
+ },
644
+ metric: { node: "absolute_case", edge: "absolute" },
645
+ },
646
+ };
647
+
648
+ /**
649
+ * Sixty activities with skip paths, self-loops and back-edges — the layout budget case,
650
+ * and the one where the overview thumbnail earns its place: at this size the reader
651
+ * navigates by the minimap, so a blank one is not a cosmetic loss.
652
+ *
653
+ * Only the thumbnail is asserted here. Label crowding at sixty activities is a real
654
+ * property of the graph rather than a layout defect, so the pill/card check belongs on the
655
+ * two eleven-activity stories, where a collision means the spacing is wrong.
656
+ */
657
+ export const LargeGraph: Story = {
658
+ args: {
659
+ graph: largeGraph(60),
660
+ metric: { node: "absolute_case", edge: "absolute" },
661
+ showMiniMap: true,
662
+ },
663
+ play: async ({ canvasElement }) => {
664
+ await waitFor(
665
+ () =>
666
+ expect(miniMapNodeCount(canvasElement)).toBe(
667
+ canvasElement.querySelectorAll(".react-flow__node").length,
668
+ ),
669
+ { timeout: 15_000 },
670
+ );
671
+ expect(miniMapNodeCount(canvasElement)).toBeGreaterThan(1);
672
+ },
673
+ };