@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/core/index.d.ts +1029 -0
- package/dist/core/index.js +1553 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/process-worker.js +462 -0
- package/dist/core/process-worker.js.map +1 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +3146 -0
- package/dist/index.js.map +1 -0
- package/dist/test/index.d.ts +196 -0
- package/dist/test/index.js +527 -0
- package/dist/test/index.js.map +1 -0
- package/package.json +80 -0
- package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
- package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
- package/src/abstraction-controls/abstraction-controls.tsx +288 -0
- package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
- package/src/abstraction-controls/auto-abstraction.ts +128 -0
- package/src/abstraction-controls/index.ts +4 -0
- package/src/core/abstract-graph.test.ts +209 -0
- package/src/core/abstract-graph.ts +407 -0
- package/src/core/adapters/csv.test.ts +131 -0
- package/src/core/adapters/csv.ts +146 -0
- package/src/core/adapters/flat.test.ts +149 -0
- package/src/core/adapters/flat.ts +168 -0
- package/src/core/aggregate-performance.test.ts +208 -0
- package/src/core/aggregate-performance.ts +200 -0
- package/src/core/detect-rework.test.ts +134 -0
- package/src/core/detect-rework.ts +100 -0
- package/src/core/discover-graph.test.ts +378 -0
- package/src/core/discover-graph.ts +202 -0
- package/src/core/duration-stats.test.ts +116 -0
- package/src/core/duration-stats.ts +162 -0
- package/src/core/event-log.test.ts +224 -0
- package/src/core/event-log.ts +244 -0
- package/src/core/extract-variants.test.ts +126 -0
- package/src/core/extract-variants.ts +140 -0
- package/src/core/filter-log.test.ts +193 -0
- package/src/core/filter-log.ts +215 -0
- package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
- package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
- package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
- package/src/core/fixtures/order-to-cash-small.json +200 -0
- package/src/core/fixtures/synthetic-log.test.ts +109 -0
- package/src/core/fixtures/synthetic-log.ts +167 -0
- package/src/core/index.ts +118 -0
- package/src/core/reconcile-graph.test.ts +175 -0
- package/src/core/reconcile-graph.ts +107 -0
- package/src/core/scale.test.ts +80 -0
- package/src/core/scale.ts +100 -0
- package/src/core/types.ts +151 -0
- package/src/core/worker/create-process-worker.test.ts +255 -0
- package/src/core/worker/create-process-worker.ts +211 -0
- package/src/core/worker/process-worker.ts +80 -0
- package/src/index.ts +29 -0
- package/src/metric-layer-switch/index.ts +6 -0
- package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
- package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
- package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
- package/src/process-explorer.stories.tsx +392 -0
- package/src/process-kpi-strip/index.ts +6 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
- package/src/process-map/index.ts +13 -0
- package/src/process-map/map-model.test.ts +326 -0
- package/src/process-map/map-model.ts +873 -0
- package/src/process-map/process-activity-node.tsx +200 -0
- package/src/process-map/process-map-context.ts +71 -0
- package/src/process-map/process-map.stories.tsx +673 -0
- package/src/process-map/process-map.test.tsx +523 -0
- package/src/process-map/process-map.tsx +979 -0
- package/src/process-map/process-transition-edge.test.tsx +160 -0
- package/src/process-map/process-transition-edge.tsx +151 -0
- package/src/process-map/use-process-layout.test.tsx +265 -0
- package/src/process-map/use-process-layout.ts +315 -0
- package/src/test/contract.test.ts +99 -0
- package/src/test/contract.ts +118 -0
- package/src/test/doubles.test.tsx +51 -0
- package/src/test/doubles.tsx +82 -0
- package/src/test/index.ts +34 -0
- package/src/test/primitives.tsx +35 -0
- package/src/use-process-explorer/index.ts +8 -0
- package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
- 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
|
+
}
|