@elabs-ai/components-process 4.2.0 → 5.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 (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,109 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CaseTimeline — the single-case Gantt every case drill-down opens into (RM-055, issue
5
+ * #204, §4 R12): "single-case timeline with activity durations and waiting time".
6
+ *
7
+ * A THIN wrapper: it computes {@link buildCaseTimelineInstances}'s model and renders
8
+ * `@elabs-ai/components-charts`'s `Gantt` — it does not draw a bar, a gap band or a grid
9
+ * line itself (`.claude/rules/data.md` — "primitives go down, compositions go up"). Waiting
10
+ * time reuses RM-047's `GanttTask.gaps` hatched-band rendering directly; there is no second
11
+ * idle-time renderer here.
12
+ *
13
+ * ## The parallel flag carries a text channel, not just a colour
14
+ *
15
+ * Two overlapping instances get `status="info"` (an existing `Gantt` tone — no new color,
16
+ * per the RM's own instruction), but WCAG 1.4.1 forbids colour as the ONLY channel for a
17
+ * status. `name` (a `ReactNode` `Gantt` already supports) carries a visible "Parallel" tag
18
+ * plus an `sr-only` explanation for anyone who cannot see the tone at all.
19
+ */
20
+ import { forwardRef, useMemo, type HTMLAttributes } from "react";
21
+ import { Gantt, type GanttGap, type GanttTask } from "@elabs-ai/components-charts";
22
+ import { Badge, useLocale } from "@elabs-ai/components-ui";
23
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
24
+ import type { EventRow } from "../core/types";
25
+ import { formatDurationMs } from "../process-map/map-model";
26
+ import {
27
+ buildCaseTimelineInstances,
28
+ type CaseTimelineInstance,
29
+ type CaseTimelineModelOptions,
30
+ } from "./case-timeline-model";
31
+
32
+ export interface CaseTimelineProps
33
+ extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect">, CaseTimelineModelOptions {
34
+ caseId: string;
35
+ /** This case's own events, straight from `core`'s `EventLog.events`. */
36
+ events: EventRow[];
37
+ }
38
+
39
+ function instanceName(
40
+ instance: CaseTimelineInstance,
41
+ t: ReturnType<typeof useLocale>["t"],
42
+ ): GanttTask["name"] {
43
+ if (!instance.isParallel) return instance.activity;
44
+ return (
45
+ <span className="inline-flex items-center gap-1.5">
46
+ <span>{instance.activity}</span>
47
+ <Badge variant="info">{t("process.caseTimeline.parallelLabel")}</Badge>
48
+ <span className="sr-only">, {t("process.caseTimeline.parallelSuffix")}</span>
49
+ </span>
50
+ );
51
+ }
52
+
53
+ function instanceGaps(
54
+ instance: CaseTimelineInstance,
55
+ t: ReturnType<typeof useLocale>["t"],
56
+ ): GanttGap[] | undefined {
57
+ if (!instance.gap) return undefined;
58
+ return [
59
+ {
60
+ start: instance.gap.start,
61
+ end: instance.gap.end,
62
+ label: t("process.caseTimeline.gapLabel", {
63
+ duration: formatDurationMs(instance.gap.durationMs),
64
+ }),
65
+ },
66
+ ];
67
+ }
68
+
69
+ export const CaseTimeline = forwardRef<HTMLDivElement, CaseTimelineProps>(function CaseTimeline(
70
+ { caseId, events, parallelismThreshold, className, ...props },
71
+ ref,
72
+ ) {
73
+ const { t } = useLocale();
74
+
75
+ const instances = useMemo(
76
+ () => buildCaseTimelineInstances(events, { parallelismThreshold }),
77
+ [events, parallelismThreshold],
78
+ );
79
+
80
+ const tasks = useMemo<GanttTask[]>(
81
+ () =>
82
+ instances.map((instance) => {
83
+ const task: GanttTask = {
84
+ id: `${caseId}-${instance.id}`,
85
+ name: instanceName(instance, t),
86
+ start: instance.start,
87
+ end: instance.end,
88
+ };
89
+ if (instance.isParallel) task.status = "info";
90
+ const gaps = instanceGaps(instance, t);
91
+ if (gaps) task.gaps = gaps;
92
+ return task;
93
+ }),
94
+ [instances, caseId, t],
95
+ );
96
+
97
+ return (
98
+ <Gantt
99
+ ref={ref}
100
+ data-slot="case-timeline"
101
+ aria-label={t("process.caseTimeline.label")}
102
+ tasks={tasks}
103
+ defaultViewMode="auto"
104
+ labelPosition="end"
105
+ className={cn(className)}
106
+ {...props}
107
+ />
108
+ );
109
+ });
@@ -0,0 +1,9 @@
1
+ export { CaseTimeline } from "./case-timeline";
2
+ export type { CaseTimelineProps } from "./case-timeline";
3
+
4
+ export { buildCaseTimelineInstances } from "./case-timeline-model";
5
+ export type {
6
+ CaseTimelineGap,
7
+ CaseTimelineInstance,
8
+ CaseTimelineModelOptions,
9
+ } from "./case-timeline-model";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The conformance fixture RM-062's components, tests and stories share — a small,
3
+ * hand-built purchase-approval log replayed against its happy path, so every number a
4
+ * story shows can be checked by hand.
5
+ *
6
+ * Happy path: Register → Check credit → Approve → Pay (every step required).
7
+ *
8
+ * | cases | trace | deviation |
9
+ * | --------- | ---------------------------------------------- | ------------ |
10
+ * | c01 … c06 | Register, Check credit, Approve, Pay | none |
11
+ * | c07, c08 | Register, Approve, Pay | `skipped` |
12
+ * | c09 | Register, Check credit, Escalate, Approve, Pay | `undesired` |
13
+ * | c10 | Register, Check credit, Approve | `incomplete` |
14
+ *
15
+ * Cases start one per fortnight from 2026-01-05 (UTC), so a monthly
16
+ * `conformanceRateSeries` has more than one point.
17
+ *
18
+ * Framework-free: no React, so a unit test and a story import the same data.
19
+ */
20
+ import type { HappyPath } from "../core/reference-model";
21
+ import type { EventLog, EventRow } from "../core/types";
22
+
23
+ /** The fixture's prescribed process. */
24
+ export const CONFORMANCE_FIXTURE_PATH: HappyPath = {
25
+ id: "purchase-approval",
26
+ label: "Purchase approval",
27
+ steps: [
28
+ { activity: "Register" },
29
+ { activity: "Check credit" },
30
+ { activity: "Approve" },
31
+ { activity: "Pay" },
32
+ ],
33
+ };
34
+
35
+ const HOUR = 3_600_000;
36
+ const DAY = 24 * HOUR;
37
+ const FIRST_START = Date.UTC(2026, 0, 5, 9);
38
+
39
+ const TRACES: ReadonlyArray<readonly [string, readonly string[]]> = [
40
+ ["c01", ["Register", "Check credit", "Approve", "Pay"]],
41
+ ["c02", ["Register", "Check credit", "Approve", "Pay"]],
42
+ ["c03", ["Register", "Check credit", "Approve", "Pay"]],
43
+ ["c04", ["Register", "Check credit", "Approve", "Pay"]],
44
+ ["c05", ["Register", "Check credit", "Approve", "Pay"]],
45
+ ["c06", ["Register", "Check credit", "Approve", "Pay"]],
46
+ ["c07", ["Register", "Approve", "Pay"]],
47
+ ["c08", ["Register", "Approve", "Pay"]],
48
+ ["c09", ["Register", "Check credit", "Escalate", "Approve", "Pay"]],
49
+ ["c10", ["Register", "Check credit", "Approve"]],
50
+ ];
51
+
52
+ function rows(caseId: string, start: number, trace: readonly string[]): EventRow[] {
53
+ return trace.map((activity, i) => ({ caseId, activity, timestamp: start + i * HOUR }));
54
+ }
55
+
56
+ /** The fixture event log. */
57
+ export const CONFORMANCE_FIXTURE_LOG: EventLog = {
58
+ events: TRACES.flatMap(([caseId, trace], i) => rows(caseId, FIRST_START + i * 14 * DAY, trace)),
59
+ };
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ConformanceLegend — the key for the conformance overlay (RM-062).
5
+ *
6
+ * Follows `@elabs-ai/components-flow`'s `Legend` pattern (a titled swatch list on a
7
+ * raised surface) but cannot BE that `Legend`: its categorical variant keys by colour
8
+ * alone, and analysis §5.4 forbids a bare colour key. Each entry here pairs three
9
+ * channels — the tone, the glyph (circle / triangle / square) and a line sample in the
10
+ * state's dash (solid / dotted / dashed) — plus the state's word, all read from the one
11
+ * {@link CONFORMANCE_STATE_ENCODING} table the map's nodes and edges also read.
12
+ *
13
+ * Built from HTML and Lucide glyphs only; no SVG is authored here.
14
+ */
15
+ import { forwardRef, type HTMLAttributes } from "react";
16
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
17
+ import {
18
+ CONFORMANCE_STATE_DEFAULT_LABELS,
19
+ CONFORMANCE_STATE_ENCODING,
20
+ CONFORMANCE_STATES,
21
+ type ConformanceState,
22
+ type ConformanceStateLabels,
23
+ } from "./conformance-state";
24
+
25
+ /** Props for {@link ConformanceStateMark}. */
26
+ export interface ConformanceStateMarkProps extends HTMLAttributes<HTMLSpanElement> {
27
+ state: ConformanceState;
28
+ /** Override the state's word. */
29
+ labels?: Partial<ConformanceStateLabels>;
30
+ }
31
+
32
+ /**
33
+ * One state as glyph + coloured word — the text form a table cell prints. The glyph is
34
+ * `aria-hidden`; the word is the accessible reading.
35
+ */
36
+ export const ConformanceStateMark = forwardRef<HTMLSpanElement, ConformanceStateMarkProps>(
37
+ function ConformanceStateMark({ state, labels, className, ...props }, ref) {
38
+ const encoding = CONFORMANCE_STATE_ENCODING[state];
39
+ const Glyph = encoding.icon;
40
+ const word = labels?.[state] ?? CONFORMANCE_STATE_DEFAULT_LABELS[state];
41
+ return (
42
+ <span
43
+ ref={ref}
44
+ data-slot="conformance-state-mark"
45
+ data-conformance={state}
46
+ data-glyph={encoding.glyph}
47
+ className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
48
+ {...props}
49
+ >
50
+ <Glyph aria-hidden="true" className={cn("size-3.5 shrink-0", encoding.markClass)} />
51
+ <span className={encoding.textClass}>{word}</span>
52
+ </span>
53
+ );
54
+ },
55
+ );
56
+
57
+ /** Props for {@link ConformanceLegend}. */
58
+ export interface ConformanceLegendProps extends HTMLAttributes<HTMLDivElement> {
59
+ /** Override any state word or the title. */
60
+ labels?: Partial<ConformanceStateLabels>;
61
+ }
62
+
63
+ /** The three-state key: tone + glyph + dash + word per entry. */
64
+ export const ConformanceLegend = forwardRef<HTMLDivElement, ConformanceLegendProps>(
65
+ function ConformanceLegend({ labels: labelOverrides, className, ...props }, ref) {
66
+ const labels = { ...CONFORMANCE_STATE_DEFAULT_LABELS, ...labelOverrides };
67
+ return (
68
+ <div
69
+ ref={ref}
70
+ role="group"
71
+ aria-label={labels.column}
72
+ data-slot="conformance-legend"
73
+ className={cn(
74
+ "flex flex-col gap-1.5 rounded-lg bg-surface-elevated p-3 shadow-ring-sm",
75
+ className,
76
+ )}
77
+ {...props}
78
+ >
79
+ <div aria-hidden="true" className="text-caption font-medium text-foreground">
80
+ {labels.column}
81
+ </div>
82
+ <ul className="flex flex-wrap gap-x-4 gap-y-1.5">
83
+ {CONFORMANCE_STATES.map((state) => {
84
+ const encoding = CONFORMANCE_STATE_ENCODING[state];
85
+ const Glyph = encoding.icon;
86
+ return (
87
+ <li
88
+ key={state}
89
+ data-slot="conformance-legend-item"
90
+ data-conformance={state}
91
+ data-glyph={encoding.glyph}
92
+ data-dash={encoding.dash}
93
+ className="flex items-center gap-2 text-caption text-muted-foreground"
94
+ >
95
+ <Glyph aria-hidden="true" className={cn("size-3.5 shrink-0", encoding.markClass)} />
96
+ <span
97
+ aria-hidden="true"
98
+ data-slot="conformance-legend-dash"
99
+ className={cn("w-6 shrink-0 border-t-2", encoding.borderClass)}
100
+ />
101
+ {labels[state]}
102
+ </li>
103
+ );
104
+ })}
105
+ </ul>
106
+ </div>
107
+ );
108
+ },
109
+ );
@@ -0,0 +1,116 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { expect, waitFor, within } from "storybook/test";
4
+ import { discoverGraph } from "../core/discover-graph";
5
+ import { liftHappyPath } from "../core/reference-model";
6
+ import { tokenReplay } from "../core/token-replay";
7
+ import { CONFORMANCE_FIXTURE_LOG, CONFORMANCE_FIXTURE_PATH } from "./conformance-fixture";
8
+ import { ConformanceLegend } from "./conformance-legend";
9
+ import { ConformanceOverlay } from "./conformance-overlay";
10
+
11
+ const graph = discoverGraph(CONFORMANCE_FIXTURE_LOG);
12
+ const conformance = tokenReplay(CONFORMANCE_FIXTURE_LOG, liftHappyPath(CONFORMANCE_FIXTURE_PATH));
13
+
14
+ const meta = {
15
+ title: "Process/ConformanceOverlay",
16
+ component: ConformanceOverlay,
17
+ tags: ["autodocs"],
18
+ parameters: {
19
+ layout: "fullscreen",
20
+ docs: {
21
+ description: {
22
+ component:
23
+ "A `ProcessMap` painted with a token-replay result: every activity and transition " +
24
+ "is `both` (in log and model), `logOnly` (the log does it, the model does not) or " +
25
+ "`modelOnly` (the model expects it, cases skipped it). Each state carries a status " +
26
+ "tone, a glyph, a line style and a word in its accessible name — never colour alone. " +
27
+ "`data-conformance` is added beside `data-selection`, never in place of it.",
28
+ },
29
+ },
30
+ },
31
+ decorators: [
32
+ (Story) => (
33
+ <div className="h-[40rem] w-full bg-background p-4">
34
+ <Story />
35
+ </div>
36
+ ),
37
+ ],
38
+ args: { graph, conformance },
39
+ } satisfies Meta<typeof ConformanceOverlay>;
40
+ export default meta;
41
+ type Story = StoryObj<typeof meta>;
42
+
43
+ /** The canvas: glyph marker + frame dash per activity, dash + pill glyph per transition. */
44
+ export const Default: Story = {
45
+ play: async ({ canvasElement }) => {
46
+ await waitFor(
47
+ () =>
48
+ expect(canvasElement.querySelectorAll(".react-flow__node[data-conformance]").length).toBe(
49
+ graph.activities.length,
50
+ ),
51
+ { timeout: 5000 },
52
+ );
53
+ // Every state on the canvas pairs with ONE glyph and ONE dash, and no two states share
54
+ // either — the greyscale test, asserted.
55
+ const glyphByState = new Map<string, string>();
56
+ const dashByState = new Map<string, string>();
57
+ for (const marker of canvasElement.querySelectorAll<HTMLElement>(
58
+ '[data-slot="process-activity-node-conformance"]',
59
+ )) {
60
+ const state = marker.closest("[data-conformance]")!.getAttribute("data-conformance")!;
61
+ const glyph = marker.getAttribute("data-glyph")!;
62
+ const dash = marker.getAttribute("data-dash")!;
63
+ if (glyphByState.has(state)) await expect(glyphByState.get(state)).toBe(glyph);
64
+ glyphByState.set(state, glyph);
65
+ dashByState.set(state, dash);
66
+ }
67
+ await expect([...glyphByState.keys()].sort()).toEqual(["both", "logOnly", "modelOnly"]);
68
+ await expect(new Set(glyphByState.values()).size).toBe(3);
69
+ await expect(new Set(dashByState.values()).size).toBe(3);
70
+
71
+ const skipped = canvasElement.querySelector('.react-flow__node[data-id="Check credit"]')!;
72
+ await expect(skipped).toHaveAttribute("data-conformance", "modelOnly");
73
+ await expect(skipped.getAttribute("aria-label")).toMatch(/Model only/);
74
+
75
+ const legend = within(canvasElement).getByRole("group", { name: "Conformance" });
76
+ await expect(within(legend).getAllByRole("listitem")).toHaveLength(3);
77
+ },
78
+ };
79
+
80
+ /** The accessible twin: a Conformance column whose cell prints the glyph and the word. */
81
+ export const TableView: Story = {
82
+ args: { tableView: true },
83
+ decorators: [
84
+ (Story) => (
85
+ <div className="h-[40rem] w-full overflow-auto">
86
+ <Story />
87
+ </div>
88
+ ),
89
+ ],
90
+ play: async ({ canvasElement }) => {
91
+ const canvas = within(canvasElement);
92
+ const activities = await canvas.findByRole("table", { name: /Activities/ });
93
+ await expect(
94
+ within(activities).getByRole("columnheader", { name: "Conformance" }),
95
+ ).toBeVisible();
96
+ const row = within(activities).getByRole("row", { name: /Check credit/ });
97
+ await expect(row).toHaveAttribute("data-conformance", "modelOnly");
98
+ await expect(within(row).getByText(/Model only/)).toBeVisible();
99
+ },
100
+ };
101
+
102
+ /** No replay yet — the map's own loading panel; the legend waits with it. */
103
+ export const Loading: Story = {
104
+ args: { loading: true },
105
+ };
106
+
107
+ /** The legend on its own, for a toolbar or side panel beside the map. */
108
+ export const Legend: Story = {
109
+ render: () => <ConformanceLegend />,
110
+ play: async ({ canvasElement }) => {
111
+ const items = canvasElement.querySelectorAll('[data-slot="conformance-legend-item"]');
112
+ await expect(items).toHaveLength(3);
113
+ await expect(new Set([...items].map((item) => item.getAttribute("data-glyph"))).size).toBe(3);
114
+ await expect(new Set([...items].map((item) => item.getAttribute("data-dash"))).size).toBe(3);
115
+ },
116
+ };
@@ -0,0 +1,88 @@
1
+ import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { discoverGraph } from "../core/discover-graph";
4
+ import { liftHappyPath } from "../core/reference-model";
5
+ import { tokenReplay } from "../core/token-replay";
6
+ import { ProcessMap } from "../process-map/process-map";
7
+ import { CONFORMANCE_FIXTURE_LOG, CONFORMANCE_FIXTURE_PATH } from "./conformance-fixture";
8
+ import { ConformanceLegend } from "./conformance-legend";
9
+ import { ConformanceOverlay } from "./conformance-overlay";
10
+
11
+ // jsdom has no `DOMMatrixReadOnly`, which React Flow's transform math needs to mount the
12
+ // canvas at all — the same missing-browser-API fill `process-map.test.tsx` documents.
13
+ if (typeof globalThis.DOMMatrixReadOnly === "undefined") {
14
+ class DOMMatrixReadOnlyPolyfill {
15
+ m22 = 1;
16
+ constructor(_init?: unknown) {}
17
+ }
18
+ globalThis.DOMMatrixReadOnly = DOMMatrixReadOnlyPolyfill as unknown as typeof DOMMatrixReadOnly;
19
+ }
20
+
21
+ afterEach(cleanup);
22
+
23
+ const graph = discoverGraph(CONFORMANCE_FIXTURE_LOG);
24
+ const conformance = tokenReplay(CONFORMANCE_FIXTURE_LOG, liftHappyPath(CONFORMANCE_FIXTURE_PATH));
25
+ const metric = { node: "absolute_case", edge: "absolute" } as const;
26
+
27
+ describe("ConformanceLegend", () => {
28
+ it("pairs every state with a distinct glyph and dash, and names it in text", () => {
29
+ render(<ConformanceLegend />);
30
+ const legend = screen.getByRole("group", { name: "Conformance" });
31
+ const items = legend.querySelectorAll('[data-slot="conformance-legend-item"]');
32
+ expect(items).toHaveLength(3);
33
+ const glyphs = new Set([...items].map((item) => item.getAttribute("data-glyph")));
34
+ const dashes = new Set([...items].map((item) => item.getAttribute("data-dash")));
35
+ expect(glyphs.size).toBe(3);
36
+ expect(dashes.size).toBe(3);
37
+ expect(within(legend).getByText(/Model only/)).toBeInTheDocument();
38
+ });
39
+ });
40
+
41
+ describe("ConformanceOverlay — table twin", () => {
42
+ it("adds a Conformance column whose rows carry data-conformance and a word", () => {
43
+ render(<ConformanceOverlay graph={graph} conformance={conformance} tableView />);
44
+ const activityTable = document.querySelector<HTMLElement>(
45
+ '[data-slot="process-map-activity-table"]',
46
+ )!;
47
+ expect(within(activityTable).getByRole("columnheader", { name: "Conformance" })).toBeVisible();
48
+ const skipped = within(activityTable).getByRole("row", { name: /Check credit/ });
49
+ expect(skipped).toHaveAttribute("data-conformance", "modelOnly");
50
+ expect(within(skipped).getByText(/Model only/)).toBeInTheDocument();
51
+
52
+ const transitionTable = document.querySelector<HTMLElement>(
53
+ '[data-slot="process-map-transition-table"]',
54
+ )!;
55
+ const diverged = [...transitionTable.querySelectorAll("tbody tr")].find(
56
+ (row) => row.textContent?.startsWith("RegisterApprove") ?? false,
57
+ );
58
+ expect(diverged).toHaveAttribute("data-conformance", "logOnly");
59
+ });
60
+
61
+ it("hides the legend while loading", () => {
62
+ render(<ConformanceOverlay graph={graph} conformance={conformance} loading />);
63
+ expect(document.querySelector('[data-slot="conformance-legend"]')).toBeNull();
64
+ expect(screen.getByRole("status")).toBeInTheDocument();
65
+ });
66
+ });
67
+
68
+ describe("ProcessMap — conformance is additive", () => {
69
+ it("renders no conformance column or attribute without the prop", () => {
70
+ render(<ProcessMap graph={graph} metric={metric} tableView />);
71
+ expect(screen.queryByRole("columnheader", { name: "Conformance" })).toBeNull();
72
+ expect(document.querySelector("[data-conformance]")).toBeNull();
73
+ });
74
+
75
+ it("puts data-conformance on React Flow's node element and a glyph marker inside", async () => {
76
+ render(<ConformanceOverlay graph={graph} conformance={conformance} />);
77
+ await waitFor(() =>
78
+ expect(document.querySelectorAll(".react-flow__node").length).toBe(graph.activities.length),
79
+ );
80
+ const node = document.querySelector<HTMLElement>('.react-flow__node[data-id="Check credit"]')!;
81
+ expect(node).toHaveAttribute("data-conformance", "modelOnly");
82
+ expect(node).toHaveAttribute("data-selection", "associated");
83
+ expect(node).toHaveAttribute("aria-label", expect.stringMatching(/Model only/));
84
+ const marker = node.querySelector('[data-slot="process-activity-node-conformance"]');
85
+ expect(marker).toHaveAttribute("data-glyph", "square");
86
+ expect(marker).toHaveAttribute("data-dash", "dashed");
87
+ });
88
+ });
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ConformanceOverlay — the process map read against a reference model (RM-062).
5
+ *
6
+ * A thin composition: `ProcessMap` with its `conformance` prop always set, plus a
7
+ * {@link ConformanceLegend} explaining the three states. It authors no node or edge
8
+ * rendering of its own — the tone, glyph and dash per element come from `ProcessMap`'s own
9
+ * activity node and transition edge, so the overlay and the plain map can never drift
10
+ * (`pnpm check --rule process-reuse`).
11
+ *
12
+ * Every state reaches the reader on three visual channels (tone, glyph, line style) and as
13
+ * text (the accessible name on the canvas, a Conformance column in `tableView`), so the
14
+ * overlay never relies on colour alone (analysis §5.4, WCAG 1.4.1).
15
+ */
16
+ import { forwardRef, type HTMLAttributes } from "react";
17
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
18
+ import type { FlowLayoutDirection } from "@elabs-ai/components-flow";
19
+ import type { ConformanceResult } from "../core/conformance";
20
+ import type { ProcessGraph } from "../core/types";
21
+ import type { ProcessMetricSpec, ProcessSelection } from "../process-map/map-model";
22
+ import { ProcessMap, type ProcessMapProps } from "../process-map/process-map";
23
+ import { ConformanceLegend } from "./conformance-legend";
24
+ import type { ConformanceStateLabels } from "./conformance-state";
25
+
26
+ /** The metric the overlay paints when none is given: case frequency on both marks. */
27
+ export const CONFORMANCE_OVERLAY_DEFAULT_METRIC: ProcessMetricSpec = Object.freeze({
28
+ node: "absolute_case",
29
+ edge: "absolute",
30
+ }) as ProcessMetricSpec;
31
+
32
+ /** Props for {@link ConformanceOverlay}. `onSelect` shadows the DOM handler, so it is omitted. */
33
+ export interface ConformanceOverlayProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
34
+ /** The discovered graph to overlay. */
35
+ graph: ProcessGraph;
36
+ /** The replay result (`tokenReplay`) against the reference model. */
37
+ conformance: ConformanceResult;
38
+ /** Which readings the nodes and edges print. @default case frequency */
39
+ metric?: ProcessMetricSpec;
40
+ /** @default "TB" */
41
+ direction?: FlowLayoutDirection;
42
+ /** Controlled selection, passed straight to `ProcessMap`. */
43
+ selection?: ProcessSelection | null;
44
+ onSelect?: ProcessMapProps["onSelect"];
45
+ onFilterIntent?: ProcessMapProps["onFilterIntent"];
46
+ /** Render the accessible table twin instead of the canvas. @default false */
47
+ tableView?: boolean;
48
+ /** No graph or replay yet. Renders the map's loading panel and hides the legend. */
49
+ loading?: boolean;
50
+ /** Override the legend's words. */
51
+ labels?: Partial<ConformanceStateLabels>;
52
+ }
53
+
54
+ /**
55
+ * The conformance overlay.
56
+ *
57
+ * @example
58
+ * ```tsx
59
+ * const model = useMemo(() => liftHappyPath(happyPath), [happyPath]);
60
+ * const conformance = useMemo(() => tokenReplay(log, model), [log, model]);
61
+ * <ConformanceOverlay graph={graph} conformance={conformance} />
62
+ * ```
63
+ */
64
+ export const ConformanceOverlay = forwardRef<HTMLDivElement, ConformanceOverlayProps>(
65
+ function ConformanceOverlay(
66
+ {
67
+ graph,
68
+ conformance,
69
+ metric = CONFORMANCE_OVERLAY_DEFAULT_METRIC,
70
+ direction,
71
+ selection,
72
+ onSelect,
73
+ onFilterIntent,
74
+ tableView = false,
75
+ loading = false,
76
+ labels,
77
+ className,
78
+ ...props
79
+ },
80
+ ref,
81
+ ) {
82
+ return (
83
+ <div
84
+ ref={ref}
85
+ data-slot="conformance-overlay"
86
+ data-view={tableView ? "table" : "canvas"}
87
+ className={cn("flex size-full min-h-0 flex-col gap-3", className)}
88
+ {...props}
89
+ >
90
+ {loading ? null : <ConformanceLegend labels={labels} className="self-start" />}
91
+ <div data-slot="conformance-overlay-map" className="min-h-64 flex-1">
92
+ <ProcessMap
93
+ graph={graph}
94
+ metric={metric}
95
+ conformance={conformance}
96
+ direction={direction}
97
+ selection={selection}
98
+ onSelect={onSelect}
99
+ onFilterIntent={onFilterIntent}
100
+ tableView={tableView}
101
+ loading={loading}
102
+ />
103
+ </div>
104
+ </div>
105
+ );
106
+ },
107
+ );
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { tokenReplay } from "../core/token-replay";
3
+ import { liftHappyPath } from "../core/reference-model";
4
+ import { discoverGraph } from "../core/discover-graph";
5
+ import { buildProcessMapModel } from "../process-map/map-model";
6
+ import { CONFORMANCE_FIXTURE_LOG, CONFORMANCE_FIXTURE_PATH } from "./conformance-fixture";
7
+ import {
8
+ activityConformance,
9
+ CONFORMANCE_STATE_ENCODING,
10
+ CONFORMANCE_STATES,
11
+ resolveConformanceStates,
12
+ transitionConformance,
13
+ withActivityConformance,
14
+ withTransitionConformance,
15
+ } from "./conformance-state";
16
+
17
+ const conformance = tokenReplay(CONFORMANCE_FIXTURE_LOG, liftHappyPath(CONFORMANCE_FIXTURE_PATH));
18
+ const states = resolveConformanceStates(conformance);
19
+
20
+ describe("resolveConformanceStates — the RM-062 acceptance fixture", () => {
21
+ it("marks the deliberately skipped step modelOnly", () => {
22
+ expect(activityConformance(states, "Check credit")).toBe("modelOnly");
23
+ });
24
+
25
+ it("marks an activity outside the model logOnly and conforming steps both", () => {
26
+ expect(activityConformance(states, "Escalate")).toBe("logOnly");
27
+ expect(activityConformance(states, "Register")).toBe("both");
28
+ expect(activityConformance(states, "Pay")).toBe("both");
29
+ // An `incomplete` deviation does not move a modelled activity out of the model.
30
+ expect(activityConformance(states, "Approve")).toBe("both");
31
+ });
32
+
33
+ it("marks the edges where the log diverged logOnly", () => {
34
+ // The skip: cases jumped straight from Register to Approve.
35
+ expect(transitionConformance(states, "Register", "Approve")).toBe("logOnly");
36
+ // Into and out of the undesired activity.
37
+ expect(transitionConformance(states, "Check credit", "Escalate")).toBe("logOnly");
38
+ expect(transitionConformance(states, "Escalate", "Approve")).toBe("logOnly");
39
+ // The prescribed arcs.
40
+ expect(transitionConformance(states, "Register", "Check credit")).toBe("both");
41
+ expect(transitionConformance(states, "Approve", "Pay")).toBe("both");
42
+ });
43
+ });
44
+
45
+ describe("CONFORMANCE_STATE_ENCODING — never colour alone", () => {
46
+ it("gives each state a distinct tone, glyph and dash", () => {
47
+ for (const channel of ["tone", "glyph", "dash"] as const) {
48
+ const values = CONFORMANCE_STATES.map((state) => CONFORMANCE_STATE_ENCODING[state][channel]);
49
+ expect(new Set(values).size).toBe(CONFORMANCE_STATES.length);
50
+ }
51
+ });
52
+ });
53
+
54
+ describe("withActivityConformance / withTransitionConformance", () => {
55
+ const model = buildProcessMapModel({
56
+ graph: discoverGraph(CONFORMANCE_FIXTURE_LOG),
57
+ metric: { node: "absolute_case", edge: "absolute" },
58
+ });
59
+
60
+ it("adds data-conformance beside data-selection and names the state", () => {
61
+ const node = model.nodes.find((n) => n.id === "Check credit")!;
62
+ const decorated = withActivityConformance(node, states);
63
+ expect(decorated.data.conformance).toBe("modelOnly");
64
+ expect(decorated.domAttributes).toMatchObject({
65
+ "data-selection": node.data.selectionState,
66
+ "data-conformance": "modelOnly",
67
+ });
68
+ expect(decorated.ariaLabel).toMatch(/Model only/);
69
+ expect(decorated.ariaLabel?.startsWith(node.ariaLabel ?? "")).toBe(true);
70
+ });
71
+
72
+ it("folds the state into the edge's label-pill name as well as the edge's own", () => {
73
+ const edge = model.edges.find((e) => e.source === "Register" && e.target === "Approve")!;
74
+ const decorated = withTransitionConformance(edge, states);
75
+ expect(decorated.data?.conformance).toBe("logOnly");
76
+ expect(decorated.ariaLabel).toMatch(/Log only/);
77
+ expect(decorated.data?.ariaLabel).toBe(decorated.ariaLabel);
78
+ });
79
+ });