@principal-ai/subsystems-react 0.36.2 → 0.37.1

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 (47) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreWalkthroughCodeView.d.ts +19 -1
  6. package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreWalkthroughCodeView.js +53 -2
  8. package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -1
  9. package/dist/subsystem/ComponentDeclaration.d.ts +13 -1
  10. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  11. package/dist/subsystem/ComponentDeclaration.js +58 -7
  12. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  13. package/dist/subsystem/ConstructsCatalog.d.ts +6 -3
  14. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  15. package/dist/subsystem/ConstructsCatalog.js +206 -217
  16. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -0
  18. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.js +108 -31
  20. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  21. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
  22. package/dist/subsystem/SymbolInspectionCard.js +1 -1
  23. package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
  24. package/dist/subsystem/model.d.ts.map +1 -1
  25. package/dist/subsystem/model.js +7 -1
  26. package/dist/subsystem/model.js.map +1 -1
  27. package/dist/utils/edgeLabel.d.ts +51 -0
  28. package/dist/utils/edgeLabel.d.ts.map +1 -0
  29. package/dist/utils/edgeLabel.js +81 -0
  30. package/dist/utils/edgeLabel.js.map +1 -0
  31. package/dist/utils/elkLayout.d.ts +6 -0
  32. package/dist/utils/elkLayout.d.ts.map +1 -1
  33. package/dist/utils/elkLayout.js +27 -6
  34. package/dist/utils/elkLayout.js.map +1 -1
  35. package/package.json +3 -2
  36. package/src/index.ts +7 -0
  37. package/src/pierre/PierreWalkthroughCodeView.tsx +75 -1
  38. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +222 -0
  39. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +443 -0
  40. package/src/subsystem/ComponentDeclaration.tsx +100 -7
  41. package/src/subsystem/ConstructsCatalog.tsx +291 -323
  42. package/src/subsystem/SubsystemComponentGraph.tsx +173 -30
  43. package/src/subsystem/SymbolInspectionCard.tsx +10 -9
  44. package/src/subsystem/model.ts +7 -1
  45. package/src/utils/edgeLabel.test.ts +23 -0
  46. package/src/utils/edgeLabel.ts +95 -0
  47. package/src/utils/elkLayout.ts +41 -6
@@ -3,15 +3,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  * ConstructsCatalog — master/detail for a constructs-only subsystem
4
4
  * (components, no topology or walkthrough edges).
5
5
  *
6
- * Left: the model's constructs as a toggle list. Right: signatures for
7
- * every construct currently on (`ComponentDeclaration`, stacked). File
8
- * opens still use the bottom FileDrawer when the host injects a viewer.
6
+ * Left: the model's files as a tree; clicking a file toggles its
7
+ * constructs. Right: signatures grouped by repo, and within a repo stacked
8
+ * per file under one combined file header carrying the path and a
9
+ * description toggle; a declaration with a known line labels just that
10
+ * construct's line in a file-like gutter. File opens still use the bottom
11
+ * FileDrawer when the host injects a viewer.
9
12
  */
10
13
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
14
  import { useTheme } from '@principal-ade/industry-theme';
12
15
  import { IndustryMarkdownSlide } from 'themed-markdown';
13
- import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react';
14
- import { constructBadgeColor, constructBadgeLabel, deriveNameFromSymbol, formatPurl, } from './model';
16
+ import { AlignLeft, ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react';
17
+ import { formatPurl, } from './model';
15
18
  import { ComponentDeclaration } from './ComponentDeclaration';
16
19
  import { SubsystemDiagnosticToggle } from './DiagnosticToggle';
17
20
  import { FileDrawer } from './FileDrawer';
@@ -81,6 +84,24 @@ function repoLogo(c) {
81
84
  return `https://github.com/${m[1]}.png?size=40`;
82
85
  return undefined;
83
86
  }
87
+ /**
88
+ * Split a repo's declarations into consecutive runs that share a `file`, so
89
+ * each run can render as one connected stack. Components with no `file` are
90
+ * never merged (they have no shared location to group under).
91
+ */
92
+ function splitByFileRun(items) {
93
+ const runs = [];
94
+ for (const c of items) {
95
+ const prev = runs[runs.length - 1];
96
+ const key = c.file || `alias:${c.alias}`;
97
+ const prevKey = prev && prev[0] ? prev[0].file || `alias:${prev[0].alias}` : null;
98
+ if (prev && prevKey === key)
99
+ prev.push(c);
100
+ else
101
+ runs.push([c]);
102
+ }
103
+ return runs;
104
+ }
84
105
  export function ConstructsCatalog({ components, onSelect, title, hideSidebar, description, diagnostic, sidebarExtra, sidebarAfterDescription, renderFileViewer, renderFileView, onFileSelect, componentVerification, onInspectSymbol, }) {
85
106
  const { theme, mode } = useTheme();
86
107
  const pierreTheme = resolvePierreSyntaxThemeName(mode);
@@ -104,27 +125,51 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
104
125
  })
105
126
  .map((x) => x.c);
106
127
  }, [components]);
107
- const [visibleAliases, setVisibleAliases] = useState(() => orderedComponents[0] ? [orderedComponents[0].alias] : []);
108
- const [focusedAlias, setFocusedAlias] = useState(() => orderedComponents[0]?.alias ?? null);
128
+ const [visibleAliases, setVisibleAliases] = useState([]);
129
+ const [focusedAlias, setFocusedAlias] = useState(null);
130
+ const [searchQuery, setSearchQuery] = useState('');
109
131
  const [descriptionVisible, setDescriptionVisible] = useState(false);
110
132
  const [descToggleHover, setDescToggleHover] = useState(false);
111
133
  const [hoveredRow, setHoveredRow] = useState(null);
112
- const [sidebarTab, setSidebarTab] = useState('files');
113
134
  const [collapsedFolders, setCollapsedFolders] = useState(new Set());
135
+ // Files whose shared-header description toggle is expanded (keyed by file run).
136
+ const [openPurposes, setOpenPurposes] = useState(new Set());
114
137
  const [drawer, setDrawer] = useState(null);
115
- const focusedAliasRef = useRef(focusedAlias);
116
- focusedAliasRef.current = focusedAlias;
117
138
  const visibleSet = useMemo(() => new Set(visibleAliases), [visibleAliases]);
118
139
  const visibleComponents = useMemo(() => orderedComponents.filter((c) => visibleSet.has(c.alias)), [orderedComponents, visibleSet]);
140
+ // When nothing is toggled on, the right pane falls back to a searchable list
141
+ // of every construct, rendered as if all were selected.
142
+ const searchActive = visibleComponents.length === 0;
143
+ const searchResults = useMemo(() => {
144
+ if (!searchActive)
145
+ return visibleComponents;
146
+ const q = searchQuery.trim().toLowerCase();
147
+ if (!q)
148
+ return orderedComponents;
149
+ return orderedComponents.filter((c) => {
150
+ const haystack = [
151
+ c.name,
152
+ c.symbol,
153
+ c.alias,
154
+ c.file,
155
+ c.construct,
156
+ c.stereotype,
157
+ c.purpose,
158
+ ]
159
+ .filter(Boolean)
160
+ .join(' ')
161
+ .toLowerCase();
162
+ return haystack.includes(q);
163
+ });
164
+ }, [searchActive, searchQuery, orderedComponents, visibleComponents]);
119
165
  const fileTree = useMemo(() => buildFileTree(orderedComponents), [orderedComponents]);
120
166
  const hasFiles = useMemo(() => orderedComponents.some((c) => !!c.file), [orderedComponents]);
121
- const effectiveTab = hasFiles ? sidebarTab : 'constructs';
122
- // Group visible declarations by purl so the repo/app identity (logo + name)
123
- // renders once per group instead of on every card.
167
+ // Group the declarations on screen by purl so the repo/app identity
168
+ // (logo + name) renders once per group instead of on every card.
124
169
  const repoGroups = useMemo(() => {
125
170
  const groups = [];
126
171
  const index = new Map();
127
- for (const c of visibleComponents) {
172
+ for (const c of searchResults) {
128
173
  const key = c.purl || c.alias;
129
174
  let at = index.get(key);
130
175
  if (at == null) {
@@ -137,12 +182,7 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
137
182
  groups[at].items.push(c);
138
183
  }
139
184
  return groups;
140
- }, [visibleComponents]);
141
- useEffect(() => {
142
- if (!focusedAlias)
143
- return;
144
- document.getElementById(`construct-${focusedAlias}`)?.scrollIntoView({ block: 'nearest' });
145
- }, [focusedAlias]);
185
+ }, [searchResults]);
146
186
  useEffect(() => {
147
187
  const last = visibleAliases[visibleAliases.length - 1];
148
188
  if (!last)
@@ -163,34 +203,17 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
163
203
  .getElementById(`construct-signature-${match.alias}`)
164
204
  ?.scrollIntoView({ block: 'nearest' });
165
205
  }, [orderedComponents, drawer]);
166
- // Drop aliases that left the model; if nothing remains, show the first construct.
206
+ // Drop aliases that left the model; an empty set is valid (search fallback).
167
207
  useEffect(() => {
168
208
  const aliases = new Set(orderedComponents.map((c) => c.alias));
169
- setVisibleAliases((prev) => {
170
- const next = prev.filter((alias) => aliases.has(alias));
171
- if (next.length > 0 || orderedComponents.length === 0)
172
- return next;
173
- return [orderedComponents[0].alias];
174
- });
175
- setFocusedAlias((prev) => {
176
- if (prev && aliases.has(prev))
177
- return prev;
178
- return orderedComponents[0]?.alias ?? null;
179
- });
209
+ setVisibleAliases((prev) => prev.filter((alias) => aliases.has(alias)));
210
+ setFocusedAlias((prev) => (prev && aliases.has(prev) ? prev : null));
180
211
  }, [orderedComponents]);
181
- const toggle = useCallback((alias) => {
182
- setFocusedAlias(alias);
183
- setVisibleAliases((prev) => prev.includes(alias) ? prev.filter((x) => x !== alias) : [...prev, alias]);
184
- onSelect?.(alias);
185
- }, [onSelect]);
186
212
  const show = useCallback((alias) => {
187
213
  setFocusedAlias(alias);
188
214
  setVisibleAliases((prev) => (prev.includes(alias) ? prev : [...prev, alias]));
189
215
  onSelect?.(alias);
190
216
  }, [onSelect]);
191
- const focusAt = useCallback((alias) => {
192
- setFocusedAlias(alias);
193
- }, []);
194
217
  const toggleFolder = useCallback((path) => {
195
218
  setCollapsedFolders((prev) => {
196
219
  const next = new Set(prev);
@@ -201,8 +224,18 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
201
224
  return next;
202
225
  });
203
226
  }, []);
227
+ const togglePurpose = useCallback((key) => {
228
+ setOpenPurposes((prev) => {
229
+ const next = new Set(prev);
230
+ if (next.has(key))
231
+ next.delete(key);
232
+ else
233
+ next.add(key);
234
+ return next;
235
+ });
236
+ }, []);
204
237
  /** File-tree click: show every construct declared in the file, or hide them
205
- * all when they're already shown — mirroring a construct row toggle. */
238
+ * all when they're already shown — a bulk toggle of the file's constructs. */
206
239
  const toggleFile = useCallback((comps) => {
207
240
  const aliases = comps.map((c) => c.alias);
208
241
  setVisibleAliases((prev) => {
@@ -307,8 +340,17 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
307
340
  const comp = resolveRelated(components, ref);
308
341
  if (!comp)
309
342
  return;
343
+ // In the all-constructs search fallback nothing needs revealing — just
344
+ // surface the target. Otherwise add it to the visible set.
345
+ if (searchActive) {
346
+ setFocusedAlias(comp.alias);
347
+ document
348
+ .getElementById(`construct-signature-${comp.alias}`)
349
+ ?.scrollIntoView({ block: 'nearest' });
350
+ return;
351
+ }
310
352
  show(comp.alias);
311
- }, [components, show]);
353
+ }, [components, show, searchActive]);
312
354
  const fileViewer = useMemo(() => {
313
355
  if (renderFileViewer)
314
356
  return renderFileViewer;
@@ -324,57 +366,14 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
324
366
  const fileViewerRef = useRef(fileViewer);
325
367
  fileViewerRef.current = fileViewer;
326
368
  const renderDrawerContent = useCallback((file, opts) => fileViewerRef.current?.(file, opts) ?? null, []);
327
- const moveFocus = useCallback((delta) => {
328
- if (orderedComponents.length === 0)
329
- return;
330
- const idx = Math.max(0, orderedComponents.findIndex((c) => c.alias === focusedAliasRef.current));
331
- const next = orderedComponents[(idx + delta + orderedComponents.length) % orderedComponents.length];
332
- focusAt(next.alias);
333
- }, [orderedComponents, focusAt]);
334
369
  useEffect(() => {
335
370
  const onKey = (e) => {
336
- const el = e.target;
337
- if (el &&
338
- (el.tagName === 'INPUT' ||
339
- el.tagName === 'TEXTAREA' ||
340
- el.tagName === 'SELECT' ||
341
- el.isContentEditable)) {
342
- return;
343
- }
344
- if (e.metaKey || e.ctrlKey || e.altKey)
345
- return;
346
- if (e.key === 'ArrowDown') {
347
- e.preventDefault();
348
- moveFocus(1);
349
- }
350
- else if (e.key === 'ArrowUp') {
351
- e.preventDefault();
352
- moveFocus(-1);
353
- }
354
- else if (e.key === 'Home') {
355
- e.preventDefault();
356
- if (orderedComponents[0])
357
- focusAt(orderedComponents[0].alias);
358
- }
359
- else if (e.key === 'End') {
360
- e.preventDefault();
361
- const last = orderedComponents[orderedComponents.length - 1];
362
- if (last)
363
- focusAt(last.alias);
364
- }
365
- else if (e.key === ' ' || e.key === 'Enter') {
366
- if (focusedAliasRef.current) {
367
- e.preventDefault();
368
- toggle(focusedAliasRef.current);
369
- }
370
- }
371
- else if (e.key === 'Escape' && drawer) {
371
+ if (e.key === 'Escape' && drawer)
372
372
  setDrawer(null);
373
- }
374
373
  };
375
374
  window.addEventListener('keydown', onKey);
376
375
  return () => window.removeEventListener('keydown', onKey);
377
- }, [orderedComponents, drawer, moveFocus, focusAt, toggle]);
376
+ }, [drawer]);
378
377
  const showChrome = !hideSidebar &&
379
378
  !!(title || description || diagnostic || sidebarExtra || sidebarAfterDescription);
380
379
  const showDesc = !!description && descriptionVisible;
@@ -428,125 +427,38 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
428
427
  background: descriptionVisible || descToggleHover ? theme.colors.border : 'transparent',
429
428
  color: descriptionVisible || descToggleHover ? theme.colors.text : muted,
430
429
  cursor: 'pointer',
431
- }, children: _jsx(FileText, { size: 14 }) }))] })] })), showDesc && (_jsx("div", { style: { fontSize: theme.fontSizes[0], lineHeight: 1.5 }, children: _jsx(IndustryMarkdownSlide, { content: description, slideIdPrefix: "constructs-desc", slideIndex: 0, isVisible: true, theme: theme, disableScroll: true, disableBasePadding: true, enableKeyboardScrolling: false, autoFocusOnVisible: false }) })), sidebarAfterDescription] })), _jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
432
- display: 'flex',
433
- width: '100%',
434
- flexShrink: 0,
435
- borderBottom: `1px solid ${theme.colors.border}`,
436
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
437
- }, children: ['files', 'constructs'].map((tab) => {
438
- if (tab === 'files' && !hasFiles)
439
- return null;
440
- const active = effectiveTab === tab;
441
- return (_jsx("button", { type: "button", role: "tab", "aria-selected": active, onClick: () => setSidebarTab(tab), style: {
442
- flex: 1,
443
- minWidth: 0,
444
- padding: '8px 8px',
445
- border: 'none',
446
- borderRadius: 0,
447
- background: active
448
- ? theme.colors.background
449
- : 'transparent',
450
- color: active ? theme.colors.text : muted,
451
- fontSize: theme.fontSizes[0],
452
- fontFamily: theme.fonts.monospace,
453
- letterSpacing: 0.4,
454
- textTransform: 'uppercase',
455
- cursor: 'pointer',
456
- }, children: tab === 'constructs' ? 'Constructs' : 'Files' }, tab));
457
- }) }), effectiveTab === 'constructs' ? (_jsx("ul", { role: "listbox", "aria-label": "Constructs", "aria-multiselectable": "true", "aria-activedescendant": focusedAlias ? `construct-${focusedAlias}` : undefined, style: {
458
- listStyle: 'none',
459
- margin: 0,
460
- padding: '0 8px 12px',
461
- overflowY: 'auto',
462
- flex: 1,
463
- minHeight: 0,
464
- }, children: orderedComponents.map((c) => {
465
- const visible = visibleSet.has(c.alias);
466
- const focused = c.alias === focusedAlias;
467
- const color = componentColor(c, pierreTheme);
468
- const badgeColor = constructBadgeColor(c) ?? color;
469
- const badgeLabel = constructBadgeLabel(c);
470
- const displayName = deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype);
471
- const rowOpen = drawer != null &&
472
- drawer.startLine != null &&
473
- !!c.file &&
474
- c.file === drawer.file &&
475
- c.declarationRef?.startLine === drawer.startLine;
476
- const hovered = hoveredRow === c.alias;
477
- return (_jsx("li", { role: "presentation", children: _jsxs("button", { type: "button", id: `construct-${c.alias}`, role: "option", "aria-selected": visible, "data-testid": `construct-row-${c.alias}`, onClick: () => toggle(c.alias), onMouseEnter: () => setHoveredRow(c.alias), onMouseLeave: () => setHoveredRow((h) => (h === c.alias ? null : h)), style: {
478
- display: 'flex',
479
- flexDirection: 'column',
480
- alignItems: 'stretch',
481
- gap: 2,
482
- width: '100%',
483
- margin: '2px 0',
484
- padding: '8px 10px 8px 12px',
485
- textAlign: 'left',
486
- border: 'none',
487
- borderRadius: 6,
488
- background: hovered
489
- ? theme.colors.border
490
- : visible
491
- ? theme.colors.background
492
- : 'transparent',
493
- cursor: 'pointer',
494
- transition: 'background 100ms ease, opacity 100ms ease',
495
- boxShadow: [
496
- visible ? `inset 0 0 0 1px ${theme.colors.border}` : null,
497
- rowOpen ? `inset 2px 0 0 ${accentColor}` : null,
498
- ]
499
- .filter(Boolean)
500
- .join(', ') || undefined,
501
- outline: focused && !visible ? `1px dotted ${muted}` : undefined,
502
- outlineOffset: -1,
503
- opacity: visible ? 1 : hovered ? 1 : 0.72,
504
- }, children: [_jsx("span", { style: {
505
- display: 'flex',
506
- alignItems: 'baseline',
507
- gap: 8,
508
- minWidth: 0,
509
- }, children: _jsx("span", { style: {
510
- flex: 1,
511
- minWidth: 0,
512
- overflow: 'hidden',
513
- textOverflow: 'ellipsis',
514
- whiteSpace: 'nowrap',
515
- fontFamily: theme.fonts.monospace,
516
- fontSize: theme.fontSizes[1],
517
- color,
518
- fontWeight: visible ? 600 : 500,
519
- }, children: displayName }) }), badgeLabel && (_jsx("span", { style: {
520
- display: 'flex',
521
- alignItems: 'center',
522
- minWidth: 0,
523
- }, children: _jsx("span", { style: {
524
- flex: 1,
525
- minWidth: 0,
526
- overflow: 'hidden',
527
- textOverflow: 'ellipsis',
528
- whiteSpace: 'nowrap',
529
- fontFamily: theme.fonts.monospace,
530
- fontSize: theme.fontSizes[0],
531
- letterSpacing: 0.4,
532
- textTransform: 'uppercase',
533
- color: badgeColor,
534
- }, children: badgeLabel }) }))] }) }, c.alias));
535
- }) })) : (_jsx("ul", { role: "tree", "aria-label": "Files", style: {
430
+ }, children: _jsx(FileText, { size: 14 }) }))] })] })), showDesc && (_jsx("div", { style: { fontSize: theme.fontSizes[0], lineHeight: 1.5 }, children: _jsx(IndustryMarkdownSlide, { content: description, slideIdPrefix: "constructs-desc", slideIndex: 0, isVisible: true, theme: theme, disableScroll: true, disableBasePadding: true, enableKeyboardScrolling: false, autoFocusOnVisible: false }) })), sidebarAfterDescription] })), hasFiles ? (_jsx("ul", { role: "tree", "aria-label": "Files", style: {
536
431
  listStyle: 'none',
537
432
  margin: 0,
538
433
  padding: '6px 8px 12px',
539
434
  overflowY: 'auto',
540
435
  flex: 1,
541
436
  minHeight: 0,
542
- }, children: renderTree(fileTree, 0) }))] }), _jsxs("div", { style: {
437
+ }, children: renderTree(fileTree, 0) })) : (_jsx("p", { style: {
438
+ margin: 0,
439
+ padding: '12px 16px',
440
+ color: muted,
441
+ fontFamily: theme.fonts.body,
442
+ fontSize: theme.fontSizes[0],
443
+ }, children: "No files in this model." }))] }), _jsxs("div", { style: {
543
444
  position: 'relative',
544
445
  flex: 1,
545
446
  minWidth: 0,
546
447
  minHeight: 0,
547
448
  display: 'flex',
548
449
  flexDirection: 'column',
549
- }, children: [_jsx("div", { "data-testid": "construct-signature", style: {
450
+ }, children: [searchActive && (_jsx("div", { style: { flexShrink: 0, padding: '12px 16px 0' }, children: _jsx("input", { type: "search", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: "Search constructs\u2026", "aria-label": "Search constructs", "data-testid": "construct-search", style: {
451
+ width: '100%',
452
+ boxSizing: 'border-box',
453
+ padding: '8px 10px',
454
+ border: `1px solid ${theme.colors.border}`,
455
+ borderRadius: 6,
456
+ background: theme.colors.background,
457
+ color: theme.colors.text,
458
+ fontFamily: theme.fonts.monospace,
459
+ fontSize: theme.fontSizes[1],
460
+ outline: 'none',
461
+ } }) })), _jsx("div", { "data-testid": "construct-signature", style: {
550
462
  flex: 1,
551
463
  minHeight: 0,
552
464
  overflow: 'auto',
@@ -554,7 +466,7 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
554
466
  display: 'flex',
555
467
  flexDirection: 'column',
556
468
  gap: 12,
557
- }, children: visibleComponents.length > 0 ? (repoGroups.map((group) => (_jsxs("div", { style: {
469
+ }, children: searchResults.length > 0 ? (repoGroups.map((group) => (_jsxs("div", { style: {
558
470
  display: 'flex',
559
471
  flexDirection: 'column',
560
472
  gap: 12,
@@ -565,35 +477,112 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
565
477
  color: theme.colors.textSecondary ?? muted,
566
478
  fontFamily: theme.fonts.body,
567
479
  fontSize: theme.fontSizes[1],
568
- }, children: group.label })] }), group.items.map((c) => {
569
- // File-level: every declaration sharing the open file shows the
570
- // indicator. Line-level: only the declaration actually being
571
- // highlighted gets the accent border.
572
- const fileOpen = drawer != null && !!c.file && c.file === drawer.file;
573
- const lineOpen = fileOpen &&
574
- drawer.startLine != null &&
575
- c.declarationRef?.startLine === drawer.startLine;
576
- return (_jsx("div", { id: `construct-signature-${c.alias}`, style: {
577
- border: `1px solid ${lineOpen ? accentColor : theme.colors.border}`,
578
- boxShadow: lineOpen ? `0 0 0 1px ${accentColor}` : undefined,
480
+ }, children: group.label })] }), splitByFileRun(group.items).map((run) => {
481
+ const lead = run[0];
482
+ const hasFile = !!lead.file;
483
+ // Every file-backed run gets the combined header, one
484
+ // declaration or many, so the style stays consistent.
485
+ const showHeader = hasFile;
486
+ const runKey = `${group.key}:${lead.file || lead.alias}`;
487
+ const runHasPurpose = run.some((c) => !!c.purpose?.trim());
488
+ const purposeOpen = openPurposes.has(runKey);
489
+ const runFileOpen = drawer != null && hasFile && lead.file === drawer.file;
490
+ return (_jsxs("div", { style: {
491
+ display: 'flex',
492
+ flexDirection: 'column',
493
+ border: `1px solid ${runFileOpen ? accentColor : theme.colors.border}`,
579
494
  borderRadius: 8,
580
495
  overflow: 'hidden',
581
496
  flexShrink: 0,
582
- }, children: _jsx(ComponentDeclaration, { component: c, onOpenFile: fileViewer ? onOpenFile : undefined, defaultShowFile: !!fileViewer, onRelatedSelect: onRelatedSelect, onInspectSymbol: onInspectSymbol
583
- ? (symbol, ref) => onInspectSymbol({
584
- purl: c.purl,
585
- file: c.file,
586
- symbol,
587
- ref,
588
- })
589
- : undefined, fileOpen: fileOpen, declarationOpen: lineOpen, showRepoIdentity: false, fileBadgeChrome: !!fileViewer, verification: componentVerification && focusedAlias === c.alias
590
- ? componentVerification
591
- : undefined }) }, c.alias));
497
+ }, children: [showHeader && (_jsxs("div", { style: {
498
+ display: 'flex',
499
+ alignItems: 'center',
500
+ gap: 6,
501
+ width: '100%',
502
+ boxSizing: 'border-box',
503
+ padding: '6px 10px',
504
+ borderBottom: `1px solid ${runFileOpen ? accentColor : theme.colors.border}`,
505
+ background: runFileOpen
506
+ ? `${accentColor}14`
507
+ : theme.colors.backgroundSecondary ??
508
+ theme.colors.background,
509
+ color: runFileOpen
510
+ ? accentColor
511
+ : theme.colors.textSecondary ?? muted,
512
+ fontFamily: theme.fonts.monospace,
513
+ fontSize: theme.fontSizes[0],
514
+ }, children: [_jsxs("button", { type: "button", title: fileViewer ? `Open ${lead.file}` : lead.file, onClick: fileViewer
515
+ ? () => onOpenFile(lead.file)
516
+ : undefined, style: {
517
+ display: 'flex',
518
+ alignItems: 'center',
519
+ gap: 6,
520
+ flex: 1,
521
+ minWidth: 0,
522
+ padding: 0,
523
+ border: 'none',
524
+ background: 'transparent',
525
+ color: 'inherit',
526
+ fontFamily: 'inherit',
527
+ fontSize: 'inherit',
528
+ textAlign: 'left',
529
+ cursor: fileViewer ? 'pointer' : 'default',
530
+ }, children: [_jsx(FileText, { size: 12, style: { flexShrink: 0 } }), _jsx("span", { style: {
531
+ overflow: 'hidden',
532
+ textOverflow: 'ellipsis',
533
+ whiteSpace: 'nowrap',
534
+ }, children: lead.file })] }), runHasPurpose && (_jsx("button", { type: "button", title: purposeOpen ? 'Hide description' : 'Show description', "aria-label": purposeOpen ? 'Hide description' : 'Show description', "aria-expanded": purposeOpen, onClick: () => togglePurpose(runKey), style: {
535
+ display: 'inline-flex',
536
+ alignItems: 'center',
537
+ justifyContent: 'center',
538
+ flexShrink: 0,
539
+ width: 20,
540
+ height: 20,
541
+ padding: 0,
542
+ border: 'none',
543
+ borderRadius: 4,
544
+ background: 'transparent',
545
+ color: purposeOpen
546
+ ? accentColor
547
+ : theme.colors.textSecondary ?? muted,
548
+ cursor: 'pointer',
549
+ }, children: _jsx(AlignLeft, { size: 13 }) }))] })), run.map((c, i) => {
550
+ // File-level: every declaration sharing the open file
551
+ // shows the indicator. Line-level: only the declaration
552
+ // actually highlighted gets the inner accent ring.
553
+ const fileOpen = drawer != null && !!c.file && c.file === drawer.file;
554
+ const lineOpen = fileOpen &&
555
+ drawer.startLine != null &&
556
+ c.declarationRef?.startLine === drawer.startLine;
557
+ // A combined header owns the path, so declarations under
558
+ // it drop their own badge; those with no known line just
559
+ // render unnumbered. Without a header, keep the badge.
560
+ const numbered = hasFile && c.declarationRef?.startLine != null;
561
+ return (_jsx("div", { id: `construct-signature-${c.alias}`, style: {
562
+ borderTop: i > 0
563
+ ? `1px solid ${theme.colors.border}`
564
+ : undefined,
565
+ boxShadow: lineOpen
566
+ ? `inset 0 0 0 1px ${accentColor}`
567
+ : undefined,
568
+ }, children: _jsx(ComponentDeclaration, { component: c, onOpenFile: fileViewer ? onOpenFile : undefined, defaultShowFile: showHeader ? false : !!fileViewer, onRelatedSelect: onRelatedSelect, onInspectSymbol: onInspectSymbol
569
+ ? (symbol, ref) => onInspectSymbol({
570
+ purl: c.purl,
571
+ file: c.file,
572
+ symbol,
573
+ ref,
574
+ })
575
+ : undefined, fileOpen: fileOpen, declarationOpen: lineOpen, showRepoIdentity: false, fileBadgeChrome: showHeader ? false : !!fileViewer, hideActions: showHeader, showPurpose: showHeader ? purposeOpen : undefined, lineNumbers: numbered, verification: componentVerification && focusedAlias === c.alias
576
+ ? componentVerification
577
+ : undefined }) }, c.alias));
578
+ })] }, runKey));
592
579
  })] }, group.key)))) : (_jsx("p", { style: {
593
580
  margin: 0,
594
581
  color: muted,
595
582
  fontFamily: theme.fonts.body,
596
583
  fontSize: theme.fontSizes[1],
597
- }, children: "Toggle constructs on the left to show their signatures." })) }), _jsx(FileDrawer, { title: drawer?.file ?? null, onClose: () => setDrawer(null), fillHeight: drawer != null && drawer.startLine == null, children: drawer ? (_jsx(FileDrawerContent, { render: renderDrawerContent, file: drawer.file, startLine: drawer.startLine })) : null })] })] }));
584
+ }, children: searchQuery.trim()
585
+ ? `No constructs match “${searchQuery.trim()}”.`
586
+ : 'No constructs in this model.' })) }), _jsx(FileDrawer, { title: drawer?.file ?? null, onClose: () => setDrawer(null), fillHeight: drawer != null && drawer.startLine == null, children: drawer ? (_jsx(FileDrawerContent, { render: renderDrawerContent, file: drawer.file, startLine: drawer.startLine })) : null })] })] }));
598
587
  }
599
588
  //# sourceMappingURL=ConstructsCatalog.js.map