@principal-ai/subsystems-react 0.53.5 → 0.54.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 (108) hide show
  1. package/dist/index.d.ts +8 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  6. package/dist/pierre/PierreTrailCodeView.js +7 -5
  7. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  8. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  9. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +1 -2
  10. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  11. package/dist/subsystem/C4Graph.d.ts +144 -9
  12. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  13. package/dist/subsystem/C4Graph.js +924 -148
  14. package/dist/subsystem/C4Graph.js.map +1 -1
  15. package/dist/subsystem/C4NodeCard.d.ts +22 -1
  16. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  17. package/dist/subsystem/C4NodeCard.js +10 -2
  18. package/dist/subsystem/C4NodeCard.js.map +1 -1
  19. package/dist/subsystem/ComponentDeclaration.d.ts +5 -4
  20. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  21. package/dist/subsystem/ComponentDeclaration.js +12 -4
  22. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  23. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  24. package/dist/subsystem/ConstructsCatalog.js +10 -7
  25. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  26. package/dist/subsystem/GraphSidebar.d.ts +71 -0
  27. package/dist/subsystem/GraphSidebar.d.ts.map +1 -0
  28. package/dist/subsystem/GraphSidebar.js +145 -0
  29. package/dist/subsystem/GraphSidebar.js.map +1 -0
  30. package/dist/subsystem/GraphWithSidebar.d.ts +24 -0
  31. package/dist/subsystem/GraphWithSidebar.d.ts.map +1 -0
  32. package/dist/subsystem/GraphWithSidebar.js +25 -0
  33. package/dist/subsystem/GraphWithSidebar.js.map +1 -0
  34. package/dist/subsystem/SubsystemComponentGraph.d.ts +33 -0
  35. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  36. package/dist/subsystem/SubsystemComponentGraph.js +364 -411
  37. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  38. package/dist/subsystem/TrailStepsNav.d.ts +48 -0
  39. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -0
  40. package/dist/subsystem/TrailStepsNav.js +189 -0
  41. package/dist/subsystem/TrailStepsNav.js.map +1 -0
  42. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  43. package/dist/subsystem/TrailsPanel.js +3 -4
  44. package/dist/subsystem/TrailsPanel.js.map +1 -1
  45. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +151 -0
  46. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -0
  47. package/dist/subsystem/__fixtures__/c4Drilldown.js +783 -0
  48. package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -0
  49. package/dist/subsystem/c4.d.ts +39 -8
  50. package/dist/subsystem/c4.d.ts.map +1 -1
  51. package/dist/subsystem/c4.js.map +1 -1
  52. package/dist/subsystem/declarationRef.d.ts +26 -18
  53. package/dist/subsystem/declarationRef.d.ts.map +1 -1
  54. package/dist/subsystem/declarationRef.js +9 -4
  55. package/dist/subsystem/declarationRef.js.map +1 -1
  56. package/dist/subsystem/model.d.ts +19 -1
  57. package/dist/subsystem/model.d.ts.map +1 -1
  58. package/dist/subsystem/model.js +12 -2
  59. package/dist/subsystem/model.js.map +1 -1
  60. package/dist/subsystem/nodes.d.ts.map +1 -1
  61. package/dist/subsystem/nodes.js +24 -4
  62. package/dist/subsystem/nodes.js.map +1 -1
  63. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  64. package/dist/subsystem/trailBrief.js +6 -1
  65. package/dist/subsystem/trailBrief.js.map +1 -1
  66. package/dist/utils/elkLayout.d.ts.map +1 -1
  67. package/dist/utils/elkLayout.js +10 -8
  68. package/dist/utils/elkLayout.js.map +1 -1
  69. package/package.json +3 -3
  70. package/src/index.ts +9 -2
  71. package/src/pierre/PierreTrailCodeView.tsx +9 -13
  72. package/src/stories/Subsystem/C4Graph/C4ToComponentDrill.stories.tsx +496 -0
  73. package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +7 -7
  74. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +19 -25
  75. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +8 -10
  76. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +19 -19
  77. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +6 -6
  78. package/src/stories/Subsystem/ComponentGraph/TrailAutoplay.stories.tsx +7 -7
  79. package/src/stories/Subsystem/ComponentGraph/Trails.stories.tsx +27 -30
  80. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +1 -2
  81. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +10 -13
  82. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +3 -4
  83. package/src/stories/Subsystem/Sidebar/GraphSidebar.stories.tsx +206 -0
  84. package/src/subsystem/C4Graph.groups.test.ts +96 -2
  85. package/src/subsystem/C4Graph.tsx +1377 -165
  86. package/src/subsystem/C4NodeCard.test.ts +1 -2
  87. package/src/subsystem/C4NodeCard.tsx +24 -2
  88. package/src/subsystem/ComponentDeclaration.tsx +18 -8
  89. package/src/subsystem/ConstructsCatalog.tsx +23 -8
  90. package/src/subsystem/GraphSidebar.test.tsx +152 -0
  91. package/src/subsystem/GraphSidebar.tsx +300 -0
  92. package/src/subsystem/GraphWithSidebar.test.tsx +78 -0
  93. package/src/subsystem/GraphWithSidebar.tsx +60 -0
  94. package/src/subsystem/SubsystemComponentGraph.tsx +284 -370
  95. package/src/subsystem/TrailStepsNav.test.tsx +179 -0
  96. package/src/subsystem/TrailStepsNav.tsx +372 -0
  97. package/src/subsystem/TrailsPanel.tsx +3 -4
  98. package/src/subsystem/__fixtures__/c4Drilldown.test.ts +338 -0
  99. package/src/subsystem/__fixtures__/c4Drilldown.ts +847 -0
  100. package/src/subsystem/c4.ts +41 -8
  101. package/src/subsystem/declarationRef.ts +28 -19
  102. package/src/subsystem/model.test.ts +32 -12
  103. package/src/subsystem/model.ts +30 -1
  104. package/src/subsystem/nodes.group.test.tsx +82 -0
  105. package/src/subsystem/nodes.tsx +30 -2
  106. package/src/subsystem/trailBrief.test.ts +2 -2
  107. package/src/subsystem/trailBrief.ts +6 -1
  108. package/src/utils/elkLayout.ts +10 -8
@@ -0,0 +1,179 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { createElement } from 'react';
3
+ import { Window } from 'happy-dom';
4
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
5
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
6
+ import { TrailStepsNav } from './TrailStepsNav';
7
+ import type { SubsystemTrail } from './model';
8
+
9
+ // Bun's happy-dom test environment isn't registered (no @happy-dom/global-
10
+ // registrator), so wire the DOM primitives React DOM needs before rendering.
11
+ const dom = new Window();
12
+ const globals = globalThis as unknown as Record<string, unknown>;
13
+ globals.window = dom;
14
+ globals.document = dom.document;
15
+ globals.navigator = dom.navigator;
16
+ globals.HTMLElement = dom.HTMLElement;
17
+ globals.Node = dom.Node;
18
+ globals.Element = dom.Element;
19
+ globals.Event = dom.Event;
20
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
21
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
22
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
23
+ globals.localStorage = dom.localStorage;
24
+ globals.matchMedia = dom.matchMedia.bind(dom);
25
+
26
+ afterEach(cleanup);
27
+
28
+ const step = (from: string, to: string, symbol: string, annotation?: string) => ({
29
+ from,
30
+ to,
31
+ mechanism: 'calls' as const,
32
+ file: `${from}.ts`,
33
+ line: 1,
34
+ purl: `pkg:github/x#${from}.ts`,
35
+ symbol,
36
+ ...(annotation ? { annotation } : {}),
37
+ });
38
+
39
+ const TRAILS: SubsystemTrail[] = [
40
+ {
41
+ id: 't1',
42
+ title: 'First flow',
43
+ steps: [step('a', 'b', 'A.go', 'Read the source seam.'), step('b', 'c', 'B.go')],
44
+ },
45
+ { id: 't2', title: 'Second flow', steps: [step('c', 'd', 'C.go')] },
46
+ ];
47
+
48
+ function renderNav(props: Record<string, unknown>) {
49
+ return render(
50
+ createElement(
51
+ ThemeProvider,
52
+ { theme: defaultEditorTheme },
53
+ createElement(TrailStepsNav as never, props as never),
54
+ ),
55
+ );
56
+ }
57
+
58
+ describe('TrailStepsNav', () => {
59
+ test('lists trails with step counts and focuses on click', () => {
60
+ let focused: string | null = null;
61
+ const { getByText, getByRole } = renderNav({
62
+ trails: TRAILS,
63
+ focusedTrailId: null,
64
+ focusedStepIndex: null,
65
+ hoveredTrailStep: null,
66
+ onFocusFlow: (t: SubsystemTrail) => {
67
+ focused = t.id;
68
+ },
69
+ onClearFocus: () => {},
70
+ onFocusStep: () => {},
71
+ onHoverStep: () => {},
72
+ onHoverFlow: () => {},
73
+ onLeaveStep: () => {},
74
+ });
75
+ expect(getByText('First flow')).toBeTruthy();
76
+ expect(getByText('Second flow')).toBeTruthy();
77
+ fireEvent.click(getByRole('button', { name: /First flow/ }));
78
+ expect(focused).toBe('t1');
79
+ });
80
+
81
+ test('a hovered trail row previews the flow', () => {
82
+ const hovered: string[] = [];
83
+ const { getByRole } = renderNav({
84
+ trails: TRAILS,
85
+ focusedTrailId: null,
86
+ focusedStepIndex: null,
87
+ hoveredTrailStep: null,
88
+ onFocusFlow: () => {},
89
+ onClearFocus: () => {},
90
+ onFocusStep: () => {},
91
+ onHoverStep: () => {},
92
+ onHoverFlow: (t: SubsystemTrail) => hovered.push(t.id),
93
+ onLeaveStep: () => {},
94
+ });
95
+ fireEvent.mouseEnter(getByRole('button', { name: /Second flow/ }));
96
+ expect(hovered).toContain('t2');
97
+ });
98
+
99
+ test('back slides the step pane out before its steps unmount', async () => {
100
+ const base = {
101
+ trails: TRAILS,
102
+ focusedStepIndex: null,
103
+ hoveredTrailStep: null,
104
+ onFocusFlow: () => {},
105
+ onClearFocus: () => {},
106
+ onFocusStep: () => {},
107
+ onHoverStep: () => {},
108
+ onHoverFlow: () => {},
109
+ onLeaveStep: () => {},
110
+ transitionMs: 30,
111
+ };
112
+ const { getByText, queryByText, rerender } = renderNav({ ...base, focusedTrailId: 't1' });
113
+ expect(getByText('A.go')).toBeTruthy();
114
+ // Back clears the focus, but the pane keeps its content while it slides out.
115
+ rerender(
116
+ createElement(
117
+ ThemeProvider,
118
+ { theme: defaultEditorTheme },
119
+ createElement(TrailStepsNav as never, { ...base, focusedTrailId: null } as never),
120
+ ),
121
+ );
122
+ expect(queryByText('A.go')).toBeTruthy();
123
+ // Once the transition has run, the step content is dropped.
124
+ await new Promise((r) => setTimeout(r, 30 + 40));
125
+ expect(queryByText('A.go')).toBeNull();
126
+ });
127
+
128
+ test('a focused trail shows its steps; back clears the focus', () => {
129
+ let cleared = false;
130
+ let focusedStep: number | null = null;
131
+ const { getByText, getByLabelText, getByRole } = renderNav({
132
+ trails: TRAILS,
133
+ focusedTrailId: 't1',
134
+ focusedStepIndex: null,
135
+ hoveredTrailStep: null,
136
+ onFocusFlow: () => {},
137
+ onClearFocus: () => {
138
+ cleared = true;
139
+ },
140
+ onFocusStep: (_t: SubsystemTrail, i: number) => {
141
+ focusedStep = i;
142
+ },
143
+ onHoverStep: () => {},
144
+ onHoverFlow: () => {},
145
+ onLeaveStep: () => {},
146
+ });
147
+ // The step pane is translated in; both steps render by symbol, and an
148
+ // annotated step shows its note as the row's description.
149
+ expect(getByText('A.go')).toBeTruthy();
150
+ expect(getByText('B.go')).toBeTruthy();
151
+ expect(getByText('Read the source seam.')).toBeTruthy();
152
+ fireEvent.click(getByRole('button', { name: /B\.go/ }));
153
+ expect(focusedStep).toBe(1);
154
+ fireEvent.click(getByLabelText('Back to trails'));
155
+ expect(cleared).toBe(true);
156
+ });
157
+
158
+ test('clicking the step-pane header title also goes back', () => {
159
+ let cleared = false;
160
+ const { getByRole } = renderNav({
161
+ trails: TRAILS,
162
+ focusedTrailId: 't1',
163
+ focusedStepIndex: null,
164
+ hoveredTrailStep: null,
165
+ onFocusFlow: () => {},
166
+ onClearFocus: () => {
167
+ cleared = true;
168
+ },
169
+ onFocusStep: () => {},
170
+ onHoverStep: () => {},
171
+ onHoverFlow: () => {},
172
+ onLeaveStep: () => {},
173
+ });
174
+ // The header title is its own button (the list row's accessible name
175
+ // includes the step count, so the exact title matches the header).
176
+ fireEvent.click(getByRole('button', { name: 'First flow' }));
177
+ expect(cleared).toBe(true);
178
+ });
179
+ });
@@ -0,0 +1,372 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+ import { ChevronRight, Route, X } from 'lucide-react';
4
+ import { useTheme } from '@principal-ade/industry-theme';
5
+ import type { SubsystemTrail } from './model';
6
+
7
+ /**
8
+ * TrailStepsNav — the trails side panel as a two-step slide: a list of trails,
9
+ * and a step pane that slides in from the right when a trail is picked (and
10
+ * back out when focus clears). The counterpart to `TrailsPanel`'s inline
11
+ * expand — same data and callbacks, a push instead of a dropdown.
12
+ *
13
+ * Pure presentation: the host owns the focus state and the canvas response.
14
+ * `onFocusFlow` opens the step pane (and, in the C4 drill, hides the elements
15
+ * the trail does not touch); `onClearFocus` closes it. Hovering a row previews
16
+ * that trail (`onHoverFlow`); hovering a step previews the hop
17
+ * (`onHoverStep`), which the host dims to.
18
+ *
19
+ * Back is a slide, not a swap: clearing the focus leaves the last trail's steps
20
+ * rendered until the pane has finished sliding out, so the detail view is what
21
+ * moves, not a blank pane.
22
+ *
23
+ * Each step row leads with its `annotation` (the free-text note anchored to its
24
+ * site line) as the row's description, with the `symbol` beneath it.
25
+ */
26
+
27
+ export interface TrailStepsNavProps {
28
+ /** Ordered trails — the list pane. */
29
+ trails: SubsystemTrail[];
30
+ /** Focused trail id, or null for the list pane. */
31
+ focusedTrailId: string | null;
32
+ /** Focused step within the focused trail, or null for the whole flow. */
33
+ focusedStepIndex: number | null;
34
+ /** Hovered trail/step, for the preview highlight (`stepIndex: null` = whole). */
35
+ hoveredTrailStep: { trailId: string; stepIndex: number | null } | null;
36
+ /** Pick a trail: focus the whole flow — this opens the step pane. */
37
+ onFocusFlow: (trail: SubsystemTrail) => void;
38
+ /** Back: clear the focus — this closes the step pane. */
39
+ onClearFocus: () => void;
40
+ onFocusStep: (trail: SubsystemTrail, stepIndex: number) => void;
41
+ onHoverStep: (trail: SubsystemTrail, stepIndex: number) => void;
42
+ onHoverFlow: (trail: SubsystemTrail) => void;
43
+ onLeaveStep: () => void;
44
+ /** Aliases of components marked `proposed` — a row whose steps touch one tints. */
45
+ proposedAliases?: ReadonlySet<string>;
46
+ /** Slide duration in ms. @default 340 */
47
+ transitionMs?: number;
48
+ }
49
+
50
+ const PROPOSED_COLOR = 'darkgoldenrod';
51
+
52
+ const PANE: CSSProperties = {
53
+ position: 'absolute',
54
+ inset: 0,
55
+ display: 'flex',
56
+ flexDirection: 'column',
57
+ minHeight: 0,
58
+ willChange: 'transform',
59
+ };
60
+
61
+ function touchesProposed(trail: SubsystemTrail, proposed?: ReadonlySet<string>): boolean {
62
+ if (!proposed) return false;
63
+ return trail.steps.some((s) => proposed.has(s.from) || proposed.has(s.to));
64
+ }
65
+
66
+ export function TrailStepsNav({
67
+ trails,
68
+ focusedTrailId,
69
+ focusedStepIndex,
70
+ hoveredTrailStep,
71
+ onFocusFlow,
72
+ onClearFocus,
73
+ onFocusStep,
74
+ onHoverStep,
75
+ onHoverFlow,
76
+ onLeaveStep,
77
+ proposedAliases,
78
+ transitionMs = 340,
79
+ }: TrailStepsNavProps) {
80
+ const { theme } = useTheme();
81
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
82
+ const hoverBg = theme.colors.backgroundHover ?? theme.colors.backgroundSecondary ?? '#232323';
83
+ const transition = `transform ${transitionMs}ms cubic-bezier(0.2, 0, 0, 1)`;
84
+
85
+ // Hovering a collapsed row previews the whole trail while nothing is focused;
86
+ // the list pane is the only place that happens.
87
+ const [hoveredRow, setHoveredRow] = useState<string | null>(null);
88
+ // Hovering the detail title (or the close) lights the close, so the title
89
+ // reads as the same back action.
90
+ const [headerHover, setHeaderHover] = useState(false);
91
+
92
+ const focused = trails.find((t) => t.id === focusedTrailId) ?? null;
93
+ const open = focused != null;
94
+
95
+ // The step pane is a translated layer, not a mounted/unmounted one: on back,
96
+ // `focused` goes null immediately, which would blank the pane's content the
97
+ // instant the slide-out starts. Hold the last focused trail through the
98
+ // transition, then drop it — slide first, unmount after. A fresh focus lands
99
+ // immediately (and cancels any pending drop).
100
+ const [displayedTrail, setDisplayedTrail] = useState<SubsystemTrail | null>(focused);
101
+ useEffect(() => {
102
+ if (focused) {
103
+ setDisplayedTrail(focused);
104
+ return;
105
+ }
106
+ const timer = setTimeout(() => setDisplayedTrail(null), transitionMs);
107
+ return () => clearTimeout(timer);
108
+ }, [focused, transitionMs]);
109
+
110
+ // A focused trail (from the graph side) always lands on the step pane.
111
+ const leaveList = useCallback(() => {
112
+ setHoveredRow(null);
113
+ }, []);
114
+
115
+ useEffect(() => {
116
+ if (open) setHoveredRow(null);
117
+ setHeaderHover(false);
118
+ }, [open]);
119
+
120
+ return (
121
+ <div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
122
+ {/* List pane. */}
123
+ <div
124
+ aria-hidden={open}
125
+ style={{
126
+ ...PANE,
127
+ transform: open ? 'translateX(-100%)' : 'translateX(0)',
128
+ transition,
129
+ }}
130
+ onMouseLeave={leaveList}
131
+ >
132
+ <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 0 12px' }}>
133
+ {trails.map((trail) => {
134
+ const proposed = touchesProposed(trail, proposedAliases);
135
+ const focusedRow = trail.id === focusedTrailId;
136
+ const hovered = hoveredRow === trail.id;
137
+ return (
138
+ <button
139
+ key={trail.id}
140
+ type="button"
141
+ onMouseEnter={() => {
142
+ setHoveredRow(trail.id);
143
+ onHoverFlow(trail);
144
+ }}
145
+ onMouseLeave={leaveList}
146
+ onClick={() => onFocusFlow(trail)}
147
+ style={{
148
+ display: 'flex',
149
+ alignItems: 'center',
150
+ gap: 8,
151
+ width: '100%',
152
+ minWidth: 0,
153
+ padding: '10px 8px 10px 12px',
154
+ border: 'none',
155
+ borderRadius: 0,
156
+ background: focusedRow || hovered ? hoverBg : 'transparent',
157
+ cursor: 'pointer',
158
+ textAlign: 'left',
159
+ transition: 'background 120ms ease',
160
+ }}
161
+ >
162
+ <Route
163
+ size={14}
164
+ strokeWidth={2}
165
+ aria-hidden
166
+ style={{
167
+ flexShrink: 0,
168
+ color: focusedRow || hovered ? theme.colors.primary : muted,
169
+ transition: 'color 120ms ease',
170
+ }}
171
+ />
172
+ <span
173
+ style={{
174
+ flex: 1,
175
+ minWidth: 0,
176
+ overflowWrap: 'anywhere',
177
+ fontSize: theme.fontSizes[1],
178
+ fontFamily: theme.fonts.monospace,
179
+ fontWeight: 600,
180
+ color: proposed ? PROPOSED_COLOR : theme.colors.text,
181
+ }}
182
+ >
183
+ {trail.title}
184
+ </span>
185
+ <span
186
+ style={{
187
+ flexShrink: 0,
188
+ fontSize: theme.fontSizes[0],
189
+ fontFamily: theme.fonts.monospace,
190
+ color: muted,
191
+ }}
192
+ >
193
+ {trail.steps.length}
194
+ </span>
195
+ <ChevronRight size={14} strokeWidth={2} style={{ flexShrink: 0, color: muted }} />
196
+ </button>
197
+ );
198
+ })}
199
+ </div>
200
+ </div>
201
+
202
+ {/* Step pane. */}
203
+ <div
204
+ aria-hidden={!open}
205
+ style={{
206
+ ...PANE,
207
+ transform: open ? 'translateX(0)' : 'translateX(100%)',
208
+ transition,
209
+ }}
210
+ >
211
+ {displayedTrail && (
212
+ <>
213
+ <div
214
+ style={{
215
+ flexShrink: 0,
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ gap: 8,
219
+ // Left inset + leading cell + gap match the step rows below, so
220
+ // the trail icon and the step numbers share one column.
221
+ padding: '6px 6px 6px 12px',
222
+ borderBottom: `1px solid ${theme.colors.border}`,
223
+ }}
224
+ >
225
+ <Route
226
+ size={14}
227
+ strokeWidth={2}
228
+ aria-hidden
229
+ // The focused trail: the icon stays primary, matching the row's
230
+ // hover colour in the list it came from.
231
+ style={{ flexShrink: 0, color: theme.colors.primary }}
232
+ />
233
+ {/* The title is the back affordance too: clicking it goes back to
234
+ the list, like the trailing close. */}
235
+ <button
236
+ type="button"
237
+ onClick={onClearFocus}
238
+ onMouseEnter={() => setHeaderHover(true)}
239
+ onMouseLeave={() => setHeaderHover(false)}
240
+ style={{
241
+ flex: 1,
242
+ minWidth: 0,
243
+ display: 'flex',
244
+ alignItems: 'center',
245
+ padding: 0,
246
+ border: 'none',
247
+ background: 'transparent',
248
+ cursor: 'pointer',
249
+ textAlign: 'left',
250
+ overflowWrap: 'anywhere',
251
+ fontSize: theme.fontSizes[1],
252
+ fontFamily: theme.fonts.monospace,
253
+ fontWeight: 600,
254
+ color: touchesProposed(displayedTrail, proposedAliases) ? PROPOSED_COLOR : theme.colors.text,
255
+ }}
256
+ >
257
+ {displayedTrail.title}
258
+ </button>
259
+ <button
260
+ type="button"
261
+ aria-label="Back to trails"
262
+ title="All trails"
263
+ onClick={onClearFocus}
264
+ onMouseEnter={() => setHeaderHover(true)}
265
+ onMouseLeave={() => setHeaderHover(false)}
266
+ style={{
267
+ display: 'inline-flex',
268
+ alignItems: 'center',
269
+ justifyContent: 'center',
270
+ flexShrink: 0,
271
+ width: 24,
272
+ height: 24,
273
+ padding: 0,
274
+ border: 'none',
275
+ borderRadius: 4,
276
+ background: headerHover ? (theme.colors.border ?? hoverBg) : 'transparent',
277
+ color: headerHover ? theme.colors.text : muted,
278
+ cursor: 'pointer',
279
+ transition: 'background 120ms ease, color 120ms ease',
280
+ }}
281
+ >
282
+ <X size={16} strokeWidth={2} />
283
+ </button>
284
+ </div>
285
+ <div
286
+ style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 0 12px' }}
287
+ onMouseLeave={onLeaveStep}
288
+ >
289
+ {displayedTrail.steps.map((step, i) => {
290
+ const active = focusedStepIndex === i;
291
+ const hovered =
292
+ hoveredTrailStep?.trailId === displayedTrail.id &&
293
+ hoveredTrailStep.stepIndex === i;
294
+ return (
295
+ <button
296
+ key={`${step.from}->${step.to}-${i}`}
297
+ type="button"
298
+ onMouseEnter={() => onHoverStep(displayedTrail, i)}
299
+ onClick={() => onFocusStep(displayedTrail, i)}
300
+ style={{
301
+ display: 'flex',
302
+ alignItems: 'flex-start',
303
+ gap: 8,
304
+ width: '100%',
305
+ minWidth: 0,
306
+ padding: '8px 16px 8px 12px',
307
+ border: 'none',
308
+ borderRadius: 0,
309
+ background: active || hovered ? hoverBg : 'transparent',
310
+ cursor: 'pointer',
311
+ textAlign: 'left',
312
+ transition: 'background 120ms ease',
313
+ }}
314
+ >
315
+ <span
316
+ style={{
317
+ flexShrink: 0,
318
+ // Same 14px leading column as the header's trail icon,
319
+ // so the digit sits under it.
320
+ width: 14,
321
+ textAlign: 'center',
322
+ paddingTop: 1,
323
+ fontSize: theme.fontSizes[0],
324
+ fontFamily: theme.fonts.monospace,
325
+ color: active ? theme.colors.text : muted,
326
+ }}
327
+ >
328
+ {i + 1}
329
+ </span>
330
+ <span
331
+ style={{
332
+ flex: 1,
333
+ minWidth: 0,
334
+ display: 'flex',
335
+ flexDirection: 'column',
336
+ gap: 2,
337
+ }}
338
+ >
339
+ {step.annotation && (
340
+ <span
341
+ style={{
342
+ minWidth: 0,
343
+ overflowWrap: 'anywhere',
344
+ fontSize: theme.fontSizes[1],
345
+ fontFamily: theme.fonts.monospace,
346
+ color: theme.colors.text,
347
+ }}
348
+ >
349
+ {step.annotation}
350
+ </span>
351
+ )}
352
+ <span
353
+ style={{
354
+ fontSize: theme.fontSizes[0],
355
+ fontFamily: theme.fonts.body,
356
+ color: muted,
357
+ lineHeight: 1.35,
358
+ }}
359
+ >
360
+ {step.symbol ?? step.from}
361
+ </span>
362
+ </span>
363
+ </button>
364
+ );
365
+ })}
366
+ </div>
367
+ </>
368
+ )}
369
+ </div>
370
+ </div>
371
+ );
372
+ }
@@ -223,7 +223,7 @@ function TrailFlow({
223
223
  display: 'flex',
224
224
  alignItems: 'center',
225
225
  gap: 4,
226
- height: TRAIL_CONTROL_SIZE,
226
+ minHeight: TRAIL_CONTROL_SIZE,
227
227
  padding: '0 0 0 16px',
228
228
  background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
229
229
  transition: 'background 120ms ease',
@@ -259,9 +259,8 @@ function TrailFlow({
259
259
  >
260
260
  <span
261
261
  style={{
262
- overflow: 'hidden',
263
- textOverflow: 'ellipsis',
264
- whiteSpace: 'nowrap',
262
+ minWidth: 0,
263
+ overflowWrap: 'anywhere',
265
264
  fontSize: theme.fontSizes[1],
266
265
  fontFamily: theme.fonts.monospace,
267
266
  fontWeight: 600,