@elabs-ai/components-process 4.1.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 (145) 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__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { CaseTimeline } from "./case-timeline";
3
+ import type { EventRow } from "../core/types";
4
+
5
+ const CASE_ID = "case-1";
6
+
7
+ /** Three activities, back to back — no waiting time, no overlap. */
8
+ const SEQUENTIAL_EVENTS: EventRow[] = [
9
+ { caseId: CASE_ID, activity: "Create Order", startTimestamp: 0, timestamp: 1_800_000 },
10
+ {
11
+ caseId: CASE_ID,
12
+ activity: "Check Credit",
13
+ startTimestamp: 1_800_000,
14
+ timestamp: 3_600_000,
15
+ },
16
+ {
17
+ caseId: CASE_ID,
18
+ activity: "Approve Order",
19
+ startTimestamp: 3_600_000,
20
+ timestamp: 5_400_000,
21
+ },
22
+ ];
23
+
24
+ /** Two gaps: the case waits between every pair of activities. */
25
+ const WAITING_TIME_EVENTS: EventRow[] = [
26
+ { caseId: CASE_ID, activity: "Create Order", startTimestamp: 0, timestamp: 1_800_000 },
27
+ {
28
+ caseId: CASE_ID,
29
+ activity: "Check Credit",
30
+ startTimestamp: 3_600_000,
31
+ timestamp: 5_400_000,
32
+ },
33
+ {
34
+ caseId: CASE_ID,
35
+ activity: "Approve Order",
36
+ startTimestamp: 9_000_000,
37
+ timestamp: 10_800_000,
38
+ },
39
+ ];
40
+
41
+ /** "Check Credit" and "Check Inventory" run at the same time — both flag parallel. */
42
+ const PARALLEL_EVENTS: EventRow[] = [
43
+ { caseId: CASE_ID, activity: "Check Credit", startTimestamp: 0, timestamp: 3_600_000 },
44
+ {
45
+ caseId: CASE_ID,
46
+ activity: "Check Inventory",
47
+ startTimestamp: 1_800_000,
48
+ timestamp: 5_400_000,
49
+ },
50
+ {
51
+ caseId: CASE_ID,
52
+ activity: "Approve Order",
53
+ startTimestamp: 7_200_000,
54
+ timestamp: 9_000_000,
55
+ },
56
+ ];
57
+
58
+ const meta = {
59
+ title: "Process/CaseTimeline",
60
+ component: CaseTimeline,
61
+ tags: ["autodocs"],
62
+ parameters: {
63
+ docs: {
64
+ description: {
65
+ component:
66
+ "A thin wrapper over `@elabs-ai/components-charts`'s `Gantt` (§4 R12): one row per " +
67
+ "activity instance, waiting time as RM-047 gap bands, overlapping instances flagged " +
68
+ "parallel with an existing `Gantt` tone plus a visible, non-colour text tag.",
69
+ },
70
+ },
71
+ },
72
+ } satisfies Meta<typeof CaseTimeline>;
73
+ export default meta;
74
+ type Story = StoryObj<typeof meta>;
75
+
76
+ /** No waiting time, no overlap — the plain case. */
77
+ export const Sequential: Story = {
78
+ args: { caseId: CASE_ID, events: SEQUENTIAL_EVENTS },
79
+ };
80
+
81
+ /** Two hatched gap bands render the idle time between activities (RM-047). */
82
+ export const WithWaitingTime: Story = {
83
+ args: { caseId: CASE_ID, events: WAITING_TIME_EVENTS },
84
+ };
85
+
86
+ /** "Check Credit" and "Check Inventory" overlap — both render the parallel tag. */
87
+ export const ParallelActivities: Story = {
88
+ args: { caseId: CASE_ID, events: PARALLEL_EVENTS },
89
+ };
90
+
91
+ /** No events for this case yet — `Gantt`'s own built-in empty state. */
92
+ export const Empty: Story = {
93
+ args: { caseId: CASE_ID, events: [] },
94
+ };
@@ -0,0 +1,51 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { CaseTimeline } from "./case-timeline";
4
+ import type { EventRow } from "../core/types";
5
+
6
+ afterEach(cleanup);
7
+
8
+ const CASE_ID = "case-1";
9
+
10
+ const PARALLEL_EVENTS: EventRow[] = [
11
+ { caseId: CASE_ID, activity: "Check Credit", startTimestamp: 0, timestamp: 3_600_000 },
12
+ {
13
+ caseId: CASE_ID,
14
+ activity: "Check Inventory",
15
+ startTimestamp: 1_800_000,
16
+ timestamp: 5_400_000,
17
+ },
18
+ { caseId: CASE_ID, activity: "Approve Order", startTimestamp: 7_200_000, timestamp: 9_000_000 },
19
+ ];
20
+
21
+ describe("CaseTimeline — rendering", () => {
22
+ it("renders one row per activity instance", () => {
23
+ render(<CaseTimeline caseId={CASE_ID} events={PARALLEL_EVENTS} />);
24
+ const treeitems = screen.getAllByRole("treeitem");
25
+ expect(treeitems).toHaveLength(3);
26
+ });
27
+
28
+ it("renders the empty state when the case has no events", () => {
29
+ render(<CaseTimeline caseId={CASE_ID} events={[]} />);
30
+ expect(screen.getByText(/no tasks to display/i)).toBeInTheDocument();
31
+ });
32
+
33
+ it("flags overlapping instances with a visible, non-colour 'Parallel' tag (WCAG 1.4.1)", () => {
34
+ render(<CaseTimeline caseId={CASE_ID} events={PARALLEL_EVENTS} />);
35
+ // Two overlapping instances ("Check Credit" and "Check Inventory") each carry the
36
+ // text tag; "Approve Order" (no overlap) does not.
37
+ const parallelTags = screen.getAllByText("Parallel");
38
+ expect(parallelTags.length).toBeGreaterThanOrEqual(2);
39
+
40
+ const treeitems = screen.getAllByRole("treeitem");
41
+ const approveOrderRow = treeitems.find((el) => el.textContent?.includes("Approve Order"));
42
+ expect(approveOrderRow?.textContent).not.toContain("Parallel");
43
+ });
44
+
45
+ it("respects a custom parallelismThreshold", () => {
46
+ render(
47
+ <CaseTimeline caseId={CASE_ID} events={PARALLEL_EVENTS} parallelismThreshold={10_000_000} />,
48
+ );
49
+ expect(screen.queryByText("Parallel")).not.toBeInTheDocument();
50
+ });
51
+ });
@@ -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
+ });