@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
- package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +9 -5
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.js +10 -5
- package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
- package/dist/subsystem/graphChrome.d.ts +15 -0
- package/dist/subsystem/graphChrome.d.ts.map +1 -1
- package/dist/subsystem/graphChrome.js +17 -0
- package/dist/subsystem/graphChrome.js.map +1 -1
- package/dist/subsystem/model.d.ts +26 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +90 -2
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +12 -9
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +45 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +114 -84
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/signature.test.ts +1 -1
- package/src/index.ts +1 -0
- package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
- package/src/subsystem/SubsystemAggregateGraph.tsx +191 -21
- package/src/subsystem/SubsystemComponentGraph.tsx +18 -4
- package/src/subsystem/SymbolInspectionCard.tsx +31 -12
- package/src/subsystem/graphChrome.tsx +20 -0
- package/src/subsystem/model.test.ts +91 -0
- package/src/subsystem/model.ts +94 -2
- package/src/subsystem/nodes.tsx +12 -9
- package/src/utils/elkLayout.test.ts +117 -3
- package/src/utils/elkLayout.ts +150 -81
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
- /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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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[
|
|
131
|
+
fontSize: theme.fontSizes[2],
|
|
132
|
+
fontWeight: 600,
|
|
120
133
|
letterSpacing: 0.5,
|
|
121
134
|
textTransform: 'uppercase',
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
: (theme.colors.textMuted ?? theme.colors.textSecondary),
|
|
135
|
+
lineHeight: '18px',
|
|
136
|
+
color,
|
|
125
137
|
background: theme.colors.background,
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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, {
|
|
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({
|
|
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
|
-
<
|
|
358
|
-
{
|
|
359
|
-
|
|
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: [
|