@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.
- 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/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/package.json +1 -1
- package/src/stories/Canvases/C4/C4Draft.stories.tsx +238 -0
- package/src/subsystem/C4Graph.tsx +10 -4
- 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
|
@@ -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
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
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('
|
|
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
|