@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,128 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { ProcessKpiStrip } from "./process-kpi-strip";
4
+
5
+ const meta = {
6
+ title: "Process/ProcessKpiStrip",
7
+ component: ProcessKpiStrip,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "Six KPI tiles for a process-mining session, built on `@elabs-ai/components-charts`' " +
14
+ "`MetricGrid`/`MetricCard` rather than authoring a new tile (ADR 0012). Cases, " +
15
+ "events, variants, median throughput, and rework rate always have a real number; " +
16
+ "conformance is the one tile that can genuinely have no value yet — no conformance " +
17
+ "checking has run — and renders a 'Not available' state rather than a fabricated 0%.",
18
+ },
19
+ },
20
+ },
21
+ } satisfies Meta<typeof ProcessKpiStrip>;
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ const kpis = {
26
+ cases: 240,
27
+ events: 1_842,
28
+ variants: 37,
29
+ medianThroughput: 3 * 24 * 60 * 60 * 1000,
30
+ reworkRate: 0.18,
31
+ };
32
+
33
+ /** All six tiles with real values, including a measured conformance score. */
34
+ export const Default: Story = {
35
+ args: { kpis, conformance: 0.91 },
36
+ };
37
+
38
+ /** No conformance model has run yet — a genuine "not available" state, not a fabricated 0%. */
39
+ export const ConformanceUnavailable: Story = {
40
+ args: { kpis, conformance: null },
41
+ };
42
+
43
+ /**
44
+ * Every tile carries a trend sparkline alongside its headline number (#359: all six, not
45
+ * five — a missing `medianThroughput` series used to leave that one tile with no mark).
46
+ */
47
+ export const WithTrends: Story = {
48
+ args: {
49
+ kpis,
50
+ conformance: 0.91,
51
+ trends: {
52
+ cases: [180, 190, 205, 198, 220, 232, 240],
53
+ events: [1200, 1350, 1480, 1520, 1690, 1780, 1842],
54
+ variants: [22, 25, 28, 30, 33, 35, 37],
55
+ medianThroughput: [
56
+ 4 * 24 * 60 * 60 * 1000,
57
+ 3.6 * 24 * 60 * 60 * 1000,
58
+ 3.4 * 24 * 60 * 60 * 1000,
59
+ 3.2 * 24 * 60 * 60 * 1000,
60
+ 3.1 * 24 * 60 * 60 * 1000,
61
+ 3.05 * 24 * 60 * 60 * 1000,
62
+ 3 * 24 * 60 * 60 * 1000,
63
+ ],
64
+ reworkRate: [0.24, 0.22, 0.21, 0.2, 0.19, 0.18, 0.18],
65
+ conformance: [0.82, 0.84, 0.86, 0.87, 0.89, 0.9, 0.91],
66
+ },
67
+ },
68
+ play: async ({ canvasElement }) => {
69
+ const canvas = within(canvasElement);
70
+ // #359 lock: every sparkline's accessible name describes the trend, never repeats the
71
+ // tile's own label.
72
+ for (const label of [
73
+ "Cases",
74
+ "Events",
75
+ "Variants",
76
+ "Median throughput",
77
+ "Rework rate",
78
+ "Conformance",
79
+ ]) {
80
+ const sparkline = canvas.getByRole("img", { name: new RegExp(`^${label}, `) });
81
+ await expect(sparkline).not.toHaveAccessibleName(label);
82
+ }
83
+ },
84
+ };
85
+
86
+ /** No data yet — `loading` reserves every tile's final shape (`MetricGrid`'s own skeleton). */
87
+ export const Loading: Story = {
88
+ args: { kpis, conformance: 0.91, loading: true },
89
+ };
90
+
91
+ /**
92
+ * Regression lock (RM-052 round 2, #227, F3): the conformance tile must occupy the SAME
93
+ * box height whether or not a conformance score has actually been measured. Before the
94
+ * fix, `description` was only ever given a value in the "not available" branch, so the
95
+ * measured and unavailable states rendered different heights — the tile visibly reflowed
96
+ * depending on which conformance state a reader happened to see. Renders both states
97
+ * side by side and asserts the conformance tile's own box is pixel-identical between
98
+ * them.
99
+ */
100
+ export const ReflowRegressionLock: Story = {
101
+ render: () => (
102
+ <div className="flex flex-col gap-6">
103
+ <ProcessKpiStrip kpis={kpis} conformance={0.91} data-testid="measured" />
104
+ <ProcessKpiStrip kpis={kpis} conformance={null} data-testid="unavailable" />
105
+ </div>
106
+ ),
107
+ play: async ({ canvasElement }) => {
108
+ const canvas = within(canvasElement);
109
+ const measuredStrip = canvas.getByTestId("measured");
110
+ const unavailableStrip = canvas.getByTestId("unavailable");
111
+
112
+ const conformanceTileHeight = (strip: HTMLElement): number => {
113
+ const label = within(strip).getByText("Conformance");
114
+ const tile = label.closest('[class*="rounded-lg"]');
115
+ if (!tile) throw new Error("Could not locate the conformance tile's own card element");
116
+ return tile.getBoundingClientRect().height;
117
+ };
118
+
119
+ const measuredHeight = conformanceTileHeight(measuredStrip);
120
+ const unavailableHeight = conformanceTileHeight(unavailableStrip);
121
+
122
+ // Pixel-identical, not merely "close" — both states render the exact same DOM shape
123
+ // (a `<p>` wrapping either a visible or an `invisible` copy of the same hint string),
124
+ // so nothing here should be able to introduce even a sub-pixel difference.
125
+ await expect(measuredHeight).toBe(unavailableHeight);
126
+ await expect(measuredHeight).toBeGreaterThan(0);
127
+ },
128
+ };
@@ -0,0 +1,106 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { ProcessKpiStrip, type ProcessKpiStripKpis } from "./process-kpi-strip";
4
+
5
+ afterEach(cleanup);
6
+
7
+ const kpis: ProcessKpiStripKpis = {
8
+ cases: 240,
9
+ events: 1_842,
10
+ variants: 37,
11
+ medianThroughput: 3 * 24 * 60 * 60 * 1000,
12
+ reworkRate: 0.18,
13
+ };
14
+
15
+ describe("ProcessKpiStrip — rendering", () => {
16
+ it("renders all six KPI labels", () => {
17
+ render(<ProcessKpiStrip kpis={kpis} conformance={0.91} />);
18
+ expect(screen.getByText("Cases")).toBeInTheDocument();
19
+ expect(screen.getByText("Events")).toBeInTheDocument();
20
+ expect(screen.getByText("Variants")).toBeInTheDocument();
21
+ expect(screen.getByText("Median throughput")).toBeInTheDocument();
22
+ expect(screen.getByText("Rework rate")).toBeInTheDocument();
23
+ expect(screen.getByText("Conformance")).toBeInTheDocument();
24
+ });
25
+ });
26
+
27
+ describe("ProcessKpiStrip — conformance unavailable (the RM-052 acceptance criterion)", () => {
28
+ it("renders a real 'not available' state instead of a fabricated 0% when conformance is null", () => {
29
+ render(<ProcessKpiStrip kpis={kpis} conformance={null} />);
30
+ expect(screen.getByText("Not available")).toBeInTheDocument();
31
+ expect(screen.getByText("Run conformance checking to see this metric.")).toBeInTheDocument();
32
+ // Never render a numeric 0% in the gap — that would read as a real, alarming measurement.
33
+ expect(screen.queryByText("0%")).not.toBeInTheDocument();
34
+ });
35
+
36
+ it("renders a real percentage once conformance has actually been measured", () => {
37
+ render(<ProcessKpiStrip kpis={kpis} conformance={0.91} />);
38
+ expect(screen.queryByText("Not available")).not.toBeInTheDocument();
39
+ expect(screen.getByText("91%")).toBeInTheDocument();
40
+ });
41
+ });
42
+
43
+ describe("ProcessKpiStrip — loading", () => {
44
+ it("does not throw and reserves the region's live-announcement role while loading", () => {
45
+ render(<ProcessKpiStrip kpis={kpis} conformance={0.91} loading />);
46
+ expect(screen.getByRole("status")).toBeInTheDocument();
47
+ });
48
+ });
49
+
50
+ // #359 — a sparkline's accessible name must describe the TREND, not repeat the tile's own
51
+ // label. Passing the label straight through overrides Sparkline's own, more informative
52
+ // default, so a screen-reader user hears the tile's name twice and learns nothing about
53
+ // the series.
54
+ describe("ProcessKpiStrip — trend sparkline accessible names (#359)", () => {
55
+ const DAY = 24 * 60 * 60 * 1000;
56
+ const trends = {
57
+ cases: [180, 190, 205, 198, 220, 232, 240],
58
+ events: [1200, 1350, 1480, 1520, 1690, 1780, 1842],
59
+ variants: [22, 25, 28, 30, 33, 35, 37],
60
+ medianThroughput: [4 * DAY, 3.6 * DAY, 3.4 * DAY, 3.2 * DAY, 3.1 * DAY, 3.05 * DAY, 3 * DAY],
61
+ reworkRate: [0.24, 0.22, 0.21, 0.2, 0.19, 0.18, 0.18],
62
+ conformance: [0.82, 0.84, 0.86, 0.87, 0.89, 0.9, 0.91],
63
+ };
64
+
65
+ it("never names a sparkline the same as its tile's own label", () => {
66
+ render(<ProcessKpiStrip kpis={kpis} conformance={0.91} trends={trends} />);
67
+ for (const label of [
68
+ "Cases",
69
+ "Events",
70
+ "Variants",
71
+ "Median throughput",
72
+ "Rework rate",
73
+ "Conformance",
74
+ ]) {
75
+ const sparkline = screen.getByRole("img", { name: new RegExp(`^${label}, `) });
76
+ expect(sparkline).not.toHaveAccessibleName(label);
77
+ }
78
+ });
79
+
80
+ it("describes the direction and the formatted first/last values, using each tile's own formatter", () => {
81
+ render(<ProcessKpiStrip kpis={kpis} conformance={0.91} trends={trends} />);
82
+
83
+ expect(screen.getByRole("img", { name: /^Cases,/ })).toHaveAccessibleName(
84
+ "Cases, 7-period trend, rising from 180 to 240",
85
+ );
86
+ expect(screen.getByRole("img", { name: /^Events,/ })).toHaveAccessibleName(
87
+ "Events, 7-period trend, rising from 1,200 to 1,842",
88
+ );
89
+ expect(screen.getByRole("img", { name: /^Variants,/ })).toHaveAccessibleName(
90
+ "Variants, 7-period trend, rising from 22 to 37",
91
+ );
92
+ // Duration, formatted with the same `formatDurationMs` the tile's own value uses —
93
+ // not a raw millisecond figure.
94
+ expect(screen.getByRole("img", { name: /^Median throughput,/ })).toHaveAccessibleName(
95
+ "Median throughput, 7-period trend, falling from 4.0 d to 3.0 d",
96
+ );
97
+ // Percentage, formatted the same way the tile's `valueFormat="percent"` renders it —
98
+ // not the raw 0..1 fraction.
99
+ expect(screen.getByRole("img", { name: /^Rework rate,/ })).toHaveAccessibleName(
100
+ "Rework rate, 7-period trend, falling from 24% to 18%",
101
+ );
102
+ expect(screen.getByRole("img", { name: /^Conformance,/ })).toHaveAccessibleName(
103
+ "Conformance, 7-period trend, rising from 82% to 91%",
104
+ );
105
+ });
106
+ });
@@ -0,0 +1,237 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProcessKpiStrip — the six numbers every process-mining session opens with (RM-052,
5
+ * issue #227): cases, events, variants, median throughput, rework rate, and conformance.
6
+ *
7
+ * Built entirely on `MetricGrid`/`MetricCard` (`@elabs-ai/components-charts` /
8
+ * `@elabs-ai/components-ui`) — this file authors no grid, no live region and no
9
+ * loading skeleton of its own. `MetricGrid`'s own `loading` prop already forwards
10
+ * `loading`/`announceLoading={false}` to every `MetricCard` child and owns the single
11
+ * region-level `role="status"` announcement (loading-states.md §a11y, "one live region
12
+ * per region, not per box") — reusing it is what keeps this component from re-deriving
13
+ * that convention badly.
14
+ *
15
+ * ## Conformance never fabricates a number
16
+ *
17
+ * `conformance` is `number | null | undefined` on purpose: a fraction (0..1) once a
18
+ * conformance model has actually been fitted against the log, or `null`/`undefined`
19
+ * when none has. Rendering `0` in the gap would read as "0% conformant" — a real,
20
+ * alarming measurement — when the honest state is "not measured". The unavailable
21
+ * state renders a muted icon + word instead of a number, and carries its own
22
+ * `description` explaining why.
23
+ *
24
+ * ## The conformance tile never reflows across states (RM-052 round 2, #227, F3)
25
+ *
26
+ * `MetricCard` only reserves a description line when it is GIVEN a `description` — so
27
+ * passing `description={hasConformance ? undefined : hint}` (the naive reading) made the
28
+ * tile's own box height depend on the very state it's rendering: measured shorter with a
29
+ * conformance score, taller in the "not available" state. The fix keeps the description
30
+ * SLOT present in every state — loading, measured, and unavailable all pass a `description`
31
+ * — but only the unavailable state's text is visible; the measured state's copy of the same
32
+ * string is rendered `invisible` (`visibility: hidden` + `aria-hidden`), which reserves the
33
+ * identical line height without showing misleading or duplicated copy to a sighted or
34
+ * screen-reader user.
35
+ */
36
+ import { type HTMLAttributes } from "react";
37
+ import { CircleSlash2 } from "lucide-react";
38
+ import { MetricCard, type LocaleContextValue } from "@elabs-ai/components-ui";
39
+ import { useLocale } from "@elabs-ai/components-ui";
40
+ import { MetricGrid } from "@elabs-ai/components-charts";
41
+ import { Sparkline } from "@elabs-ai/components-charts";
42
+ import { formatDurationMs } from "../process-map/map-model";
43
+
44
+ export interface ProcessKpiStripKpis {
45
+ cases: number;
46
+ events: number;
47
+ variants: number;
48
+ medianThroughput: number;
49
+ reworkRate: number;
50
+ }
51
+
52
+ export type ProcessKpiStripTrendKey = keyof ProcessKpiStripKpis | "conformance";
53
+
54
+ export interface ProcessKpiStripProps extends HTMLAttributes<HTMLDivElement> {
55
+ kpis: ProcessKpiStripKpis;
56
+ /** `0..1`, or `null`/`undefined` when no conformance model has been fitted. */
57
+ conformance?: number | null;
58
+ /** Optional per-tile trend series (oldest -> newest), keyed by KPI. */
59
+ trends?: Partial<Record<ProcessKpiStripTrendKey, number[]>>;
60
+ loading?: boolean;
61
+ }
62
+
63
+ /** How a trend's two endpoints are formatted — mirrors each tile's own headline format. */
64
+ type TrendValueFormat = "count" | "duration" | "percent";
65
+
66
+ /** The trend's direction, as the locale key for its word ("rising"/"falling"/"steady"). */
67
+ function trendDirectionKey(
68
+ first: number,
69
+ last: number,
70
+ ):
71
+ | "process.kpiStrip.trendRising"
72
+ | "process.kpiStrip.trendFalling"
73
+ | "process.kpiStrip.trendSteady" {
74
+ if (last > first) return "process.kpiStrip.trendRising";
75
+ if (last < first) return "process.kpiStrip.trendFalling";
76
+ return "process.kpiStrip.trendSteady";
77
+ }
78
+
79
+ /** Format one trend endpoint the same way the tile's own headline value is formatted. */
80
+ function formatTrendValue(
81
+ formatNumber: LocaleContextValue["formatNumber"],
82
+ value: number,
83
+ format: TrendValueFormat,
84
+ ): string {
85
+ switch (format) {
86
+ case "duration":
87
+ return formatDurationMs(value);
88
+ case "percent":
89
+ return formatNumber(value, { style: "percent", maximumFractionDigits: 1 });
90
+ case "count":
91
+ default:
92
+ return formatNumber(value);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * A sparkline's text alternative (#359): the subject plus what the picture shows, not the
98
+ * subject alone — the tile's own label already names the subject, and `Sparkline`'s own
99
+ * default (which describes the series) is strictly more informative than repeating it.
100
+ * Numbers are formatted the same way the tile's own headline value is, so the visible and
101
+ * accessible readings never disagree (a raw 0.18 beside a tile printing 18% would).
102
+ */
103
+ function trendVisual(
104
+ values: number[] | undefined,
105
+ subject: string,
106
+ format: TrendValueFormat,
107
+ t: LocaleContextValue["t"],
108
+ formatNumber: LocaleContextValue["formatNumber"],
109
+ ) {
110
+ if (!values || values.length === 0) return undefined;
111
+ const first = values[0]!;
112
+ const last = values[values.length - 1]!;
113
+ const label = t("process.kpiStrip.trendAlt", {
114
+ subject,
115
+ periods: values.length,
116
+ direction: t(trendDirectionKey(first, last)),
117
+ first: formatTrendValue(formatNumber, first, format),
118
+ last: formatTrendValue(formatNumber, last, format),
119
+ });
120
+ return <Sparkline values={values} label={label} />;
121
+ }
122
+
123
+ export function ProcessKpiStrip({
124
+ kpis,
125
+ conformance,
126
+ trends,
127
+ loading = false,
128
+ className,
129
+ ...props
130
+ }: ProcessKpiStripProps) {
131
+ const { t, formatNumber } = useLocale();
132
+
133
+ const hasConformance = conformance !== null && conformance !== undefined;
134
+ const conformanceHint = t("process.kpiStrip.conformanceUnavailableHint");
135
+
136
+ return (
137
+ <div data-slot="process-kpi-strip" className={className} {...props}>
138
+ <MetricGrid columns={3} loading={loading}>
139
+ <MetricCard
140
+ label={t("process.kpiStrip.cases")}
141
+ value={kpis.cases}
142
+ announceLoading={false}
143
+ visual={trendVisual(trends?.cases, t("process.kpiStrip.cases"), "count", t, formatNumber)}
144
+ />
145
+ <MetricCard
146
+ label={t("process.kpiStrip.events")}
147
+ value={kpis.events}
148
+ announceLoading={false}
149
+ visual={trendVisual(
150
+ trends?.events,
151
+ t("process.kpiStrip.events"),
152
+ "count",
153
+ t,
154
+ formatNumber,
155
+ )}
156
+ />
157
+ <MetricCard
158
+ label={t("process.kpiStrip.variants")}
159
+ value={kpis.variants}
160
+ announceLoading={false}
161
+ visual={trendVisual(
162
+ trends?.variants,
163
+ t("process.kpiStrip.variants"),
164
+ "count",
165
+ t,
166
+ formatNumber,
167
+ )}
168
+ />
169
+ <MetricCard
170
+ label={t("process.kpiStrip.medianThroughput")}
171
+ value={formatDurationMs(kpis.medianThroughput)}
172
+ announceLoading={false}
173
+ visual={trendVisual(
174
+ trends?.medianThroughput,
175
+ t("process.kpiStrip.medianThroughput"),
176
+ "duration",
177
+ t,
178
+ formatNumber,
179
+ )}
180
+ />
181
+ <MetricCard
182
+ label={t("process.kpiStrip.reworkRate")}
183
+ value={kpis.reworkRate}
184
+ valueFormat="percent"
185
+ announceLoading={false}
186
+ visual={trendVisual(
187
+ trends?.reworkRate,
188
+ t("process.kpiStrip.reworkRate"),
189
+ "percent",
190
+ t,
191
+ formatNumber,
192
+ )}
193
+ />
194
+ <MetricCard
195
+ label={t("process.kpiStrip.conformance")}
196
+ value={
197
+ hasConformance ? (
198
+ (conformance as number)
199
+ ) : (
200
+ <span
201
+ data-slot="process-kpi-strip-conformance-unavailable"
202
+ className="text-title inline-flex items-center gap-1.5 text-muted-foreground"
203
+ >
204
+ <CircleSlash2 aria-hidden="true" className="size-5" />
205
+ {t("process.kpiStrip.conformanceUnavailable")}
206
+ </span>
207
+ )
208
+ }
209
+ valueFormat={hasConformance ? "percent" : undefined}
210
+ description={
211
+ hasConformance ? (
212
+ // Same slot, same height, every state (F3) — invisible rather than absent, so
213
+ // the tile's box never reflows depending on whether conformance was measured.
214
+ <span aria-hidden="true" className="invisible">
215
+ {conformanceHint}
216
+ </span>
217
+ ) : (
218
+ conformanceHint
219
+ )
220
+ }
221
+ announceLoading={false}
222
+ visual={
223
+ hasConformance
224
+ ? trendVisual(
225
+ trends?.conformance,
226
+ t("process.kpiStrip.conformance"),
227
+ "percent",
228
+ t,
229
+ formatNumber,
230
+ )
231
+ : undefined
232
+ }
233
+ />
234
+ </MetricGrid>
235
+ </div>
236
+ );
237
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The process map (RM-051) — public surface.
3
+ *
4
+ * Only what a consumer composes with: the three components, the hover context two of them
5
+ * read, the layout hook, and the whole `map-model` vocabulary (the model is what keeps the
6
+ * canvas and the `tableView` twin printing identical numbers, so it is public on purpose).
7
+ */
8
+ export * from "./map-model";
9
+ export * from "./process-map";
10
+ export * from "./process-map-context";
11
+ export * from "./process-activity-node";
12
+ export * from "./process-transition-edge";
13
+ export * from "./use-process-layout";