@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.
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +10 -4
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4ProjectionView.d.ts +60 -0
- package/dist/subsystem/C4ProjectionView.d.ts.map +1 -0
- package/dist/subsystem/C4ProjectionView.js +336 -0
- package/dist/subsystem/C4ProjectionView.js.map +1 -0
- package/dist/subsystem/C4SketchView.d.ts +23 -0
- package/dist/subsystem/C4SketchView.d.ts.map +1 -0
- package/dist/subsystem/C4SketchView.js +119 -0
- package/dist/subsystem/C4SketchView.js.map +1 -0
- package/dist/subsystem/DescriptionOverlay.d.ts +6 -0
- package/dist/subsystem/DescriptionOverlay.d.ts.map +1 -0
- package/dist/subsystem/DescriptionOverlay.js +58 -0
- package/dist/subsystem/DescriptionOverlay.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +289 -271
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +4 -27
- package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -1
- package/dist/subsystem/__fixtures__/c4Drilldown.js +4 -56
- package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -1
- package/dist/subsystem/__fixtures__/c4Sketch.d.ts +9 -0
- package/dist/subsystem/__fixtures__/c4Sketch.d.ts.map +1 -0
- package/dist/subsystem/__fixtures__/c4Sketch.js +89 -0
- package/dist/subsystem/__fixtures__/c4Sketch.js.map +1 -0
- package/dist/subsystem/c4Sketch.d.ts +74 -0
- package/dist/subsystem/c4Sketch.d.ts.map +1 -0
- package/dist/subsystem/c4Sketch.js +9 -0
- package/dist/subsystem/c4Sketch.js.map +1 -0
- package/dist/subsystem/c4TrailFocus.d.ts +30 -0
- package/dist/subsystem/c4TrailFocus.d.ts.map +1 -0
- package/dist/subsystem/c4TrailFocus.js +57 -0
- package/dist/subsystem/c4TrailFocus.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +14 -0
- package/src/stories/Canvases/C4/C4Draft.stories.tsx +238 -0
- package/src/stories/Canvases/C4/C4Sketch.stories.tsx +36 -0
- package/src/stories/Canvases/C4/C4ToComponentDrill.stories.tsx +18 -429
- package/src/subsystem/C4Graph.tsx +10 -4
- package/src/subsystem/C4ProjectionView.tsx +595 -0
- package/src/subsystem/C4SketchView.tsx +241 -0
- package/src/subsystem/DescriptionOverlay.test.tsx +76 -0
- package/src/subsystem/DescriptionOverlay.tsx +101 -0
- package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +10 -3
- package/src/subsystem/SubsystemComponentGraph.tsx +44 -12
- package/src/subsystem/__fixtures__/c4Drilldown.ts +7 -58
- package/src/subsystem/__fixtures__/c4Sketch.ts +94 -0
- package/src/subsystem/c4Sketch.ts +78 -0
- 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('
|
|
53
|
+
test('truncates the description without scrolling and offers the full description', () => {
|
|
54
54
|
const body = renderGraph();
|
|
55
|
-
const description = body.querySelector('[style*="
|
|
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.
|
|
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
|
-
<
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
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
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
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
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|