@principal-ai/subsystems-react 0.56.12 → 0.56.14

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 (54) hide show
  1. package/dist/index.d.ts +6 -0
  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/subsystem/C4Graph.d.ts.map +1 -1
  6. package/dist/subsystem/C4Graph.js +10 -4
  7. package/dist/subsystem/C4Graph.js.map +1 -1
  8. package/dist/subsystem/C4ProjectionView.d.ts +60 -0
  9. package/dist/subsystem/C4ProjectionView.d.ts.map +1 -0
  10. package/dist/subsystem/C4ProjectionView.js +336 -0
  11. package/dist/subsystem/C4ProjectionView.js.map +1 -0
  12. package/dist/subsystem/C4SketchView.d.ts +23 -0
  13. package/dist/subsystem/C4SketchView.d.ts.map +1 -0
  14. package/dist/subsystem/C4SketchView.js +119 -0
  15. package/dist/subsystem/C4SketchView.js.map +1 -0
  16. package/dist/subsystem/DescriptionOverlay.d.ts +6 -0
  17. package/dist/subsystem/DescriptionOverlay.d.ts.map +1 -0
  18. package/dist/subsystem/DescriptionOverlay.js +58 -0
  19. package/dist/subsystem/DescriptionOverlay.js.map +1 -0
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  21. package/dist/subsystem/SubsystemComponentGraph.js +289 -271
  22. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  23. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +4 -27
  24. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -1
  25. package/dist/subsystem/__fixtures__/c4Drilldown.js +4 -56
  26. package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -1
  27. package/dist/subsystem/__fixtures__/c4Sketch.d.ts +9 -0
  28. package/dist/subsystem/__fixtures__/c4Sketch.d.ts.map +1 -0
  29. package/dist/subsystem/__fixtures__/c4Sketch.js +89 -0
  30. package/dist/subsystem/__fixtures__/c4Sketch.js.map +1 -0
  31. package/dist/subsystem/c4Sketch.d.ts +74 -0
  32. package/dist/subsystem/c4Sketch.d.ts.map +1 -0
  33. package/dist/subsystem/c4Sketch.js +9 -0
  34. package/dist/subsystem/c4Sketch.js.map +1 -0
  35. package/dist/subsystem/c4TrailFocus.d.ts +30 -0
  36. package/dist/subsystem/c4TrailFocus.d.ts.map +1 -0
  37. package/dist/subsystem/c4TrailFocus.js +57 -0
  38. package/dist/subsystem/c4TrailFocus.js.map +1 -0
  39. package/package.json +1 -1
  40. package/src/index.ts +14 -0
  41. package/src/stories/Canvases/C4/C4Draft.stories.tsx +238 -0
  42. package/src/stories/Canvases/C4/C4Sketch.stories.tsx +36 -0
  43. package/src/stories/Canvases/C4/C4ToComponentDrill.stories.tsx +18 -429
  44. package/src/subsystem/C4Graph.tsx +10 -4
  45. package/src/subsystem/C4ProjectionView.tsx +595 -0
  46. package/src/subsystem/C4SketchView.tsx +241 -0
  47. package/src/subsystem/DescriptionOverlay.test.tsx +76 -0
  48. package/src/subsystem/DescriptionOverlay.tsx +101 -0
  49. package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +10 -3
  50. package/src/subsystem/SubsystemComponentGraph.tsx +44 -12
  51. package/src/subsystem/__fixtures__/c4Drilldown.ts +7 -58
  52. package/src/subsystem/__fixtures__/c4Sketch.ts +94 -0
  53. package/src/subsystem/c4Sketch.ts +78 -0
  54. package/src/subsystem/c4TrailFocus.ts +62 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * C4SketchView — a standalone authored C4 hypothesis.
3
+ *
4
+ * Pure presentation: the host loads the sketch and passes it in. There is no
5
+ * model comparison, grounding, or promotion here; a sketch is a diagram in its
6
+ * own right.
7
+ */
8
+
9
+ import { useCallback, useMemo, useState } from 'react';
10
+ import type { CSSProperties, ReactNode } from 'react';
11
+ import { useTheme } from '@principal-ade/industry-theme';
12
+ import { PencilRuler } from 'lucide-react';
13
+ import { IndustryMarkdownSlide } from 'themed-markdown';
14
+ import { C4Graph } from './C4Graph';
15
+ import { GraphWithSidebar } from './GraphWithSidebar';
16
+ import type { C4Sketch } from './c4Sketch';
17
+
18
+ export interface C4SketchViewProps {
19
+ sketch: C4Sketch;
20
+ /** Controlled open-container ids. Omit to let the view own them. */
21
+ openContainerIds?: readonly string[];
22
+ onOpenContainerIdsChange?: (ids: string[]) => void;
23
+ /** Extra controls in the header. */
24
+ headerActions?: ReactNode;
25
+ /** Sidebar width persistence key. */
26
+ persistKey?: string;
27
+ /** Outer style for the flex fill. */
28
+ style?: CSSProperties;
29
+ }
30
+
31
+ export function C4SketchView({
32
+ sketch,
33
+ openContainerIds,
34
+ onOpenContainerIdsChange,
35
+ headerActions,
36
+ persistKey,
37
+ style,
38
+ }: C4SketchViewProps) {
39
+ const { theme } = useTheme();
40
+ const [internalOpenIds, setInternalOpenIds] = useState<string[]>(() =>
41
+ [...(openContainerIds ?? sketch.model.openContainerIds ?? [])].sort(),
42
+ );
43
+ const resolvedOpenIds = openContainerIds
44
+ ? [...openContainerIds]
45
+ : internalOpenIds;
46
+ const setOpenIds = useCallback(
47
+ (next: string[]) => {
48
+ if (!openContainerIds) setInternalOpenIds(next);
49
+ onOpenContainerIdsChange?.(next);
50
+ },
51
+ [onOpenContainerIdsChange, openContainerIds],
52
+ );
53
+
54
+ const model = useMemo(
55
+ () => ({ ...sketch.model, openContainerIds: resolvedOpenIds }),
56
+ [resolvedOpenIds, sketch.model],
57
+ );
58
+
59
+ const toggleContainer = useCallback(
60
+ (id: string) => {
61
+ setOpenIds(
62
+ resolvedOpenIds.includes(id)
63
+ ? resolvedOpenIds.filter((openId) => openId !== id)
64
+ : [...resolvedOpenIds, id].sort(),
65
+ );
66
+ },
67
+ [resolvedOpenIds, setOpenIds],
68
+ );
69
+
70
+ const openAll = useCallback(() => {
71
+ setOpenIds(
72
+ sketch.model.nodes
73
+ .filter((node) => node.kind === 'container')
74
+ .map((node) => node.id)
75
+ .sort(),
76
+ );
77
+ }, [setOpenIds, sketch.model.nodes]);
78
+
79
+ const collapseAll = useCallback(() => setOpenIds([]), [setOpenIds]);
80
+
81
+ const notes = sketch.notes?.trim();
82
+ const notesPanel = notes ? (
83
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
84
+ <div
85
+ style={{
86
+ fontSize: 10,
87
+ fontFamily: theme.fonts.monospace,
88
+ letterSpacing: 0.4,
89
+ color: theme.colors.textSecondary,
90
+ }}
91
+ >
92
+ ABOUT
93
+ </div>
94
+ <IndustryMarkdownSlide
95
+ content={notes}
96
+ slideIdPrefix={`c4-sketch-notes-${sketch.id}`}
97
+ slideIndex={0}
98
+ isVisible={true}
99
+ theme={theme}
100
+ disableScroll={true}
101
+ disableBasePadding
102
+ enableKeyboardScrolling={false}
103
+ autoFocusOnVisible={false}
104
+ />
105
+ </div>
106
+ ) : null;
107
+
108
+ return (
109
+ <div
110
+ style={{
111
+ width: '100%',
112
+ height: '100%',
113
+ display: 'flex',
114
+ flexDirection: 'column',
115
+ background: theme.colors.background,
116
+ ...style,
117
+ }}
118
+ >
119
+ <header
120
+ style={{
121
+ flexShrink: 0,
122
+ display: 'flex',
123
+ alignItems: 'center',
124
+ gap: 12,
125
+ flexWrap: 'wrap',
126
+ padding: '12px 16px',
127
+ background: theme.colors.background,
128
+ borderBottom: `1px solid ${theme.colors.border}`,
129
+ }}
130
+ >
131
+ <div
132
+ style={{
133
+ minWidth: 240,
134
+ flex: 1,
135
+ display: 'flex',
136
+ alignItems: 'center',
137
+ gap: 8,
138
+ flexWrap: 'wrap',
139
+ }}
140
+ >
141
+ <h2
142
+ style={{
143
+ margin: 0,
144
+ minWidth: 0,
145
+ fontSize: theme.fontSizes[2],
146
+ fontWeight: 600,
147
+ color: theme.colors.text,
148
+ fontFamily: theme.fonts.monospace,
149
+ overflowWrap: 'anywhere',
150
+ }}
151
+ >
152
+ {sketch.title}
153
+ </h2>
154
+ <span
155
+ style={{
156
+ display: 'inline-flex',
157
+ alignItems: 'center',
158
+ gap: 4,
159
+ padding: '1px 7px',
160
+ borderRadius: 4,
161
+ border: '1px solid #e3b341',
162
+ color: '#e3b341',
163
+ fontFamily: theme.fonts.monospace,
164
+ fontSize: 10,
165
+ letterSpacing: 0.4,
166
+ flexShrink: 0,
167
+ }}
168
+ >
169
+ <PencilRuler size={10} aria-hidden="true" />
170
+ SKETCH
171
+ </span>
172
+ </div>
173
+ <div
174
+ style={{
175
+ marginLeft: 'auto',
176
+ flexShrink: 0,
177
+ display: 'flex',
178
+ alignItems: 'center',
179
+ gap: 6,
180
+ flexWrap: 'wrap',
181
+ }}
182
+ >
183
+ {headerActions}
184
+ <button
185
+ type="button"
186
+ onClick={openAll}
187
+ style={smallButton(theme, { width: 132 })}
188
+ >
189
+ Open all
190
+ </button>
191
+ <button
192
+ type="button"
193
+ onClick={collapseAll}
194
+ style={smallButton(theme, { width: 132 })}
195
+ >
196
+ Collapse all
197
+ </button>
198
+ </div>
199
+ </header>
200
+
201
+ <div style={{ flex: 1, minHeight: 0 }}>
202
+ <GraphWithSidebar
203
+ hidden={!notes}
204
+ stretchHeader
205
+ defaultWidth={320}
206
+ minWidth={240}
207
+ maxWidth={480}
208
+ persistKey={persistKey ?? `c4-sketch-notes-${sketch.id}`}
209
+ header={notesPanel}
210
+ >
211
+ <C4Graph model={model} onToggleContainer={toggleContainer} />
212
+ </GraphWithSidebar>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ function smallButton(
219
+ theme: ReturnType<typeof useTheme>['theme'],
220
+ opts: { width?: number } = {},
221
+ ) {
222
+ return {
223
+ flexShrink: 0,
224
+ display: 'inline-flex',
225
+ alignItems: 'center',
226
+ justifyContent: 'center',
227
+ gap: 5,
228
+ ...(opts.width ? { width: opts.width } : {}),
229
+ height: 34,
230
+ padding: '0 10px',
231
+ boxSizing: 'border-box',
232
+ whiteSpace: 'nowrap',
233
+ borderRadius: 6,
234
+ border: `1px solid ${theme.colors.border}`,
235
+ background: 'transparent',
236
+ color: theme.colors.textSecondary,
237
+ fontFamily: theme.fonts.body,
238
+ fontSize: theme.fontSizes[1],
239
+ cursor: 'pointer',
240
+ } as const;
241
+ }
@@ -0,0 +1,76 @@
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 { SubsystemComponentGraph } from './SubsystemComponentGraph';
7
+
8
+ const dom = new Window();
9
+ const globals = globalThis as unknown as Record<string, unknown>;
10
+ globals.window = dom;
11
+ globals.document = dom.document;
12
+ globals.navigator = dom.navigator;
13
+ globals.HTMLElement = dom.HTMLElement;
14
+ globals.SVGElement = dom.SVGElement;
15
+ globals.Node = dom.Node;
16
+ globals.Element = dom.Element;
17
+ globals.Event = dom.Event;
18
+ globals.localStorage = dom.localStorage;
19
+ globals.ResizeObserver = dom.ResizeObserver;
20
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
21
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
22
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
23
+ globals.matchMedia = dom.matchMedia.bind(dom);
24
+
25
+ afterEach(cleanup);
26
+
27
+ function renderGraph(description: string | undefined) {
28
+ return render(createElement(
29
+ ThemeProvider,
30
+ { theme: defaultEditorTheme },
31
+ createElement(SubsystemComponentGraph, {
32
+ components: [
33
+ { alias: 'entry', name: 'Entry', construct: 'function', file: 'entry.ts' },
34
+ { alias: 'store', name: 'Store', construct: 'function', file: 'store.ts' },
35
+ ],
36
+ trails: [{
37
+ id: 'request',
38
+ title: 'Request flow',
39
+ steps: [{ from: 'entry', to: 'store', mechanism: 'calls' }],
40
+ }],
41
+ description,
42
+ }),
43
+ ));
44
+ }
45
+
46
+ describe('subsystem description overlay', () => {
47
+ for (const closeWithEscape of [false, true]) {
48
+ test(`More opens the full markdown over the graph and closes with ${closeWithEscape ? 'Escape' : 'the close button'}`, () => {
49
+ const description = `${'A long description. '.repeat(50)}\n\n**Final detail**`;
50
+ const view = renderGraph(description);
51
+ const more = view.getByRole('button', { name: 'Read full description' });
52
+ more.focus();
53
+ fireEvent.click(more);
54
+ const overlay = view.getByRole('dialog', { name: 'Subsystem description' });
55
+ expect(overlay.textContent).toContain('A long description. '.repeat(50).trim());
56
+ expect(overlay.querySelector('strong')?.textContent).toBe('Final detail');
57
+ expect(overlay.style.position).toBe('absolute');
58
+ expect(overlay.parentElement?.contains(view.getByLabelText('Resize sidebar'))).toBe(false);
59
+ expect(more.getAttribute('aria-expanded')).toBe('true');
60
+ const close = view.getByRole('button', { name: 'Close description' });
61
+ expect(document.activeElement).toBe(close);
62
+ if (closeWithEscape) fireEvent.keyDown(close, { key: 'Escape' });
63
+ else fireEvent.click(close);
64
+ expect(view.queryByRole('dialog')).toBeNull();
65
+ expect(more.getAttribute('aria-expanded')).toBe('false');
66
+ expect(document.activeElement).toBe(more);
67
+ expect(view.getByText('Request flow')).toBeTruthy();
68
+ });
69
+ }
70
+
71
+ test('does not offer More when there is no description', () => {
72
+ const view = renderGraph(undefined);
73
+ expect(view.queryByRole('button', { name: 'Read full description' })).toBeNull();
74
+ expect(view.queryByRole('dialog')).toBeNull();
75
+ });
76
+ });
@@ -0,0 +1,101 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useTheme } from '@principal-ade/industry-theme';
3
+ import { X } from 'lucide-react';
4
+ import { IndustryMarkdownSlide } from 'themed-markdown';
5
+
6
+ export function DescriptionOverlay({ description, onClose }: {
7
+ description: string;
8
+ onClose: () => void;
9
+ }) {
10
+ const { theme } = useTheme();
11
+ const closeRef = useRef<HTMLButtonElement>(null);
12
+
13
+ useEffect(() => {
14
+ const previousFocus = document.activeElement;
15
+ closeRef.current?.focus();
16
+ return () => {
17
+ if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
18
+ };
19
+ }, []);
20
+
21
+ return (
22
+ <div
23
+ role="dialog"
24
+ aria-label="Subsystem description"
25
+ onKeyDown={(event) => {
26
+ if (event.key === 'Escape') {
27
+ event.stopPropagation();
28
+ onClose();
29
+ }
30
+ }}
31
+ style={{
32
+ position: 'absolute',
33
+ inset: 0,
34
+ zIndex: 10,
35
+ display: 'flex',
36
+ flexDirection: 'column',
37
+ background: theme.colors.background,
38
+ color: theme.colors.text,
39
+ }}
40
+ >
41
+ <div style={{
42
+ display: 'flex',
43
+ alignItems: 'center',
44
+ gap: 8,
45
+ flexShrink: 0,
46
+ padding: '12px 16px',
47
+ borderBottom: `1px solid ${theme.colors.border}`,
48
+ }}>
49
+ <h3 style={{
50
+ flex: 1,
51
+ margin: 0,
52
+ fontSize: theme.fontSizes[1],
53
+ fontFamily: theme.fonts.monospace,
54
+ textTransform: 'uppercase',
55
+ color: theme.colors.textSecondary,
56
+ }}>
57
+ Overview
58
+ </h3>
59
+ <button
60
+ ref={closeRef}
61
+ type="button"
62
+ aria-label="Close description"
63
+ onClick={onClose}
64
+ style={{
65
+ display: 'inline-flex',
66
+ alignItems: 'center',
67
+ justifyContent: 'center',
68
+ width: 24,
69
+ height: 24,
70
+ padding: 0,
71
+ border: 'none',
72
+ borderRadius: 4,
73
+ background: 'transparent',
74
+ color: theme.colors.textSecondary,
75
+ cursor: 'pointer',
76
+ }}
77
+ >
78
+ <X size={14} />
79
+ </button>
80
+ </div>
81
+ <div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '20px 0 32px' }}>
82
+ <div style={{ lineHeight: 1.7 }}>
83
+ <IndustryMarkdownSlide
84
+ content={description}
85
+ slideIdPrefix="subsystem-desc-overlay"
86
+ slideIndex={0}
87
+ isVisible
88
+ theme={theme}
89
+ disableScroll
90
+ disableBasePadding
91
+ transparentBackground
92
+ additionalPadding={{ left: '24px', right: '24px' }}
93
+ fontSizeScale={1.05}
94
+ enableKeyboardScrolling={false}
95
+ autoFocusOnVisible={false}
96
+ />
97
+ </div>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
@@ -50,12 +50,19 @@ describe('subsystem graph sidebar', () => {
50
50
  expect(content?.textContent).not.toContain('Request subsystem');
51
51
  });
52
52
 
53
- test('caps the scrollable description at 30% above the trails', () => {
53
+ test('truncates the description without scrolling and offers the full description', () => {
54
54
  const body = renderGraph();
55
- const description = body.querySelector('[style*="max-height:30%"]') as HTMLElement;
55
+ const description = body.querySelector('[style*="-webkit-line-clamp:6"]') as HTMLElement;
56
56
  expect(description).not.toBeNull();
57
57
  expect(description.textContent).toContain('Always visible description.');
58
- expect(description.style.overflowY).toBe('auto');
58
+ expect(description.style.overflow).toBe('hidden');
59
+ expect(description.style.maxHeight).toBe('9em');
60
+ const more = body.querySelector('button[aria-label="Read full description"]');
61
+ expect(more?.textContent).toBe('More');
62
+ expect(more?.getAttribute('aria-haspopup')).toBe('dialog');
63
+ expect(more?.getAttribute('aria-expanded')).toBe('false');
64
+ expect(body.querySelector('[role="dialog"]')).toBeNull();
65
+ expect(body.querySelector('[style*="max-height:30%"]')).toBeNull();
59
66
  });
60
67
 
61
68
  test('keeps the Files button in the header when no name is provided', () => {
@@ -89,6 +89,7 @@ import type { ComponentVerificationState } from './ComponentDeclaration';
89
89
  import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
90
90
  import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
91
91
  import { GraphWithSidebar } from './GraphWithSidebar';
92
+ import { DescriptionOverlay } from './DescriptionOverlay';
92
93
  import type { GraphSidebarTab } from './GraphSidebar';
93
94
  import { TrailsPanel, TRAIL_PLAY_PAUSE_MS } from './TrailsPanel';
94
95
  import type { StepEmphasisMap } from './stepEmphasis';
@@ -618,6 +619,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
618
619
  function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirection: layoutDirectionProp, defaultLayoutDirection = 'horizontal', onLayoutDirectionChange, hideLayoutToggle = false, refocusOnLayoutFlip = true, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, stepEmphasis, onRemoveStepEmphasis, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, issues, showIssues, filesActive, sidebarScope, onSidebarTrailChange, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
619
620
  const { theme } = useTheme();
620
621
  const { fitView, fitBounds } = useReactFlow();
622
+ const [descriptionOpen, setDescriptionOpen] = useState(false);
621
623
  const viewport = useViewport();
622
624
  // Restored once per mount from `localStorage` (see `readViewState`). Each
623
625
  // graph is its own tab/mount, so `persistKey` is stable for a mount.
@@ -2730,17 +2732,45 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2730
2732
  {sidebarExtra}
2731
2733
  {description && (
2732
2734
  <div style={{ fontSize: theme.fontSizes[0], lineHeight: 1.5 }}>
2733
- <IndustryMarkdownSlide
2734
- content={description}
2735
- slideIdPrefix="subsystem-desc"
2736
- slideIndex={0}
2737
- isVisible={true}
2738
- theme={theme}
2739
- disableScroll={true}
2740
- disableBasePadding
2741
- enableKeyboardScrolling={false}
2742
- autoFocusOnVisible={false}
2743
- />
2735
+ <div style={{
2736
+ display: '-webkit-box',
2737
+ WebkitBoxOrient: 'vertical',
2738
+ WebkitLineClamp: 6,
2739
+ maxHeight: '9em',
2740
+ overflow: 'hidden',
2741
+ }}>
2742
+ <IndustryMarkdownSlide
2743
+ content={description}
2744
+ slideIdPrefix="subsystem-desc"
2745
+ slideIndex={0}
2746
+ isVisible={true}
2747
+ theme={theme}
2748
+ disableScroll={true}
2749
+ disableBasePadding
2750
+ enableKeyboardScrolling={false}
2751
+ autoFocusOnVisible={false}
2752
+ />
2753
+ </div>
2754
+ <button
2755
+ type="button"
2756
+ aria-label="Read full description"
2757
+ aria-haspopup="dialog"
2758
+ aria-expanded={descriptionOpen}
2759
+ onClick={() => setDescriptionOpen(true)}
2760
+ style={{
2761
+ marginTop: 6,
2762
+ padding: 0,
2763
+ border: 'none',
2764
+ background: 'transparent',
2765
+ color: theme.colors.textSecondary,
2766
+ fontFamily: theme.fonts.body,
2767
+ fontSize: theme.fontSizes[0],
2768
+ textDecoration: 'underline',
2769
+ cursor: 'pointer',
2770
+ }}
2771
+ >
2772
+ More
2773
+ </button>
2744
2774
  </div>
2745
2775
  )}
2746
2776
  {sidebarAfterDescription}
@@ -2864,7 +2894,6 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2864
2894
  <GraphWithSidebar
2865
2895
  hidden={!sidebarVisible}
2866
2896
  bodyHidden={!sidebarBodyVisible}
2867
- headerMaxHeight={sidebarBodyVisible ? '30%' : '100%'}
2868
2897
  header={sidebarHeader}
2869
2898
  tabs={sidebarTabs}
2870
2899
  activeTab={diagnosticsTab}
@@ -2877,6 +2906,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2877
2906
  persistKey={persistKey}
2878
2907
  onResizeEnd={() => requestAnimationFrame(() => fitView())}
2879
2908
  >
2909
+ {!hideSidebar && description && descriptionOpen && (
2910
+ <DescriptionOverlay description={description} onClose={() => setDescriptionOpen(false)} />
2911
+ )}
2880
2912
  {/* Graph area */}
2881
2913
  <div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: committedDirection === 'vertical' ? 'row' : 'column', minHeight: 0, minWidth: 0 }}>
2882
2914
  {/* Canvas box — the edge-label overlay and legend anchor here, so
@@ -753,65 +753,14 @@ export function rollupEdgeTrailRefs(opts: {
753
753
  }
754
754
 
755
755
  /**
756
- * The C4 elements a trail touches — every element that owns a step endpoint.
757
- *
758
- * This is the reverse of the memberships above: given a step's `from`/`to`
759
- * alias, which C4 box(es) hold it. Because a container's own process-claim
760
- * membership AND the authored components nested inside it both claim the
761
- * aliases, BOTH end up in the set: the container is the visible card at
762
- * container view, its components are the visible cards when it is open, so
763
- * either view stays honest.
764
- *
765
- * The complement of the result (all element ids minus these) is what a
766
- * trail-driven emphasis pass should dim. Pure, and the spec the story's
767
- * "players stay bright" is asserted against.
768
- */
769
- export function deriveTrailPlayerElements(
770
- steps: SubsystemTrail['steps'],
771
- members: Readonly<Record<string, readonly string[]>>,
772
- ): Set<string> {
773
- const byAlias = new Map<string, string[]>();
774
- for (const [elementId, aliases] of Object.entries(members)) {
775
- for (const alias of aliases) {
776
- const list = byAlias.get(alias);
777
- if (list) list.push(elementId);
778
- else byAlias.set(alias, [elementId]);
779
- }
780
- }
781
- const players = new Set<string>();
782
- for (const step of steps) {
783
- for (const id of byAlias.get(step.from) ?? []) players.add(id);
784
- for (const id of byAlias.get(step.to) ?? []) players.add(id);
785
- }
786
- return players;
787
- }
788
-
789
- /**
790
- * The C4 edges that carry the current focus — the line-level half of the
791
- * emphasis pair above.
792
- *
793
- * An edge is lit iff its authored `trailRefs` cover the source: the whole
794
- * trail, or one specific step of it. A step that is entirely internal to a
795
- * container folds into no C4 line (the rollup only keeps cross-boundary
796
- * hops), so its source lights NOTHING — every line dims, which is honest:
797
- * no drawn edge represents that hop.
756
+ * Trail-focus helpers live in production code now (`c4TrailFocus.ts`); keep
757
+ * these re-exports so the fixture's tests and the drill story can import them
758
+ * from the same place they always did.
798
759
  */
799
- export function deriveLitC4Edges(
800
- source: { trailId: string; stepIndex?: number },
801
- edges: readonly C4Edge[],
802
- ): Set<string> {
803
- const lit = new Set<string>();
804
- for (const edge of edges) {
805
- for (const ref of edge.trailRefs ?? []) {
806
- if (ref.trailId !== source.trailId) continue;
807
- if (source.stepIndex == null || ref.stepIndices.includes(source.stepIndex)) {
808
- lit.add(edge.id);
809
- break;
810
- }
811
- }
812
- }
813
- return lit;
814
- }
760
+ export {
761
+ deriveLitC4Edges,
762
+ deriveTrailPlayerElements,
763
+ } from '../c4TrailFocus';
815
764
 
816
765
  /**
817
766
  * Process claims the accepted C4 element set backs NOT at all — the set the