@principal-ai/subsystems-react 0.56.11 → 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.
Files changed (38) hide show
  1. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  2. package/dist/subsystem/C4Graph.js +10 -4
  3. package/dist/subsystem/C4Graph.js.map +1 -1
  4. package/dist/subsystem/DescriptionOverlay.d.ts +6 -0
  5. package/dist/subsystem/DescriptionOverlay.d.ts.map +1 -0
  6. package/dist/subsystem/DescriptionOverlay.js +58 -0
  7. package/dist/subsystem/DescriptionOverlay.js.map +1 -0
  8. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  9. package/dist/subsystem/DiagnosticToggle.js +2 -1
  10. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  11. package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -1
  12. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.js +307 -284
  14. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  15. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  16. package/dist/subsystem/SubsystemFileTree.js +2 -1
  17. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  18. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  19. package/dist/subsystem/TrailStepsNav.js +11 -8
  20. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  21. package/dist/subsystem/TrailsPanel.d.ts +1 -1
  22. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  23. package/dist/subsystem/TrailsPanel.js +42 -66
  24. package/dist/subsystem/TrailsPanel.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/stories/Canvases/C4/C4Draft.stories.tsx +238 -0
  27. package/src/stories/Canvases/Subsystem/TrailSidebarViews.stories.tsx +2 -2
  28. package/src/subsystem/C4Graph.tsx +10 -4
  29. package/src/subsystem/DescriptionOverlay.test.tsx +76 -0
  30. package/src/subsystem/DescriptionOverlay.tsx +101 -0
  31. package/src/subsystem/DiagnosticToggle.tsx +2 -1
  32. package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +81 -10
  33. package/src/subsystem/SubsystemComponentGraph.tsx +68 -26
  34. package/src/subsystem/SubsystemFileTree.tsx +2 -1
  35. package/src/subsystem/TrailStepsNav.test.tsx +38 -0
  36. package/src/subsystem/TrailStepsNav.tsx +11 -8
  37. package/src/subsystem/TrailsPanel.test.tsx +99 -35
  38. package/src/subsystem/TrailsPanel.tsx +38 -85
@@ -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
+ };
@@ -13,11 +13,11 @@ const components: SubsystemComponent[] = [
13
13
  { alias: 'mail', name: 'Mail', construct: 'function', file: 'src/mail.ts', purl: 'pkg:github/acme/app' },
14
14
  ];
15
15
  const trails: SubsystemTrail[] = [
16
- { id: 'checkout', title: 'Save checkout', steps: [
16
+ { id: 'checkout', title: 'Validate the checkout and persist the customer’s order with all selected items', steps: [
17
17
  { from: 'checkout', to: 'store', mechanism: 'calls', file: 'src/checkout.ts',
18
18
  purl: 'pkg:github/acme/app', symbol: 'saveCheckout', annotation: 'Persist the checkout.' },
19
19
  ] },
20
- { id: 'report', title: 'Send report', steps: [
20
+ { id: 'report', title: 'Generate the monthly account activity report and deliver it to every subscribed recipient', steps: [
21
21
  { from: 'report', to: 'mail', mechanism: 'calls', file: 'src/report.ts',
22
22
  purl: 'pkg:github/acme/app', symbol: 'sendReport' },
23
23
  ] },
@@ -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
+ }
@@ -155,7 +155,7 @@ export function SubsystemDiagnosticToggle({
155
155
  justifyContent: bordered || segmented ? 'center' : undefined,
156
156
  gap: 4,
157
157
  height: bordered || segmented ? 28 : 22,
158
- padding: bordered || segmented ? '4px 7px' : showCount ? '0 7px 0 6px' : '0 5px',
158
+ padding: segmented ? '4px 16px' : bordered ? '4px 7px' : showCount ? '0 7px 0 6px' : '0 5px',
159
159
  border: bordered ? `1px solid ${theme.colors.border}` : 'none',
160
160
  borderRadius: 4,
161
161
  background,
@@ -227,6 +227,7 @@ export function SubsystemDiagnosticToggle({
227
227
  fontFamily: theme.fonts.monospace,
228
228
  fontSize: theme.fontSizes[0],
229
229
  fontWeight: 600,
230
+ marginLeft: segmented && labelText ? 4 : undefined,
230
231
  lineHeight: 1,
231
232
  }}
232
233
  >
@@ -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', () => {
@@ -73,16 +80,51 @@ describe('subsystem graph sidebar', () => {
73
80
  });
74
81
 
75
82
  test('groups Trails, Files, and Issues into one switch with Trails selected by default', () => {
76
- const body = renderGraph();
83
+ const body = renderGraph({ diagnostic: { status: 'issues', issueCount: 1 } });
77
84
  const group = body.querySelector('header [role="group"][aria-label="Sidebar view"]');
78
85
  expect(group?.querySelectorAll('button').length).toBe(3);
79
86
  expect(group?.querySelectorAll('button[aria-pressed="true"]').length).toBe(1);
80
- expect(group?.querySelector('button[aria-label="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
81
- expect(group?.querySelector('button[aria-label="Trails"]')?.textContent).toBe('Trails');
82
- expect(group?.querySelector('button[aria-label^="Issues:"]')?.textContent).toBe('Issues');
87
+ expect(group?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
88
+ expect(group?.querySelector('button[aria-label^="Trails"]')?.textContent).toBe('Trails1');
89
+ expect(group?.querySelector('button[aria-label^="Issues:"]')?.textContent).toBe('Issues1');
83
90
  expect((group as HTMLElement).style.borderStyle).toBe('solid');
91
+ expect((group as HTMLElement).style.gridAutoColumns).toBe('1fr');
92
+ for (const button of group!.querySelectorAll('button')) {
93
+ expect((button as HTMLElement).style.paddingLeft).toBe('16px');
94
+ expect((button as HTMLElement).style.paddingRight).toBe('16px');
95
+ expect((button.lastElementChild as HTMLElement).style.marginLeft).toBe('4px');
96
+ }
84
97
  });
85
98
 
99
+ for (const count of [0, 1, 3]) {
100
+ test(`shows ${count} in the plural Trails option`, () => {
101
+ const body = renderGraph({
102
+ trails: Array.from({ length: count }, (_, index) => ({ ...trails![0], id: `trail-${index}` })),
103
+ graphifyRelations,
104
+ });
105
+ const button = body.querySelector('header button[aria-label^="Trails"]');
106
+ expect(button?.textContent).toBe(`Trails${count}`);
107
+ expect(button?.getAttribute('aria-label')).toBe(`Trails (${count} ${count === 1 ? 'trail' : 'trails'})`);
108
+ });
109
+ }
110
+
111
+ for (const props of [
112
+ {},
113
+ { diagnostic: { status: 'ok' as const, issueCount: 0 } },
114
+ { issues: [], diagnostic: { status: 'issues' as const, issueCount: 3 } },
115
+ { issues: [], showIssues: true },
116
+ ]) {
117
+ test(`hides Issues and keeps Trails active for an empty scope: ${JSON.stringify(props)}`, () => {
118
+ const body = renderGraph(props);
119
+ const group = body.querySelector('header [aria-label="Sidebar view"]');
120
+ expect(group?.querySelectorAll('button').length).toBe(2);
121
+ expect(group?.querySelector('button[aria-label^="Issues:"]')).toBeNull();
122
+ expect(group?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
123
+ expect(body.textContent).toContain('Request flow');
124
+ expect((group as HTMLElement).style.gridAutoColumns).toBe('1fr');
125
+ });
126
+ }
127
+
86
128
  test('omits the Files button when no components have files', () => {
87
129
  const body = renderGraph({
88
130
  components: components.map((component) => ({ ...component, file: '' })),
@@ -111,7 +153,7 @@ describe('subsystem graph sidebar', () => {
111
153
  expect(toggle?.getAttribute('aria-pressed')).toBe(String(active));
112
154
  expect(header?.querySelectorAll('button').length).toBe(3);
113
155
  expect(header?.querySelectorAll('button[aria-pressed="true"]').length).toBe(1);
114
- expect(header?.querySelector('button[aria-label="Trails"]')?.getAttribute('aria-pressed')).toBe(String(!active));
156
+ expect(header?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe(String(!active));
115
157
  expect(body.querySelectorAll('button[aria-label^="Issues:"]').length).toBe(1);
116
158
  expect(header?.nextElementSibling?.querySelector('button[aria-label^="Issues:"]')).toBeNull();
117
159
  expect(body.textContent).toContain('Always visible description.');
@@ -190,7 +232,7 @@ describe('subsystem graph sidebar', () => {
190
232
  try {
191
233
  const body = renderGraph({ persistKey: 'trail-nav-test' });
192
234
  expect(body.querySelector('header button[aria-label="Trail"]')?.textContent).toBe('Trail');
193
- expect(body.querySelector('header button[aria-label="Trails"]')).toBeNull();
235
+ expect(body.querySelector('header button[aria-label^="Trails"]')).toBeNull();
194
236
  expect(body.querySelector('[aria-hidden="false"] button[aria-label="Back to trails"]')).not.toBeNull();
195
237
  expect(body.querySelector('button[aria-label^="Play "]')).toBeNull();
196
238
  expect(body.querySelector('button[aria-label^="Focus layout"]')).toBeNull();
@@ -234,6 +276,32 @@ describe('subsystem graph sidebar', () => {
234
276
  }
235
277
  });
236
278
 
279
+ test('hides Issues for a selected trail with no findings even when other trails have issues', () => {
280
+ const previousWindow = globalThis.window;
281
+ const dom = new Window();
282
+ dom.localStorage.setItem('principal.subsystems.viewState.empty-trail-issues-test', JSON.stringify({
283
+ sidebarTrailId: 'request',
284
+ }));
285
+ globalThis.window = dom as unknown as typeof window;
286
+ try {
287
+ const body = renderGraph({
288
+ persistKey: 'empty-trail-issues-test',
289
+ showIssues: true,
290
+ issues: [{
291
+ id: 'unrelated', severity: 'error', kind: 'missing_file', message: 'Unrelated finding',
292
+ target: { kind: 'component', id: 'other', label: 'Other' },
293
+ }],
294
+ diagnostic: { status: 'issues', issueCount: 1 },
295
+ });
296
+ expect(body.querySelector('header button[aria-label^="Issues:"]')).toBeNull();
297
+ expect(body.querySelector('header button[aria-label="Trail"]')?.getAttribute('aria-pressed')).toBe('true');
298
+ expect(body.querySelector('[aria-hidden="false"] button[aria-label="Back to trails"]')).not.toBeNull();
299
+ expect(body.querySelector('[aria-hidden="false"]')?.textContent).toContain('Entry');
300
+ } finally {
301
+ globalThis.window = previousWindow;
302
+ }
303
+ });
304
+
237
305
  test('static graphs keep their description but do not show a file panel', () => {
238
306
  const body = renderGraph({ trails: undefined, graphifyRelations });
239
307
  expect(body.textContent).toContain('Always visible description.');
@@ -254,7 +322,10 @@ describe('subsystem graph sidebar', () => {
254
322
  });
255
323
 
256
324
  test('the description stays visible while diagnostics replace the trails', () => {
257
- const body = renderGraph({ showIssues: true, issues: [] });
325
+ const body = renderGraph({ showIssues: true, issues: [{
326
+ id: 'related', severity: 'error', kind: 'missing_file', message: 'Related finding',
327
+ target: { kind: 'component', id: 'entry', label: 'Entry' },
328
+ }] });
258
329
  expect(body.textContent).toContain('Always visible description.');
259
330
  expect(body.textContent).not.toContain('Request flow');
260
331
  expect(body.querySelector('[aria-label="Resize sidebar"]')).not.toBeNull();