@principal-ai/subsystems-react 0.37.0 → 0.37.2

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 (40) 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/subsystem/ComponentDeclaration.d.ts +16 -1
  6. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  7. package/dist/subsystem/ComponentDeclaration.js +75 -7
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/ConstructsCatalog.d.ts +13 -3
  10. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  11. package/dist/subsystem/ConstructsCatalog.js +273 -220
  12. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.js +36 -28
  15. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  16. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
  17. package/dist/subsystem/SymbolInspectionCard.js +1 -1
  18. package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
  19. package/dist/subsystem/model.d.ts.map +1 -1
  20. package/dist/subsystem/model.js +7 -1
  21. package/dist/subsystem/model.js.map +1 -1
  22. package/dist/utils/edgeLabel.d.ts +51 -0
  23. package/dist/utils/edgeLabel.d.ts.map +1 -0
  24. package/dist/utils/edgeLabel.js +81 -0
  25. package/dist/utils/edgeLabel.js.map +1 -0
  26. package/dist/utils/elkLayout.d.ts +6 -0
  27. package/dist/utils/elkLayout.d.ts.map +1 -1
  28. package/dist/utils/elkLayout.js +27 -6
  29. package/dist/utils/elkLayout.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/index.ts +7 -0
  32. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +457 -0
  33. package/src/subsystem/ComponentDeclaration.tsx +130 -7
  34. package/src/subsystem/ConstructsCatalog.tsx +431 -341
  35. package/src/subsystem/SubsystemComponentGraph.tsx +74 -29
  36. package/src/subsystem/SymbolInspectionCard.tsx +10 -9
  37. package/src/subsystem/model.ts +7 -1
  38. package/src/utils/edgeLabel.test.ts +23 -0
  39. package/src/utils/edgeLabel.ts +95 -0
  40. package/src/utils/elkLayout.ts +41 -6
@@ -3,15 +3,21 @@ 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, and `external` constructs get a
11
+ * header naming their kind with the body naming the construct. The model
12
+ * description's toggle sits in the left chrome and opens the markdown as an
13
+ * overlay over the construct area. File opens still use the bottom FileDrawer
14
+ * when the host injects a viewer.
9
15
  */
10
16
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
17
  import { useTheme } from '@principal-ade/industry-theme';
12
18
  import { IndustryMarkdownSlide } from 'themed-markdown';
13
- import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react';
14
- import { constructBadgeColor, constructBadgeLabel, deriveNameFromSymbol, formatPurl, } from './model';
19
+ import { AlignLeft, Box, ChevronDown, ChevronRight, FileText, Folder, X, } from 'lucide-react';
20
+ import { formatPurl, } from './model';
15
21
  import { ComponentDeclaration } from './ComponentDeclaration';
16
22
  import { SubsystemDiagnosticToggle } from './DiagnosticToggle';
17
23
  import { FileDrawer } from './FileDrawer';
@@ -81,6 +87,24 @@ function repoLogo(c) {
81
87
  return `https://github.com/${m[1]}.png?size=40`;
82
88
  return undefined;
83
89
  }
90
+ /**
91
+ * Split a repo's declarations into consecutive runs that share a `file`, so
92
+ * each run can render as one connected stack. Components with no `file` are
93
+ * never merged (they have no shared location to group under).
94
+ */
95
+ function splitByFileRun(items) {
96
+ const runs = [];
97
+ for (const c of items) {
98
+ const prev = runs[runs.length - 1];
99
+ const key = c.file || `alias:${c.alias}`;
100
+ const prevKey = prev && prev[0] ? prev[0].file || `alias:${prev[0].alias}` : null;
101
+ if (prev && prevKey === key)
102
+ prev.push(c);
103
+ else
104
+ runs.push([c]);
105
+ }
106
+ return runs;
107
+ }
84
108
  export function ConstructsCatalog({ components, onSelect, title, hideSidebar, description, diagnostic, sidebarExtra, sidebarAfterDescription, renderFileViewer, renderFileView, onFileSelect, componentVerification, onInspectSymbol, }) {
85
109
  const { theme, mode } = useTheme();
86
110
  const pierreTheme = resolvePierreSyntaxThemeName(mode);
@@ -104,27 +128,52 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
104
128
  })
105
129
  .map((x) => x.c);
106
130
  }, [components]);
107
- const [visibleAliases, setVisibleAliases] = useState(() => orderedComponents[0] ? [orderedComponents[0].alias] : []);
108
- const [focusedAlias, setFocusedAlias] = useState(() => orderedComponents[0]?.alias ?? null);
131
+ const [visibleAliases, setVisibleAliases] = useState([]);
132
+ const [focusedAlias, setFocusedAlias] = useState(null);
133
+ const [searchQuery, setSearchQuery] = useState('');
109
134
  const [descriptionVisible, setDescriptionVisible] = useState(false);
110
135
  const [descToggleHover, setDescToggleHover] = useState(false);
111
136
  const [hoveredRow, setHoveredRow] = useState(null);
112
- const [sidebarTab, setSidebarTab] = useState('files');
113
137
  const [collapsedFolders, setCollapsedFolders] = useState(new Set());
138
+ // Explicit description toggles per file run. Unset falls back to the kind's
139
+ // default (externals open, files closed).
140
+ const [purposeOverrides, setPurposeOverrides] = useState({});
114
141
  const [drawer, setDrawer] = useState(null);
115
- const focusedAliasRef = useRef(focusedAlias);
116
- focusedAliasRef.current = focusedAlias;
117
142
  const visibleSet = useMemo(() => new Set(visibleAliases), [visibleAliases]);
118
143
  const visibleComponents = useMemo(() => orderedComponents.filter((c) => visibleSet.has(c.alias)), [orderedComponents, visibleSet]);
144
+ // When nothing is toggled on, the right pane falls back to a searchable list
145
+ // of every construct, rendered as if all were selected.
146
+ const searchActive = visibleComponents.length === 0;
147
+ const searchResults = useMemo(() => {
148
+ if (!searchActive)
149
+ return visibleComponents;
150
+ const q = searchQuery.trim().toLowerCase();
151
+ if (!q)
152
+ return orderedComponents;
153
+ return orderedComponents.filter((c) => {
154
+ const haystack = [
155
+ c.name,
156
+ c.symbol,
157
+ c.alias,
158
+ c.file,
159
+ c.construct,
160
+ c.stereotype,
161
+ c.purpose,
162
+ ]
163
+ .filter(Boolean)
164
+ .join(' ')
165
+ .toLowerCase();
166
+ return haystack.includes(q);
167
+ });
168
+ }, [searchActive, searchQuery, orderedComponents, visibleComponents]);
119
169
  const fileTree = useMemo(() => buildFileTree(orderedComponents), [orderedComponents]);
120
170
  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.
171
+ // Group the declarations on screen by purl so the repo/app identity
172
+ // (logo + name) renders once per group instead of on every card.
124
173
  const repoGroups = useMemo(() => {
125
174
  const groups = [];
126
175
  const index = new Map();
127
- for (const c of visibleComponents) {
176
+ for (const c of searchResults) {
128
177
  const key = c.purl || c.alias;
129
178
  let at = index.get(key);
130
179
  if (at == null) {
@@ -137,12 +186,7 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
137
186
  groups[at].items.push(c);
138
187
  }
139
188
  return groups;
140
- }, [visibleComponents]);
141
- useEffect(() => {
142
- if (!focusedAlias)
143
- return;
144
- document.getElementById(`construct-${focusedAlias}`)?.scrollIntoView({ block: 'nearest' });
145
- }, [focusedAlias]);
189
+ }, [searchResults]);
146
190
  useEffect(() => {
147
191
  const last = visibleAliases[visibleAliases.length - 1];
148
192
  if (!last)
@@ -163,34 +207,17 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
163
207
  .getElementById(`construct-signature-${match.alias}`)
164
208
  ?.scrollIntoView({ block: 'nearest' });
165
209
  }, [orderedComponents, drawer]);
166
- // Drop aliases that left the model; if nothing remains, show the first construct.
210
+ // Drop aliases that left the model; an empty set is valid (search fallback).
167
211
  useEffect(() => {
168
212
  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
- });
213
+ setVisibleAliases((prev) => prev.filter((alias) => aliases.has(alias)));
214
+ setFocusedAlias((prev) => (prev && aliases.has(prev) ? prev : null));
180
215
  }, [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
216
  const show = useCallback((alias) => {
187
217
  setFocusedAlias(alias);
188
218
  setVisibleAliases((prev) => (prev.includes(alias) ? prev : [...prev, alias]));
189
219
  onSelect?.(alias);
190
220
  }, [onSelect]);
191
- const focusAt = useCallback((alias) => {
192
- setFocusedAlias(alias);
193
- }, []);
194
221
  const toggleFolder = useCallback((path) => {
195
222
  setCollapsedFolders((prev) => {
196
223
  const next = new Set(prev);
@@ -201,8 +228,11 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
201
228
  return next;
202
229
  });
203
230
  }, []);
231
+ const setPurposeOpen = useCallback((key, open) => {
232
+ setPurposeOverrides((prev) => ({ ...prev, [key]: open }));
233
+ }, []);
204
234
  /** 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. */
235
+ * all when they're already shown — a bulk toggle of the file's constructs. */
206
236
  const toggleFile = useCallback((comps) => {
207
237
  const aliases = comps.map((c) => c.alias);
208
238
  setVisibleAliases((prev) => {
@@ -307,8 +337,17 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
307
337
  const comp = resolveRelated(components, ref);
308
338
  if (!comp)
309
339
  return;
340
+ // In the all-constructs search fallback nothing needs revealing — just
341
+ // surface the target. Otherwise add it to the visible set.
342
+ if (searchActive) {
343
+ setFocusedAlias(comp.alias);
344
+ document
345
+ .getElementById(`construct-signature-${comp.alias}`)
346
+ ?.scrollIntoView({ block: 'nearest' });
347
+ return;
348
+ }
310
349
  show(comp.alias);
311
- }, [components, show]);
350
+ }, [components, show, searchActive]);
312
351
  const fileViewer = useMemo(() => {
313
352
  if (renderFileViewer)
314
353
  return renderFileViewer;
@@ -324,60 +363,20 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
324
363
  const fileViewerRef = useRef(fileViewer);
325
364
  fileViewerRef.current = fileViewer;
326
365
  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
366
  useEffect(() => {
335
367
  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) {
368
+ if (e.key === 'Escape' && drawer)
372
369
  setDrawer(null);
373
- }
374
370
  };
375
371
  window.addEventListener('keydown', onKey);
376
372
  return () => window.removeEventListener('keydown', onKey);
377
- }, [orderedComponents, drawer, moveFocus, focusAt, toggle]);
373
+ }, [drawer]);
378
374
  const showChrome = !hideSidebar &&
379
- !!(title || description || diagnostic || sidebarExtra || sidebarAfterDescription);
380
- const showDesc = !!description && descriptionVisible;
375
+ !!(title ||
376
+ description ||
377
+ diagnostic ||
378
+ sidebarExtra ||
379
+ sidebarAfterDescription);
381
380
  return (_jsxs("div", { "data-testid": "constructs-catalog", style: {
382
381
  width: '100%',
383
382
  height: '100%',
@@ -396,7 +395,6 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
396
395
  flexDirection: 'column',
397
396
  overflow: 'hidden',
398
397
  }, children: [showChrome && (_jsxs("div", { style: {
399
- flex: showDesc ? '0 0 auto' : undefined,
400
398
  padding: '16px 16px 8px',
401
399
  display: 'flex',
402
400
  flexDirection: 'column',
@@ -428,125 +426,88 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
428
426
  background: descriptionVisible || descToggleHover ? theme.colors.border : 'transparent',
429
427
  color: descriptionVisible || descToggleHover ? theme.colors.text : muted,
430
428
  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: {
429
+ }, children: _jsx(FileText, { size: 14 }) }))] })] })), sidebarAfterDescription] })), hasFiles ? (_jsx("ul", { role: "tree", "aria-label": "Files", style: {
536
430
  listStyle: 'none',
537
431
  margin: 0,
538
432
  padding: '6px 8px 12px',
539
433
  overflowY: 'auto',
540
434
  flex: 1,
541
435
  minHeight: 0,
542
- }, children: renderTree(fileTree, 0) }))] }), _jsxs("div", { style: {
436
+ }, children: renderTree(fileTree, 0) })) : (_jsx("p", { style: {
437
+ margin: 0,
438
+ padding: '12px 16px',
439
+ color: muted,
440
+ fontFamily: theme.fonts.body,
441
+ fontSize: theme.fontSizes[0],
442
+ }, children: "No files in this model." }))] }), _jsxs("div", { style: {
543
443
  position: 'relative',
544
444
  flex: 1,
545
445
  minWidth: 0,
546
446
  minHeight: 0,
547
447
  display: 'flex',
548
448
  flexDirection: 'column',
549
- }, children: [_jsx("div", { "data-testid": "construct-signature", style: {
449
+ }, children: [description && descriptionVisible && (_jsxs(_Fragment, { children: [_jsx("div", { onClick: () => setDescriptionVisible(false), style: {
450
+ position: 'absolute',
451
+ inset: 0,
452
+ zIndex: 2,
453
+ background: 'rgba(0,0,0,0.45)',
454
+ } }), _jsxs("div", { style: {
455
+ position: 'absolute',
456
+ top: '50%',
457
+ left: '50%',
458
+ transform: 'translate(-50%, -50%)',
459
+ zIndex: 3,
460
+ width: 360,
461
+ maxWidth: 'calc(100% - 16px)',
462
+ maxHeight: '70%',
463
+ overflow: 'auto',
464
+ padding: 16,
465
+ border: `1px solid ${theme.colors.border}`,
466
+ borderRadius: 8,
467
+ background: theme.colors.background,
468
+ boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
469
+ fontSize: theme.fontSizes[0],
470
+ lineHeight: 1.5,
471
+ }, children: [_jsxs("div", { style: {
472
+ display: 'flex',
473
+ alignItems: 'center',
474
+ gap: 8,
475
+ marginBottom: 12,
476
+ }, children: [_jsx("h3", { style: {
477
+ margin: 0,
478
+ flex: 1,
479
+ minWidth: 0,
480
+ fontSize: theme.fontSizes[1],
481
+ fontWeight: 600,
482
+ fontFamily: theme.fonts.monospace,
483
+ letterSpacing: 0.4,
484
+ textTransform: 'uppercase',
485
+ color: theme.colors.textSecondary ?? muted,
486
+ }, children: "Overview" }), _jsx("button", { type: "button", "aria-label": "Close description", title: "Close", onClick: () => setDescriptionVisible(false), style: {
487
+ display: 'inline-flex',
488
+ alignItems: 'center',
489
+ justifyContent: 'center',
490
+ flexShrink: 0,
491
+ width: 22,
492
+ height: 22,
493
+ padding: 0,
494
+ border: 'none',
495
+ borderRadius: 4,
496
+ background: 'transparent',
497
+ color: muted,
498
+ cursor: 'pointer',
499
+ }, children: _jsx(X, { size: 14 }) })] }), _jsx(IndustryMarkdownSlide, { content: description, slideIdPrefix: "constructs-desc-overlay", slideIndex: 0, isVisible: true, theme: theme, disableScroll: true, disableBasePadding: true, enableKeyboardScrolling: false, autoFocusOnVisible: false })] })] })), 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: {
500
+ width: '100%',
501
+ boxSizing: 'border-box',
502
+ padding: '8px 10px',
503
+ border: `1px solid ${theme.colors.border}`,
504
+ borderRadius: 6,
505
+ background: theme.colors.background,
506
+ color: theme.colors.text,
507
+ fontFamily: theme.fonts.monospace,
508
+ fontSize: theme.fontSizes[1],
509
+ outline: 'none',
510
+ } }) })), _jsx("div", { "data-testid": "construct-signature", style: {
550
511
  flex: 1,
551
512
  minHeight: 0,
552
513
  overflow: 'auto',
@@ -554,7 +515,7 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
554
515
  display: 'flex',
555
516
  flexDirection: 'column',
556
517
  gap: 12,
557
- }, children: visibleComponents.length > 0 ? (repoGroups.map((group) => (_jsxs("div", { style: {
518
+ }, children: searchResults.length > 0 ? (repoGroups.map((group) => (_jsxs("div", { style: {
558
519
  display: 'flex',
559
520
  flexDirection: 'column',
560
521
  gap: 12,
@@ -565,35 +526,127 @@ export function ConstructsCatalog({ components, onSelect, title, hideSidebar, de
565
526
  color: theme.colors.textSecondary ?? muted,
566
527
  fontFamily: theme.fonts.body,
567
528
  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,
529
+ }, children: group.label })] }), splitByFileRun(group.items).map((run) => {
530
+ const lead = run[0];
531
+ const hasFile = !!lead.file;
532
+ const isExternal = lead.construct === 'external';
533
+ // File-backed runs and externals both get a header; externals
534
+ // name their kind instead of a path, so the body doesn't have
535
+ // to spell it out.
536
+ const showHeader = hasFile || isExternal;
537
+ const runKey = `${group.key}:${lead.file || lead.alias}`;
538
+ const runHasPurpose = run.some((c) => !!c.purpose?.trim());
539
+ const purposeOpen = purposeOverrides[runKey] ?? isExternal;
540
+ const runFileOpen = drawer != null && hasFile && lead.file === drawer.file;
541
+ return (_jsxs("div", { style: {
542
+ display: 'flex',
543
+ flexDirection: 'column',
544
+ border: `1px solid ${runFileOpen ? accentColor : theme.colors.border}`,
579
545
  borderRadius: 8,
580
546
  overflow: 'hidden',
581
547
  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));
548
+ }, children: [showHeader && (_jsxs("div", { style: {
549
+ display: 'flex',
550
+ alignItems: 'center',
551
+ gap: 6,
552
+ width: '100%',
553
+ boxSizing: 'border-box',
554
+ padding: '6px 10px',
555
+ borderBottom: `1px solid ${runFileOpen ? accentColor : theme.colors.border}`,
556
+ background: runFileOpen
557
+ ? `${accentColor}14`
558
+ : theme.colors.backgroundSecondary ??
559
+ theme.colors.background,
560
+ color: runFileOpen
561
+ ? accentColor
562
+ : theme.colors.textSecondary ?? muted,
563
+ fontFamily: theme.fonts.monospace,
564
+ fontSize: theme.fontSizes[0],
565
+ }, children: [isExternal ? (_jsxs("span", { style: {
566
+ display: 'flex',
567
+ alignItems: 'center',
568
+ gap: 6,
569
+ flex: 1,
570
+ minWidth: 0,
571
+ }, children: [_jsx(Box, { size: 12, style: { flexShrink: 0 } }), _jsx("span", { style: {
572
+ overflow: 'hidden',
573
+ textOverflow: 'ellipsis',
574
+ whiteSpace: 'nowrap',
575
+ }, children: "external" })] })) : (_jsxs("button", { type: "button", title: fileViewer ? `Open ${lead.file}` : lead.file, onClick: fileViewer
576
+ ? () => onOpenFile(lead.file)
577
+ : undefined, style: {
578
+ display: 'flex',
579
+ alignItems: 'center',
580
+ gap: 6,
581
+ flex: 1,
582
+ minWidth: 0,
583
+ padding: 0,
584
+ border: 'none',
585
+ background: 'transparent',
586
+ color: 'inherit',
587
+ fontFamily: 'inherit',
588
+ fontSize: 'inherit',
589
+ textAlign: 'left',
590
+ cursor: fileViewer ? 'pointer' : 'default',
591
+ }, children: [_jsx(FileText, { size: 12, style: { flexShrink: 0 } }), _jsx("span", { style: {
592
+ overflow: 'hidden',
593
+ textOverflow: 'ellipsis',
594
+ whiteSpace: 'nowrap',
595
+ }, 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: () => setPurposeOpen(runKey, !purposeOpen), style: {
596
+ display: 'inline-flex',
597
+ alignItems: 'center',
598
+ justifyContent: 'center',
599
+ flexShrink: 0,
600
+ width: 20,
601
+ height: 20,
602
+ padding: 0,
603
+ border: 'none',
604
+ borderRadius: 4,
605
+ background: 'transparent',
606
+ color: purposeOpen
607
+ ? accentColor
608
+ : theme.colors.textSecondary ?? muted,
609
+ cursor: 'pointer',
610
+ }, children: _jsx(AlignLeft, { size: 13 }) }))] })), run.map((c, i) => {
611
+ // File-level: every declaration sharing the open file
612
+ // shows the indicator. Line-level: only the declaration
613
+ // actually highlighted gets the inner accent ring.
614
+ const fileOpen = drawer != null && !!c.file && c.file === drawer.file;
615
+ const lineOpen = fileOpen &&
616
+ drawer.startLine != null &&
617
+ c.declarationRef?.startLine === drawer.startLine;
618
+ // A combined header owns the path, so declarations under
619
+ // it drop their own badge; those with no known line just
620
+ // render unnumbered. Without a header, keep the badge.
621
+ const numbered = hasFile && c.declarationRef?.startLine != null;
622
+ // The header names the external kind, so the body just
623
+ // names the construct.
624
+ const isExt = c.construct === 'external';
625
+ return (_jsx("div", { id: `construct-signature-${c.alias}`, style: {
626
+ borderTop: i > 0
627
+ ? `1px solid ${theme.colors.border}`
628
+ : undefined,
629
+ boxShadow: lineOpen
630
+ ? `inset 0 0 0 1px ${accentColor}`
631
+ : undefined,
632
+ }, children: _jsx(ComponentDeclaration, { component: c, onOpenFile: fileViewer ? onOpenFile : undefined, defaultShowFile: showHeader ? false : !!fileViewer, onRelatedSelect: onRelatedSelect, onInspectSymbol: onInspectSymbol
633
+ ? (symbol, ref) => onInspectSymbol({
634
+ purl: c.purl,
635
+ file: c.file,
636
+ symbol,
637
+ ref,
638
+ })
639
+ : undefined, fileOpen: fileOpen, declarationOpen: lineOpen, showRepoIdentity: false, fileBadgeChrome: showHeader ? false : !!fileViewer, hideActions: showHeader, externalName: isExt, showPurpose: showHeader ? purposeOpen : undefined, lineNumbers: numbered, verification: componentVerification && focusedAlias === c.alias
640
+ ? componentVerification
641
+ : undefined }) }, c.alias));
642
+ })] }, runKey));
592
643
  })] }, group.key)))) : (_jsx("p", { style: {
593
644
  margin: 0,
594
645
  color: muted,
595
646
  fontFamily: theme.fonts.body,
596
647
  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 })] })] }));
648
+ }, children: searchQuery.trim()
649
+ ? `No constructs match “${searchQuery.trim()}”.`
650
+ : '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
651
  }
599
652
  //# sourceMappingURL=ConstructsCatalog.js.map