@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,200 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProcessActivityNode — one activity on the process map (RM-051).
5
+ *
6
+ * ## It COMPOSES `FlowNode`; it does not fork it
7
+ *
8
+ * The frame, the handles, the tone glyph, the selection ring and — this is the load-bearing
9
+ * part — the keyboard focus indicator repaired in #312 all come from
10
+ * `@elabs-ai/components-flow`'s `FlowNode`, rendered here with process-shaped data. Nothing
11
+ * about React Flow's proxied focus (the `:focus-visible` state lands on React Flow's own
12
+ * wrapper element, one level above anything this package renders) is re-implemented, and
13
+ * the ancestor-variant class string that makes it work is not copied. If `FlowNode`'s focus
14
+ * treatment changes, this node changes with it.
15
+ *
16
+ * The three text slots `FlowNode` already owns carry the whole reading:
17
+ *
18
+ * - `kind` (eyebrow) — what the number MEANS ("Cases", "Median duration");
19
+ * - `title` — the activity name;
20
+ * - `subtitle` — the primary metric, and the secondary metric when one is asked for.
21
+ *
22
+ * ## Encoding: never colour alone (WCAG 1.4.1)
23
+ *
24
+ * The node metric reaches the reader three ways, and the first two survive greyscale:
25
+ * the **printed value** in the subtitle, the **length** of the meter bar under the node,
26
+ * and the meter's **saturation** against the surface→primary ramp. Two nodes with
27
+ * different values differ in text and in bar length before they differ in hue, which is
28
+ * what keeps this surface honest while the two open flow-colour defects (#321, #297) are
29
+ * unfixed.
30
+ *
31
+ * Start and end are a glyph PLUS a word in the accessible name (never a colour); rework is
32
+ * a counted badge; the tri-state selection is driven by the `data-selection` attribute
33
+ * `map-model`'s `domAttributes` sets on React Flow's own node element, so a consumer can
34
+ * select on `[data-selection="excluded"]` without reaching into this component. An
35
+ * excluded node is deliberately NOT `aria-disabled`: it stays fully operable (clicking it
36
+ * is how a reader filters it back in), and `activityAriaLabel` already appends the word
37
+ * "excluded" to its accessible name, so the state reaches assistive technology through
38
+ * real text rather than a lie about disablement.
39
+ *
40
+ * ## Ghosting an excluded node without erasing its boundary (#352)
41
+ *
42
+ * A single `opacity-35` on this whole subtree used to dim the card, its border AND its
43
+ * text together — since the card's fill barely differs from the canvas
44
+ * (white-on-near-white in the light theme), the 1px border was the ONLY structural cue
45
+ * telling a reader "there is still a card here", and dimming it along with everything else
46
+ * measured under WCAG's own 3:1 non-text floor, while the eyebrow/title text measured as
47
+ * low as 1.64:1/1.97:1 against 4.5:1 — a real axe-caught 1.4.3 failure, not a cosmetic one.
48
+ * This component now composes `FlowNode` UNCHANGED (no fork, no new prop on it) and reaches
49
+ * the ghost treatment from outside on two channels that never touch text:
50
+ *
51
+ * - **The card's border-colour token is locally overridden** (`--border` → `--border-strong`
52
+ * on the wrapping frame div) so `FlowNode`'s own `border-border` utility resolves to the
53
+ * stronger rung automatically — a plain CSS custom-property cascade trick, not a fork.
54
+ * - **The card's fill token is locally re-tinted** (`--flow-node` → a `card`/`surface-muted`
55
+ * mix) so `FlowNode`'s `bg-flow-node` utility reads as visibly quieter. This is a
56
+ * background-COLOUR change, not an opacity change — it cannot wash out the text painted
57
+ * on top of it the way a translucent scrim would (a scrim was tried and rejected for
58
+ * exactly this reason: it sits in the same paint layer as the text it was meant to spare).
59
+ *
60
+ * Eyebrow/title/subtitle text is never dimmed and never overlaid — see {@link GHOST_OPACITY}'s
61
+ * own docblock for why (0.35 over that text's already-modest contrast fails 4.5:1 in both
62
+ * themes). The meter's fill (a redundant, `aria-hidden`, non-text channel) still dims at the
63
+ * shared rung.
64
+ */
65
+ import { useMemo, type CSSProperties } from "react";
66
+ import { CircleDot, Flag, Play, RefreshCw } from "lucide-react";
67
+ import { Badge } from "@elabs-ai/components-ui";
68
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
69
+ import { FlowNode, type FlowNodeData } from "@elabs-ai/components-flow";
70
+ import type { NodeProps } from "@xyflow/react";
71
+ import { useProcessMapHover } from "./process-map-context";
72
+ import { activityRole, GHOST_OPACITY, type ProcessMapNode } from "./map-model";
73
+
74
+ /**
75
+ * Local override for the ghost frame: retargets `--border` (what `FlowNode`'s own
76
+ * `border-border` utility resolves through) to the stronger rung, and `--flow-node` (what
77
+ * `bg-flow-node` resolves through) to a quieter fill blended from two OTHER tokens
78
+ * (`--card`, `--surface-muted` — never `--flow-node` itself, which would be a
79
+ * self-referencing custom property and silently fail to apply) — WITHOUT touching
80
+ * `FlowNode`'s source or props. Both values cascade to `FlowNode`'s root div through the
81
+ * plain CSS custom-property lookup at the point each `var()` is used, same as any other
82
+ * theme override in this codebase (`.claude/rules/theming.md` §5). `--card` stands in for
83
+ * `--flow-node`'s own usual value here (the two are near-identical in both reference
84
+ * themes) so the blend reads as "quieter", not as a hue shift.
85
+ */
86
+ const GHOST_FRAME_STYLE = {
87
+ "--border": "var(--border-strong)",
88
+ "--flow-node": "color-mix(in oklab, var(--card) 60%, var(--surface-muted) 40%)",
89
+ } as CSSProperties;
90
+
91
+ /**
92
+ * The meter's fill as a mix of the recessed surface and the brand plate.
93
+ *
94
+ * `color-mix` in an inline `style` rather than a Tailwind opacity utility because the
95
+ * fraction is continuous, per node, and only known at render — but both endpoints stay
96
+ * semantic tokens, so a re-brand still reaches it and no raw colour is authored.
97
+ */
98
+ function meterFill(saturation: number): string {
99
+ const percent = Math.round(Math.min(1, Math.max(0, saturation)) * 100);
100
+ return `color-mix(in oklab, var(--primary) ${percent}%, var(--surface-muted))`;
101
+ }
102
+
103
+ /** Start/end glyph pairing, in `FlowNode`'s own tone-glyph idiom. */
104
+ function roleIcon(isStart: boolean, isEnd: boolean) {
105
+ if (isStart && isEnd) return CircleDot;
106
+ if (isStart) return Play;
107
+ if (isEnd) return Flag;
108
+ return undefined;
109
+ }
110
+
111
+ /**
112
+ * Branded process-map activity node. Register it in
113
+ * `nodeTypes={{ "process-activity": ProcessActivityNode }}` and build nodes with
114
+ * `buildProcessMapModel` rather than by hand — the model is what keeps the canvas and the
115
+ * `TableView` twin printing the same numbers.
116
+ */
117
+ export function ProcessActivityNode(props: NodeProps<ProcessMapNode>) {
118
+ const { data } = props;
119
+ const hover = useProcessMapHover();
120
+ const RoleIcon = roleIcon(data.isStart, data.isEnd);
121
+ const isHovered = hover.activityId === props.id;
122
+ const isDimmed = data.selectionState === "excluded";
123
+
124
+ const percent = Math.round(Math.min(1, Math.max(0, data.saturation)) * 100);
125
+
126
+ const flowData = useMemo<FlowNodeData>(
127
+ () => ({
128
+ title: data.title,
129
+ kind: data.metricLabel,
130
+ subtitle: data.secondaryLabel
131
+ ? `${data.primaryLabel} · ${data.secondaryLabel}`
132
+ : data.primaryLabel,
133
+ icon: RoleIcon ? <RoleIcon aria-hidden="true" /> : undefined,
134
+ // `tone` is a COLOUR axis in FlowNode. The process map never uses it to carry a
135
+ // metric — the fill would then be the only channel — so it stays default and the
136
+ // role/rework signals are carried by the glyph, the badge and the accessible name.
137
+ tone: "default",
138
+ // The metric's second, colour-free channel: bar LENGTH. `aria-hidden` because the
139
+ // same number is already printed in the subtitle above and repeated in the node's
140
+ // accessible name — a third announcement would be noise, not access. The fill (not
141
+ // the text) is the one thing here that still dims at the shared ghost rung.
142
+ footer: (
143
+ <div
144
+ aria-hidden="true"
145
+ data-slot="process-activity-node-meter"
146
+ data-percent={percent}
147
+ className="h-1.5 w-full overflow-hidden rounded-full bg-surface-muted transition-opacity duration-fast ease-standard motion-reduce:transition-none"
148
+ style={isDimmed ? { opacity: GHOST_OPACITY } : undefined}
149
+ >
150
+ <div
151
+ className="h-full rounded-full transition-[width] duration-base ease-standard motion-reduce:transition-none"
152
+ style={{ width: `${percent}%`, background: meterFill(data.saturation) }}
153
+ />
154
+ </div>
155
+ ),
156
+ }),
157
+ [
158
+ data.title,
159
+ data.metricLabel,
160
+ data.primaryLabel,
161
+ data.secondaryLabel,
162
+ data.saturation,
163
+ RoleIcon,
164
+ percent,
165
+ isDimmed,
166
+ ],
167
+ );
168
+
169
+ return (
170
+ <div
171
+ data-slot="process-activity-node"
172
+ data-selection={data.selectionState}
173
+ data-role={activityRole(data).toLowerCase()}
174
+ data-hover={isHovered ? "true" : undefined}
175
+ // `relative` positions the rework badge — and, because the meter now lives INSIDE
176
+ // the card (`FlowNode`'s `footer` slot), this wrapper is exactly as tall as the
177
+ // card. That equality is load-bearing, not cosmetic: React Flow lays every
178
+ // `<Handle>` out against the nearest positioned ancestor, so any row rendered here
179
+ // as a sibling of the card pushes every connector dot off the card's border by its
180
+ // own height. The meter used to sit here and did exactly that.
181
+ className={cn("relative", isHovered && "z-10")}
182
+ >
183
+ {data.reworkCount ? (
184
+ <Badge
185
+ variant="warning"
186
+ data-slot="process-activity-node-rework"
187
+ className="absolute -end-2 -top-2 z-10 gap-1 px-1.5 py-0 text-meta tabular-nums"
188
+ >
189
+ <RefreshCw aria-hidden="true" className="size-3" />
190
+ <span aria-hidden="true">{data.reworkCount}</span>
191
+ <span className="sr-only">{data.reworkCount} repeated executions</span>
192
+ </Badge>
193
+ ) : null}
194
+
195
+ <div data-slot="process-activity-node-frame" style={isDimmed ? GHOST_FRAME_STYLE : undefined}>
196
+ <FlowNode {...props} type="brand" data={flowData} />
197
+ </div>
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The process map's hover channel (RM-051).
5
+ *
6
+ * Hovering an activity has to highlight the activity AND every edge incident to it. The
7
+ * naive way to do that is to rebuild the model with a `hovered` field, which re-derives
8
+ * every metric and every label for a mouse move. This context is the seam that avoids it:
9
+ * hover lives in one small object beside the model, so a pointer move re-renders the node
10
+ * and edge COMPONENTS (which is what React does) but never rebuilds `buildProcessMapModel`
11
+ * and never re-runs `layoutFlow`. The model and the layout are memoized on inputs that do
12
+ * not include hover, which is the property the map depends on.
13
+ *
14
+ * Kept in its own module rather than inside `process-map.tsx` so the node and the edge can
15
+ * both read it without either importing the other, and without a cycle back through the
16
+ * component that provides it.
17
+ */
18
+ import { createContext, use, type KeyboardEvent as ReactKeyboardEvent } from "react";
19
+
20
+ /** What the node and edge components read while something is hovered. */
21
+ export interface ProcessMapHoverState {
22
+ /** The hovered activity id, or `null` when the pointer is not on a node. */
23
+ activityId: string | null;
24
+ /** Ids of the edges incident to {@link activityId}. Empty when nothing is hovered. */
25
+ incidentEdgeIds: ReadonlySet<string>;
26
+ }
27
+
28
+ /** The resting value — nothing hovered. Frozen and shared, so it is referentially stable. */
29
+ export const EMPTY_PROCESS_MAP_HOVER: ProcessMapHoverState = Object.freeze({
30
+ activityId: null,
31
+ incidentEdgeIds: new Set<string>() as ReadonlySet<string>,
32
+ });
33
+
34
+ /**
35
+ * Hover state for one process map. `ProcessMap` provides it; `ProcessActivityNode` and
36
+ * `ProcessTransitionEdge` consume it. Default is {@link EMPTY_PROCESS_MAP_HOVER}, so both
37
+ * components render correctly outside a `ProcessMap` (in a story or a unit test).
38
+ */
39
+ export const ProcessMapHoverContext = createContext<ProcessMapHoverState>(EMPTY_PROCESS_MAP_HOVER);
40
+
41
+ /** Read the current hover state. `use()` per the repo's new-context-read convention. */
42
+ export function useProcessMapHover(): ProcessMapHoverState {
43
+ return use(ProcessMapHoverContext);
44
+ }
45
+
46
+ /**
47
+ * How an edge hands a key press back to the map (RM-051).
48
+ *
49
+ * An edge's label pill is rendered through React Flow's `EdgeLabelRenderer`, which is a
50
+ * PORTAL: the button lands in `.react-flow__edgelabel-renderer`, outside the edge's own
51
+ * `<g>`, so it has no `[data-id]` ancestor for the map's root key handler to read. React
52
+ * portal events still bubble up the REACT tree, though — so `ProcessTransitionEdge`, which
53
+ * knows its own `id`, catches the key on its wrapper `<g>` and calls this. It is the ONLY
54
+ * way `Enter` and `f` stay usable on a transition now that the edge `<g>` itself is not a
55
+ * tab stop.
56
+ *
57
+ * The default is a no-op, so `ProcessTransitionEdge` renders standalone (a story, a unit
58
+ * test) without a provider.
59
+ */
60
+ export type ProcessMapEdgeKeyHandler = (edgeId: string, event: ReactKeyboardEvent) => void;
61
+
62
+ const NOOP_EDGE_KEY_HANDLER: ProcessMapEdgeKeyHandler = () => {};
63
+
64
+ /** Provided by `ProcessMap`; consumed by `ProcessTransitionEdge`. */
65
+ export const ProcessMapEdgeKeyContext =
66
+ createContext<ProcessMapEdgeKeyHandler>(NOOP_EDGE_KEY_HANDLER);
67
+
68
+ /** Read the map's edge key handler. `use()` per the repo's new-context-read convention. */
69
+ export function useProcessMapEdgeKeys(): ProcessMapEdgeKeyHandler {
70
+ return use(ProcessMapEdgeKeyContext);
71
+ }