@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
@@ -2,20 +2,20 @@
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. File opens still use the bottom
10
+ * FileDrawer when the host injects a viewer.
8
11
  */
9
12
 
10
13
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
14
  import type { ReactNode } from 'react';
12
15
  import { useTheme } from '@principal-ade/industry-theme';
13
16
  import { IndustryMarkdownSlide } from 'themed-markdown';
14
- import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react';
17
+ import { AlignLeft, ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react';
15
18
  import {
16
- constructBadgeColor,
17
- constructBadgeLabel,
18
- deriveNameFromSymbol,
19
19
  formatPurl,
20
20
  type SubsystemComponent,
21
21
  } from './model';
@@ -140,6 +140,24 @@ function repoLogo(c: SubsystemComponent): string | undefined {
140
140
  return undefined;
141
141
  }
142
142
 
143
+ /**
144
+ * Split a repo's declarations into consecutive runs that share a `file`, so
145
+ * each run can render as one connected stack. Components with no `file` are
146
+ * never merged (they have no shared location to group under).
147
+ */
148
+ function splitByFileRun(items: SubsystemComponent[]): SubsystemComponent[][] {
149
+ const runs: SubsystemComponent[][] = [];
150
+ for (const c of items) {
151
+ const prev = runs[runs.length - 1];
152
+ const key = c.file || `alias:${c.alias}`;
153
+ const prevKey =
154
+ prev && prev[0] ? prev[0].file || `alias:${prev[0].alias}` : null;
155
+ if (prev && prevKey === key) prev.push(c);
156
+ else runs.push([c]);
157
+ }
158
+ return runs;
159
+ }
160
+
143
161
  export function ConstructsCatalog({
144
162
  components,
145
163
  onSelect,
@@ -177,24 +195,20 @@ export function ConstructsCatalog({
177
195
  .map((x) => x.c);
178
196
  }, [components]);
179
197
 
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
- );
198
+ const [visibleAliases, setVisibleAliases] = useState<string[]>([]);
199
+ const [focusedAlias, setFocusedAlias] = useState<string | null>(null);
200
+ const [searchQuery, setSearchQuery] = useState('');
186
201
  const [descriptionVisible, setDescriptionVisible] = useState(false);
187
202
  const [descToggleHover, setDescToggleHover] = useState(false);
188
203
  const [hoveredRow, setHoveredRow] = useState<string | null>(null);
189
- const [sidebarTab, setSidebarTab] = useState<'constructs' | 'files'>('files');
190
204
  const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
191
205
  new Set(),
192
206
  );
207
+ // Files whose shared-header description toggle is expanded (keyed by file run).
208
+ const [openPurposes, setOpenPurposes] = useState<Set<string>>(new Set());
193
209
  const [drawer, setDrawer] = useState<{ file: string; startLine?: number } | null>(
194
210
  null,
195
211
  );
196
- const focusedAliasRef = useRef<string | null>(focusedAlias);
197
- focusedAliasRef.current = focusedAlias;
198
212
 
199
213
  const visibleSet = useMemo(() => new Set(visibleAliases), [visibleAliases]);
200
214
  const visibleComponents = useMemo(
@@ -202,15 +216,38 @@ export function ConstructsCatalog({
202
216
  [orderedComponents, visibleSet],
203
217
  );
204
218
 
219
+ // When nothing is toggled on, the right pane falls back to a searchable list
220
+ // of every construct, rendered as if all were selected.
221
+ const searchActive = visibleComponents.length === 0;
222
+ const searchResults = useMemo(() => {
223
+ if (!searchActive) return visibleComponents;
224
+ const q = searchQuery.trim().toLowerCase();
225
+ if (!q) return orderedComponents;
226
+ return orderedComponents.filter((c) => {
227
+ const haystack = [
228
+ c.name,
229
+ c.symbol,
230
+ c.alias,
231
+ c.file,
232
+ c.construct,
233
+ c.stereotype,
234
+ c.purpose,
235
+ ]
236
+ .filter(Boolean)
237
+ .join(' ')
238
+ .toLowerCase();
239
+ return haystack.includes(q);
240
+ });
241
+ }, [searchActive, searchQuery, orderedComponents, visibleComponents]);
242
+
205
243
  const fileTree = useMemo(() => buildFileTree(orderedComponents), [orderedComponents]);
206
244
  const hasFiles = useMemo(
207
245
  () => orderedComponents.some((c) => !!c.file),
208
246
  [orderedComponents],
209
247
  );
210
- const effectiveTab = hasFiles ? sidebarTab : 'constructs';
211
248
 
212
- // Group visible declarations by purl so the repo/app identity (logo + name)
213
- // renders once per group instead of on every card.
249
+ // Group the declarations on screen by purl so the repo/app identity
250
+ // (logo + name) renders once per group instead of on every card.
214
251
  const repoGroups = useMemo(() => {
215
252
  const groups: Array<{
216
253
  key: string;
@@ -219,7 +256,7 @@ export function ConstructsCatalog({
219
256
  items: SubsystemComponent[];
220
257
  }> = [];
221
258
  const index = new Map<string, number>();
222
- for (const c of visibleComponents) {
259
+ for (const c of searchResults) {
223
260
  const key = c.purl || c.alias;
224
261
  let at = index.get(key);
225
262
  if (at == null) {
@@ -231,12 +268,7 @@ export function ConstructsCatalog({
231
268
  groups[at]!.items.push(c);
232
269
  }
233
270
  return groups;
234
- }, [visibleComponents]);
235
-
236
- useEffect(() => {
237
- if (!focusedAlias) return;
238
- document.getElementById(`construct-${focusedAlias}`)?.scrollIntoView({ block: 'nearest' });
239
- }, [focusedAlias]);
271
+ }, [searchResults]);
240
272
 
241
273
  useEffect(() => {
242
274
  const last = visibleAliases[visibleAliases.length - 1];
@@ -260,31 +292,13 @@ export function ConstructsCatalog({
260
292
  ?.scrollIntoView({ block: 'nearest' });
261
293
  }, [orderedComponents, drawer]);
262
294
 
263
- // Drop aliases that left the model; if nothing remains, show the first construct.
295
+ // Drop aliases that left the model; an empty set is valid (search fallback).
264
296
  useEffect(() => {
265
297
  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
- });
298
+ setVisibleAliases((prev) => prev.filter((alias) => aliases.has(alias)));
299
+ setFocusedAlias((prev) => (prev && aliases.has(prev) ? prev : null));
275
300
  }, [orderedComponents]);
276
301
 
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
302
  const show = useCallback(
289
303
  (alias: string) => {
290
304
  setFocusedAlias(alias);
@@ -294,13 +308,6 @@ export function ConstructsCatalog({
294
308
  [onSelect],
295
309
  );
296
310
 
297
- const focusAt = useCallback(
298
- (alias: string) => {
299
- setFocusedAlias(alias);
300
- },
301
- [],
302
- );
303
-
304
311
  const toggleFolder = useCallback((path: string) => {
305
312
  setCollapsedFolders((prev) => {
306
313
  const next = new Set(prev);
@@ -310,8 +317,17 @@ export function ConstructsCatalog({
310
317
  });
311
318
  }, []);
312
319
 
320
+ const togglePurpose = useCallback((key: string) => {
321
+ setOpenPurposes((prev) => {
322
+ const next = new Set(prev);
323
+ if (next.has(key)) next.delete(key);
324
+ else next.add(key);
325
+ return next;
326
+ });
327
+ }, []);
328
+
313
329
  /** 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. */
330
+ * all when they're already shown — a bulk toggle of the file's constructs. */
315
331
  const toggleFile = useCallback(
316
332
  (comps: SubsystemComponent[]) => {
317
333
  const aliases = comps.map((c) => c.alias);
@@ -480,9 +496,18 @@ export function ConstructsCatalog({
480
496
  (ref: string) => {
481
497
  const comp = resolveRelated(components, ref);
482
498
  if (!comp) return;
499
+ // In the all-constructs search fallback nothing needs revealing — just
500
+ // surface the target. Otherwise add it to the visible set.
501
+ if (searchActive) {
502
+ setFocusedAlias(comp.alias);
503
+ document
504
+ .getElementById(`construct-signature-${comp.alias}`)
505
+ ?.scrollIntoView({ block: 'nearest' });
506
+ return;
507
+ }
483
508
  show(comp.alias);
484
509
  },
485
- [components, show],
510
+ [components, show, searchActive],
486
511
  );
487
512
 
488
513
  const fileViewer = useMemo(() => {
@@ -507,60 +532,13 @@ export function ConstructsCatalog({
507
532
  [],
508
533
  );
509
534
 
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
535
  useEffect(() => {
527
536
  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
- }
537
+ if (e.key === 'Escape' && drawer) setDrawer(null);
560
538
  };
561
539
  window.addEventListener('keydown', onKey);
562
540
  return () => window.removeEventListener('keydown', onKey);
563
- }, [orderedComponents, drawer, moveFocus, focusAt, toggle]);
541
+ }, [drawer]);
564
542
 
565
543
  const showChrome =
566
544
  !hideSidebar &&
@@ -682,183 +660,7 @@ export function ConstructsCatalog({
682
660
  {sidebarAfterDescription}
683
661
  </div>
684
662
  )}
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
- ) : (
663
+ {hasFiles ? (
862
664
  <ul
863
665
  role="tree"
864
666
  aria-label="Files"
@@ -873,6 +675,18 @@ export function ConstructsCatalog({
873
675
  >
874
676
  {renderTree(fileTree, 0)}
875
677
  </ul>
678
+ ) : (
679
+ <p
680
+ style={{
681
+ margin: 0,
682
+ padding: '12px 16px',
683
+ color: muted,
684
+ fontFamily: theme.fonts.body,
685
+ fontSize: theme.fontSizes[0],
686
+ }}
687
+ >
688
+ No files in this model.
689
+ </p>
876
690
  )}
877
691
  </aside>
878
692
  <div
@@ -885,6 +699,30 @@ export function ConstructsCatalog({
885
699
  flexDirection: 'column',
886
700
  }}
887
701
  >
702
+ {searchActive && (
703
+ <div style={{ flexShrink: 0, padding: '12px 16px 0' }}>
704
+ <input
705
+ type="search"
706
+ value={searchQuery}
707
+ onChange={(e) => setSearchQuery(e.target.value)}
708
+ placeholder="Search constructs…"
709
+ aria-label="Search constructs"
710
+ data-testid="construct-search"
711
+ style={{
712
+ width: '100%',
713
+ boxSizing: 'border-box',
714
+ padding: '8px 10px',
715
+ border: `1px solid ${theme.colors.border}`,
716
+ borderRadius: 6,
717
+ background: theme.colors.background,
718
+ color: theme.colors.text,
719
+ fontFamily: theme.fonts.monospace,
720
+ fontSize: theme.fontSizes[1],
721
+ outline: 'none',
722
+ }}
723
+ />
724
+ </div>
725
+ )}
888
726
  <div
889
727
  data-testid="construct-signature"
890
728
  style={{
@@ -897,7 +735,7 @@ export function ConstructsCatalog({
897
735
  gap: 12,
898
736
  }}
899
737
  >
900
- {visibleComponents.length > 0 ? (
738
+ {searchResults.length > 0 ? (
901
739
  repoGroups.map((group) => (
902
740
  <div
903
741
  key={group.key}
@@ -931,53 +769,181 @@ export function ConstructsCatalog({
931
769
  {group.label}
932
770
  </span>
933
771
  </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;
772
+ {splitByFileRun(group.items).map((run) => {
773
+ const lead = run[0]!;
774
+ const hasFile = !!lead.file;
775
+ // Every file-backed run gets the combined header, one
776
+ // declaration or many, so the style stays consistent.
777
+ const showHeader = hasFile;
778
+ const runKey = `${group.key}:${lead.file || lead.alias}`;
779
+ const runHasPurpose = run.some((c) => !!c.purpose?.trim());
780
+ const purposeOpen = openPurposes.has(runKey);
781
+ const runFileOpen =
782
+ drawer != null && hasFile && lead.file === drawer.file;
943
783
  return (
944
784
  <div
945
- key={c.alias}
946
- id={`construct-signature-${c.alias}`}
785
+ key={runKey}
947
786
  style={{
948
- border: `1px solid ${lineOpen ? accentColor : theme.colors.border}`,
949
- boxShadow: lineOpen ? `0 0 0 1px ${accentColor}` : undefined,
787
+ display: 'flex',
788
+ flexDirection: 'column',
789
+ border: `1px solid ${runFileOpen ? accentColor : theme.colors.border}`,
950
790
  borderRadius: 8,
951
791
  overflow: 'hidden',
952
792
  flexShrink: 0,
953
793
  }}
954
794
  >
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
- />
795
+ {showHeader && (
796
+ <div
797
+ style={{
798
+ display: 'flex',
799
+ alignItems: 'center',
800
+ gap: 6,
801
+ width: '100%',
802
+ boxSizing: 'border-box',
803
+ padding: '6px 10px',
804
+ borderBottom: `1px solid ${
805
+ runFileOpen ? accentColor : theme.colors.border
806
+ }`,
807
+ background: runFileOpen
808
+ ? `${accentColor}14`
809
+ : theme.colors.backgroundSecondary ??
810
+ theme.colors.background,
811
+ color: runFileOpen
812
+ ? accentColor
813
+ : theme.colors.textSecondary ?? muted,
814
+ fontFamily: theme.fonts.monospace,
815
+ fontSize: theme.fontSizes[0],
816
+ }}
817
+ >
818
+ <button
819
+ type="button"
820
+ title={fileViewer ? `Open ${lead.file}` : lead.file}
821
+ onClick={
822
+ fileViewer
823
+ ? () => onOpenFile(lead.file)
824
+ : undefined
825
+ }
826
+ style={{
827
+ display: 'flex',
828
+ alignItems: 'center',
829
+ gap: 6,
830
+ flex: 1,
831
+ minWidth: 0,
832
+ padding: 0,
833
+ border: 'none',
834
+ background: 'transparent',
835
+ color: 'inherit',
836
+ fontFamily: 'inherit',
837
+ fontSize: 'inherit',
838
+ textAlign: 'left',
839
+ cursor: fileViewer ? 'pointer' : 'default',
840
+ }}
841
+ >
842
+ <FileText size={12} style={{ flexShrink: 0 }} />
843
+ <span
844
+ style={{
845
+ overflow: 'hidden',
846
+ textOverflow: 'ellipsis',
847
+ whiteSpace: 'nowrap',
848
+ }}
849
+ >
850
+ {lead.file}
851
+ </span>
852
+ </button>
853
+ {runHasPurpose && (
854
+ <button
855
+ type="button"
856
+ title={
857
+ purposeOpen ? 'Hide description' : 'Show description'
858
+ }
859
+ aria-label={
860
+ purposeOpen ? 'Hide description' : 'Show description'
861
+ }
862
+ aria-expanded={purposeOpen}
863
+ onClick={() => togglePurpose(runKey)}
864
+ style={{
865
+ display: 'inline-flex',
866
+ alignItems: 'center',
867
+ justifyContent: 'center',
868
+ flexShrink: 0,
869
+ width: 20,
870
+ height: 20,
871
+ padding: 0,
872
+ border: 'none',
873
+ borderRadius: 4,
874
+ background: 'transparent',
875
+ color: purposeOpen
876
+ ? accentColor
877
+ : theme.colors.textSecondary ?? muted,
878
+ cursor: 'pointer',
879
+ }}
880
+ >
881
+ <AlignLeft size={13} />
882
+ </button>
883
+ )}
884
+ </div>
885
+ )}
886
+ {run.map((c, i) => {
887
+ // File-level: every declaration sharing the open file
888
+ // shows the indicator. Line-level: only the declaration
889
+ // actually highlighted gets the inner accent ring.
890
+ const fileOpen =
891
+ drawer != null && !!c.file && c.file === drawer.file;
892
+ const lineOpen =
893
+ fileOpen &&
894
+ drawer.startLine != null &&
895
+ c.declarationRef?.startLine === drawer.startLine;
896
+ // A combined header owns the path, so declarations under
897
+ // it drop their own badge; those with no known line just
898
+ // render unnumbered. Without a header, keep the badge.
899
+ const numbered =
900
+ hasFile && c.declarationRef?.startLine != null;
901
+ return (
902
+ <div
903
+ key={c.alias}
904
+ id={`construct-signature-${c.alias}`}
905
+ style={{
906
+ borderTop:
907
+ i > 0
908
+ ? `1px solid ${theme.colors.border}`
909
+ : undefined,
910
+ boxShadow: lineOpen
911
+ ? `inset 0 0 0 1px ${accentColor}`
912
+ : undefined,
913
+ }}
914
+ >
915
+ <ComponentDeclaration
916
+ component={c}
917
+ onOpenFile={fileViewer ? onOpenFile : undefined}
918
+ defaultShowFile={showHeader ? false : !!fileViewer}
919
+ onRelatedSelect={onRelatedSelect}
920
+ onInspectSymbol={
921
+ onInspectSymbol
922
+ ? (symbol, ref) =>
923
+ onInspectSymbol({
924
+ purl: c.purl,
925
+ file: c.file,
926
+ symbol,
927
+ ref,
928
+ })
929
+ : undefined
930
+ }
931
+ fileOpen={fileOpen}
932
+ declarationOpen={lineOpen}
933
+ showRepoIdentity={false}
934
+ fileBadgeChrome={showHeader ? false : !!fileViewer}
935
+ hideActions={showHeader}
936
+ showPurpose={showHeader ? purposeOpen : undefined}
937
+ lineNumbers={numbered}
938
+ verification={
939
+ componentVerification && focusedAlias === c.alias
940
+ ? componentVerification
941
+ : undefined
942
+ }
943
+ />
944
+ </div>
945
+ );
946
+ })}
981
947
  </div>
982
948
  );
983
949
  })}
@@ -992,7 +958,9 @@ export function ConstructsCatalog({
992
958
  fontSize: theme.fontSizes[1],
993
959
  }}
994
960
  >
995
- Toggle constructs on the left to show their signatures.
961
+ {searchQuery.trim()
962
+ ? `No constructs match “${searchQuery.trim()}”.`
963
+ : 'No constructs in this model.'}
996
964
  </p>
997
965
  )}
998
966
  </div>