@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
@@ -2,20 +2,31 @@
2
2
  * ConstructsCatalog — master/detail for a constructs-only subsystem
3
3
  * (components, no topology or walkthrough edges).
4
4
  *
5
- * Left: the model's constructs as a toggle list. Right: signatures for
6
- * every construct currently on (`ComponentDeclaration`, stacked). File
7
- * opens still use the bottom FileDrawer when the host injects a viewer.
5
+ * Left: the model's files as a tree; clicking a file toggles its
6
+ * constructs. Right: signatures grouped by repo, and within a repo stacked
7
+ * per file under one combined file header carrying the path and a
8
+ * description toggle; a declaration with a known line labels just that
9
+ * construct's line in a file-like gutter, and `external` constructs get a
10
+ * header naming their kind with the body naming the construct. The model
11
+ * description's toggle sits in the left chrome and opens the markdown as an
12
+ * overlay over the construct area. File opens still use the bottom FileDrawer
13
+ * when the host injects a viewer.
8
14
  */
9
15
 
10
16
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
17
  import type { ReactNode } from 'react';
12
18
  import { useTheme } from '@principal-ade/industry-theme';
13
19
  import { IndustryMarkdownSlide } from 'themed-markdown';
14
- import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react';
15
20
  import {
16
- constructBadgeColor,
17
- constructBadgeLabel,
18
- deriveNameFromSymbol,
21
+ AlignLeft,
22
+ Box,
23
+ ChevronDown,
24
+ ChevronRight,
25
+ FileText,
26
+ Folder,
27
+ X,
28
+ } from 'lucide-react';
29
+ import {
19
30
  formatPurl,
20
31
  type SubsystemComponent,
21
32
  } from './model';
@@ -33,6 +44,10 @@ export interface ConstructsCatalogProps {
33
44
  onSelect?: (componentAlias: string) => void;
34
45
  title?: string;
35
46
  hideSidebar?: boolean;
47
+ /**
48
+ * Model description. Its toggle lives in the left chrome; opening it shows
49
+ * the markdown as an overlay over the construct area.
50
+ */
36
51
  description?: string;
37
52
  diagnostic?: SubsystemDiagnostic;
38
53
  sidebarExtra?: ReactNode;
@@ -140,6 +155,24 @@ function repoLogo(c: SubsystemComponent): string | undefined {
140
155
  return undefined;
141
156
  }
142
157
 
158
+ /**
159
+ * Split a repo's declarations into consecutive runs that share a `file`, so
160
+ * each run can render as one connected stack. Components with no `file` are
161
+ * never merged (they have no shared location to group under).
162
+ */
163
+ function splitByFileRun(items: SubsystemComponent[]): SubsystemComponent[][] {
164
+ const runs: SubsystemComponent[][] = [];
165
+ for (const c of items) {
166
+ const prev = runs[runs.length - 1];
167
+ const key = c.file || `alias:${c.alias}`;
168
+ const prevKey =
169
+ prev && prev[0] ? prev[0].file || `alias:${prev[0].alias}` : null;
170
+ if (prev && prevKey === key) prev.push(c);
171
+ else runs.push([c]);
172
+ }
173
+ return runs;
174
+ }
175
+
143
176
  export function ConstructsCatalog({
144
177
  components,
145
178
  onSelect,
@@ -177,24 +210,23 @@ export function ConstructsCatalog({
177
210
  .map((x) => x.c);
178
211
  }, [components]);
179
212
 
180
- const [visibleAliases, setVisibleAliases] = useState<string[]>(() =>
181
- orderedComponents[0] ? [orderedComponents[0].alias] : [],
182
- );
183
- const [focusedAlias, setFocusedAlias] = useState<string | null>(
184
- () => orderedComponents[0]?.alias ?? null,
185
- );
213
+ const [visibleAliases, setVisibleAliases] = useState<string[]>([]);
214
+ const [focusedAlias, setFocusedAlias] = useState<string | null>(null);
215
+ const [searchQuery, setSearchQuery] = useState('');
186
216
  const [descriptionVisible, setDescriptionVisible] = useState(false);
187
217
  const [descToggleHover, setDescToggleHover] = useState(false);
188
218
  const [hoveredRow, setHoveredRow] = useState<string | null>(null);
189
- const [sidebarTab, setSidebarTab] = useState<'constructs' | 'files'>('files');
190
219
  const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
191
220
  new Set(),
192
221
  );
222
+ // Explicit description toggles per file run. Unset falls back to the kind's
223
+ // default (externals open, files closed).
224
+ const [purposeOverrides, setPurposeOverrides] = useState<
225
+ Record<string, boolean>
226
+ >({});
193
227
  const [drawer, setDrawer] = useState<{ file: string; startLine?: number } | null>(
194
228
  null,
195
229
  );
196
- const focusedAliasRef = useRef<string | null>(focusedAlias);
197
- focusedAliasRef.current = focusedAlias;
198
230
 
199
231
  const visibleSet = useMemo(() => new Set(visibleAliases), [visibleAliases]);
200
232
  const visibleComponents = useMemo(
@@ -202,15 +234,38 @@ export function ConstructsCatalog({
202
234
  [orderedComponents, visibleSet],
203
235
  );
204
236
 
237
+ // When nothing is toggled on, the right pane falls back to a searchable list
238
+ // of every construct, rendered as if all were selected.
239
+ const searchActive = visibleComponents.length === 0;
240
+ const searchResults = useMemo(() => {
241
+ if (!searchActive) return visibleComponents;
242
+ const q = searchQuery.trim().toLowerCase();
243
+ if (!q) return orderedComponents;
244
+ return orderedComponents.filter((c) => {
245
+ const haystack = [
246
+ c.name,
247
+ c.symbol,
248
+ c.alias,
249
+ c.file,
250
+ c.construct,
251
+ c.stereotype,
252
+ c.purpose,
253
+ ]
254
+ .filter(Boolean)
255
+ .join(' ')
256
+ .toLowerCase();
257
+ return haystack.includes(q);
258
+ });
259
+ }, [searchActive, searchQuery, orderedComponents, visibleComponents]);
260
+
205
261
  const fileTree = useMemo(() => buildFileTree(orderedComponents), [orderedComponents]);
206
262
  const hasFiles = useMemo(
207
263
  () => orderedComponents.some((c) => !!c.file),
208
264
  [orderedComponents],
209
265
  );
210
- const effectiveTab = hasFiles ? sidebarTab : 'constructs';
211
266
 
212
- // Group visible declarations by purl so the repo/app identity (logo + name)
213
- // renders once per group instead of on every card.
267
+ // Group the declarations on screen by purl so the repo/app identity
268
+ // (logo + name) renders once per group instead of on every card.
214
269
  const repoGroups = useMemo(() => {
215
270
  const groups: Array<{
216
271
  key: string;
@@ -219,7 +274,7 @@ export function ConstructsCatalog({
219
274
  items: SubsystemComponent[];
220
275
  }> = [];
221
276
  const index = new Map<string, number>();
222
- for (const c of visibleComponents) {
277
+ for (const c of searchResults) {
223
278
  const key = c.purl || c.alias;
224
279
  let at = index.get(key);
225
280
  if (at == null) {
@@ -231,12 +286,7 @@ export function ConstructsCatalog({
231
286
  groups[at]!.items.push(c);
232
287
  }
233
288
  return groups;
234
- }, [visibleComponents]);
235
-
236
- useEffect(() => {
237
- if (!focusedAlias) return;
238
- document.getElementById(`construct-${focusedAlias}`)?.scrollIntoView({ block: 'nearest' });
239
- }, [focusedAlias]);
289
+ }, [searchResults]);
240
290
 
241
291
  useEffect(() => {
242
292
  const last = visibleAliases[visibleAliases.length - 1];
@@ -260,31 +310,13 @@ export function ConstructsCatalog({
260
310
  ?.scrollIntoView({ block: 'nearest' });
261
311
  }, [orderedComponents, drawer]);
262
312
 
263
- // Drop aliases that left the model; if nothing remains, show the first construct.
313
+ // Drop aliases that left the model; an empty set is valid (search fallback).
264
314
  useEffect(() => {
265
315
  const aliases = new Set(orderedComponents.map((c) => c.alias));
266
- setVisibleAliases((prev) => {
267
- const next = prev.filter((alias) => aliases.has(alias));
268
- if (next.length > 0 || orderedComponents.length === 0) return next;
269
- return [orderedComponents[0]!.alias];
270
- });
271
- setFocusedAlias((prev) => {
272
- if (prev && aliases.has(prev)) return prev;
273
- return orderedComponents[0]?.alias ?? null;
274
- });
316
+ setVisibleAliases((prev) => prev.filter((alias) => aliases.has(alias)));
317
+ setFocusedAlias((prev) => (prev && aliases.has(prev) ? prev : null));
275
318
  }, [orderedComponents]);
276
319
 
277
- const toggle = useCallback(
278
- (alias: string) => {
279
- setFocusedAlias(alias);
280
- setVisibleAliases((prev) =>
281
- prev.includes(alias) ? prev.filter((x) => x !== alias) : [...prev, alias],
282
- );
283
- onSelect?.(alias);
284
- },
285
- [onSelect],
286
- );
287
-
288
320
  const show = useCallback(
289
321
  (alias: string) => {
290
322
  setFocusedAlias(alias);
@@ -294,13 +326,6 @@ export function ConstructsCatalog({
294
326
  [onSelect],
295
327
  );
296
328
 
297
- const focusAt = useCallback(
298
- (alias: string) => {
299
- setFocusedAlias(alias);
300
- },
301
- [],
302
- );
303
-
304
329
  const toggleFolder = useCallback((path: string) => {
305
330
  setCollapsedFolders((prev) => {
306
331
  const next = new Set(prev);
@@ -310,8 +335,12 @@ export function ConstructsCatalog({
310
335
  });
311
336
  }, []);
312
337
 
338
+ const setPurposeOpen = useCallback((key: string, open: boolean) => {
339
+ setPurposeOverrides((prev) => ({ ...prev, [key]: open }));
340
+ }, []);
341
+
313
342
  /** File-tree click: show every construct declared in the file, or hide them
314
- * all when they're already shown — mirroring a construct row toggle. */
343
+ * all when they're already shown — a bulk toggle of the file's constructs. */
315
344
  const toggleFile = useCallback(
316
345
  (comps: SubsystemComponent[]) => {
317
346
  const aliases = comps.map((c) => c.alias);
@@ -480,9 +509,18 @@ export function ConstructsCatalog({
480
509
  (ref: string) => {
481
510
  const comp = resolveRelated(components, ref);
482
511
  if (!comp) return;
512
+ // In the all-constructs search fallback nothing needs revealing — just
513
+ // surface the target. Otherwise add it to the visible set.
514
+ if (searchActive) {
515
+ setFocusedAlias(comp.alias);
516
+ document
517
+ .getElementById(`construct-signature-${comp.alias}`)
518
+ ?.scrollIntoView({ block: 'nearest' });
519
+ return;
520
+ }
483
521
  show(comp.alias);
484
522
  },
485
- [components, show],
523
+ [components, show, searchActive],
486
524
  );
487
525
 
488
526
  const fileViewer = useMemo(() => {
@@ -507,65 +545,23 @@ export function ConstructsCatalog({
507
545
  [],
508
546
  );
509
547
 
510
- const moveFocus = useCallback(
511
- (delta: number) => {
512
- if (orderedComponents.length === 0) return;
513
- const idx = Math.max(
514
- 0,
515
- orderedComponents.findIndex((c) => c.alias === focusedAliasRef.current),
516
- );
517
- const next =
518
- orderedComponents[
519
- (idx + delta + orderedComponents.length) % orderedComponents.length
520
- ]!;
521
- focusAt(next.alias);
522
- },
523
- [orderedComponents, focusAt],
524
- );
525
-
526
548
  useEffect(() => {
527
549
  const onKey = (e: KeyboardEvent) => {
528
- const el = e.target as HTMLElement | null;
529
- if (
530
- el &&
531
- (el.tagName === 'INPUT' ||
532
- el.tagName === 'TEXTAREA' ||
533
- el.tagName === 'SELECT' ||
534
- el.isContentEditable)
535
- ) {
536
- return;
537
- }
538
- if (e.metaKey || e.ctrlKey || e.altKey) return;
539
- if (e.key === 'ArrowDown') {
540
- e.preventDefault();
541
- moveFocus(1);
542
- } else if (e.key === 'ArrowUp') {
543
- e.preventDefault();
544
- moveFocus(-1);
545
- } else if (e.key === 'Home') {
546
- e.preventDefault();
547
- if (orderedComponents[0]) focusAt(orderedComponents[0].alias);
548
- } else if (e.key === 'End') {
549
- e.preventDefault();
550
- const last = orderedComponents[orderedComponents.length - 1];
551
- if (last) focusAt(last.alias);
552
- } else if (e.key === ' ' || e.key === 'Enter') {
553
- if (focusedAliasRef.current) {
554
- e.preventDefault();
555
- toggle(focusedAliasRef.current);
556
- }
557
- } else if (e.key === 'Escape' && drawer) {
558
- setDrawer(null);
559
- }
550
+ if (e.key === 'Escape' && drawer) setDrawer(null);
560
551
  };
561
552
  window.addEventListener('keydown', onKey);
562
553
  return () => window.removeEventListener('keydown', onKey);
563
- }, [orderedComponents, drawer, moveFocus, focusAt, toggle]);
554
+ }, [drawer]);
564
555
 
565
556
  const showChrome =
566
557
  !hideSidebar &&
567
- !!(title || description || diagnostic || sidebarExtra || sidebarAfterDescription);
568
- const showDesc = !!description && descriptionVisible;
558
+ !!(
559
+ title ||
560
+ description ||
561
+ diagnostic ||
562
+ sidebarExtra ||
563
+ sidebarAfterDescription
564
+ );
569
565
 
570
566
  return (
571
567
  <div
@@ -595,7 +591,6 @@ export function ConstructsCatalog({
595
591
  {showChrome && (
596
592
  <div
597
593
  style={{
598
- flex: showDesc ? '0 0 auto' : undefined,
599
594
  padding: '16px 16px 8px',
600
595
  display: 'flex',
601
596
  flexDirection: 'column',
@@ -664,201 +659,10 @@ export function ConstructsCatalog({
664
659
  </div>
665
660
  </div>
666
661
  )}
667
- {showDesc && (
668
- <div style={{ fontSize: theme.fontSizes[0], lineHeight: 1.5 }}>
669
- <IndustryMarkdownSlide
670
- content={description!}
671
- slideIdPrefix="constructs-desc"
672
- slideIndex={0}
673
- isVisible={true}
674
- theme={theme}
675
- disableScroll={true}
676
- disableBasePadding
677
- enableKeyboardScrolling={false}
678
- autoFocusOnVisible={false}
679
- />
680
- </div>
681
- )}
682
662
  {sidebarAfterDescription}
683
663
  </div>
684
664
  )}
685
- <div
686
- role="tablist"
687
- aria-label="Sidebar view"
688
- style={{
689
- display: 'flex',
690
- width: '100%',
691
- flexShrink: 0,
692
- borderBottom: `1px solid ${theme.colors.border}`,
693
- background:
694
- theme.colors.backgroundSecondary ?? theme.colors.background,
695
- }}
696
- >
697
- {(['files', 'constructs'] as const).map((tab) => {
698
- if (tab === 'files' && !hasFiles) return null;
699
- const active = effectiveTab === tab;
700
- return (
701
- <button
702
- key={tab}
703
- type="button"
704
- role="tab"
705
- aria-selected={active}
706
- onClick={() => setSidebarTab(tab)}
707
- style={{
708
- flex: 1,
709
- minWidth: 0,
710
- padding: '8px 8px',
711
- border: 'none',
712
- borderRadius: 0,
713
- background: active
714
- ? theme.colors.background
715
- : 'transparent',
716
- color: active ? theme.colors.text : muted,
717
- fontSize: theme.fontSizes[0],
718
- fontFamily: theme.fonts.monospace,
719
- letterSpacing: 0.4,
720
- textTransform: 'uppercase',
721
- cursor: 'pointer',
722
- }}
723
- >
724
- {tab === 'constructs' ? 'Constructs' : 'Files'}
725
- </button>
726
- );
727
- })}
728
- </div>
729
- {effectiveTab === 'constructs' ? (
730
- <ul
731
- role="listbox"
732
- aria-label="Constructs"
733
- aria-multiselectable="true"
734
- aria-activedescendant={focusedAlias ? `construct-${focusedAlias}` : undefined}
735
- style={{
736
- listStyle: 'none',
737
- margin: 0,
738
- padding: '0 8px 12px',
739
- overflowY: 'auto',
740
- flex: 1,
741
- minHeight: 0,
742
- }}
743
- >
744
- {orderedComponents.map((c) => {
745
- const visible = visibleSet.has(c.alias);
746
- const focused = c.alias === focusedAlias;
747
- const color = componentColor(c, pierreTheme);
748
- const badgeColor = constructBadgeColor(c) ?? color;
749
- const badgeLabel = constructBadgeLabel(c);
750
- const displayName = deriveNameFromSymbol(
751
- c.symbol,
752
- c.construct,
753
- c.name,
754
- c.file,
755
- c.stereotype,
756
- );
757
- const rowOpen =
758
- drawer != null &&
759
- drawer.startLine != null &&
760
- !!c.file &&
761
- c.file === drawer.file &&
762
- c.declarationRef?.startLine === drawer.startLine;
763
- const hovered = hoveredRow === c.alias;
764
- return (
765
- <li key={c.alias} role="presentation">
766
- <button
767
- type="button"
768
- id={`construct-${c.alias}`}
769
- role="option"
770
- aria-selected={visible}
771
- data-testid={`construct-row-${c.alias}`}
772
- onClick={() => toggle(c.alias)}
773
- onMouseEnter={() => setHoveredRow(c.alias)}
774
- onMouseLeave={() =>
775
- setHoveredRow((h) => (h === c.alias ? null : h))
776
- }
777
- style={{
778
- display: 'flex',
779
- flexDirection: 'column',
780
- alignItems: 'stretch',
781
- gap: 2,
782
- width: '100%',
783
- margin: '2px 0',
784
- padding: '8px 10px 8px 12px',
785
- textAlign: 'left',
786
- border: 'none',
787
- borderRadius: 6,
788
- background: hovered
789
- ? theme.colors.border
790
- : visible
791
- ? theme.colors.background
792
- : 'transparent',
793
- cursor: 'pointer',
794
- transition: 'background 100ms ease, opacity 100ms ease',
795
- boxShadow: [
796
- visible ? `inset 0 0 0 1px ${theme.colors.border}` : null,
797
- rowOpen ? `inset 2px 0 0 ${accentColor}` : null,
798
- ]
799
- .filter(Boolean)
800
- .join(', ') || undefined,
801
- outline: focused && !visible ? `1px dotted ${muted}` : undefined,
802
- outlineOffset: -1,
803
- opacity: visible ? 1 : hovered ? 1 : 0.72,
804
- }}
805
- >
806
- <span
807
- style={{
808
- display: 'flex',
809
- alignItems: 'baseline',
810
- gap: 8,
811
- minWidth: 0,
812
- }}
813
- >
814
- <span
815
- style={{
816
- flex: 1,
817
- minWidth: 0,
818
- overflow: 'hidden',
819
- textOverflow: 'ellipsis',
820
- whiteSpace: 'nowrap',
821
- fontFamily: theme.fonts.monospace,
822
- fontSize: theme.fontSizes[1],
823
- color,
824
- fontWeight: visible ? 600 : 500,
825
- }}
826
- >
827
- {displayName}
828
- </span>
829
- </span>
830
- {badgeLabel && (
831
- <span
832
- style={{
833
- display: 'flex',
834
- alignItems: 'center',
835
- minWidth: 0,
836
- }}
837
- >
838
- <span
839
- style={{
840
- flex: 1,
841
- minWidth: 0,
842
- overflow: 'hidden',
843
- textOverflow: 'ellipsis',
844
- whiteSpace: 'nowrap',
845
- fontFamily: theme.fonts.monospace,
846
- fontSize: theme.fontSizes[0],
847
- letterSpacing: 0.4,
848
- textTransform: 'uppercase',
849
- color: badgeColor,
850
- }}
851
- >
852
- {badgeLabel}
853
- </span>
854
- </span>
855
- )}
856
- </button>
857
- </li>
858
- );
859
- })}
860
- </ul>
861
- ) : (
665
+ {hasFiles ? (
862
666
  <ul
863
667
  role="tree"
864
668
  aria-label="Files"
@@ -873,6 +677,18 @@ export function ConstructsCatalog({
873
677
  >
874
678
  {renderTree(fileTree, 0)}
875
679
  </ul>
680
+ ) : (
681
+ <p
682
+ style={{
683
+ margin: 0,
684
+ padding: '12px 16px',
685
+ color: muted,
686
+ fontFamily: theme.fonts.body,
687
+ fontSize: theme.fontSizes[0],
688
+ }}
689
+ >
690
+ No files in this model.
691
+ </p>
876
692
  )}
877
693
  </aside>
878
694
  <div
@@ -885,6 +701,121 @@ export function ConstructsCatalog({
885
701
  flexDirection: 'column',
886
702
  }}
887
703
  >
704
+ {description && descriptionVisible && (
705
+ <>
706
+ <div
707
+ onClick={() => setDescriptionVisible(false)}
708
+ style={{
709
+ position: 'absolute',
710
+ inset: 0,
711
+ zIndex: 2,
712
+ background: 'rgba(0,0,0,0.45)',
713
+ }}
714
+ />
715
+ <div
716
+ style={{
717
+ position: 'absolute',
718
+ top: '50%',
719
+ left: '50%',
720
+ transform: 'translate(-50%, -50%)',
721
+ zIndex: 3,
722
+ width: 360,
723
+ maxWidth: 'calc(100% - 16px)',
724
+ maxHeight: '70%',
725
+ overflow: 'auto',
726
+ padding: 16,
727
+ border: `1px solid ${theme.colors.border}`,
728
+ borderRadius: 8,
729
+ background: theme.colors.background,
730
+ boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
731
+ fontSize: theme.fontSizes[0],
732
+ lineHeight: 1.5,
733
+ }}
734
+ >
735
+ <div
736
+ style={{
737
+ display: 'flex',
738
+ alignItems: 'center',
739
+ gap: 8,
740
+ marginBottom: 12,
741
+ }}
742
+ >
743
+ <h3
744
+ style={{
745
+ margin: 0,
746
+ flex: 1,
747
+ minWidth: 0,
748
+ fontSize: theme.fontSizes[1],
749
+ fontWeight: 600,
750
+ fontFamily: theme.fonts.monospace,
751
+ letterSpacing: 0.4,
752
+ textTransform: 'uppercase',
753
+ color: theme.colors.textSecondary ?? muted,
754
+ }}
755
+ >
756
+ Overview
757
+ </h3>
758
+ <button
759
+ type="button"
760
+ aria-label="Close description"
761
+ title="Close"
762
+ onClick={() => setDescriptionVisible(false)}
763
+ style={{
764
+ display: 'inline-flex',
765
+ alignItems: 'center',
766
+ justifyContent: 'center',
767
+ flexShrink: 0,
768
+ width: 22,
769
+ height: 22,
770
+ padding: 0,
771
+ border: 'none',
772
+ borderRadius: 4,
773
+ background: 'transparent',
774
+ color: muted,
775
+ cursor: 'pointer',
776
+ }}
777
+ >
778
+ <X size={14} />
779
+ </button>
780
+ </div>
781
+ <IndustryMarkdownSlide
782
+ content={description}
783
+ slideIdPrefix="constructs-desc-overlay"
784
+ slideIndex={0}
785
+ isVisible
786
+ theme={theme}
787
+ disableScroll
788
+ disableBasePadding
789
+ enableKeyboardScrolling={false}
790
+ autoFocusOnVisible={false}
791
+ />
792
+ </div>
793
+ </>
794
+ )}
795
+ {searchActive && (
796
+ <div style={{ flexShrink: 0, padding: '12px 16px 0' }}>
797
+ <input
798
+ type="search"
799
+ value={searchQuery}
800
+ onChange={(e) => setSearchQuery(e.target.value)}
801
+ placeholder="Search constructs…"
802
+ aria-label="Search constructs"
803
+ data-testid="construct-search"
804
+ style={{
805
+ width: '100%',
806
+ boxSizing: 'border-box',
807
+ padding: '8px 10px',
808
+ border: `1px solid ${theme.colors.border}`,
809
+ borderRadius: 6,
810
+ background: theme.colors.background,
811
+ color: theme.colors.text,
812
+ fontFamily: theme.fonts.monospace,
813
+ fontSize: theme.fontSizes[1],
814
+ outline: 'none',
815
+ }}
816
+ />
817
+ </div>
818
+ )}
888
819
  <div
889
820
  data-testid="construct-signature"
890
821
  style={{
@@ -897,7 +828,7 @@ export function ConstructsCatalog({
897
828
  gap: 12,
898
829
  }}
899
830
  >
900
- {visibleComponents.length > 0 ? (
831
+ {searchResults.length > 0 ? (
901
832
  repoGroups.map((group) => (
902
833
  <div
903
834
  key={group.key}
@@ -931,53 +862,210 @@ export function ConstructsCatalog({
931
862
  {group.label}
932
863
  </span>
933
864
  </div>
934
- {group.items.map((c) => {
935
- // File-level: every declaration sharing the open file shows the
936
- // indicator. Line-level: only the declaration actually being
937
- // highlighted gets the accent border.
938
- const fileOpen = drawer != null && !!c.file && c.file === drawer.file;
939
- const lineOpen =
940
- fileOpen &&
941
- drawer.startLine != null &&
942
- c.declarationRef?.startLine === drawer.startLine;
865
+ {splitByFileRun(group.items).map((run) => {
866
+ const lead = run[0]!;
867
+ const hasFile = !!lead.file;
868
+ const isExternal = lead.construct === 'external';
869
+ // File-backed runs and externals both get a header; externals
870
+ // name their kind instead of a path, so the body doesn't have
871
+ // to spell it out.
872
+ const showHeader = hasFile || isExternal;
873
+ const runKey = `${group.key}:${lead.file || lead.alias}`;
874
+ const runHasPurpose = run.some((c) => !!c.purpose?.trim());
875
+ const purposeOpen = purposeOverrides[runKey] ?? isExternal;
876
+ const runFileOpen =
877
+ drawer != null && hasFile && lead.file === drawer.file;
943
878
  return (
944
879
  <div
945
- key={c.alias}
946
- id={`construct-signature-${c.alias}`}
880
+ key={runKey}
947
881
  style={{
948
- border: `1px solid ${lineOpen ? accentColor : theme.colors.border}`,
949
- boxShadow: lineOpen ? `0 0 0 1px ${accentColor}` : undefined,
882
+ display: 'flex',
883
+ flexDirection: 'column',
884
+ border: `1px solid ${runFileOpen ? accentColor : theme.colors.border}`,
950
885
  borderRadius: 8,
951
886
  overflow: 'hidden',
952
887
  flexShrink: 0,
953
888
  }}
954
889
  >
955
- <ComponentDeclaration
956
- component={c}
957
- onOpenFile={fileViewer ? onOpenFile : undefined}
958
- defaultShowFile={!!fileViewer}
959
- onRelatedSelect={onRelatedSelect}
960
- onInspectSymbol={
961
- onInspectSymbol
962
- ? (symbol, ref) =>
963
- onInspectSymbol({
964
- purl: c.purl,
965
- file: c.file,
966
- symbol,
967
- ref,
968
- })
969
- : undefined
970
- }
971
- fileOpen={fileOpen}
972
- declarationOpen={lineOpen}
973
- showRepoIdentity={false}
974
- fileBadgeChrome={!!fileViewer}
975
- verification={
976
- componentVerification && focusedAlias === c.alias
977
- ? componentVerification
978
- : undefined
979
- }
980
- />
890
+ {showHeader && (
891
+ <div
892
+ style={{
893
+ display: 'flex',
894
+ alignItems: 'center',
895
+ gap: 6,
896
+ width: '100%',
897
+ boxSizing: 'border-box',
898
+ padding: '6px 10px',
899
+ borderBottom: `1px solid ${
900
+ runFileOpen ? accentColor : theme.colors.border
901
+ }`,
902
+ background: runFileOpen
903
+ ? `${accentColor}14`
904
+ : theme.colors.backgroundSecondary ??
905
+ theme.colors.background,
906
+ color: runFileOpen
907
+ ? accentColor
908
+ : theme.colors.textSecondary ?? muted,
909
+ fontFamily: theme.fonts.monospace,
910
+ fontSize: theme.fontSizes[0],
911
+ }}
912
+ >
913
+ {isExternal ? (
914
+ <span
915
+ style={{
916
+ display: 'flex',
917
+ alignItems: 'center',
918
+ gap: 6,
919
+ flex: 1,
920
+ minWidth: 0,
921
+ }}
922
+ >
923
+ <Box size={12} style={{ flexShrink: 0 }} />
924
+ <span
925
+ style={{
926
+ overflow: 'hidden',
927
+ textOverflow: 'ellipsis',
928
+ whiteSpace: 'nowrap',
929
+ }}
930
+ >
931
+ external
932
+ </span>
933
+ </span>
934
+ ) : (
935
+ <button
936
+ type="button"
937
+ title={fileViewer ? `Open ${lead.file}` : lead.file}
938
+ onClick={
939
+ fileViewer
940
+ ? () => onOpenFile(lead.file)
941
+ : undefined
942
+ }
943
+ style={{
944
+ display: 'flex',
945
+ alignItems: 'center',
946
+ gap: 6,
947
+ flex: 1,
948
+ minWidth: 0,
949
+ padding: 0,
950
+ border: 'none',
951
+ background: 'transparent',
952
+ color: 'inherit',
953
+ fontFamily: 'inherit',
954
+ fontSize: 'inherit',
955
+ textAlign: 'left',
956
+ cursor: fileViewer ? 'pointer' : 'default',
957
+ }}
958
+ >
959
+ <FileText size={12} style={{ flexShrink: 0 }} />
960
+ <span
961
+ style={{
962
+ overflow: 'hidden',
963
+ textOverflow: 'ellipsis',
964
+ whiteSpace: 'nowrap',
965
+ }}
966
+ >
967
+ {lead.file}
968
+ </span>
969
+ </button>
970
+ )}
971
+ {runHasPurpose && (
972
+ <button
973
+ type="button"
974
+ title={
975
+ purposeOpen ? 'Hide description' : 'Show description'
976
+ }
977
+ aria-label={
978
+ purposeOpen ? 'Hide description' : 'Show description'
979
+ }
980
+ aria-expanded={purposeOpen}
981
+ onClick={() => setPurposeOpen(runKey, !purposeOpen)}
982
+ style={{
983
+ display: 'inline-flex',
984
+ alignItems: 'center',
985
+ justifyContent: 'center',
986
+ flexShrink: 0,
987
+ width: 20,
988
+ height: 20,
989
+ padding: 0,
990
+ border: 'none',
991
+ borderRadius: 4,
992
+ background: 'transparent',
993
+ color: purposeOpen
994
+ ? accentColor
995
+ : theme.colors.textSecondary ?? muted,
996
+ cursor: 'pointer',
997
+ }}
998
+ >
999
+ <AlignLeft size={13} />
1000
+ </button>
1001
+ )}
1002
+ </div>
1003
+ )}
1004
+ {run.map((c, i) => {
1005
+ // File-level: every declaration sharing the open file
1006
+ // shows the indicator. Line-level: only the declaration
1007
+ // actually highlighted gets the inner accent ring.
1008
+ const fileOpen =
1009
+ drawer != null && !!c.file && c.file === drawer.file;
1010
+ const lineOpen =
1011
+ fileOpen &&
1012
+ drawer.startLine != null &&
1013
+ c.declarationRef?.startLine === drawer.startLine;
1014
+ // A combined header owns the path, so declarations under
1015
+ // it drop their own badge; those with no known line just
1016
+ // render unnumbered. Without a header, keep the badge.
1017
+ const numbered =
1018
+ hasFile && c.declarationRef?.startLine != null;
1019
+ // The header names the external kind, so the body just
1020
+ // names the construct.
1021
+ const isExt = c.construct === 'external';
1022
+ return (
1023
+ <div
1024
+ key={c.alias}
1025
+ id={`construct-signature-${c.alias}`}
1026
+ style={{
1027
+ borderTop:
1028
+ i > 0
1029
+ ? `1px solid ${theme.colors.border}`
1030
+ : undefined,
1031
+ boxShadow: lineOpen
1032
+ ? `inset 0 0 0 1px ${accentColor}`
1033
+ : undefined,
1034
+ }}
1035
+ >
1036
+ <ComponentDeclaration
1037
+ component={c}
1038
+ onOpenFile={fileViewer ? onOpenFile : undefined}
1039
+ defaultShowFile={showHeader ? false : !!fileViewer}
1040
+ onRelatedSelect={onRelatedSelect}
1041
+ onInspectSymbol={
1042
+ onInspectSymbol
1043
+ ? (symbol, ref) =>
1044
+ onInspectSymbol({
1045
+ purl: c.purl,
1046
+ file: c.file,
1047
+ symbol,
1048
+ ref,
1049
+ })
1050
+ : undefined
1051
+ }
1052
+ fileOpen={fileOpen}
1053
+ declarationOpen={lineOpen}
1054
+ showRepoIdentity={false}
1055
+ fileBadgeChrome={showHeader ? false : !!fileViewer}
1056
+ hideActions={showHeader}
1057
+ externalName={isExt}
1058
+ showPurpose={showHeader ? purposeOpen : undefined}
1059
+ lineNumbers={numbered}
1060
+ verification={
1061
+ componentVerification && focusedAlias === c.alias
1062
+ ? componentVerification
1063
+ : undefined
1064
+ }
1065
+ />
1066
+ </div>
1067
+ );
1068
+ })}
981
1069
  </div>
982
1070
  );
983
1071
  })}
@@ -992,7 +1080,9 @@ export function ConstructsCatalog({
992
1080
  fontSize: theme.fontSizes[1],
993
1081
  }}
994
1082
  >
995
- Toggle constructs on the left to show their signatures.
1083
+ {searchQuery.trim()
1084
+ ? `No constructs match “${searchQuery.trim()}”.`
1085
+ : 'No constructs in this model.'}
996
1086
  </p>
997
1087
  )}
998
1088
  </div>