@elabs-ai/components-process 4.2.0 → 5.0.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 (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * process-filter-bar.test.tsx — RM-056, #205.
3
+ *
4
+ * `intents` carries no ids (`useProcessExplorer`'s real contract) — every assertion below
5
+ * keys by ARRAY INDEX, matching `onRemove(index)`/`clearIntent(index)`.
6
+ */
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import { render, screen } from "@testing-library/react";
9
+ import userEvent from "@testing-library/user-event";
10
+ import type { FilterIntent } from "../use-process-explorer";
11
+ import { ProcessFilterBar } from "./process-filter-bar";
12
+
13
+ const chainIntents: FilterIntent[] = [
14
+ { kind: "with", activity: "Reject Order" },
15
+ { kind: "without", activity: "Amend Order" },
16
+ { kind: "variant", ids: ["v-1", "v-2"] },
17
+ { kind: "cases", ids: ["c-1", "c-2", "c-3"] },
18
+ ];
19
+
20
+ describe("ProcessFilterBar", () => {
21
+ it("renders one chip per intent, worded as a sentence fragment, with its own excluded count", () => {
22
+ render(
23
+ <ProcessFilterBar
24
+ intents={chainIntents}
25
+ excludedByIntent={[12, 4, 30, 7]}
26
+ totalCases={100}
27
+ filteredCases={60}
28
+ onRemove={vi.fn()}
29
+ onClearAll={vi.fn()}
30
+ />,
31
+ );
32
+ expect(
33
+ screen.getByRole("button", { name: "Remove filter: Contains Reject Order · excluded 12" }),
34
+ ).toBeInTheDocument();
35
+ expect(
36
+ screen.getByRole("button", { name: "Remove filter: Excludes Amend Order · excluded 4" }),
37
+ ).toBeInTheDocument();
38
+ expect(
39
+ screen.getByRole("button", { name: "Remove filter: 2 variants selected · excluded 30" }),
40
+ ).toBeInTheDocument();
41
+ expect(
42
+ screen.getByRole("button", { name: "Remove filter: 3 cases selected · excluded 7" }),
43
+ ).toBeInTheDocument();
44
+ });
45
+
46
+ it("removing a chip calls onRemove with that intent's index and nothing else", async () => {
47
+ const user = userEvent.setup();
48
+ const onRemove = vi.fn();
49
+ render(
50
+ <ProcessFilterBar
51
+ intents={chainIntents}
52
+ excludedByIntent={[12, 4, 30, 7]}
53
+ totalCases={100}
54
+ filteredCases={60}
55
+ onRemove={onRemove}
56
+ onClearAll={vi.fn()}
57
+ />,
58
+ );
59
+ await user.click(
60
+ screen.getByRole("button", { name: "Remove filter: Excludes Amend Order · excluded 4" }),
61
+ );
62
+ expect(onRemove).toHaveBeenCalledTimes(1);
63
+ expect(onRemove).toHaveBeenCalledWith(1);
64
+ });
65
+
66
+ it("Enter on a focused chip removes it, matching FilterChip's own keyboard contract", async () => {
67
+ const user = userEvent.setup();
68
+ const onRemove = vi.fn();
69
+ render(
70
+ <ProcessFilterBar
71
+ intents={chainIntents}
72
+ excludedByIntent={[12, 4, 30, 7]}
73
+ totalCases={100}
74
+ filteredCases={60}
75
+ onRemove={onRemove}
76
+ onClearAll={vi.fn()}
77
+ />,
78
+ );
79
+ screen
80
+ .getByRole("button", { name: "Remove filter: Contains Reject Order · excluded 12" })
81
+ .focus();
82
+ await user.keyboard("{Enter}");
83
+ expect(onRemove).toHaveBeenCalledWith(0);
84
+ });
85
+
86
+ it("Clear all calls onClearAll exactly once regardless of intent count", async () => {
87
+ const user = userEvent.setup();
88
+ const onClearAll = vi.fn();
89
+ render(
90
+ <ProcessFilterBar
91
+ intents={chainIntents}
92
+ excludedByIntent={[12, 4, 30, 7]}
93
+ totalCases={100}
94
+ filteredCases={60}
95
+ onRemove={vi.fn()}
96
+ onClearAll={onClearAll}
97
+ />,
98
+ );
99
+ await user.click(screen.getByRole("button", { name: "Clear all" }));
100
+ expect(onClearAll).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it("renders no chips and no group when there are no active intents", () => {
104
+ render(
105
+ <ProcessFilterBar
106
+ intents={[]}
107
+ excludedByIntent={[]}
108
+ totalCases={100}
109
+ filteredCases={100}
110
+ onRemove={vi.fn()}
111
+ onClearAll={vi.fn()}
112
+ />,
113
+ );
114
+ expect(screen.queryByRole("group")).not.toBeInTheDocument();
115
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
116
+ expect(screen.getByText("Showing all 100 cases")).toBeInTheDocument();
117
+ });
118
+
119
+ it("states the filtered/total split exactly, with no abstraction clause when nothing is hidden", () => {
120
+ render(
121
+ <ProcessFilterBar
122
+ intents={chainIntents}
123
+ excludedByIntent={[12, 4, 30, 7]}
124
+ totalCases={13087}
125
+ filteredCases={3210}
126
+ onRemove={vi.fn()}
127
+ onClearAll={vi.fn()}
128
+ />,
129
+ );
130
+ expect(screen.getByText("Showing 3,210 of 13,087 cases")).toBeInTheDocument();
131
+ });
132
+
133
+ it("appends the abstraction clause, matching hiddenCounts.activities exactly (no double counting)", () => {
134
+ render(
135
+ <ProcessFilterBar
136
+ intents={chainIntents}
137
+ excludedByIntent={[12, 4, 30, 7]}
138
+ totalCases={13087}
139
+ filteredCases={3210}
140
+ hiddenCounts={{ activities: 12, paths: 40 }}
141
+ onRemove={vi.fn()}
142
+ onClearAll={vi.fn()}
143
+ />,
144
+ );
145
+ expect(
146
+ screen.getByText("Showing 3,210 of 13,087 cases · 12 activities hidden by abstraction"),
147
+ ).toBeInTheDocument();
148
+ });
149
+
150
+ it("omits the abstraction clause when hiddenCounts.activities is zero", () => {
151
+ render(
152
+ <ProcessFilterBar
153
+ intents={[]}
154
+ excludedByIntent={[]}
155
+ totalCases={100}
156
+ filteredCases={100}
157
+ hiddenCounts={{ activities: 0, paths: 0 }}
158
+ onRemove={vi.fn()}
159
+ onClearAll={vi.fn()}
160
+ />,
161
+ );
162
+ expect(screen.getByText("Showing all 100 cases")).toBeInTheDocument();
163
+ });
164
+
165
+ it("labels startsWith/endsWith intents as sentence fragments", () => {
166
+ render(
167
+ <ProcessFilterBar
168
+ intents={[
169
+ { kind: "startsWith", activity: "Create Order" },
170
+ { kind: "endsWith", activity: "Ship Order" },
171
+ ]}
172
+ excludedByIntent={[1, 2]}
173
+ totalCases={10}
174
+ filteredCases={5}
175
+ onRemove={vi.fn()}
176
+ onClearAll={vi.fn()}
177
+ />,
178
+ );
179
+ expect(
180
+ screen.getByRole("button", { name: "Remove filter: Starts with Create Order · excluded 1" }),
181
+ ).toBeInTheDocument();
182
+ expect(
183
+ screen.getByRole("button", { name: "Remove filter: Ends with Ship Order · excluded 2" }),
184
+ ).toBeInTheDocument();
185
+ });
186
+
187
+ it("merges a caller className onto the root", () => {
188
+ const { container } = render(
189
+ <ProcessFilterBar
190
+ intents={[]}
191
+ excludedByIntent={[]}
192
+ totalCases={0}
193
+ filteredCases={0}
194
+ onRemove={vi.fn()}
195
+ onClearAll={vi.fn()}
196
+ className="extra"
197
+ />,
198
+ );
199
+ expect(container.querySelector('[data-slot="process-filter-bar"]')).toHaveClass("extra");
200
+ });
201
+ });
@@ -0,0 +1,167 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProcessFilterBar — filter chain breadcrumbs with excluded-count chips (RM-056, #205).
5
+ *
6
+ * §4 R13 of the process-mining components analysis requires "filter breadcrumbs showing
7
+ * the active filter chain, removable, with 'excluded' counts" for every persona — the 2026
8
+ * literature review's first systematic gap is "no visible reference to what was filtered
9
+ * out", and this component is that visible reference. It composes `@elabs-ai/components-data`'s
10
+ * `FilterBar`/`FilterChip` — the chip's own `count`/`countLabel`/`onRemove` contract already
11
+ * carries the "excluded N" reading (RM-047) — and authors no chip markup of its own
12
+ * (`pnpm check --rule process-reuse`).
13
+ *
14
+ * ## Index keys, not intent ids
15
+ *
16
+ * `useProcessExplorer`'s `intents: FilterIntent[]` carries no id field — `clearIntent` and
17
+ * this bar's own `onRemove` both key by ARRAY INDEX, matching the hook's real contract
18
+ * (`.claude/scratch/process-mining/wave-2/common-brief.md`). `excludedByIntent` is the
19
+ * hook's own additive field (same file, this item's other touch): index `i` there is the
20
+ * case count intent `i` ALONE excludes from the chain, parallel to `intents[i]`.
21
+ *
22
+ * ## The summary line never double-counts
23
+ *
24
+ * `filteredCases`/`totalCases` describe what FILTERING removed; `hiddenCounts.activities`
25
+ * (from `AbstractionControls`, RM-052) describes what ABSTRACTION additionally hides from
26
+ * the picture. The two are disjoint by construction (`useProcessExplorer`'s own module
27
+ * docblock, Invariant F) — this component states them as two independently-omittable
28
+ * clauses of one sentence rather than merging them into one number, the same "two
29
+ * independently-pluralized fragments, composed at the call site" shape `AbstractionControls`
30
+ * already uses for its own hidden-count line.
31
+ */
32
+ import { forwardRef, type HTMLAttributes } from "react";
33
+ import { Button, useLocale } from "@elabs-ai/components-ui";
34
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
35
+ import { FilterBar, FilterChip } from "@elabs-ai/components-data";
36
+ import type { FilterIntent } from "../use-process-explorer";
37
+
38
+ /** One intent, worded as a chip label — label-in-value text, never a bare activity name. */
39
+ function intentChipLabel(t: ReturnType<typeof useLocale>["t"], intent: FilterIntent): string {
40
+ switch (intent.kind) {
41
+ case "with":
42
+ return t("process.filterBar.with", { activity: intent.activity });
43
+ case "without":
44
+ return t("process.filterBar.without", { activity: intent.activity });
45
+ case "startsWith":
46
+ return t("process.filterBar.startsWith", { activity: intent.activity });
47
+ case "endsWith":
48
+ return t("process.filterBar.endsWith", { activity: intent.activity });
49
+ case "variant":
50
+ return t("process.filterBar.variant", { count: intent.ids.length });
51
+ case "cases":
52
+ return t("process.filterBar.cases", { count: intent.ids.length });
53
+ default:
54
+ return t("process.filterBar.filter");
55
+ }
56
+ }
57
+
58
+ export interface ProcessFilterBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
59
+ /** The active filter chain — `useProcessExplorer`'s own `intents`, in order. */
60
+ intents: FilterIntent[];
61
+ /**
62
+ * How many cases EACH intent alone excludes, parallel to {@link intents} (same index) —
63
+ * `useProcessExplorer`'s own additive `excludedByIntent` field.
64
+ */
65
+ excludedByIntent: number[];
66
+ /** The unfiltered case count — the right half of the summary line's "X of Y". */
67
+ totalCases: number;
68
+ /** `useProcessExplorer`'s `kpis.cases` — the left half of the summary line's "X of Y". */
69
+ filteredCases: number;
70
+ /** What abstraction currently hides — `useProcessExplorer`'s own `hiddenCounts`. */
71
+ hiddenCounts?: { activities: number; paths: number };
72
+ /** Remove one intent by its index into {@link intents} — wire to `clearIntent`. */
73
+ onRemove(index: number): void;
74
+ /** Remove every intent at once. */
75
+ onClearAll(): void;
76
+ /** Accessible name for the chip row. Default from locale. */
77
+ label?: string;
78
+ }
79
+
80
+ /**
81
+ * Filter chain breadcrumbs: one removable chip per active intent, each carrying how many
82
+ * cases that intent alone excludes, plus a summary line stating what filtering and
83
+ * abstraction currently hide.
84
+ */
85
+ export const ProcessFilterBar = forwardRef<HTMLDivElement, ProcessFilterBarProps>(
86
+ function ProcessFilterBar(
87
+ {
88
+ intents,
89
+ excludedByIntent,
90
+ totalCases,
91
+ filteredCases,
92
+ hiddenCounts,
93
+ onRemove,
94
+ onClearAll,
95
+ label,
96
+ className,
97
+ ...props
98
+ },
99
+ ref,
100
+ ) {
101
+ const { t, formatNumber } = useLocale();
102
+
103
+ const hasIntents = intents.length > 0;
104
+ const hasHiddenByAbstraction = (hiddenCounts?.activities ?? 0) > 0;
105
+
106
+ // Two independently-omittable clauses, joined — mirrors `AbstractionControls`' own
107
+ // hidden-count line (see the module docblock): "what filtering removed" and "what
108
+ // abstraction additionally hides" are disjoint numbers, never merged into one.
109
+ const casesClause =
110
+ filteredCases === totalCases
111
+ ? t("process.filterBar.showingAll", {
112
+ count: totalCases,
113
+ total: formatNumber(totalCases),
114
+ })
115
+ : t("process.filterBar.showing", {
116
+ filtered: formatNumber(filteredCases),
117
+ total: formatNumber(totalCases),
118
+ });
119
+ const summary = hasHiddenByAbstraction
120
+ ? `${casesClause} · ${t("process.filterBar.hiddenByAbstraction", {
121
+ count: hiddenCounts!.activities,
122
+ })}`
123
+ : casesClause;
124
+
125
+ return (
126
+ <div
127
+ ref={ref}
128
+ data-slot="process-filter-bar"
129
+ className={cn("flex flex-col gap-2", className)}
130
+ {...props}
131
+ >
132
+ {hasIntents ? (
133
+ // `FilterBar` (data package) has no `role`/`aria-label` of its own to forward —
134
+ // this wrapping `div` is the group's real accessible-name carrier.
135
+ <div role="group" aria-label={label ?? t("process.filterBar.label")}>
136
+ <FilterBar
137
+ actions={
138
+ <Button
139
+ type="button"
140
+ variant="ghost"
141
+ size="sm"
142
+ data-slot="process-filter-bar-clear-all"
143
+ onClick={onClearAll}
144
+ >
145
+ {t("process.filterBar.clearAll")}
146
+ </Button>
147
+ }
148
+ >
149
+ {intents.map((intent, index) => (
150
+ <FilterChip
151
+ key={`${intent.kind}-${index}`}
152
+ label={intentChipLabel(t, intent)}
153
+ count={excludedByIntent[index] ?? 0}
154
+ countLabel={t("process.filterBar.excludedLabel")}
155
+ onRemove={() => onRemove(index)}
156
+ />
157
+ ))}
158
+ </FilterBar>
159
+ </div>
160
+ ) : null}
161
+ <p data-slot="process-filter-bar-summary" className="text-meta text-muted-foreground">
162
+ {summary}
163
+ </p>
164
+ </div>
165
+ );
166
+ },
167
+ );
@@ -1,7 +1,16 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { expect, within } from "storybook/test";
3
+ import { conformanceRateSeries } from "../core/conformance";
4
+ import { liftHappyPath } from "../core/reference-model";
5
+ import { tokenReplay } from "../core/token-replay";
6
+ import {
7
+ CONFORMANCE_FIXTURE_LOG,
8
+ CONFORMANCE_FIXTURE_PATH,
9
+ } from "../conformance-overlay/conformance-fixture";
3
10
  import { ProcessKpiStrip } from "./process-kpi-strip";
4
11
 
12
+ const conformanceModel = liftHappyPath(CONFORMANCE_FIXTURE_PATH);
13
+
5
14
  const meta = {
6
15
  title: "Process/ProcessKpiStrip",
7
16
  component: ProcessKpiStrip,
@@ -83,6 +92,43 @@ export const WithTrends: Story = {
83
92
  },
84
93
  };
85
94
 
95
+ /**
96
+ * `layout="inline"`: the same six numbers as one dense ribbon, for a workspace whose height
97
+ * belongs to the process map. Cells are divided by hairlines and the ribbon scrolls sideways
98
+ * when it runs out of room.
99
+ */
100
+ export const Inline: Story = {
101
+ args: { ...WithTrends.args, layout: "inline" },
102
+ play: async ({ canvasElement }) => {
103
+ const strip = canvasElement.querySelector<HTMLElement>('[data-slot="process-kpi-strip"]');
104
+ await expect(strip).toHaveAttribute("data-layout", "inline");
105
+ await expect(
106
+ canvasElement.querySelectorAll('[data-slot="process-kpi-strip-cell"]').length,
107
+ ).toBe(6);
108
+ // One row: the ribbon is no taller than a single card line.
109
+ await expect(strip!.getBoundingClientRect().height).toBeLessThan(80);
110
+ },
111
+ };
112
+
113
+ /**
114
+ * RM-062: the conformance tile reads a token-replay result directly (its mean fitness) and
115
+ * plots `conformanceRateSeries` as its sparkline.
116
+ */
117
+ export const ConformanceFromReplay: Story = {
118
+ args: {
119
+ kpis,
120
+ conformance: tokenReplay(CONFORMANCE_FIXTURE_LOG, conformanceModel),
121
+ conformanceSeries: conformanceRateSeries(CONFORMANCE_FIXTURE_LOG, conformanceModel, "month"),
122
+ },
123
+ play: async ({ canvasElement }) => {
124
+ const canvas = within(canvasElement);
125
+ await expect(canvas.getByText("91.7%")).toBeVisible();
126
+ await expect(canvas.getByRole("img", { name: /^Conformance,/ })).toHaveAccessibleName(
127
+ "Conformance, 5-period trend, falling from 100% to 75%",
128
+ );
129
+ },
130
+ };
131
+
86
132
  /** No data yet — `loading` reserves every tile's final shape (`MetricGrid`'s own skeleton). */
87
133
  export const Loading: Story = {
88
134
  args: { kpis, conformance: 0.91, loading: true },
@@ -98,6 +144,7 @@ export const Loading: Story = {
98
144
  * them.
99
145
  */
100
146
  export const ReflowRegressionLock: Story = {
147
+ name: "Conformance tile keeps its height when unmeasured",
101
148
  render: () => (
102
149
  <div className="flex flex-col gap-6">
103
150
  <ProcessKpiStrip kpis={kpis} conformance={0.91} data-testid="measured" />
@@ -1,6 +1,13 @@
1
1
  import { cleanup, render, screen } from "@testing-library/react";
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
3
  import { ProcessKpiStrip, type ProcessKpiStripKpis } from "./process-kpi-strip";
4
+ import { conformanceRateSeries } from "../core/conformance";
5
+ import { liftHappyPath } from "../core/reference-model";
6
+ import { tokenReplay } from "../core/token-replay";
7
+ import {
8
+ CONFORMANCE_FIXTURE_LOG,
9
+ CONFORMANCE_FIXTURE_PATH,
10
+ } from "../conformance-overlay/conformance-fixture";
4
11
 
5
12
  afterEach(cleanup);
6
13
 
@@ -104,3 +111,63 @@ describe("ProcessKpiStrip — trend sparkline accessible names (#359)", () => {
104
111
  );
105
112
  });
106
113
  });
114
+
115
+ // RM-062 — the conformance tile reads a replay result and its rate-over-time series.
116
+ describe("ProcessKpiStrip — conformance from a replay result (RM-062)", () => {
117
+ const model = liftHappyPath(CONFORMANCE_FIXTURE_PATH);
118
+ const conformance = tokenReplay(CONFORMANCE_FIXTURE_LOG, model);
119
+
120
+ it("prints the fixture's hand-computed mean fitness", () => {
121
+ // Six conforming cases fit 1; the two skips and the incomplete case fit ¾; the
122
+ // undesired case fits ½(1 − 0/5) + ½(1 − 1/6) = 11/12.
123
+ const expected = (6 + 3 * 0.75 + 11 / 12) / 10;
124
+ expect(conformance.overallFitness).toBeCloseTo(expected, 12);
125
+ render(<ProcessKpiStrip kpis={kpis} conformance={conformance} />);
126
+ expect(screen.getByText("91.7%")).toBeInTheDocument();
127
+ expect(screen.queryByText("Not available")).not.toBeInTheDocument();
128
+ });
129
+
130
+ it("plots conformanceRateSeries as the tile's sparkline, ahead of trends.conformance", () => {
131
+ const series = conformanceRateSeries(CONFORMANCE_FIXTURE_LOG, model, "month");
132
+ render(
133
+ <ProcessKpiStrip
134
+ kpis={kpis}
135
+ conformance={conformance}
136
+ conformanceSeries={series}
137
+ trends={{ conformance: [0.1, 0.2] }}
138
+ />,
139
+ );
140
+ expect(screen.getByRole("img", { name: /^Conformance,/ })).toHaveAccessibleName(
141
+ `Conformance, ${series.length}-period trend, falling from 100% to 75%`,
142
+ );
143
+ });
144
+ });
145
+
146
+ describe('ProcessKpiStrip — layout="inline"', () => {
147
+ it("renders the same six KPIs as one ribbon of cells, with no card grid", () => {
148
+ const { container } = render(
149
+ <ProcessKpiStrip kpis={kpis} conformance={0.91} layout="inline" />,
150
+ );
151
+ const strip = container.querySelector('[data-slot="process-kpi-strip"]');
152
+ expect(strip).toHaveAttribute("data-layout", "inline");
153
+ expect(container.querySelectorAll('[data-slot="process-kpi-strip-cell"]')).toHaveLength(6);
154
+ expect(container.querySelector('[data-slot="metric-card"]')).toBeNull();
155
+ expect(screen.getByText("240")).toBeInTheDocument();
156
+ expect(screen.getByText("18%")).toBeInTheDocument();
157
+ expect(screen.getByText("91%")).toBeInTheDocument();
158
+ expect(screen.getByText("3.0 d")).toBeInTheDocument();
159
+ });
160
+
161
+ it("keeps the honest 'not available' state and never prints a fabricated 0%", () => {
162
+ render(<ProcessKpiStrip kpis={kpis} conformance={null} layout="inline" />);
163
+ expect(screen.getByText("Not available")).toBeInTheDocument();
164
+ expect(screen.queryByText("0%")).not.toBeInTheDocument();
165
+ });
166
+
167
+ it("the default layout is unchanged: no data-layout attribute, six metric cards", () => {
168
+ const { container } = render(<ProcessKpiStrip kpis={kpis} conformance={0.91} />);
169
+ expect(container.querySelector('[data-slot="process-kpi-strip"]')).not.toHaveAttribute(
170
+ "data-layout",
171
+ );
172
+ });
173
+ });