@principal-ai/subsystems-react 0.35.6 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.d.ts +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/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
  6. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
  7. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
  8. package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
  9. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
  10. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  11. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  12. package/dist/subsystem/SubsystemComponentGraph.js +9 -5
  13. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  14. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
  15. package/dist/subsystem/SymbolInspectionCard.js +10 -5
  16. package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
  17. package/dist/subsystem/graphChrome.d.ts +15 -0
  18. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  19. package/dist/subsystem/graphChrome.js +17 -0
  20. package/dist/subsystem/graphChrome.js.map +1 -1
  21. package/dist/subsystem/model.d.ts +26 -0
  22. package/dist/subsystem/model.d.ts.map +1 -1
  23. package/dist/subsystem/model.js +90 -2
  24. package/dist/subsystem/model.js.map +1 -1
  25. package/dist/subsystem/nodes.d.ts.map +1 -1
  26. package/dist/subsystem/nodes.js +12 -9
  27. package/dist/subsystem/nodes.js.map +1 -1
  28. package/dist/utils/elkLayout.d.ts +45 -0
  29. package/dist/utils/elkLayout.d.ts.map +1 -1
  30. package/dist/utils/elkLayout.js +114 -84
  31. package/dist/utils/elkLayout.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/graphify/signature.test.ts +1 -1
  34. package/src/index.ts +1 -0
  35. package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
  36. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
  37. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
  38. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
  39. package/src/subsystem/SubsystemAggregateGraph.tsx +191 -21
  40. package/src/subsystem/SubsystemComponentGraph.tsx +18 -4
  41. package/src/subsystem/SymbolInspectionCard.tsx +31 -12
  42. package/src/subsystem/graphChrome.tsx +20 -0
  43. package/src/subsystem/model.test.ts +91 -0
  44. package/src/subsystem/model.ts +94 -2
  45. package/src/subsystem/nodes.tsx +12 -9
  46. package/src/utils/elkLayout.test.ts +117 -3
  47. package/src/utils/elkLayout.ts +150 -81
  48. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
  49. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
  50. /package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.ts +0 -0
@@ -27,8 +27,8 @@ import {
27
27
  } from '@xyflow/react';
28
28
  import { useTheme } from '@principal-ade/industry-theme';
29
29
  import { computeElkLayout } from '../utils/elkLayout';
30
- import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
31
- import { GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
30
+ import { describeConstructBreakdown, MECHANISM_COLOR, assignBoundaryColors, boundaryFill } from './model';
31
+ import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
32
32
 
33
33
  export interface AggregateFrameMember {
34
34
  alias: string;
@@ -91,13 +91,16 @@ function edgeColor(mechanism: string | undefined, fallback: string): string {
91
91
  }
92
92
 
93
93
  function AggregateFrameGroupView(
94
- props: NodeProps<Node<{ label: string; kind: 'process' | 'external' }>>,
94
+ props: NodeProps<Node<{ label: string; color: string; kind: 'process' | 'external' }>>,
95
95
  ) {
96
96
  const { theme } = useTheme();
97
97
  const external = props.data.kind === 'external';
98
- const border = external
98
+ // Processes take the hue assigned to their key, so boundaries read as regions
99
+ // at a glance; externals keep the warning accent, meaning "outside the repo".
100
+ // Dashed stays reserved for containment — solid chrome means a real entity.
101
+ const color = external
99
102
  ? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
100
- : (theme.colors.border ?? '#333');
103
+ : props.data.color;
101
104
  return (
102
105
  <div
103
106
  style={{
@@ -105,25 +108,42 @@ function AggregateFrameGroupView(
105
108
  height: '100%',
106
109
  boxSizing: 'border-box',
107
110
  borderRadius: 12,
108
- border: `2px ${external ? 'solid' : 'dashed'} ${border}`,
109
- background: 'transparent',
111
+ border: `2px dashed ${color}`,
112
+ background: external ? 'transparent' : boundaryFill(color),
110
113
  pointerEvents: 'none',
111
114
  }}
112
115
  >
113
116
  <span
114
117
  style={{
115
118
  position: 'absolute',
116
- top: -11,
119
+ // Centred on the frame's top border; translateY keeps it centred as
120
+ // the padding/border below grow instead of re-tuning a magic `top`.
121
+ top: 0,
122
+ transform: 'translateY(-50%)',
117
123
  left: 12,
124
+ // Edges route in through the frame's top edge, right where this
125
+ // label sits. The solid border + vertical padding give the opaque
126
+ // chip enough backing to mask a crossing edge with a gap rather than
127
+ // running flush against the text, and zIndex keeps the label above
128
+ // sibling frame nodes (mirrors the component graph's badges).
129
+ zIndex: 1,
118
130
  fontFamily: theme.fonts.monospace,
119
- fontSize: theme.fontSizes[1],
131
+ fontSize: theme.fontSizes[2],
132
+ fontWeight: 600,
120
133
  letterSpacing: 0.5,
121
134
  textTransform: 'uppercase',
122
- color: external
123
- ? border
124
- : (theme.colors.textMuted ?? theme.colors.textSecondary),
135
+ lineHeight: '18px',
136
+ color,
125
137
  background: theme.colors.background,
126
- padding: '0 8px',
138
+ // A rounded chip leaves transparent notches at its corners, and an
139
+ // edge crossing the frame's top border shows through them. The halo
140
+ // is the same colour as the fill, so it squares the corners back off
141
+ // invisibly while covering the antialiased boundary row too.
142
+ boxShadow: `0 0 0 1.5px ${theme.colors.background}`,
143
+ border: `2px solid ${color}`,
144
+ borderRadius: 4,
145
+ padding: '4px 10px',
146
+ boxSizing: 'border-box',
127
147
  whiteSpace: 'nowrap',
128
148
  }}
129
149
  >
@@ -295,16 +315,37 @@ function Inner({
295
315
  const [ready, setReady] = useState(false);
296
316
  const [selectedId, setSelectedId] = useState<string | null>(null);
297
317
  const [hoveredId, setHoveredId] = useState<string | null>(null);
318
+ const [legendOpen, setLegendOpen] = useState(true);
298
319
 
299
320
  const layoutKey = useMemo(
300
321
  () =>
301
322
  [
302
- ...frames.map((f) => f.id).sort(),
323
+ // Group assignment is part of the key: a frame keeps its id when its
324
+ // majority process flips, and the boundary colors follow the group.
325
+ ...frames
326
+ .map((f) => `${f.id}\0${f.group?.kind ?? ''}\0${f.group?.key ?? ''}`)
327
+ .sort(),
303
328
  ...edges.map((e) => `${e.from}\0${e.to}\0${e.steps}`).sort(),
304
329
  ].join('\n'),
305
330
  [frames, edges, hubs],
306
331
  );
307
332
 
333
+ // Process boundaries and their frame counts. Derived from the frames, so the
334
+ // legend and the boundary nodes can never drift from what is actually drawn.
335
+ const processBoundaries = useMemo(() => {
336
+ const counts = new Map<string, number>();
337
+ for (const f of frames) {
338
+ if (f.group?.kind !== 'process') continue;
339
+ counts.set(f.group.key, (counts.get(f.group.key) ?? 0) + 1);
340
+ }
341
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
342
+ }, [frames]);
343
+
344
+ const boundaryColors = useMemo(
345
+ () => assignBoundaryColors(processBoundaries.map(([key]) => key)),
346
+ [processBoundaries],
347
+ );
348
+
308
349
  useEffect(() => {
309
350
  let alive = true;
310
351
  setReady(false);
@@ -377,6 +418,11 @@ function Inner({
377
418
  // process frames.
378
419
  preserveNodePositions: false,
379
420
  groups,
421
+ // Every process and external boundary is real, including one holding a
422
+ // single frame — a quiet process still has components in it, and
423
+ // without this ELK drops the boundary and promotes the lone frame to
424
+ // root, which is indistinguishable from the process not existing.
425
+ keepSingletonGroups: true,
380
426
  })
381
427
  .then((result) => {
382
428
  if (!alive) return;
@@ -395,6 +441,10 @@ function Inner({
395
441
  selectable: false,
396
442
  data: {
397
443
  label: def?.key ?? id,
444
+ color:
445
+ (def?.kind === 'process' ? boundaryColors.get(def.key) : undefined) ??
446
+ theme.colors.border ??
447
+ '#333',
398
448
  kind: def?.kind ?? 'process',
399
449
  },
400
450
  });
@@ -429,7 +479,27 @@ function Inner({
429
479
  // eslint-disable-next-line react-hooks/exhaustive-deps
430
480
  }, [layoutKey]);
431
481
 
482
+ // A boundary is a container, not an endpoint: edges reference frame/hub
483
+ // ids, never the group shell's. Hovering one therefore needs its own rule.
484
+ // Treating the shell as the focus put *nothing* in its neighbour set, so the
485
+ // boundary's own contents were the ones that dimmed. Instead: keep every node
486
+ // lit and dim only the edges that do not touch the boundary.
487
+ const boundaryMembers = useMemo(() => {
488
+ const focus = hoveredId ?? selectedId;
489
+ if (!focus) return null;
490
+ if (nodes.find((n) => n.id === focus)?.type !== 'aggregate-frame-group') {
491
+ return null;
492
+ }
493
+ const members = new Set<string>();
494
+ for (const n of nodes) {
495
+ if ((n as { parentId?: string }).parentId === focus) members.add(n.id);
496
+ }
497
+ return members;
498
+ }, [hoveredId, selectedId, nodes]);
499
+
432
500
  const neighborIds = useMemo(() => {
501
+ // Boundary hover owns its own dimming (edges only) — no node focus set.
502
+ if (boundaryMembers) return null;
433
503
  const focus = hoveredId ?? selectedId;
434
504
  if (!focus) return null;
435
505
  const set = new Set<string>([focus]);
@@ -438,7 +508,7 @@ function Inner({
438
508
  if (e.to === focus) set.add(e.from);
439
509
  }
440
510
  return set;
441
- }, [hoveredId, selectedId, edges]);
511
+ }, [hoveredId, selectedId, edges, boundaryMembers]);
442
512
 
443
513
  const selected = selectedId ? (frames.find((f) => f.id === selectedId) ?? null) : null;
444
514
 
@@ -458,35 +528,47 @@ function Inner({
458
528
  data: {
459
529
  ...(n.data as object),
460
530
  selected: selectedId === n.id,
461
- dimmed: neighborIds ? !neighborIds.has(n.id) : false,
531
+ // Boundary hover dims everything outside the boundary; otherwise
532
+ // dim anything outside the focused node's neighbourhood.
533
+ dimmed: boundaryMembers
534
+ ? !boundaryMembers.has(n.id)
535
+ : neighborIds
536
+ ? !neighborIds.has(n.id)
537
+ : false,
462
538
  // Surviving parentId means ELK built this node's process frame —
463
539
  // the node renders nested, so a process box drops its redundant name.
464
540
  nested: (n as { parentId?: string }).parentId != null,
465
541
  },
466
542
  })),
467
- [nodes, selectedId, neighborIds],
543
+ [nodes, selectedId, neighborIds, boundaryMembers],
468
544
  );
469
545
 
470
546
  const displayEdges = useMemo(
471
547
  () =>
472
548
  rfEdges.map((e) => {
473
- const dimmed =
474
- neighborIds != null &&
475
- !(neighborIds.has(e.source) && neighborIds.has(e.target));
549
+ // Hovering a boundary keeps only the edges wholly inside it lit — a
550
+ // boundary-crossing edge has an endpoint outside, so it dims with the
551
+ // rest. Otherwise fall back to the node-focus rule.
552
+ const dimmed = boundaryMembers
553
+ ? !(boundaryMembers.has(e.source) && boundaryMembers.has(e.target))
554
+ : neighborIds != null &&
555
+ !(neighborIds.has(e.source) && neighborIds.has(e.target));
476
556
  return {
477
557
  ...e,
478
558
  style: { ...(e.style as object), opacity: dimmed ? 0.12 : 0.9 },
479
559
  };
480
560
  }),
481
- [rfEdges, neighborIds],
561
+ [rfEdges, neighborIds, boundaryMembers],
482
562
  );
483
563
 
484
564
  return (
485
565
  <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
566
+ <GraphLayerStyle />
486
567
  <ReactFlow
487
568
  nodes={displayNodes}
488
569
  edges={displayEdges}
489
570
  nodeTypes={nodeTypes}
571
+ className={GRAPH_CANVAS_CLASS}
490
572
  {...GRAPH_NAV_PROPS}
491
573
  onNodeClick={onNodeClick}
492
574
  onNodeMouseEnter={(_e, node) => setHoveredId(node.id)}
@@ -534,6 +616,94 @@ function Inner({
534
616
  {title}
535
617
  </div>
536
618
  )}
619
+ {processBoundaries.length > 1 && (
620
+ <div
621
+ style={{
622
+ position: 'absolute',
623
+ top: 12,
624
+ // Sits opposite the selection panel; shift clear of it while open.
625
+ right: selected ? 304 : 12,
626
+ maxHeight: '40%',
627
+ display: 'flex',
628
+ flexDirection: 'column',
629
+ gap: 3,
630
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
631
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
632
+ borderRadius: 6,
633
+ padding: '6px 10px',
634
+ // Only the header toggles; the body must not eat canvas panning.
635
+ pointerEvents: 'none',
636
+ }}
637
+ >
638
+ <button
639
+ type="button"
640
+ onClick={() => setLegendOpen((v) => !v)}
641
+ aria-expanded={legendOpen}
642
+ style={{
643
+ pointerEvents: 'auto',
644
+ display: 'flex',
645
+ alignItems: 'center',
646
+ gap: 6,
647
+ padding: 0,
648
+ border: 'none',
649
+ background: 'none',
650
+ cursor: 'pointer',
651
+ fontFamily: theme.fonts.monospace,
652
+ fontSize: theme.fontSizes[0],
653
+ letterSpacing: 0.5,
654
+ textTransform: 'uppercase',
655
+ color: muted,
656
+ }}
657
+ >
658
+ <span>{legendOpen ? '▾' : '▸'}</span>
659
+ <span>legend</span>
660
+ </button>
661
+ {legendOpen && (
662
+ <div
663
+ style={{
664
+ display: 'flex',
665
+ flexDirection: 'column',
666
+ gap: 3,
667
+ maxHeight: '40vh',
668
+ overflowY: 'auto',
669
+ }}
670
+ >
671
+ {processBoundaries.map(([key, count]) => {
672
+ const color = boundaryColors.get(key) ?? muted;
673
+ return (
674
+ <span
675
+ key={key}
676
+ style={{
677
+ display: 'flex',
678
+ alignItems: 'center',
679
+ gap: 6,
680
+ fontFamily: theme.fonts.monospace,
681
+ fontSize: theme.fontSizes[0],
682
+ color: theme.colors.text,
683
+ whiteSpace: 'nowrap',
684
+ }}
685
+ >
686
+ <span
687
+ style={{
688
+ width: 10,
689
+ height: 10,
690
+ flexShrink: 0,
691
+ borderRadius: 3,
692
+ border: `2px dashed ${color}`,
693
+ background: boundaryFill(color),
694
+ }}
695
+ />
696
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 160 }} title={key}>
697
+ {key}
698
+ </span>
699
+ <span style={{ color: muted }}>{count}</span>
700
+ </span>
701
+ );
702
+ })}
703
+ </div>
704
+ )}
705
+ </div>
706
+ )}
537
707
  {selected && (
538
708
  <div
539
709
  style={{
@@ -62,7 +62,7 @@ import {
62
62
  } from './IssueList';
63
63
  import { SubsystemFileTree } from './SubsystemFileTree';
64
64
  import { GraphLayoutCover } from './GraphLayoutCover';
65
- import { GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
65
+ import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
66
66
  import { ComponentDeclaration } from './ComponentDeclaration';
67
67
  import type { ComponentVerificationState } from './ComponentDeclaration';
68
68
  import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
@@ -173,6 +173,14 @@ export interface SubsystemComponentGraphProps {
173
173
  maxNodeWidth?: number;
174
174
  /** Show edge labels (mechanism names) on the graph. @default true */
175
175
  showEdgeLabels?: boolean;
176
+ /**
177
+ * Keep boundary frames for 1-member process / module / package regions. The
178
+ * singleton rule (frames need 2+ members) drops these, which also hid quiet
179
+ * regions like a one-file process that owns real behaviour. Defaults to true
180
+ * here so the component graph matches the aggregate graph, which always keeps
181
+ * them; pass false to fall back to the 2+ member rule.
182
+ */
183
+ showSingletonFrames?: boolean;
176
184
  /**
177
185
  * Which edge vocabulary the canvas draws. The relation and walkthrough
178
186
  * vocabularies are disjoint, so a graph carrying both shows one or the
@@ -396,7 +404,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
396
404
  measured: { w: number; h: number } | null;
397
405
  }
398
406
 
399
- function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, persistKey }: InnerProps) {
407
+ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, persistKey }: InnerProps) {
400
408
  const { theme } = useTheme();
401
409
  const { fitView } = useReactFlow();
402
410
  const viewport = useViewport();
@@ -657,7 +665,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
657
665
  relations: relationsRef.current,
658
666
  walkthroughs: walkthroughsRef.current,
659
667
  };
660
- void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels })
668
+ void buildSubsystemGraph(doc, {
669
+ maxNodeWidth,
670
+ showEdgeLabels,
671
+ showSingletonFrames,
672
+ })
661
673
  .then(({ nodes, edges: e }) => {
662
674
  if (!alive) return;
663
675
  // Prune dims for removed leaves; keep measurements for stable ids so a
@@ -706,7 +718,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
706
718
  const gen = ++pass2GenRef.current;
707
719
  void buildSubsystemGraph(
708
720
  { components, relations, walkthroughs },
709
- { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights },
721
+ { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames },
710
722
  )
711
723
  .then(({ nodes, edges: e }) => {
712
724
  if (gen !== pass2GenRef.current) return;
@@ -1974,12 +1986,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1974
1986
  })}
1975
1987
  </div>
1976
1988
  )}
1989
+ <GraphLayerStyle />
1977
1990
  <ReactFlow
1978
1991
  key={`${baseNodesKey}-${baseEdgesKey}`}
1979
1992
  nodes={dispNodes}
1980
1993
  edges={dispEdges}
1981
1994
  nodeTypes={nodeTypes}
1982
1995
  edgeTypes={edgeTypes}
1996
+ className={GRAPH_CANVAS_CLASS}
1983
1997
  {...GRAPH_NAV_PROPS}
1984
1998
  onNodeClick={onNodeClick}
1985
1999
  onEdgeClick={onEdgeClick}
@@ -64,7 +64,15 @@ function WrappablePath({ path }: { path: string }) {
64
64
  }
65
65
 
66
66
  /** Label stacked above the value, so long values get the full width. */
67
- function StackedRow({ label, children }: { label: string; children: ReactNode }) {
67
+ function StackedRow({
68
+ label,
69
+ children,
70
+ labelSize,
71
+ }: {
72
+ label: string;
73
+ children: ReactNode;
74
+ labelSize?: number;
75
+ }) {
68
76
  const { theme } = useTheme();
69
77
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
70
78
  return (
@@ -73,7 +81,7 @@ function StackedRow({ label, children }: { label: string; children: ReactNode })
73
81
  style={{
74
82
  color: muted,
75
83
  fontFamily: theme.fonts.body,
76
- fontSize: theme.fontSizes[0],
84
+ fontSize: labelSize ?? theme.fontSizes[0],
77
85
  textTransform: 'uppercase',
78
86
  letterSpacing: 0.4,
79
87
  }}
@@ -261,6 +269,7 @@ export function SymbolInspectionCard({
261
269
  const mono = theme.fonts.monospace;
262
270
  const small = theme.fontSizes[0];
263
271
  const body = theme.fontSizes[1];
272
+ const [hoveredCandidate, setHoveredCandidate] = useState<string | null>(null);
264
273
 
265
274
  const info = inspection ?? undefined;
266
275
  const node = info?.node;
@@ -354,9 +363,14 @@ export function SymbolInspectionCard({
354
363
  )}
355
364
 
356
365
  {!error && !hasBody && (
357
- <span style={{ color: theme.colors.accent ?? theme.colors.secondary, fontWeight: 600 }}>
358
- {symbolRef.name}
359
- </span>
366
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
367
+ <span style={{ color: theme.colors.accent ?? theme.colors.secondary, fontWeight: 600 }}>
368
+ {symbolRef.name}
369
+ </span>
370
+ <span style={{ color: muted, fontFamily: theme.fonts.body, fontSize: body }}>
371
+ {statusText}
372
+ </span>
373
+ </div>
360
374
  )}
361
375
  </div>
362
376
 
@@ -391,14 +405,8 @@ export function SymbolInspectionCard({
391
405
  </StackedRow>
392
406
  )}
393
407
 
394
- {!hasBody && (
395
- <span style={{ color: muted, fontFamily: theme.fonts.body, fontSize: body }}>
396
- {statusText}
397
- </span>
398
- )}
399
-
400
408
  {info?.candidates && info.candidates.length > 0 && (
401
- <StackedRow label="Candidates">
409
+ <StackedRow label="Candidates" labelSize={body}>
402
410
  <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
403
411
  {info.candidates.slice(0, 6).map((c) =>
404
412
  // Ambiguous candidates share the symbol name, so the file is
@@ -409,11 +417,22 @@ export function SymbolInspectionCard({
409
417
  role={onOpenFile ? 'button' : undefined}
410
418
  tabIndex={onOpenFile ? 0 : undefined}
411
419
  onClick={onOpenFile ? () => onOpenFile(c.sourceFile!, undefined) : undefined}
420
+ onMouseEnter={
421
+ onOpenFile ? () => setHoveredCandidate(c.nodeId) : undefined
422
+ }
423
+ onMouseLeave={
424
+ onOpenFile
425
+ ? () => setHoveredCandidate((h) => (h === c.nodeId ? null : h))
426
+ : undefined
427
+ }
412
428
  style={{
413
429
  fontFamily: mono,
414
430
  fontSize: body,
415
431
  color: theme.colors.accent ?? theme.colors.secondary,
416
432
  cursor: onOpenFile ? 'pointer' : 'default',
433
+ textDecorationLine:
434
+ onOpenFile && hoveredCandidate === c.nodeId ? 'underline' : 'none',
435
+ textUnderlineOffset: 2,
417
436
  }}
418
437
  >
419
438
  <WrappablePath path={c.sourceFile} />
@@ -30,6 +30,26 @@ export const GRAPH_NAV_PROPS = {
30
30
  edgesReconnectable: false,
31
31
  } satisfies Partial<ReactFlowProps>;
32
32
 
33
+ /**
34
+ * React Flow mounts its edge SVG (`.react-flow__edges`) and node layer
35
+ * (`.react-flow__nodes`) as sibling containers inside the viewport, both at
36
+ * `z-index: auto`, and leans on DOM order (edges first, nodes second) to keep
37
+ * nodes on top. Nodes carry an inline `z-index: 0`, which on its own does not
38
+ * reliably beat the edge layer, so an edge routing into a boundary frame's top
39
+ * border paints *over* that frame's label. Lift the whole node layer above the
40
+ * edge layer once for every graph that spreads `GRAPH_NAV_PROPS`.
41
+ *
42
+ * Scoped by `GRAPH_CANVAS_CLASS` so it only touches our canvases.
43
+ */
44
+ export const GRAPH_CANVAS_CLASS = 'subsystem-graph-canvas';
45
+
46
+ export const GRAPH_LAYER_CSS = `.${GRAPH_CANVAS_CLASS} .react-flow__nodes { z-index: 1; }`;
47
+
48
+ /** Scoped style tag for `GRAPH_LAYER_CSS`; render once beside `<ReactFlow>`. */
49
+ export function GraphLayerStyle() {
50
+ return <style>{GRAPH_LAYER_CSS}</style>;
51
+ }
52
+
33
53
  /** Background dots + zoom/fit/interactive widget; render inside `<ReactFlow>`. */
34
54
  export function GraphChrome() {
35
55
  return (
@@ -435,6 +435,97 @@ describe('subsystem graph model', () => {
435
435
  expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
436
436
  });
437
437
 
438
+ // NOTE: buildSubsystemGraph cannot assert ELK-built frames here — elkjs
439
+ // constructs a Worker, which bun's test env lacks, so every call falls back
440
+ // to manual positions. Frame existence is covered by planCompoundGroups
441
+ // tests in utils/elkLayout.test.ts; these cover the grouping inputs.
442
+
443
+ test('buildBoundaryLayoutGroups keeps singleton regions only when asked', () => {
444
+ const doc = {
445
+ components: [
446
+ { alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
447
+ { alias: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
448
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
449
+ ],
450
+ };
451
+ const off = buildBoundaryLayoutGroups(doc as never);
452
+ expect(off.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
453
+
454
+ const on = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
455
+ const lonely = on.find((g) => g.id === processGroupNodeId('app/lonely'));
456
+ expect(lonely).toBeDefined();
457
+ expect(lonely!.memberAliases).toEqual(['solo']);
458
+ });
459
+
460
+ test('subsystem component graph keeps singleton frames unless opted out', () => {
461
+ // The component graph's default differs from buildBoundaryLayoutGroups'
462
+ // own default: SubsystemComponentGraph passes showSingletonFrames=true so
463
+ // a one-file process keeps its boundary, matching the aggregate graph.
464
+ // Only an explicit false falls back to the 2+ member rule.
465
+ const doc = {
466
+ components: [
467
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
468
+ ],
469
+ };
470
+ // Mirrors the Inner default: `showSingletonFrames = true`.
471
+ const componentGraphDefault = true;
472
+ const kept = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: componentGraphDefault });
473
+ expect(kept.map((g) => g.id)).toContain(processGroupNodeId('app/lonely'));
474
+
475
+ const optedOut = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: false });
476
+ expect(optedOut.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
477
+ });
478
+
479
+ test('buildBoundaryLayoutGroups parents every leaf to exactly one frame', () => {
480
+ // The invariant that keeps ELK from throwing "value already present".
481
+ // Singletons do not claim (see buildBoundaryLayoutGroups): they always
482
+ // nest, so the enclosing frame's own filter has to exclude them. This
483
+ // asserts the outcome rather than the mechanism.
484
+ const doc = {
485
+ components: [
486
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
487
+ { alias: 'x', name: 'x', construct: 'function', file: 'x.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
488
+ { alias: 'y', name: 'y', construct: 'function', file: 'y.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
489
+ { alias: 'z', name: 'z', construct: 'function', file: 'z.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
490
+ { alias: 'w', name: 'w', construct: 'function', file: 'w.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
491
+ // module-less leaf in a multi-member process
492
+ { alias: 'loose', name: 'loose', construct: 'function', file: 'l.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
493
+ ],
494
+ };
495
+ const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
496
+ const owners = new Map<string, string[]>();
497
+ for (const g of groups) {
498
+ for (const alias of g.memberAliases) {
499
+ owners.set(alias, [...(owners.get(alias) ?? []), g.id]);
500
+ }
501
+ }
502
+ for (const [alias, claimedBy] of owners) {
503
+ expect(new Set(claimedBy).size, `leaf ${alias} claimed by ${claimedBy.join(', ')}`).toBe(1);
504
+ }
505
+ // The singleton process nests under its package rather than claiming.
506
+ const lonely = groups.find((g) => g.id === processGroupNodeId('app/lonely'));
507
+ expect(lonely!.memberAliases).toEqual(['solo']);
508
+ expect(lonely!.parentId).toBe(packageGroupNodeId('pkg:github/acme/app'));
509
+ });
510
+
511
+ test('buildBoundaryLayoutGroups singleton module claims its leaf from the process', () => {
512
+ const doc = {
513
+ components: [
514
+ { alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
515
+ { alias: 'm1', name: 'm1', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
516
+ { alias: 'm2', name: 'm2', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
517
+ ],
518
+ };
519
+ const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
520
+ const mod = groups.find((g) => g.id === moduleGroupNodeId('src/one.ts'));
521
+ const proc = groups.find((g) => g.id === processGroupNodeId('app/host'));
522
+ expect(mod!.memberAliases).toEqual(['m1', 'm2']);
523
+ // The module is nested, so the process lists the module — not its
524
+ // leaves. `a` has no module, so it stays a direct leaf of the process.
525
+ expect(proc!.memberAliases).toEqual([moduleGroupNodeId('src/one.ts'), 'a']);
526
+ expect(proc!.parentId).toBeUndefined();
527
+ });
528
+
438
529
  test('buildSubsystemGraph frames multi-member modules', async () => {
439
530
  const { nodes, regions } = await buildSubsystemGraph({
440
531
  components: [