@principal-ai/subsystems-react 0.56.3 → 0.56.5

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 (50) hide show
  1. package/dist/index.d.ts +6 -0
  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/subsystem/DecorationPanel.d.ts +37 -0
  6. package/dist/subsystem/DecorationPanel.d.ts.map +1 -0
  7. package/dist/subsystem/DecorationPanel.js +147 -0
  8. package/dist/subsystem/DecorationPanel.js.map +1 -0
  9. package/dist/subsystem/DiagnosticToggle.d.ts +7 -1
  10. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  11. package/dist/subsystem/DiagnosticToggle.js +8 -7
  12. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  13. package/dist/subsystem/GraphSidebar.d.ts +4 -2
  14. package/dist/subsystem/GraphSidebar.d.ts.map +1 -1
  15. package/dist/subsystem/GraphSidebar.js +3 -1
  16. package/dist/subsystem/GraphSidebar.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -13
  18. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.js +112 -272
  20. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  21. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  22. package/dist/subsystem/TrailStepsNav.js +6 -4
  23. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  24. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  25. package/dist/subsystem/TrailsPanel.js +14 -10
  26. package/dist/subsystem/TrailsPanel.js.map +1 -1
  27. package/dist/subsystem/decoration.d.ts +112 -0
  28. package/dist/subsystem/decoration.d.ts.map +1 -0
  29. package/dist/subsystem/decoration.js +54 -0
  30. package/dist/subsystem/decoration.js.map +1 -0
  31. package/package.json +3 -3
  32. package/src/index.ts +37 -0
  33. package/src/stories/Canvases/C4/C4ToComponentDrill.stories.tsx +38 -71
  34. package/src/stories/Canvases/Subsystem/GraphTitle.stories.tsx +1 -2
  35. package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +528 -0
  36. package/src/stories/Components/DecorationPanel.stories.tsx +160 -0
  37. package/src/subsystem/DecorationPanel.test.tsx +128 -0
  38. package/src/subsystem/DecorationPanel.tsx +334 -0
  39. package/src/subsystem/DiagnosticToggle.tsx +20 -5
  40. package/src/subsystem/GraphSidebar.test.tsx +18 -0
  41. package/src/subsystem/GraphSidebar.tsx +6 -1
  42. package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +218 -0
  43. package/src/subsystem/SubsystemComponentGraph.tsx +184 -414
  44. package/src/subsystem/TrailStepsNav.test.tsx +32 -0
  45. package/src/subsystem/TrailStepsNav.tsx +6 -4
  46. package/src/subsystem/TrailsPanel.test.tsx +20 -0
  47. package/src/subsystem/TrailsPanel.tsx +11 -1
  48. package/src/subsystem/decoration.test.ts +72 -0
  49. package/src/subsystem/decoration.ts +151 -0
  50. package/src/subsystem/stepEmphasis.test.ts +110 -0
@@ -0,0 +1,128 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
2
+ import { Window } from 'happy-dom';
3
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { DecorationPanel, type DecorationPanelProps } from './DecorationPanel';
6
+ import { decorationTrailKey, type SubsystemDecoration } from './decoration';
7
+
8
+ // Same DOM shim as TrailsPanel.test: happy-dom lacks ResizeObserver, which
9
+ // themed-markdown uses to measure its slide container.
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.MouseEvent = dom.MouseEvent;
20
+ globals.FocusEvent = dom.FocusEvent;
21
+ globals.KeyboardEvent = dom.KeyboardEvent;
22
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
23
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
24
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
25
+ globals.localStorage = dom.localStorage;
26
+ globals.sessionStorage = dom.sessionStorage;
27
+ globals.matchMedia = dom.matchMedia.bind(dom);
28
+ globals.ResizeObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ };
33
+
34
+ afterEach(cleanup);
35
+
36
+ const decoration: SubsystemDecoration = {
37
+ version: 1,
38
+ id: 'dec-sync',
39
+ title: 'Local-first sync',
40
+ description: 'A **reading path** for the save flow.',
41
+ trails: [
42
+ { kind: 'base', modelId: 'sg-desktop', trailId: 'tl-save' },
43
+ { kind: 'base', modelId: 'sg-server', trailId: 'tl-persist' },
44
+ {
45
+ kind: 'draft',
46
+ modelId: 'sg-desktop',
47
+ trail: { id: 'tl-planned', title: 'Planned sync', steps: [] },
48
+ },
49
+ ],
50
+ draft: {
51
+ components: [
52
+ { alias: 'sync-agent', name: 'SyncAgent', construct: 'class' },
53
+ ],
54
+ edges: [{ from: 'tab', to: 'sync-agent', mechanism: 'produces' }],
55
+ },
56
+ provenance: { source: 'author', at: '2026-10-20T00:00:00.000Z' },
57
+ };
58
+
59
+ const resolveTrail: NonNullable<DecorationPanelProps['resolveTrail']> = (ref) => {
60
+ if (ref.kind === 'draft') return undefined;
61
+ const key = decorationTrailKey(ref);
62
+ if (key === 'sg-desktop:tl-save') return { title: 'Save drawing', stepCount: 4 };
63
+ if (key === 'sg-server:tl-persist') return { title: 'Persist file', stepCount: 5 };
64
+ return undefined;
65
+ };
66
+
67
+ function renderPanel(overrides: Partial<DecorationPanelProps> = {}) {
68
+ const props: DecorationPanelProps = {
69
+ decoration,
70
+ active: true,
71
+ onToggleActive: () => {},
72
+ resolveTrail,
73
+ modelLabels: new Map([
74
+ ['sg-desktop', 'desktop-app'],
75
+ ['sg-server', 'sync-service'],
76
+ ]),
77
+ ...overrides,
78
+ };
79
+ return render(
80
+ <ThemeProvider theme={defaultEditorTheme}>
81
+ <DecorationPanel {...props} />
82
+ </ThemeProvider>,
83
+ );
84
+ }
85
+
86
+ describe('DecorationPanel', () => {
87
+ test('renders the description, grouped trails, and draft structure', () => {
88
+ const { getByText, container } = renderPanel();
89
+ expect(container.textContent).toContain('reading path');
90
+ expect(getByText('desktop-app')).not.toBeNull();
91
+ expect(getByText('sync-service')).not.toBeNull();
92
+ expect(getByText('Save drawing')).not.toBeNull();
93
+ expect(getByText('Persist file')).not.toBeNull();
94
+ expect(getByText('Planned sync')).not.toBeNull();
95
+ expect(getByText('draft')).not.toBeNull();
96
+ expect(getByText('SyncAgent')).not.toBeNull();
97
+ expect(getByText('+ 1 draft edge')).not.toBeNull();
98
+ });
99
+
100
+ test('the toggle reports the next active state', () => {
101
+ const onToggleActive = mock(() => {});
102
+ const { getByLabelText } = renderPanel({ onToggleActive });
103
+ fireEvent.click(getByLabelText('Turn decoration off'));
104
+ expect(onToggleActive).toHaveBeenCalledWith(false);
105
+ });
106
+
107
+ test('selecting a trail hands the ref back to the host', () => {
108
+ const onSelectTrail = mock(() => {});
109
+ const { getByText } = renderPanel({ onSelectTrail });
110
+ fireEvent.click(getByText('Save drawing'));
111
+ expect(onSelectTrail).toHaveBeenCalledWith({
112
+ kind: 'base',
113
+ modelId: 'sg-desktop',
114
+ trailId: 'tl-save',
115
+ });
116
+ });
117
+
118
+ test('selecting a draft component hands the component back', () => {
119
+ const onSelectDraftComponent = mock(() => {});
120
+ const { getByText } = renderPanel({ onSelectDraftComponent });
121
+ fireEvent.click(getByText('SyncAgent'));
122
+ expect(onSelectDraftComponent).toHaveBeenCalledWith({
123
+ alias: 'sync-agent',
124
+ name: 'SyncAgent',
125
+ construct: 'class',
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,334 @@
1
+ /**
2
+ * DecorationPanel — the reading surface for one `SubsystemDecoration`.
3
+ *
4
+ * A decoration is an additive, unverified layer over one or more subsystem
5
+ * models: markdown prose, an ordered list of trails (each naming its own
6
+ * model), and optional draft structure. This panel is the container's own
7
+ * surface — it deliberately does not render the graph. Selecting a trail (or
8
+ * a draft component) is handed back to the host, which decides whether to
9
+ * focus a model, open a tab, or overlay the draft.
10
+ *
11
+ * Nothing here writes: the first prototype is read-only plus an on/off toggle.
12
+ */
13
+
14
+ import type { CSSProperties } from 'react';
15
+ import { useTheme } from '@principal-ade/industry-theme';
16
+ import { Box, Eye, EyeOff, Network, Route, Sparkles } from 'lucide-react';
17
+ import { IndustryMarkdownSlide } from 'themed-markdown';
18
+ import {
19
+ decorationTrailId,
20
+ groupDecorationTrailsByModel,
21
+ type DecorationDraftComponent,
22
+ type DecorationTrailRef,
23
+ type SubsystemDecoration,
24
+ } from './decoration';
25
+
26
+ export interface DecorationPanelTrailMeta {
27
+ title: string;
28
+ stepCount?: number;
29
+ }
30
+
31
+ export interface DecorationPanelProps {
32
+ decoration: SubsystemDecoration;
33
+ /** Whether the decoration overlay is currently on. */
34
+ active: boolean;
35
+ onToggleActive: (active: boolean) => void;
36
+ /** Click a trail row. The host focuses the owning model/trail. */
37
+ onSelectTrail?: (ref: DecorationTrailRef) => void;
38
+ /** Click a draft component. Omit to render the draft list read-only. */
39
+ onSelectDraftComponent?: (component: DecorationDraftComponent) => void;
40
+ /**
41
+ * Resolve a base trail's display title/step count from its owning model.
42
+ * Draft trails carry their content inline and need no resolver.
43
+ */
44
+ resolveTrail?: (ref: DecorationTrailRef) => DecorationPanelTrailMeta | undefined;
45
+ /** Friendly model names, keyed by `modelId`. Falls back to the id. */
46
+ modelLabels?: ReadonlyMap<string, string>;
47
+ }
48
+
49
+ export function DecorationPanel({
50
+ decoration,
51
+ active,
52
+ onToggleActive,
53
+ onSelectTrail,
54
+ onSelectDraftComponent,
55
+ resolveTrail,
56
+ modelLabels,
57
+ }: DecorationPanelProps) {
58
+ const { theme } = useTheme();
59
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
60
+ const secondary = theme.colors.textSecondary ?? muted;
61
+ const warning = theme.colors.warning ?? secondary;
62
+ const accent = theme.colors.accent ?? theme.colors.primary ?? theme.colors.text;
63
+ const groups = groupDecorationTrailsByModel(decoration.trails);
64
+ const draftComponents = decoration.draft?.components ?? [];
65
+ const draftEdges = decoration.draft?.edges ?? [];
66
+ const sectionLabel: CSSProperties = {
67
+ fontFamily: theme.fonts.monospace,
68
+ fontSize: theme.fontSizes[0],
69
+ fontWeight: theme.fontWeights.semibold,
70
+ letterSpacing: '0.06em',
71
+ textTransform: 'uppercase',
72
+ };
73
+
74
+ return (
75
+ <div
76
+ style={{
77
+ display: 'flex',
78
+ flexDirection: 'column',
79
+ width: '100%',
80
+ height: '100%',
81
+ minHeight: 0,
82
+ background: theme.colors.background,
83
+ color: theme.colors.text,
84
+ }}
85
+ >
86
+ <header
87
+ style={{
88
+ flexShrink: 0,
89
+ padding: '14px 16px 10px',
90
+ borderBottom: `1px solid ${theme.colors.border}`,
91
+ }}
92
+ >
93
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
94
+ <Sparkles size={15} strokeWidth={2} color={accent} aria-hidden />
95
+ <h2
96
+ style={{
97
+ flex: 1,
98
+ minWidth: 0,
99
+ margin: 0,
100
+ overflowWrap: 'anywhere',
101
+ fontFamily: theme.fonts.monospace,
102
+ fontSize: theme.fontSizes[2],
103
+ fontWeight: theme.fontWeights.semibold,
104
+ }}
105
+ >
106
+ {decoration.title}
107
+ </h2>
108
+ <button
109
+ type="button"
110
+ aria-pressed={active}
111
+ aria-label={active ? 'Turn decoration off' : 'Turn decoration on'}
112
+ title={active ? 'Turn decoration off' : 'Turn decoration on'}
113
+ onClick={() => onToggleActive(!active)}
114
+ style={{
115
+ display: 'inline-flex',
116
+ alignItems: 'center',
117
+ gap: 4,
118
+ flexShrink: 0,
119
+ padding: '3px 8px',
120
+ borderRadius: 4,
121
+ border: `1px solid ${active ? accent : theme.colors.border}`,
122
+ background: active ? `${accent}18` : 'transparent',
123
+ color: active ? accent : muted,
124
+ fontFamily: theme.fonts.monospace,
125
+ fontSize: theme.fontSizes[0],
126
+ cursor: 'pointer',
127
+ }}
128
+ >
129
+ {active ? <Eye size={12} strokeWidth={2} /> : <EyeOff size={12} strokeWidth={2} />}
130
+ {active ? 'On' : 'Off'}
131
+ </button>
132
+ </div>
133
+ </header>
134
+
135
+ <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 16px 18px' }}>
136
+ {decoration.description && (
137
+ <div style={{ fontSize: theme.fontSizes[1], lineHeight: theme.lineHeights.body }}>
138
+ <IndustryMarkdownSlide
139
+ content={decoration.description}
140
+ slideIdPrefix={`decoration-${decoration.id}`}
141
+ slideIndex={0}
142
+ isVisible={true}
143
+ theme={theme}
144
+ disableScroll={true}
145
+ disableBasePadding
146
+ enableKeyboardScrolling={false}
147
+ autoFocusOnVisible={false}
148
+ />
149
+ </div>
150
+ )}
151
+
152
+ <section
153
+ style={{
154
+ marginTop: 16,
155
+ paddingTop: 14,
156
+ borderTop: `1px solid ${theme.colors.border}`,
157
+ }}
158
+ >
159
+ <div style={{ ...sectionLabel, color: secondary }}>
160
+ Trails · {decoration.trails.length}
161
+ </div>
162
+ {groups.map((group) => (
163
+ <div key={group.modelId}>
164
+ <div
165
+ style={{
166
+ display: 'flex',
167
+ alignItems: 'center',
168
+ gap: 6,
169
+ margin: '12px 0 3px',
170
+ }}
171
+ >
172
+ <Network size={14} strokeWidth={2} color={secondary} aria-hidden />
173
+ <span
174
+ style={{
175
+ minWidth: 0,
176
+ overflow: 'hidden',
177
+ textOverflow: 'ellipsis',
178
+ whiteSpace: 'nowrap',
179
+ fontFamily: theme.fonts.monospace,
180
+ fontSize: theme.fontSizes[1],
181
+ fontWeight: theme.fontWeights.semibold,
182
+ color: theme.colors.text,
183
+ }}
184
+ >
185
+ {modelLabels?.get(group.modelId) ?? group.modelId}
186
+ </span>
187
+ </div>
188
+ {group.trails.map((ref) => {
189
+ const meta = resolveTrail?.(ref);
190
+ const title =
191
+ meta?.title ??
192
+ (ref.kind === 'draft' ? ref.trail.title : ref.trailId);
193
+ const stepCount =
194
+ meta?.stepCount ??
195
+ (ref.kind === 'draft' ? ref.trail.steps.length : undefined);
196
+ return (
197
+ <button
198
+ key={decorationTrailId(ref)}
199
+ type="button"
200
+ onClick={() => onSelectTrail?.(ref)}
201
+ style={{
202
+ display: 'flex',
203
+ alignItems: 'center',
204
+ gap: 8,
205
+ width: '100%',
206
+ minWidth: 0,
207
+ padding: '6px 6px 6px 0',
208
+ border: 'none',
209
+ borderRadius: 4,
210
+ background: 'transparent',
211
+ color: theme.colors.text,
212
+ textAlign: 'left',
213
+ cursor: onSelectTrail ? 'pointer' : 'default',
214
+ }}
215
+ >
216
+ <Route size={13} strokeWidth={2} color={muted} aria-hidden />
217
+ <span
218
+ style={{
219
+ flex: 1,
220
+ minWidth: 0,
221
+ overflow: 'hidden',
222
+ textOverflow: 'ellipsis',
223
+ whiteSpace: 'nowrap',
224
+ fontFamily: theme.fonts.monospace,
225
+ fontSize: theme.fontSizes[1],
226
+ }}
227
+ >
228
+ {title}
229
+ </span>
230
+ {ref.kind === 'draft' && (
231
+ <span
232
+ style={{
233
+ flexShrink: 0,
234
+ padding: '1px 5px',
235
+ borderRadius: 3,
236
+ border: `1px solid ${warning}`,
237
+ color: warning,
238
+ fontFamily: theme.fonts.monospace,
239
+ fontSize: theme.fontSizes[0],
240
+ }}
241
+ >
242
+ draft
243
+ </span>
244
+ )}
245
+ {stepCount != null && (
246
+ <span
247
+ style={{
248
+ flexShrink: 0,
249
+ fontFamily: theme.fonts.monospace,
250
+ fontSize: theme.fontSizes[0],
251
+ color: secondary,
252
+ }}
253
+ >
254
+ {stepCount}
255
+ </span>
256
+ )}
257
+ </button>
258
+ );
259
+ })}
260
+ </div>
261
+ ))}
262
+ </section>
263
+
264
+ {(draftComponents.length > 0 || draftEdges.length > 0) && (
265
+ <section style={{ marginTop: 18 }}>
266
+ <div style={{ ...sectionLabel, color: secondary }}>
267
+ Draft structure · not verified
268
+ </div>
269
+ {draftComponents.map((component) => (
270
+ <button
271
+ key={component.alias}
272
+ type="button"
273
+ onClick={() => onSelectDraftComponent?.(component)}
274
+ style={{
275
+ display: 'flex',
276
+ alignItems: 'center',
277
+ gap: 8,
278
+ width: '100%',
279
+ minWidth: 0,
280
+ padding: '6px 6px 6px 0',
281
+ border: 'none',
282
+ borderRadius: 4,
283
+ background: 'transparent',
284
+ color: theme.colors.text,
285
+ textAlign: 'left',
286
+ cursor: onSelectDraftComponent ? 'pointer' : 'default',
287
+ }}
288
+ >
289
+ <Box size={13} strokeWidth={2} color={muted} aria-hidden />
290
+ <span
291
+ style={{
292
+ flex: 1,
293
+ minWidth: 0,
294
+ overflow: 'hidden',
295
+ textOverflow: 'ellipsis',
296
+ whiteSpace: 'nowrap',
297
+ fontFamily: theme.fonts.monospace,
298
+ fontSize: theme.fontSizes[1],
299
+ }}
300
+ >
301
+ {component.name}
302
+ </span>
303
+ {component.construct && (
304
+ <span
305
+ style={{
306
+ flexShrink: 0,
307
+ fontFamily: theme.fonts.monospace,
308
+ fontSize: theme.fontSizes[0],
309
+ color: secondary,
310
+ }}
311
+ >
312
+ {component.construct}
313
+ </span>
314
+ )}
315
+ </button>
316
+ ))}
317
+ {draftEdges.length > 0 && (
318
+ <div
319
+ style={{
320
+ marginTop: 4,
321
+ fontFamily: theme.fonts.monospace,
322
+ fontSize: theme.fontSizes[0],
323
+ color: secondary,
324
+ }}
325
+ >
326
+ + {draftEdges.length} draft edge{draftEdges.length === 1 ? '' : 's'}
327
+ </div>
328
+ )}
329
+ </section>
330
+ )}
331
+ </div>
332
+ </div>
333
+ );
334
+ }
@@ -90,6 +90,12 @@ export function diagnosticTitle(diagnostic: SubsystemDiagnostic): string {
90
90
  export interface SubsystemDiagnosticToggleProps extends SubsystemDiagnostic {
91
91
  /** Icon size in px. @default 14 */
92
92
  size?: number;
93
+ /** Optional visible label beside the status icon. */
94
+ labelText?: string;
95
+ /** Outline the button in header toolbars. */
96
+ bordered?: boolean;
97
+ /** Match an option inside a shared segmented view switch. */
98
+ segmented?: boolean;
93
99
  }
94
100
 
95
101
  export function SubsystemDiagnosticToggle({
@@ -100,6 +106,9 @@ export function SubsystemDiagnosticToggle({
100
106
  busy = false,
101
107
  active = false,
102
108
  size = 14,
109
+ labelText,
110
+ bordered = false,
111
+ segmented = false,
103
112
  onToggle,
104
113
  ...rest
105
114
  }: SubsystemDiagnosticToggleProps) {
@@ -122,7 +131,7 @@ export function SubsystemDiagnosticToggle({
122
131
  const showFixable = fixableCount > 0 && !busy;
123
132
  const fixableColor = theme.colors.success ?? '#2da44e';
124
133
  const background = active
125
- ? hexWithAlpha(color, hover ? 0.24 : 0.16)
134
+ ? segmented ? theme.colors.border : hexWithAlpha(color, hover ? 0.24 : 0.16)
126
135
  : hover
127
136
  ? theme.colors.border
128
137
  : 'transparent';
@@ -138,15 +147,16 @@ export function SubsystemDiagnosticToggle({
138
147
  onMouseEnter={() => setHover(true)}
139
148
  onMouseLeave={() => setHover(false)}
140
149
  title={label}
141
- aria-label={label}
150
+ aria-label={labelText ? `${labelText}: ${label}` : label}
142
151
  aria-pressed={active}
143
152
  style={{
144
153
  display: 'inline-flex',
145
154
  alignItems: 'center',
155
+ justifyContent: bordered || segmented ? 'center' : undefined,
146
156
  gap: 4,
147
- height: 22,
148
- padding: showCount ? '0 7px 0 6px' : '0 5px',
149
- border: 'none',
157
+ height: bordered || segmented ? 28 : 22,
158
+ padding: bordered || segmented ? '4px 7px' : showCount ? '0 7px 0 6px' : '0 5px',
159
+ border: bordered ? `1px solid ${theme.colors.border}` : 'none',
150
160
  borderRadius: 4,
151
161
  background,
152
162
  color,
@@ -206,6 +216,11 @@ export function SubsystemDiagnosticToggle({
206
216
  </span>
207
217
  )}
208
218
  </span>
219
+ {labelText && (
220
+ <span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0] }}>
221
+ {labelText}
222
+ </span>
223
+ )}
209
224
  {showCount && (
210
225
  <span
211
226
  style={{
@@ -95,6 +95,24 @@ describe('GraphSidebar chrome', () => {
95
95
  expect(queryByText('body')).toBeNull();
96
96
  });
97
97
 
98
+ test('a capped header fits its content while the body fills the remaining space', () => {
99
+ const { container } = renderSidebar({
100
+ header: createElement('span', null, 'description'),
101
+ headerMaxHeight: '30%',
102
+ sidebarBody: createElement('span', null, 'trails'),
103
+ });
104
+ const column = sidebarColumn(container);
105
+ const header = column.firstElementChild as HTMLElement;
106
+ const body = column.lastElementChild as HTMLElement;
107
+ expect(header.style.flexGrow).toBe('0');
108
+ expect(header.style.flexBasis).toBe('auto');
109
+ expect(header.style.maxHeight).toBe('30%');
110
+ expect(header.style.boxSizing).toBe('border-box');
111
+ expect(header.style.overflowY).toBe('auto');
112
+ expect(body.style.flexGrow).toBe('1');
113
+ expect(body.style.height).toBe('');
114
+ });
115
+
98
116
  test('controlled width applies to the column and reports drag changes', () => {
99
117
  let reports = 0;
100
118
  const { container, getByLabelText } = renderSidebar({
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
2
+ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
3
3
  import { useTheme } from '@principal-ade/industry-theme';
4
4
 
5
5
  /**
@@ -55,6 +55,8 @@ export interface GraphSidebarProps {
55
55
  /** Give the header the flexible share of the column, pinning the lower
56
56
  * panel at half height (e.g. a description that unpacks on demand). */
57
57
  stretchHeader?: boolean;
58
+ /** Cap the upper block while letting short content use only the height it needs. */
59
+ headerMaxHeight?: CSSProperties['maxHeight'];
58
60
  /** Controlled sidebar width (px). */
59
61
  width?: number;
60
62
  onWidthChange?: (width: number) => void;
@@ -100,6 +102,7 @@ export function GraphSidebar({
100
102
  hidden = false,
101
103
  bodyHidden = false,
102
104
  stretchHeader = false,
105
+ headerMaxHeight,
103
106
  width,
104
107
  onWidthChange,
105
108
  defaultWidth = DEFAULT_WIDTH,
@@ -207,6 +210,8 @@ export function GraphSidebar({
207
210
  style={{
208
211
  flex: stretchHeader ? 1 : '0 0 auto',
209
212
  minHeight: 0,
213
+ maxHeight: headerMaxHeight,
214
+ boxSizing: 'border-box',
210
215
  overflowY: 'auto',
211
216
  padding: 16,
212
217
  display: 'flex',