@principal-ai/subsystems-react 0.53.6 → 0.55.0

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 (110) hide show
  1. package/dist/index.d.ts +8 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  6. package/dist/pierre/PierreTrailCodeView.js +7 -5
  7. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  8. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  9. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +1 -2
  10. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  11. package/dist/subsystem/C4Graph.d.ts +144 -9
  12. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  13. package/dist/subsystem/C4Graph.js +924 -148
  14. package/dist/subsystem/C4Graph.js.map +1 -1
  15. package/dist/subsystem/C4NodeCard.d.ts +22 -1
  16. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  17. package/dist/subsystem/C4NodeCard.js +10 -2
  18. package/dist/subsystem/C4NodeCard.js.map +1 -1
  19. package/dist/subsystem/ComponentDeclaration.d.ts +5 -4
  20. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  21. package/dist/subsystem/ComponentDeclaration.js +12 -4
  22. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  23. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  24. package/dist/subsystem/ConstructsCatalog.js +10 -7
  25. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  26. package/dist/subsystem/GraphSidebar.d.ts +71 -0
  27. package/dist/subsystem/GraphSidebar.d.ts.map +1 -0
  28. package/dist/subsystem/GraphSidebar.js +145 -0
  29. package/dist/subsystem/GraphSidebar.js.map +1 -0
  30. package/dist/subsystem/GraphWithSidebar.d.ts +24 -0
  31. package/dist/subsystem/GraphWithSidebar.d.ts.map +1 -0
  32. package/dist/subsystem/GraphWithSidebar.js +25 -0
  33. package/dist/subsystem/GraphWithSidebar.js.map +1 -0
  34. package/dist/subsystem/IssueList.d.ts +4 -5
  35. package/dist/subsystem/IssueList.d.ts.map +1 -1
  36. package/dist/subsystem/IssueList.js +33 -28
  37. package/dist/subsystem/IssueList.js.map +1 -1
  38. package/dist/subsystem/SubsystemComponentGraph.d.ts +33 -0
  39. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  40. package/dist/subsystem/SubsystemComponentGraph.js +367 -414
  41. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  42. package/dist/subsystem/TrailStepsNav.d.ts +48 -0
  43. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -0
  44. package/dist/subsystem/TrailStepsNav.js +189 -0
  45. package/dist/subsystem/TrailStepsNav.js.map +1 -0
  46. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  47. package/dist/subsystem/TrailsPanel.js +3 -4
  48. package/dist/subsystem/TrailsPanel.js.map +1 -1
  49. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +151 -0
  50. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -0
  51. package/dist/subsystem/__fixtures__/c4Drilldown.js +783 -0
  52. package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -0
  53. package/dist/subsystem/c4.d.ts +39 -8
  54. package/dist/subsystem/c4.d.ts.map +1 -1
  55. package/dist/subsystem/c4.js.map +1 -1
  56. package/dist/subsystem/declarationRef.d.ts +26 -18
  57. package/dist/subsystem/declarationRef.d.ts.map +1 -1
  58. package/dist/subsystem/declarationRef.js +9 -4
  59. package/dist/subsystem/declarationRef.js.map +1 -1
  60. package/dist/subsystem/model.d.ts +19 -1
  61. package/dist/subsystem/model.d.ts.map +1 -1
  62. package/dist/subsystem/model.js +12 -2
  63. package/dist/subsystem/model.js.map +1 -1
  64. package/dist/subsystem/nodes.d.ts.map +1 -1
  65. package/dist/subsystem/nodes.js +24 -4
  66. package/dist/subsystem/nodes.js.map +1 -1
  67. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  68. package/dist/subsystem/trailBrief.js +6 -1
  69. package/dist/subsystem/trailBrief.js.map +1 -1
  70. package/package.json +3 -3
  71. package/src/index.ts +9 -2
  72. package/src/pierre/PierreTrailCodeView.tsx +9 -13
  73. package/src/stories/Subsystem/C4Graph/C4ToComponentDrill.stories.tsx +496 -0
  74. package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +7 -7
  75. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +19 -25
  76. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +8 -10
  77. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +40 -33
  78. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +6 -6
  79. package/src/stories/Subsystem/ComponentGraph/TrailAutoplay.stories.tsx +7 -7
  80. package/src/stories/Subsystem/ComponentGraph/Trails.stories.tsx +27 -30
  81. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +1 -2
  82. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +10 -13
  83. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +3 -4
  84. package/src/stories/Subsystem/IssueIcons.stories.tsx +65 -0
  85. package/src/stories/Subsystem/Sidebar/GraphSidebar.stories.tsx +206 -0
  86. package/src/subsystem/C4Graph.groups.test.ts +96 -2
  87. package/src/subsystem/C4Graph.tsx +1377 -165
  88. package/src/subsystem/C4NodeCard.test.ts +1 -2
  89. package/src/subsystem/C4NodeCard.tsx +24 -2
  90. package/src/subsystem/ComponentDeclaration.tsx +18 -8
  91. package/src/subsystem/ConstructsCatalog.tsx +23 -8
  92. package/src/subsystem/GraphSidebar.test.tsx +152 -0
  93. package/src/subsystem/GraphSidebar.tsx +300 -0
  94. package/src/subsystem/GraphWithSidebar.test.tsx +78 -0
  95. package/src/subsystem/GraphWithSidebar.tsx +60 -0
  96. package/src/subsystem/IssueList.tsx +34 -29
  97. package/src/subsystem/SubsystemComponentGraph.tsx +290 -376
  98. package/src/subsystem/TrailStepsNav.test.tsx +179 -0
  99. package/src/subsystem/TrailStepsNav.tsx +372 -0
  100. package/src/subsystem/TrailsPanel.tsx +3 -4
  101. package/src/subsystem/__fixtures__/c4Drilldown.test.ts +338 -0
  102. package/src/subsystem/__fixtures__/c4Drilldown.ts +847 -0
  103. package/src/subsystem/c4.ts +41 -8
  104. package/src/subsystem/declarationRef.ts +28 -19
  105. package/src/subsystem/model.test.ts +32 -12
  106. package/src/subsystem/model.ts +30 -1
  107. package/src/subsystem/nodes.group.test.tsx +82 -0
  108. package/src/subsystem/nodes.tsx +30 -2
  109. package/src/subsystem/trailBrief.test.ts +2 -2
  110. package/src/subsystem/trailBrief.ts +6 -1
@@ -221,7 +221,6 @@ describe('nodeSubtitle', () => {
221
221
 
222
222
  test('is empty when nothing is stated — there is no fallback', () => {
223
223
  const withTech = node({ technology: 'Bun + Electrobun' });
224
- const withMembers = node({ constructs: ['function', 'store'], members: ['a', 'b'] });
225
224
  const component = node({
226
225
  kind: 'component',
227
226
  component: {
@@ -232,7 +231,7 @@ describe('nodeSubtitle', () => {
232
231
  purl: 'pkg:x#src/subsystem/model.ts',
233
232
  },
234
233
  });
235
- for (const n of [withTech, withMembers, component]) {
234
+ for (const n of [withTech, component]) {
236
235
  expect(nodeSubtitle(n)).toBe('');
237
236
  }
238
237
  });
@@ -91,6 +91,15 @@ const CARD_PAD_Y = 10;
91
91
  /** The card's flex `gap`, between each of its rows. */
92
92
  const CARD_GAP = 3;
93
93
 
94
+ /**
95
+ * Space a component card reserves above and below the constructs nested inside
96
+ * it when it is grown (a component with constructs becomes an ELK parent, the
97
+ * same way an opened container does). A component has no technology row and no
98
+ * collapse affordance, so this is its label slot plus padding.
99
+ */
100
+ export const COMPONENT_HEADER_PAD = CARD_PAD_Y * 2 + LABEL_SLOT_H + CARD_GAP;
101
+ export const COMPONENT_FOOTER_PAD = CARD_PAD_Y;
102
+
94
103
  /** The description's line height, as a multiple of its font size. */
95
104
  const DESC_LINE_HEIGHT = 1.35;
96
105
 
@@ -416,9 +425,22 @@ export interface C4NodeCardProps {
416
425
  * the grown card to host its component cards.
417
426
  */
418
427
  children?: React.ReactNode;
428
+ /**
429
+ * Delay the width/height transition by this many ms — the drill-down
430
+ * sequences the fade before the move/grow, so the size change waits it out.
431
+ * @default 0
432
+ */
433
+ transitionDelayMs?: number;
434
+ /**
435
+ * The width/height transition duration, in ms. The graph passes its
436
+ * `MORPH_MS` so the grow and the component reveal (`settled`) share one clock
437
+ * and cannot drift apart.
438
+ * @default 420
439
+ */
440
+ sizeTransitionMs?: number;
419
441
  }
420
442
 
421
- export function C4NodeCard({ node, selected = false, handles, onClick, componentCount = 0, onExpand, width, height, showDescription = true, children }: C4NodeCardProps) {
443
+ export function C4NodeCard({ node, selected = false, handles, onClick, componentCount = 0, onExpand, width, height, showDescription = true, children, transitionDelayMs = 0, sizeTransitionMs = 420 }: C4NodeCardProps) {
422
444
  const { theme } = useTheme();
423
445
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
424
446
  const style = nodeStyle(node, theme, selected);
@@ -465,7 +487,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
465
487
  // rather than drawn outside the boundary. The description is clamped above
466
488
  // so that clipping is never what you actually see.
467
489
  height: reservedHeight,
468
- transition: 'width 420ms ease-out, height 420ms ease-out',
490
+ transition: `width ${sizeTransitionMs}ms ease-out ${transitionDelayMs}ms, height ${sizeTransitionMs}ms ease-out ${transitionDelayMs}ms`,
469
491
  overflow: 'hidden',
470
492
  boxSizing: 'border-box',
471
493
  display: 'flex',
@@ -16,7 +16,7 @@ import {
16
16
  type SubsystemComponent,
17
17
  type SubsystemDeclTokenKind,
18
18
  } from './model';
19
- import type { SubsystemOpenFileOptions } from './declarationRef';
19
+ import type { SpanFreshness, SubsystemOpenFileOptions } from './declarationRef';
20
20
  import { parseSourceLocation } from './declarationRef';
21
21
  import { resolveComponentDeclaration } from './formatDeclaration';
22
22
  import { tokenizeComponent } from './tokenizeComponent';
@@ -101,10 +101,11 @@ export interface ComponentVerificationState {
101
101
  inlineParameters?: number;
102
102
  };
103
103
  declaration?: {
104
- freshness: 'fresh' | 'stale' | 'missing' | 'unanchored' | 'unchecked';
105
- ref?: {
104
+ freshness: SpanFreshness;
105
+ span?: {
106
106
  startLine: number;
107
- lineHash: string;
107
+ endLine: number;
108
+ contentHash: string;
108
109
  };
109
110
  };
110
111
  }
@@ -235,7 +236,7 @@ function verificationSummary(
235
236
  const declFresh = v.declaration?.freshness;
236
237
  const declBit =
237
238
  declFresh && declFresh !== 'unanchored'
238
- ? ` · declaration ${declFresh}${v.declaration?.ref ? ` L${v.declaration.ref.startLine}` : ''}`
239
+ ? ` · declaration ${declFresh}${v.declaration?.span ? ` L${v.declaration.span.startLine}` : ''}`
239
240
  : '';
240
241
  const anchorBit = `exact → ${v.anchor?.label ?? v.anchor?.nodeId ?? 'node'}${loc}${declBit}`;
241
242
  if (v.construct && v.construct.match !== true) {
@@ -418,10 +419,14 @@ export function ComponentDeclaration({
418
419
  }, [inspection]);
419
420
 
420
421
  const declarationStartLine =
421
- component.declarationRef?.startLine ??
422
- verification?.declaration?.ref?.startLine ??
422
+ component.declarationSpan?.startLine ??
423
+ verification?.declaration?.span?.startLine ??
423
424
  parseSourceLocation(verification?.anchor?.source_location) ??
424
425
  undefined;
426
+ const declarationEndLine =
427
+ component.declarationSpan?.endLine ??
428
+ verification?.declaration?.span?.endLine ??
429
+ undefined;
425
430
 
426
431
  const externalKind =
427
432
  (component.declaration?.kind ?? component.construct) === 'external';
@@ -434,7 +439,12 @@ export function ComponentDeclaration({
434
439
  if (!onOpenFile || !component.file) return;
435
440
  onOpenFile(
436
441
  component.file,
437
- declarationStartLine != null ? { startLine: declarationStartLine } : undefined,
442
+ declarationStartLine != null
443
+ ? {
444
+ startLine: declarationStartLine,
445
+ ...(declarationEndLine != null ? { endLine: declarationEndLine } : {}),
446
+ }
447
+ : undefined,
438
448
  );
439
449
  };
440
450
 
@@ -75,12 +75,23 @@ const FileDrawerContent = memo(function FileDrawerContent({
75
75
  render,
76
76
  file,
77
77
  startLine,
78
+ endLine,
78
79
  }: {
79
80
  render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
80
81
  file: string;
81
82
  startLine?: number;
83
+ endLine?: number;
82
84
  }) {
83
- return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
85
+ return (
86
+ <>
87
+ {render(
88
+ file,
89
+ startLine != null
90
+ ? { startLine, ...(endLine != null ? { endLine } : {}) }
91
+ : undefined,
92
+ )}
93
+ </>
94
+ );
84
95
  });
85
96
 
86
97
  interface FileTreeNode {
@@ -236,9 +247,11 @@ export function ConstructsCatalog({
236
247
  const [purposeOverrides, setPurposeOverrides] = useState<
237
248
  Record<string, boolean>
238
249
  >({});
239
- const [drawer, setDrawer] = useState<{ file: string; startLine?: number } | null>(
240
- null,
241
- );
250
+ const [drawer, setDrawer] = useState<{
251
+ file: string;
252
+ startLine?: number;
253
+ endLine?: number;
254
+ } | null>(null);
242
255
 
243
256
  const visibleSet = useMemo(() => new Set(visibleAliases), [visibleAliases]);
244
257
  const visibleComponents = useMemo(
@@ -314,7 +327,7 @@ export function ConstructsCatalog({
314
327
  if (!drawer) return;
315
328
  const match =
316
329
  orderedComponents.find(
317
- (c) => c.file === drawer.file && c.declarationRef?.startLine === drawer.startLine,
330
+ (c) => c.file === drawer.file && c.declarationSpan?.startLine === drawer.startLine,
318
331
  ) ?? orderedComponents.find((c) => c.file === drawer.file);
319
332
  if (!match) return;
320
333
  document
@@ -508,9 +521,10 @@ export function ConstructsCatalog({
508
521
  const onOpenFile = useCallback(
509
522
  (file: string, opts?: SubsystemOpenFileOptions) => {
510
523
  const startLine = opts?.startLine;
524
+ const endLine = opts?.endLine;
511
525
  setDrawer((prev) => {
512
526
  if (prev?.file === file && prev.startLine === startLine) return null;
513
- return { file, startLine };
527
+ return { file, startLine, endLine };
514
528
  });
515
529
  onFileSelect?.(file);
516
530
  },
@@ -1013,12 +1027,12 @@ export function ConstructsCatalog({
1013
1027
  const lineOpen =
1014
1028
  fileOpen &&
1015
1029
  drawer.startLine != null &&
1016
- c.declarationRef?.startLine === drawer.startLine;
1030
+ c.declarationSpan?.startLine === drawer.startLine;
1017
1031
  // A combined header owns the path, so declarations under
1018
1032
  // it drop their own badge; those with no known line just
1019
1033
  // render unnumbered. Without a header, keep the badge.
1020
1034
  const numbered =
1021
- hasFile && c.declarationRef?.startLine != null;
1035
+ hasFile && c.declarationSpan?.startLine != null;
1022
1036
  // The header names the external kind, so the body just
1023
1037
  // names the construct.
1024
1038
  const isExt = c.construct === 'external';
@@ -1099,6 +1113,7 @@ export function ConstructsCatalog({
1099
1113
  render={renderDrawerContent}
1100
1114
  file={drawer.file}
1101
1115
  startLine={drawer.startLine}
1116
+ endLine={drawer.endLine}
1102
1117
  />
1103
1118
  ) : null}
1104
1119
  </FileDrawer>
@@ -0,0 +1,152 @@
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 { GraphSidebar } from './GraphSidebar';
7
+
8
+ // Bun's happy-dom test environment isn't registered (no @happy-dom/global-
9
+ // registrator), so wire the DOM primitives React DOM needs before rendering.
10
+ const dom = new Window();
11
+ const globals = globalThis as unknown as Record<string, unknown>;
12
+ globals.window = dom;
13
+ globals.document = dom.document;
14
+ globals.navigator = dom.navigator;
15
+ globals.HTMLElement = dom.HTMLElement;
16
+ globals.Node = dom.Node;
17
+ globals.Element = dom.Element;
18
+ globals.Event = dom.Event;
19
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
20
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
21
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
22
+ globals.localStorage = dom.localStorage;
23
+ globals.matchMedia = dom.matchMedia.bind(dom);
24
+
25
+ afterEach(cleanup);
26
+
27
+ function renderSidebar(props: Record<string, unknown>) {
28
+ return render(
29
+ createElement(
30
+ ThemeProvider,
31
+ { theme: defaultEditorTheme },
32
+ createElement(GraphSidebar as never, props as never),
33
+ ),
34
+ );
35
+ }
36
+
37
+ const sidebarColumn = (container: HTMLElement) =>
38
+ container.querySelector('div[style*="border-right"]') as HTMLElement;
39
+
40
+ describe('GraphSidebar chrome', () => {
41
+ test('renders the sidebar column and resize handle', () => {
42
+ const { container, getByText, getByLabelText } = renderSidebar({
43
+ header: createElement('span', null, 'title'),
44
+ sidebarBody: createElement('span', null, 'panel'),
45
+ });
46
+ expect(getByText('title')).toBeTruthy();
47
+ expect(getByText('panel')).toBeTruthy();
48
+ expect(getByLabelText('Resize sidebar')).toBeTruthy();
49
+ // Uncontrolled width defaults to 320px.
50
+ expect(sidebarColumn(container).style.width).toBe('320px');
51
+ });
52
+
53
+ test('hidden renders nothing — no column, no handle', () => {
54
+ const { queryByLabelText, queryByText } = renderSidebar({
55
+ hidden: true,
56
+ header: createElement('span', null, 'title'),
57
+ });
58
+ expect(queryByText('title')).toBeNull();
59
+ expect(queryByLabelText('Resize sidebar')).toBeNull();
60
+ });
61
+
62
+ test('tabs render a strip and report the change', () => {
63
+ let changed: string | null = null;
64
+ const { getByRole } = renderSidebar({
65
+ tabs: [
66
+ { id: 'a', label: 'Alpha' },
67
+ { id: 'b', label: 'Beta' },
68
+ ],
69
+ activeTab: 'a',
70
+ onTabChange: (id: string) => {
71
+ changed = id;
72
+ },
73
+ sidebarBody: createElement('span', null, 'body'),
74
+ });
75
+ expect(getByRole('tablist')).toBeTruthy();
76
+ fireEvent.click(getByRole('tab', { name: 'Beta' }));
77
+ expect(changed).toBe('b');
78
+ });
79
+
80
+ test('no tabs renders a single body without a strip', () => {
81
+ const { queryByRole, getByText } = renderSidebar({
82
+ sidebarBody: createElement('span', null, 'body'),
83
+ });
84
+ expect(getByText('body')).toBeTruthy();
85
+ expect(queryByRole('tablist')).toBeNull();
86
+ });
87
+
88
+ test('bodyHidden hides the lower panel but keeps the header', () => {
89
+ const { getByText, queryByText } = renderSidebar({
90
+ header: createElement('span', null, 'header'),
91
+ sidebarBody: createElement('span', null, 'body'),
92
+ bodyHidden: true,
93
+ });
94
+ expect(getByText('header')).toBeTruthy();
95
+ expect(queryByText('body')).toBeNull();
96
+ });
97
+
98
+ test('controlled width applies to the column and reports drag changes', () => {
99
+ let reports = 0;
100
+ const { container, getByLabelText } = renderSidebar({
101
+ width: 400,
102
+ onWidthChange: () => {
103
+ reports++;
104
+ },
105
+ sidebarBody: createElement('span', null, 'panel'),
106
+ });
107
+ expect(sidebarColumn(container).style.width).toBe('400px');
108
+ const handle = getByLabelText('Resize sidebar');
109
+ fireEvent.mouseDown(handle, { clientX: 100 });
110
+ fireEvent.mouseMove(document, { clientX: 140 });
111
+ fireEvent.mouseUp(document);
112
+ // Controlled: the width only reports — the prop stays authoritative.
113
+ expect(sidebarColumn(container).style.width).toBe('400px');
114
+ expect(reports).toBeGreaterThan(0);
115
+ });
116
+
117
+ test('an uncontrolled drag resizes the column', () => {
118
+ const { container, getByLabelText } = renderSidebar({
119
+ sidebarBody: createElement('span', null, 'panel'),
120
+ });
121
+ const handle = getByLabelText('Resize sidebar');
122
+ fireEvent.mouseDown(handle, { clientX: 100 });
123
+ fireEvent.mouseMove(document, { clientX: 180 });
124
+ fireEvent.mouseUp(document);
125
+ expect(sidebarColumn(container).style.width).toBe('400px');
126
+ });
127
+
128
+ test('a drag ends by persisting the width under persistKey', () => {
129
+ const { getByLabelText } = renderSidebar({
130
+ persistKey: 'gs-test',
131
+ sidebarBody: createElement('span', null, 'panel'),
132
+ });
133
+ const handle = getByLabelText('Resize sidebar');
134
+ fireEvent.mouseDown(handle, { clientX: 100 });
135
+ fireEvent.mouseMove(document, { clientX: 200 });
136
+ fireEvent.mouseUp(document);
137
+ expect(window.localStorage.getItem('principal.subsystems.sidebarWidth.gs-test')).toBe('420');
138
+ });
139
+
140
+ test('reports drag state to the wrapper', () => {
141
+ const states: boolean[] = [];
142
+ const { getByLabelText } = renderSidebar({
143
+ onDraggingChange: (d: boolean) => states.push(d),
144
+ sidebarBody: createElement('span', null, 'panel'),
145
+ });
146
+ const handle = getByLabelText('Resize sidebar');
147
+ fireEvent.mouseDown(handle, { clientX: 100 });
148
+ fireEvent.mouseMove(document, { clientX: 140 });
149
+ fireEvent.mouseUp(document);
150
+ expect(states).toEqual([true, false]);
151
+ });
152
+ });
@@ -0,0 +1,300 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
3
+ import { useTheme } from '@principal-ade/industry-theme';
4
+
5
+ /**
6
+ * GraphSidebar — the side panel of a graph layout, extracted from
7
+ * SubsystemComponentGraph so the panel itself is its own component. The host
8
+ * supplies everything it shows:
9
+ *
10
+ * - `header` — the upper block (title, actions, description…). Scrollable;
11
+ * takes the flexible share of the column when `stretchHeader` is set so a
12
+ * long description can unpack without squeezing the lower panel to death.
13
+ * - `tabs` + `activeTab`/`onTabChange` — an optional tab strip above the
14
+ * lower panel. Omit for a single-panel sidebar.
15
+ * - `sidebarBody` — the lower panel content (the active tab's body).
16
+ * - `footer` — pinned beneath the scrollable body.
17
+ *
18
+ * The panel is pure chrome — it owns nothing graph-specific (not the selection
19
+ * state, not the trails, not the file tree). That state stays in the graph,
20
+ * because the canvas dims from the same facts the sidebar renders; which is
21
+ * exactly why the chrome — and only the chrome — is what's shared.
22
+ *
23
+ * The right edge is the resize handle. Width is draggable, clamped to
24
+ * `minWidth`/`maxWidth`, and persisted to localStorage under
25
+ * `principal.subsystems.sidebarWidth.<persistKey>` when a `persistKey` is set
26
+ * (controlled widths instead hand every change to `onWidthChange`, and persist
27
+ * nothing).
28
+ *
29
+ * `onDraggingChange` reports drag state so a wrapper (see GraphWithSidebar)
30
+ * can lock its whole row against text selection during a resize.
31
+ */
32
+
33
+ export interface GraphSidebarTab {
34
+ id: string;
35
+ label: string;
36
+ }
37
+
38
+ export interface GraphSidebarProps {
39
+ /** Upper block of the sidebar: title, actions, description — whatever the
40
+ * graph wants above the divider. Scrolls when the sidebar is short. */
41
+ header?: ReactNode;
42
+ /** Tab strip above the lower panel. When set, `sidebarBody` renders as the
43
+ * active tab's body. Omit for a single-panel sidebar. */
44
+ tabs?: ReadonlyArray<GraphSidebarTab>;
45
+ activeTab?: string;
46
+ onTabChange?: (id: string) => void;
47
+ /** Lower panel content — the active tab's body when `tabs` is set. */
48
+ sidebarBody?: ReactNode;
49
+ /** Pinned footer of the lower panel, beneath its scrollable body. */
50
+ footer?: ReactNode;
51
+ /** Hide the sidebar entirely (and the resize handle); nothing renders. */
52
+ hidden?: boolean;
53
+ /** Hide the lower panel so only the header shows. */
54
+ bodyHidden?: boolean;
55
+ /** Give the header the flexible share of the column, pinning the lower
56
+ * panel at half height (e.g. a description that unpacks on demand). */
57
+ stretchHeader?: boolean;
58
+ /** Controlled sidebar width (px). */
59
+ width?: number;
60
+ onWidthChange?: (width: number) => void;
61
+ /** Uncontrolled starting width; a stored width for `persistKey` wins. */
62
+ defaultWidth?: number;
63
+ minWidth?: number;
64
+ maxWidth?: number;
65
+ /** Fired when a drag-resize gesture ends (hosts re-fit their canvas here). */
66
+ onResizeEnd?: () => void;
67
+ /** localStorage key the sidebar width persists under. */
68
+ persistKey?: string;
69
+ /** Aria-label for the tab strip. */
70
+ tabListLabel?: string;
71
+ /** Reports drag state so a wrapper can lock its row against selection
72
+ * while the sidebar is being resized. */
73
+ onDraggingChange?: (dragging: boolean) => void;
74
+ }
75
+
76
+ const DEFAULT_MIN_WIDTH = 240;
77
+ const DEFAULT_MAX_WIDTH = 600;
78
+ const DEFAULT_WIDTH = 320;
79
+ const SIDEBAR_WIDTH_STORAGE_PREFIX = 'principal.subsystems.sidebarWidth.';
80
+
81
+ function readStoredWidth(persistKey: string | undefined): number | null {
82
+ if (!persistKey || typeof window === 'undefined') return null;
83
+ try {
84
+ const raw = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_PREFIX + persistKey);
85
+ if (!raw) return null;
86
+ const parsed = Number(raw);
87
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ export function GraphSidebar({
94
+ header,
95
+ tabs,
96
+ activeTab,
97
+ onTabChange,
98
+ sidebarBody,
99
+ footer,
100
+ hidden = false,
101
+ bodyHidden = false,
102
+ stretchHeader = false,
103
+ width,
104
+ onWidthChange,
105
+ defaultWidth = DEFAULT_WIDTH,
106
+ minWidth = DEFAULT_MIN_WIDTH,
107
+ maxWidth = DEFAULT_MAX_WIDTH,
108
+ onResizeEnd,
109
+ persistKey,
110
+ tabListLabel = 'Sidebar sections',
111
+ onDraggingChange,
112
+ }: GraphSidebarProps) {
113
+ const { theme } = useTheme();
114
+ const controlled = width !== undefined;
115
+ const hasTabs = (tabs?.length ?? 0) > 0;
116
+ const sidebarVisible = !hidden;
117
+
118
+ // Uncontrolled width: seeded from storage when a persistKey is set, else the
119
+ // default. A new persistKey (a different model) restarts the panel at that
120
+ // model's stored width.
121
+ const [intWidth, setIntWidth] = useState(() => readStoredWidth(persistKey) ?? defaultWidth);
122
+ useEffect(() => {
123
+ if (controlled) return;
124
+ setIntWidth(readStoredWidth(persistKey) ?? defaultWidth);
125
+ }, [persistKey, defaultWidth, controlled]);
126
+
127
+ const effectiveWidth = controlled ? (width as number) : intWidth;
128
+
129
+ // Drag-resize: capture the grab, track on the document, release on mouseup,
130
+ // persisting only then — not on every mousemove.
131
+ const [dragging, setDragging] = useState(false);
132
+ const dragStartX = useRef(0);
133
+ // Mirror of the current width for the mouseup handler (a closure over the
134
+ // drag-start render would persist the stale start width).
135
+ const currentWidthRef = useRef(effectiveWidth);
136
+ currentWidthRef.current = effectiveWidth;
137
+
138
+ const onResizeStart = useCallback(
139
+ (e: ReactMouseEvent) => {
140
+ e.preventDefault();
141
+ dragStartX.current = e.clientX;
142
+ currentWidthRef.current = effectiveWidth;
143
+ setDragging(true);
144
+ onDraggingChange?.(true);
145
+ },
146
+ [effectiveWidth, onDraggingChange],
147
+ );
148
+
149
+ useEffect(() => {
150
+ if (!dragging) return;
151
+ const onMove = (e: MouseEvent) => {
152
+ const next = Math.min(
153
+ Math.max(currentWidthRef.current + (e.clientX - dragStartX.current), minWidth),
154
+ maxWidth,
155
+ );
156
+ if (controlled) {
157
+ onWidthChange?.(next);
158
+ } else {
159
+ setIntWidth(next);
160
+ }
161
+ };
162
+ const onUp = () => {
163
+ setDragging(false);
164
+ onDraggingChange?.(false);
165
+ if (persistKey && typeof window !== 'undefined') {
166
+ try {
167
+ window.localStorage.setItem(
168
+ SIDEBAR_WIDTH_STORAGE_PREFIX + persistKey,
169
+ String(currentWidthRef.current),
170
+ );
171
+ } catch {
172
+ // Storage unavailable — the width just isn't remembered.
173
+ }
174
+ }
175
+ onResizeEnd?.();
176
+ };
177
+ document.addEventListener('mousemove', onMove);
178
+ document.addEventListener('mouseup', onUp);
179
+ return () => {
180
+ document.removeEventListener('mousemove', onMove);
181
+ document.removeEventListener('mouseup', onUp);
182
+ };
183
+ }, [dragging, minWidth, maxWidth, controlled, onWidthChange, onResizeEnd, persistKey, onDraggingChange]);
184
+
185
+ if (!sidebarVisible) return null;
186
+
187
+ const clampedWidth = Math.min(Math.max(effectiveWidth, minWidth), maxWidth);
188
+
189
+ return (
190
+ <>
191
+ <div
192
+ style={{
193
+ width: clampedWidth,
194
+ minWidth: clampedWidth,
195
+ borderRight: `1px solid ${theme.colors.border}`,
196
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
197
+ display: 'flex',
198
+ flexDirection: 'column',
199
+ overflow: 'hidden',
200
+ userSelect: dragging ? 'none' : undefined,
201
+ }}
202
+ >
203
+ {header != null && (
204
+ <div
205
+ style={{
206
+ flex: stretchHeader ? 1 : '0 0 auto',
207
+ minHeight: 0,
208
+ overflowY: 'auto',
209
+ padding: 16,
210
+ display: 'flex',
211
+ flexDirection: 'column',
212
+ gap: 12,
213
+ }}
214
+ >
215
+ {header}
216
+ </div>
217
+ )}
218
+ {!bodyHidden && (hasTabs || sidebarBody != null || footer != null) && (
219
+ <div
220
+ style={{
221
+ ...(stretchHeader ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
222
+ minHeight: 160,
223
+ flexShrink: 0,
224
+ borderTop: header != null ? `1px solid ${theme.colors.border}` : undefined,
225
+ display: 'flex',
226
+ flexDirection: 'column',
227
+ overflow: 'hidden',
228
+ }}
229
+ >
230
+ {hasTabs && (
231
+ <div
232
+ role="tablist"
233
+ aria-label={tabListLabel}
234
+ style={{
235
+ display: 'flex',
236
+ width: '100%',
237
+ flexShrink: 0,
238
+ borderBottom: `1px solid ${theme.colors.border}`,
239
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
240
+ }}
241
+ >
242
+ {tabs!.map((tab) => (
243
+ <button
244
+ key={tab.id}
245
+ type="button"
246
+ role="tab"
247
+ aria-selected={activeTab === tab.id}
248
+ onClick={() => onTabChange?.(tab.id)}
249
+ style={{
250
+ flex: 1,
251
+ minWidth: 0,
252
+ padding: '8px 8px',
253
+ border: 'none',
254
+ borderRadius: 0,
255
+ background: activeTab === tab.id ? theme.colors.background : 'transparent',
256
+ color:
257
+ activeTab === tab.id ? theme.colors.text : theme.colors.textSecondary,
258
+ fontSize: theme.fontSizes[1],
259
+ fontFamily: theme.fonts.monospace,
260
+ textTransform: 'capitalize',
261
+ cursor: 'pointer',
262
+ }}
263
+ >
264
+ {tab.label}
265
+ </button>
266
+ ))}
267
+ </div>
268
+ )}
269
+ <div
270
+ style={{
271
+ flex: 1,
272
+ minHeight: 0,
273
+ display: 'flex',
274
+ flexDirection: 'column',
275
+ overflow: 'hidden',
276
+ }}
277
+ >
278
+ {sidebarBody}
279
+ </div>
280
+ {footer}
281
+ </div>
282
+ )}
283
+ </div>
284
+
285
+ <div
286
+ onMouseDown={onResizeStart}
287
+ aria-label="Resize sidebar"
288
+ title="Drag to resize"
289
+ style={{
290
+ width: 3,
291
+ flexShrink: 0,
292
+ cursor: 'col-resize',
293
+ background: theme.colors.border,
294
+ transition: 'background 120ms ease',
295
+ zIndex: 1,
296
+ }}
297
+ />
298
+ </>
299
+ );
300
+ }