@principal-ai/subsystems-react 0.56.12 → 0.56.13

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.
@@ -0,0 +1,238 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import '@xyflow/react/dist/style.css';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { C4Graph } from '../../../subsystem/C4Graph';
6
+ import type { C4ConstructGraph } from '../../../subsystem/C4Graph';
7
+ import type { C4Edge, C4Element, C4Model } from '../../../subsystem/c4';
8
+ import {
9
+ DRILLDOWN_COMPONENTS,
10
+ DRILLDOWN_PURL,
11
+ DRILLDOWN_TRAILS,
12
+ } from '../../../subsystem/__fixtures__/c4Drilldown';
13
+
14
+ const meta = {
15
+ title: 'Canvases/C4/C4Draft',
16
+ component: C4Graph,
17
+ parameters: { layout: 'fullscreen' },
18
+ tags: ['autodocs'],
19
+ decorators: [
20
+ (Story) => (
21
+ <ThemeProvider theme={defaultEditorTheme}>
22
+ <Story />
23
+ </ThemeProvider>
24
+ ),
25
+ ],
26
+ } satisfies Meta<typeof C4Graph>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // The minimal C4: no accepted containers, no authored components.
33
+ //
34
+ // Every box is derived from the model itself:
35
+ //
36
+ // - one *draft* container per runtime `process` key (members resolve by
37
+ // exact process match, the rule accepted containers use);
38
+ // - a file-less / external process becomes an external system, so lines
39
+ // leaving the boundary have somewhere to land;
40
+ // - lines are the fold of cross-boundary trail steps, labelled by
41
+ // mechanism;
42
+ // - each opened container hosts its member constructs directly, because no
43
+ // C4 component layer exists to group them.
44
+ //
45
+ // Nothing here claims verification. This is what the API's `source=draft`
46
+ // projection looks like: a reading of the model, not a stored decision.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const SYSTEM = {
50
+ id: 'system:draft',
51
+ label: 'Gist share from Studio',
52
+ repoKey: DRILLDOWN_PURL,
53
+ };
54
+
55
+ const byAlias = new Map(DRILLDOWN_COMPONENTS.map((c) => [c.alias, c]));
56
+
57
+ /** `local/disk` → `local-disk`; keeps ids readable in the story. */
58
+ function slug(value: string): string {
59
+ return value.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '');
60
+ }
61
+
62
+ interface DraftElement {
63
+ element: C4Element;
64
+ members: string[];
65
+ }
66
+
67
+ const elements: C4Element[] = [];
68
+ const membersByElement = new Map<string, string[]>();
69
+ const elementOfAlias = new Map<string, string>();
70
+
71
+ const byProcess = new Map<string, string[]>();
72
+ for (const component of DRILLDOWN_COMPONENTS) {
73
+ const process = component.process?.trim();
74
+ if (!process) continue;
75
+ const list = byProcess.get(process) ?? [];
76
+ list.push(component.alias);
77
+ byProcess.set(process, list);
78
+ }
79
+
80
+ const drafts: DraftElement[] = [];
81
+ for (const [process, members] of byProcess) {
82
+ // A process with no grounded code behind it is not a deployable unit: it is
83
+ // the model's way of naming something outside the boundary.
84
+ const fileless = members.every((alias) => !byAlias.get(alias)?.file);
85
+ const external =
86
+ fileless || process.startsWith('external/') || process.startsWith('local/');
87
+
88
+ if (external) {
89
+ const element: C4Element = {
90
+ kind: 'external-system',
91
+ id: `external:${slug(process)}`,
92
+ label: process,
93
+ members,
94
+ technology: byAlias.get(members[0] ?? '')?.framework ?? 'external',
95
+ state: 'accepted',
96
+ description: `${members.length} member${members.length === 1 ? '' : 's'} outside the boundary.`,
97
+ };
98
+ elements.push(element);
99
+ drafts.push({ element, members });
100
+ membersByElement.set(element.id, members);
101
+ } else {
102
+ const element: C4Element = {
103
+ kind: 'container',
104
+ id: `container:${slug(process)}`,
105
+ label: process,
106
+ containerKind: members.some((alias) => byAlias.get(alias)?.construct === 'store')
107
+ ? 'data-store'
108
+ : 'application',
109
+ technology: byAlias.get(members[0] ?? '')?.framework ?? '',
110
+ process,
111
+ parentId: SYSTEM.id,
112
+ state: 'accepted',
113
+ description: `${members.length} component${members.length === 1 ? '' : 's'} derived from process ${process}.`,
114
+ };
115
+ elements.push(element);
116
+ drafts.push({ element, members });
117
+ membersByElement.set(element.id, members);
118
+ }
119
+ for (const alias of members) elementOfAlias.set(alias, elements[elements.length - 1]!.id);
120
+ }
121
+
122
+ const containers = drafts.filter((d) => d.element.kind === 'container');
123
+ const externals = drafts.filter((d) => d.element.kind === 'external-system');
124
+
125
+ // Fold cross-boundary trail steps into lines. Same rule the accepted-store
126
+ // projection uses; only the membership source differs.
127
+ const edges: C4Edge[] = (() => {
128
+ const byKey = new Map<
129
+ string,
130
+ { source: string; target: string; mechanism: string; refs: Map<string, number[]> }
131
+ >();
132
+ for (const trail of DRILLDOWN_TRAILS) {
133
+ trail.steps.forEach((step, index) => {
134
+ const source = elementOfAlias.get(step.from);
135
+ const target = elementOfAlias.get(step.to);
136
+ if (!source || !target || source === target) return;
137
+ const key = [source, target, step.mechanism].join('\0');
138
+ let acc = byKey.get(key);
139
+ if (!acc) {
140
+ acc = { source, target, mechanism: step.mechanism, refs: new Map() };
141
+ byKey.set(key, acc);
142
+ }
143
+ const steps = acc.refs.get(trail.id) ?? [];
144
+ steps.push(index);
145
+ acc.refs.set(trail.id, steps);
146
+ });
147
+ }
148
+ return [...byKey.values()].map((acc) => ({
149
+ id: `e:${slug(acc.source)}-${slug(acc.target)}-${acc.mechanism}`,
150
+ source: acc.source,
151
+ target: acc.target,
152
+ label: acc.mechanism,
153
+ protocol: acc.mechanism,
154
+ mechanisms: [acc.mechanism],
155
+ count: [...acc.refs.values()].reduce((n, steps) => n + steps.length, 0),
156
+ trailRefs: [...acc.refs.entries()].map(([trailId, stepIndices]) => ({
157
+ trailId,
158
+ stepIndices,
159
+ })),
160
+ }));
161
+ })();
162
+
163
+ // No `componentOf`: the constructs nest directly under their container.
164
+ const constructGraphByContainer = new Map<string, C4ConstructGraph>();
165
+ for (const draft of containers) {
166
+ const members = membersByElement.get(draft.element.id) ?? [];
167
+ constructGraphByContainer.set(draft.element.id, {
168
+ components: members
169
+ .map((alias) => byAlias.get(alias))
170
+ .filter((c): c is NonNullable<typeof c> => Boolean(c)),
171
+ trails: DRILLDOWN_TRAILS,
172
+ });
173
+ }
174
+
175
+ const allContainerIds = containers.map((d) => d.element.id);
176
+
177
+ function Demo() {
178
+ const [openIds, setOpenIds] = useState<string[]>(allContainerIds);
179
+
180
+ const model = useMemo<C4Model>(
181
+ () => ({
182
+ view: 'container',
183
+ system: SYSTEM,
184
+ nodes: elements,
185
+ edges,
186
+ openContainerIds: openIds,
187
+ }),
188
+ [openIds],
189
+ );
190
+
191
+ const toggle = (id: string) =>
192
+ setOpenIds((prev) =>
193
+ prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id].sort(),
194
+ );
195
+
196
+ return (
197
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
198
+ <div
199
+ style={{
200
+ display: 'flex',
201
+ alignItems: 'center',
202
+ gap: 16,
203
+ padding: '7px 12px',
204
+ borderBottom: '1px solid #333',
205
+ background: '#1b1b1b',
206
+ color: '#aaa',
207
+ fontFamily: 'ui-monospace, monospace',
208
+ fontSize: 12,
209
+ }}
210
+ >
211
+ <strong style={{ color: '#e3b341' }}>DRAFT</strong>
212
+ <span>
213
+ no accepted containers — {containers.length} derived from process keys,{' '}
214
+ {externals.length} externals, {edges.length} lines
215
+ </span>
216
+ <span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
217
+ <button type="button" onClick={() => setOpenIds(allContainerIds)}>
218
+ Open all
219
+ </button>
220
+ <button type="button" onClick={() => setOpenIds([])}>
221
+ Collapse all
222
+ </button>
223
+ </span>
224
+ </div>
225
+ <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
226
+ <C4Graph
227
+ model={model}
228
+ onToggleContainer={toggle}
229
+ constructGraphByContainer={constructGraphByContainer}
230
+ />
231
+ </div>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ export const DraftFromProcessKeys: Story = {
237
+ render: () => <Demo />,
238
+ };
@@ -1383,10 +1383,16 @@ function Inner({ model, onSelectNode, onSelectEdge, onTrailClick, onToggleContai
1383
1383
  element: n,
1384
1384
  selected: false,
1385
1385
  componentCount: childComponentCount.get(n.id) ?? 0,
1386
- // A container with components opens; the same affordance closes it
1387
- // again, so the toggle lives in one place.
1388
- ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
1389
- ? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
1386
+ // A container with components — or with constructs hosted from the
1387
+ // model, when no C4 component layer exists — opens; the same
1388
+ // affordance closes it again, so the toggle lives in one place.
1389
+ ...(n.kind === 'container' &&
1390
+ ((childComponentCount.get(n.id) ?? 0) > 0 ||
1391
+ (constructGraphByContainer?.get(n.id)?.components.length ?? 0) > 0)
1392
+ ? {
1393
+ ...(canToggle ? { onExpand: () => toggle(n.id) } : {}),
1394
+ open: opened.has(n.id),
1395
+ }
1390
1396
  : {}),
1391
1397
  // A component grown to hold its constructs is a drawn-as-card group:
1392
1398
  // no description, absolute box, sized by ELK.
@@ -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