@principal-ai/subsystems-react 0.35.7 → 0.36.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/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/graphChrome.d.ts +15 -0
  15. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  16. package/dist/subsystem/graphChrome.js +17 -0
  17. package/dist/subsystem/graphChrome.js.map +1 -1
  18. package/dist/subsystem/model.d.ts +26 -0
  19. package/dist/subsystem/model.d.ts.map +1 -1
  20. package/dist/subsystem/model.js +90 -2
  21. package/dist/subsystem/model.js.map +1 -1
  22. package/dist/subsystem/nodes.d.ts.map +1 -1
  23. package/dist/subsystem/nodes.js +12 -9
  24. package/dist/subsystem/nodes.js.map +1 -1
  25. package/dist/utils/elkLayout.d.ts +45 -0
  26. package/dist/utils/elkLayout.d.ts.map +1 -1
  27. package/dist/utils/elkLayout.js +114 -84
  28. package/dist/utils/elkLayout.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/graphify/signature.test.ts +1 -1
  31. package/src/index.ts +1 -0
  32. package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
  33. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
  34. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +8 -8
  35. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +19 -11
  36. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
  37. package/src/subsystem/SubsystemAggregateGraph.tsx +229 -21
  38. package/src/subsystem/SubsystemComponentGraph.tsx +34 -16
  39. package/src/subsystem/graphChrome.tsx +20 -0
  40. package/src/subsystem/model.test.ts +105 -0
  41. package/src/subsystem/model.ts +119 -4
  42. package/src/subsystem/nodes.tsx +49 -64
  43. package/src/utils/elkLayout.test.ts +117 -3
  44. package/src/utils/elkLayout.ts +154 -82
  45. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
  46. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
  47. /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
  >
@@ -294,21 +314,44 @@ function Inner({
294
314
  const [rfEdges, setRfEdges] = useState<Edge[]>([]);
295
315
  const [ready, setReady] = useState(false);
296
316
  const [selectedId, setSelectedId] = useState<string | null>(null);
317
+ const [focusedBoundaryId, setFocusedBoundaryId] = useState<string | null>(null);
297
318
  const [hoveredId, setHoveredId] = useState<string | null>(null);
319
+ const [legendOpen, setLegendOpen] = useState(true);
298
320
 
299
321
  const layoutKey = useMemo(
300
322
  () =>
301
323
  [
302
- ...frames.map((f) => f.id).sort(),
324
+ // Group assignment is part of the key: a frame keeps its id when its
325
+ // majority process flips, and the boundary colors follow the group.
326
+ ...frames
327
+ .map((f) => `${f.id}\0${f.group?.kind ?? ''}\0${f.group?.key ?? ''}`)
328
+ .sort(),
303
329
  ...edges.map((e) => `${e.from}\0${e.to}\0${e.steps}`).sort(),
304
330
  ].join('\n'),
305
331
  [frames, edges, hubs],
306
332
  );
307
333
 
334
+ // Process boundaries and their frame counts. Derived from the frames, so the
335
+ // legend and the boundary nodes can never drift from what is actually drawn.
336
+ const processBoundaries = useMemo(() => {
337
+ const counts = new Map<string, number>();
338
+ for (const f of frames) {
339
+ if (f.group?.kind !== 'process') continue;
340
+ counts.set(f.group.key, (counts.get(f.group.key) ?? 0) + 1);
341
+ }
342
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
343
+ }, [frames]);
344
+
345
+ const boundaryColors = useMemo(
346
+ () => assignBoundaryColors(processBoundaries.map(([key]) => key)),
347
+ [processBoundaries],
348
+ );
349
+
308
350
  useEffect(() => {
309
351
  let alive = true;
310
352
  setReady(false);
311
353
  setSelectedId(null);
354
+ setFocusedBoundaryId(null);
312
355
  // Compound process frames: each box is parented to exactly one process
313
356
  // group (its majority process); process member-boxes to their own
314
357
  // process; the rest stays root-level. Exclusive assignment by
@@ -377,6 +420,11 @@ function Inner({
377
420
  // process frames.
378
421
  preserveNodePositions: false,
379
422
  groups,
423
+ // Every process and external boundary is real, including one holding a
424
+ // single frame — a quiet process still has components in it, and
425
+ // without this ELK drops the boundary and promotes the lone frame to
426
+ // root, which is indistinguishable from the process not existing.
427
+ keepSingletonGroups: true,
380
428
  })
381
429
  .then((result) => {
382
430
  if (!alive) return;
@@ -395,6 +443,10 @@ function Inner({
395
443
  selectable: false,
396
444
  data: {
397
445
  label: def?.key ?? id,
446
+ color:
447
+ (def?.kind === 'process' ? boundaryColors.get(def.key) : undefined) ??
448
+ theme.colors.border ??
449
+ '#333',
398
450
  kind: def?.kind ?? 'process',
399
451
  },
400
452
  });
@@ -429,7 +481,27 @@ function Inner({
429
481
  // eslint-disable-next-line react-hooks/exhaustive-deps
430
482
  }, [layoutKey]);
431
483
 
484
+ // A boundary is a container, not an endpoint: edges reference frame/hub
485
+ // ids, never the group shell's. Hovering one therefore needs its own rule.
486
+ // Treating the shell as the focus put *nothing* in its neighbour set, so the
487
+ // boundary's own contents were the ones that dimmed. Instead: keep every node
488
+ // lit and dim only the edges that do not touch the boundary.
489
+ const boundaryMembers = useMemo(() => {
490
+ const focus = hoveredId ?? selectedId;
491
+ if (!focus) return null;
492
+ if (nodes.find((n) => n.id === focus)?.type !== 'aggregate-frame-group') {
493
+ return null;
494
+ }
495
+ const members = new Set<string>();
496
+ for (const n of nodes) {
497
+ if ((n as { parentId?: string }).parentId === focus) members.add(n.id);
498
+ }
499
+ return members;
500
+ }, [hoveredId, selectedId, nodes]);
501
+
432
502
  const neighborIds = useMemo(() => {
503
+ // Boundary hover owns its own dimming (edges only) — no node focus set.
504
+ if (boundaryMembers) return null;
433
505
  const focus = hoveredId ?? selectedId;
434
506
  if (!focus) return null;
435
507
  const set = new Set<string>([focus]);
@@ -438,12 +510,15 @@ function Inner({
438
510
  if (e.to === focus) set.add(e.from);
439
511
  }
440
512
  return set;
441
- }, [hoveredId, selectedId, edges]);
513
+ }, [hoveredId, selectedId, edges, boundaryMembers]);
442
514
 
443
515
  const selected = selectedId ? (frames.find((f) => f.id === selectedId) ?? null) : null;
444
516
 
445
517
  const onNodeClick = useCallback(
446
518
  (_e: unknown, node: Node) => {
519
+ // Boundaries are shells, not frames — there is no frame to select, and a
520
+ // double-click would otherwise toggle the (empty) selection twice.
521
+ if (node.type === 'aggregate-frame-group') return;
447
522
  const next = selectedId === node.id ? null : node.id;
448
523
  setSelectedId(next);
449
524
  onSelectFrame?.(next);
@@ -451,6 +526,37 @@ function Inner({
451
526
  [selectedId, onSelectFrame],
452
527
  );
453
528
 
529
+ // Double-click a boundary to zoom to it; double-click it again to zoom back
530
+ // out to the whole graph. Double-clicking a different boundary moves the
531
+ // focus. A boundary's interior is covered by the frames inside it, so a hit
532
+ // on any frame resolves to its enclosing boundary — otherwise double-clicking
533
+ // "on the boundary" would usually land on a frame and do nothing.
534
+ const onNodeDoubleClick = useCallback(
535
+ (_e: unknown, node: Node) => {
536
+ const boundaryId =
537
+ node.type === 'aggregate-frame-group'
538
+ ? node.id
539
+ : (() => {
540
+ const parentId = (node as { parentId?: string }).parentId;
541
+ if (!parentId) return null;
542
+ return nodes.some(
543
+ (n) => n.id === parentId && n.type === 'aggregate-frame-group',
544
+ )
545
+ ? parentId
546
+ : null;
547
+ })();
548
+ if (!boundaryId) return;
549
+ if (focusedBoundaryId === boundaryId) {
550
+ setFocusedBoundaryId(null);
551
+ void fitView({ padding: 0.15, duration: 400 });
552
+ } else {
553
+ setFocusedBoundaryId(boundaryId);
554
+ void fitView({ nodes: [{ id: boundaryId }], padding: 0.25, duration: 400 });
555
+ }
556
+ },
557
+ [focusedBoundaryId, fitView, nodes],
558
+ );
559
+
454
560
  const displayNodes = useMemo(
455
561
  () =>
456
562
  nodes.map((n) => ({
@@ -458,41 +564,55 @@ function Inner({
458
564
  data: {
459
565
  ...(n.data as object),
460
566
  selected: selectedId === n.id,
461
- dimmed: neighborIds ? !neighborIds.has(n.id) : false,
567
+ // Boundary hover dims everything outside the boundary; otherwise
568
+ // dim anything outside the focused node's neighbourhood.
569
+ dimmed: boundaryMembers
570
+ ? !boundaryMembers.has(n.id)
571
+ : neighborIds
572
+ ? !neighborIds.has(n.id)
573
+ : false,
462
574
  // Surviving parentId means ELK built this node's process frame —
463
575
  // the node renders nested, so a process box drops its redundant name.
464
576
  nested: (n as { parentId?: string }).parentId != null,
465
577
  },
466
578
  })),
467
- [nodes, selectedId, neighborIds],
579
+ [nodes, selectedId, neighborIds, boundaryMembers],
468
580
  );
469
581
 
470
582
  const displayEdges = useMemo(
471
583
  () =>
472
584
  rfEdges.map((e) => {
473
- const dimmed =
474
- neighborIds != null &&
475
- !(neighborIds.has(e.source) && neighborIds.has(e.target));
585
+ // Hovering a boundary keeps only the edges wholly inside it lit — a
586
+ // boundary-crossing edge has an endpoint outside, so it dims with the
587
+ // rest. Otherwise fall back to the node-focus rule.
588
+ const dimmed = boundaryMembers
589
+ ? !(boundaryMembers.has(e.source) && boundaryMembers.has(e.target))
590
+ : neighborIds != null &&
591
+ !(neighborIds.has(e.source) && neighborIds.has(e.target));
476
592
  return {
477
593
  ...e,
478
594
  style: { ...(e.style as object), opacity: dimmed ? 0.12 : 0.9 },
479
595
  };
480
596
  }),
481
- [rfEdges, neighborIds],
597
+ [rfEdges, neighborIds, boundaryMembers],
482
598
  );
483
599
 
484
600
  return (
485
601
  <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
602
+ <GraphLayerStyle />
486
603
  <ReactFlow
487
604
  nodes={displayNodes}
488
605
  edges={displayEdges}
489
606
  nodeTypes={nodeTypes}
607
+ className={GRAPH_CANVAS_CLASS}
490
608
  {...GRAPH_NAV_PROPS}
491
609
  onNodeClick={onNodeClick}
610
+ onNodeDoubleClick={onNodeDoubleClick}
492
611
  onNodeMouseEnter={(_e, node) => setHoveredId(node.id)}
493
612
  onNodeMouseLeave={() => setHoveredId(null)}
494
613
  onPaneClick={() => {
495
614
  setSelectedId(null);
615
+ setFocusedBoundaryId(null);
496
616
  onSelectFrame?.(null);
497
617
  }}
498
618
  proOptions={{ hideAttribution: true }}
@@ -534,6 +654,94 @@ function Inner({
534
654
  {title}
535
655
  </div>
536
656
  )}
657
+ {processBoundaries.length > 1 && (
658
+ <div
659
+ style={{
660
+ position: 'absolute',
661
+ top: 12,
662
+ // Sits opposite the selection panel; shift clear of it while open.
663
+ right: selected ? 304 : 12,
664
+ maxHeight: '40%',
665
+ display: 'flex',
666
+ flexDirection: 'column',
667
+ gap: 3,
668
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
669
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
670
+ borderRadius: 6,
671
+ padding: '6px 10px',
672
+ // Only the header toggles; the body must not eat canvas panning.
673
+ pointerEvents: 'none',
674
+ }}
675
+ >
676
+ <button
677
+ type="button"
678
+ onClick={() => setLegendOpen((v) => !v)}
679
+ aria-expanded={legendOpen}
680
+ style={{
681
+ pointerEvents: 'auto',
682
+ display: 'flex',
683
+ alignItems: 'center',
684
+ gap: 6,
685
+ padding: 0,
686
+ border: 'none',
687
+ background: 'none',
688
+ cursor: 'pointer',
689
+ fontFamily: theme.fonts.monospace,
690
+ fontSize: theme.fontSizes[0],
691
+ letterSpacing: 0.5,
692
+ textTransform: 'uppercase',
693
+ color: muted,
694
+ }}
695
+ >
696
+ <span>{legendOpen ? '▾' : '▸'}</span>
697
+ <span>legend</span>
698
+ </button>
699
+ {legendOpen && (
700
+ <div
701
+ style={{
702
+ display: 'flex',
703
+ flexDirection: 'column',
704
+ gap: 3,
705
+ maxHeight: '40vh',
706
+ overflowY: 'auto',
707
+ }}
708
+ >
709
+ {processBoundaries.map(([key, count]) => {
710
+ const color = boundaryColors.get(key) ?? muted;
711
+ return (
712
+ <span
713
+ key={key}
714
+ style={{
715
+ display: 'flex',
716
+ alignItems: 'center',
717
+ gap: 6,
718
+ fontFamily: theme.fonts.monospace,
719
+ fontSize: theme.fontSizes[0],
720
+ color: theme.colors.text,
721
+ whiteSpace: 'nowrap',
722
+ }}
723
+ >
724
+ <span
725
+ style={{
726
+ width: 10,
727
+ height: 10,
728
+ flexShrink: 0,
729
+ borderRadius: 3,
730
+ border: `2px dashed ${color}`,
731
+ background: boundaryFill(color),
732
+ }}
733
+ />
734
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 160 }} title={key}>
735
+ {key}
736
+ </span>
737
+ <span style={{ color: muted }}>{count}</span>
738
+ </span>
739
+ );
740
+ })}
741
+ </div>
742
+ )}
743
+ </div>
744
+ )}
537
745
  {selected && (
538
746
  <div
539
747
  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;
@@ -1157,6 +1169,21 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1157
1169
  [onFileSelect],
1158
1170
  );
1159
1171
 
1172
+ // Double-click a component opens its declaration in the file drawer — the
1173
+ // `file` + anchored `declarationRef.startLine` when verify resolved one,
1174
+ // else the file top. Uses the same open path as the declaration panel's
1175
+ // file link, so the drawer and file tree stay in sync.
1176
+ const onNodeDoubleClick: NodeMouseHandler = useCallback(
1177
+ (_e, node: Node) => {
1178
+ if (node.type !== 'subsystem-component') return;
1179
+ const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
1180
+ if (!comp?.file) return;
1181
+ const startLine = comp.declarationRef?.startLine;
1182
+ onOpenDeclarationFile(comp.file, startLine != null ? { startLine } : undefined);
1183
+ },
1184
+ [onOpenDeclarationFile],
1185
+ );
1186
+
1160
1187
  const onOpenFileFromWalkthrough = useCallback(
1161
1188
  (file: string, opts?: SubsystemOpenFileOptions) => {
1162
1189
  setFileOverlay({ file, startLine: opts?.startLine });
@@ -1527,18 +1554,6 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1527
1554
  [expandedWalkthroughs, fitOverview, zoomOnWalkthroughFocus],
1528
1555
  );
1529
1556
 
1530
- // Filename-badge clicks on nodes open the drawer through the same path as
1531
- // the declaration panel's file link (toggle + tree sync, no start line).
1532
- useEffect(() => {
1533
- SUBSYSTEM_CALLBACKS.onOpenFile = (componentAlias: string) => {
1534
- const comp = components.find((c) => c.alias === componentAlias);
1535
- if (comp?.file) onOpenDeclarationFile(comp.file);
1536
- };
1537
- return () => {
1538
- SUBSYSTEM_CALLBACKS.onOpenFile = undefined;
1539
- };
1540
- }, [components, onOpenDeclarationFile]);
1541
-
1542
1557
  // Detail-panel links: related-name clicks select the matching component —
1543
1558
  // resolved by alias, name, or symbol (call labels may carry a trailing `()`).
1544
1559
  const resolveRelatedComponent = useCallback(
@@ -1974,14 +1989,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1974
1989
  })}
1975
1990
  </div>
1976
1991
  )}
1992
+ <GraphLayerStyle />
1977
1993
  <ReactFlow
1978
1994
  key={`${baseNodesKey}-${baseEdgesKey}`}
1979
1995
  nodes={dispNodes}
1980
1996
  edges={dispEdges}
1981
1997
  nodeTypes={nodeTypes}
1982
1998
  edgeTypes={edgeTypes}
1999
+ className={GRAPH_CANVAS_CLASS}
1983
2000
  {...GRAPH_NAV_PROPS}
1984
2001
  onNodeClick={onNodeClick}
2002
+ onNodeDoubleClick={onNodeDoubleClick}
1985
2003
  onEdgeClick={onEdgeClick}
1986
2004
  onNodesChange={onNodesChange}
1987
2005
  onEdgesChange={onEdgesChange}
@@ -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 (
@@ -12,6 +12,8 @@ import {
12
12
  moduleBadgeHoverLabel,
13
13
  moduleBadgeWidth,
14
14
  moduleMinWidthForBadge,
15
+ boundaryMinWidthForBadge,
16
+ MODULE_BADGE_INSET,
15
17
  packageGroupNodeId,
16
18
  buildBoundaryLayoutGroups,
17
19
  buildSubsystemGraph,
@@ -435,6 +437,97 @@ describe('subsystem graph model', () => {
435
437
  expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
436
438
  });
437
439
 
440
+ // NOTE: buildSubsystemGraph cannot assert ELK-built frames here — elkjs
441
+ // constructs a Worker, which bun's test env lacks, so every call falls back
442
+ // to manual positions. Frame existence is covered by planCompoundGroups
443
+ // tests in utils/elkLayout.test.ts; these cover the grouping inputs.
444
+
445
+ test('buildBoundaryLayoutGroups keeps singleton regions only when asked', () => {
446
+ const doc = {
447
+ components: [
448
+ { alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
449
+ { alias: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
450
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
451
+ ],
452
+ };
453
+ const off = buildBoundaryLayoutGroups(doc as never);
454
+ expect(off.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
455
+
456
+ const on = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
457
+ const lonely = on.find((g) => g.id === processGroupNodeId('app/lonely'));
458
+ expect(lonely).toBeDefined();
459
+ expect(lonely!.memberAliases).toEqual(['solo']);
460
+ });
461
+
462
+ test('subsystem component graph keeps singleton frames unless opted out', () => {
463
+ // The component graph's default differs from buildBoundaryLayoutGroups'
464
+ // own default: SubsystemComponentGraph passes showSingletonFrames=true so
465
+ // a one-file process keeps its boundary, matching the aggregate graph.
466
+ // Only an explicit false falls back to the 2+ member rule.
467
+ const doc = {
468
+ components: [
469
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
470
+ ],
471
+ };
472
+ // Mirrors the Inner default: `showSingletonFrames = true`.
473
+ const componentGraphDefault = true;
474
+ const kept = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: componentGraphDefault });
475
+ expect(kept.map((g) => g.id)).toContain(processGroupNodeId('app/lonely'));
476
+
477
+ const optedOut = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: false });
478
+ expect(optedOut.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
479
+ });
480
+
481
+ test('buildBoundaryLayoutGroups parents every leaf to exactly one frame', () => {
482
+ // The invariant that keeps ELK from throwing "value already present".
483
+ // Singletons do not claim (see buildBoundaryLayoutGroups): they always
484
+ // nest, so the enclosing frame's own filter has to exclude them. This
485
+ // asserts the outcome rather than the mechanism.
486
+ const doc = {
487
+ components: [
488
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
489
+ { alias: 'x', name: 'x', construct: 'function', file: 'x.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
490
+ { alias: 'y', name: 'y', construct: 'function', file: 'y.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
491
+ { alias: 'z', name: 'z', construct: 'function', file: 'z.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
492
+ { alias: 'w', name: 'w', construct: 'function', file: 'w.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
493
+ // module-less leaf in a multi-member process
494
+ { alias: 'loose', name: 'loose', construct: 'function', file: 'l.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
495
+ ],
496
+ };
497
+ const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
498
+ const owners = new Map<string, string[]>();
499
+ for (const g of groups) {
500
+ for (const alias of g.memberAliases) {
501
+ owners.set(alias, [...(owners.get(alias) ?? []), g.id]);
502
+ }
503
+ }
504
+ for (const [alias, claimedBy] of owners) {
505
+ expect(new Set(claimedBy).size, `leaf ${alias} claimed by ${claimedBy.join(', ')}`).toBe(1);
506
+ }
507
+ // The singleton process nests under its package rather than claiming.
508
+ const lonely = groups.find((g) => g.id === processGroupNodeId('app/lonely'));
509
+ expect(lonely!.memberAliases).toEqual(['solo']);
510
+ expect(lonely!.parentId).toBe(packageGroupNodeId('pkg:github/acme/app'));
511
+ });
512
+
513
+ test('buildBoundaryLayoutGroups singleton module claims its leaf from the process', () => {
514
+ const doc = {
515
+ components: [
516
+ { alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
517
+ { alias: 'm1', name: 'm1', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
518
+ { alias: 'm2', name: 'm2', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
519
+ ],
520
+ };
521
+ const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
522
+ const mod = groups.find((g) => g.id === moduleGroupNodeId('src/one.ts'));
523
+ const proc = groups.find((g) => g.id === processGroupNodeId('app/host'));
524
+ expect(mod!.memberAliases).toEqual(['m1', 'm2']);
525
+ // The module is nested, so the process lists the module — not its
526
+ // leaves. `a` has no module, so it stays a direct leaf of the process.
527
+ expect(proc!.memberAliases).toEqual([moduleGroupNodeId('src/one.ts'), 'a']);
528
+ expect(proc!.parentId).toBeUndefined();
529
+ });
530
+
438
531
  test('buildSubsystemGraph frames multi-member modules', async () => {
439
532
  const { nodes, regions } = await buildSubsystemGraph({
440
533
  components: [
@@ -796,6 +889,18 @@ describe('module badge labels', () => {
796
889
  });
797
890
  });
798
891
 
892
+ describe('boundary frame min width (process / package badges)', () => {
893
+ test('boundaryMinWidthForBadge reserves the full label plus padding', () => {
894
+ const label = 'principal-studio/worker';
895
+ const min = boundaryMinWidthForBadge(label);
896
+ // Clears the bare badge by an inset on both ends plus the frame border.
897
+ expect(min - moduleBadgeWidth(label)).toBe(MODULE_BADGE_INSET * 2 + 4);
898
+ expect(min).toBeGreaterThanOrEqual(moduleBadgeWidth(label));
899
+ // Longer labels need wider frames.
900
+ expect(boundaryMinWidthForBadge('principal-studio/renderer-worker')).toBeGreaterThan(min);
901
+ });
902
+ });
903
+
799
904
  describe('reorderWalkthroughs', () => {
800
905
  const wts: SubsystemWalkthrough[] = [
801
906
  { id: 'a', title: 'A', steps: [] },