@principal-ai/subsystems-react 0.36.0 → 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.
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +3 -3
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +19 -11
- package/src/subsystem/SubsystemAggregateGraph.tsx +38 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +16 -12
- package/src/subsystem/model.test.ts +14 -0
- package/src/subsystem/model.ts +25 -2
- package/src/subsystem/nodes.tsx +42 -60
- package/src/utils/elkLayout.ts +4 -1
package/package.json
CHANGED
|
@@ -105,9 +105,9 @@ const deepComponents: SubsystemComponent[] = [
|
|
|
105
105
|
const deepEdges = graphSpecFromEdges([
|
|
106
106
|
['node-comp', 'group-comp', 'references'],
|
|
107
107
|
['edge-comp', 'group-comp', 'references'],
|
|
108
|
-
['
|
|
109
|
-
['use-drawings', '
|
|
110
|
-
['
|
|
108
|
+
['model-view', 'layers', 'references'],
|
|
109
|
+
['use-drawings', 'model-view', 'references'],
|
|
110
|
+
['model-view', 'node-comp', 'references'],
|
|
111
111
|
]);
|
|
112
112
|
|
|
113
113
|
/** Collapsed module badges show `first/…/last`. Click one to expand the full
|
|
@@ -21,15 +21,28 @@ const meta = {
|
|
|
21
21
|
export default meta;
|
|
22
22
|
type Story = StoryObj<typeof meta>;
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
24
|
+
// Three deployment units + one unframed outsider: host and renderer each frame
|
|
25
|
+
// two nodes; worker is a one-member process (kept, since singleton frames
|
|
26
|
+
// default on); the service has no `process` and sits outside every boundary.
|
|
27
|
+
const PROCESS_OF: Record<string, string> = {
|
|
28
|
+
main: 'principal-studio/host',
|
|
29
|
+
store: 'principal-studio/host',
|
|
30
|
+
view: 'principal-studio/renderer',
|
|
31
|
+
bridge: 'principal-studio/renderer',
|
|
32
|
+
worker: 'principal-studio/worker',
|
|
33
|
+
};
|
|
34
|
+
|
|
26
35
|
const processComponents = [
|
|
27
36
|
...components([
|
|
28
37
|
['main', 'main', 'function', 'src/host/main.ts', 'pkg:github/principal-ai/principal-studio', 'boots the host process', 'main'],
|
|
29
38
|
['store', 'SessionStore', 'store', 'src/host/store.ts', 'pkg:github/principal-ai/principal-studio', 'retained host state', 'SessionStore'],
|
|
30
39
|
['view', 'TrailView', 'function', 'src/renderer/view.tsx', 'pkg:github/principal-ai/principal-studio', 'renders the trail', 'TrailView'],
|
|
31
40
|
['bridge', 'bridge', 'function', 'src/renderer/bridge.ts', 'pkg:github/principal-ai/principal-studio', 'IPC bridge to the host', 'bridge'],
|
|
32
|
-
|
|
41
|
+
['worker', 'runWorker', 'function', 'src/worker/run.ts', 'pkg:github/principal-ai/principal-studio', 'sole member of the worker process', 'runWorker'],
|
|
42
|
+
]).map((c) => {
|
|
43
|
+
const process: string | undefined = PROCESS_OF[c.alias];
|
|
44
|
+
return process ? { ...c, process } : c;
|
|
45
|
+
}),
|
|
33
46
|
{
|
|
34
47
|
alias: 'svc',
|
|
35
48
|
name: 'telemetry',
|
|
@@ -39,18 +52,13 @@ const processComponents = [
|
|
|
39
52
|
purpose: 'external telemetry sink (no process, never framed)',
|
|
40
53
|
role: 'service' as const,
|
|
41
54
|
},
|
|
42
|
-
]
|
|
43
|
-
i < 2
|
|
44
|
-
? { ...c, process: 'principal-studio/host' }
|
|
45
|
-
: i < 4
|
|
46
|
-
? { ...c, process: 'principal-studio/renderer' }
|
|
47
|
-
: c,
|
|
48
|
-
);
|
|
55
|
+
];
|
|
49
56
|
|
|
50
57
|
const processEdges = graphSpecFromEdges([
|
|
51
58
|
['main', 'store', 'writes'],
|
|
52
59
|
['main', 'bridge', 'calls'],
|
|
53
60
|
['bridge', 'view', 'feeds'],
|
|
61
|
+
['main', 'worker', 'calls'],
|
|
54
62
|
['main', 'svc', 'uses'],
|
|
55
63
|
]);
|
|
56
64
|
|
|
@@ -59,7 +67,7 @@ export const ProcessBoundaries: Story = {
|
|
|
59
67
|
<div style={{ width: '100%', height: '100vh' }}>
|
|
60
68
|
<SubsystemComponentGraph
|
|
61
69
|
title="Process boundaries"
|
|
62
|
-
description="Host and renderer each render in their own ELK-aware boundary frame. The telemetry service has no `process` and sits outside every boundary."
|
|
70
|
+
description="Host and renderer each render in their own ELK-aware boundary frame. The worker process holds a single node and still gets a frame (singleton frames default on). The telemetry service has no `process` and sits outside every boundary."
|
|
63
71
|
components={processComponents}
|
|
64
72
|
relations={processEdges.relations} walkthroughs={processEdges.walkthroughs}
|
|
65
73
|
/>
|
|
@@ -314,6 +314,7 @@ function Inner({
|
|
|
314
314
|
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
315
315
|
const [ready, setReady] = useState(false);
|
|
316
316
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
317
|
+
const [focusedBoundaryId, setFocusedBoundaryId] = useState<string | null>(null);
|
|
317
318
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
318
319
|
const [legendOpen, setLegendOpen] = useState(true);
|
|
319
320
|
|
|
@@ -350,6 +351,7 @@ function Inner({
|
|
|
350
351
|
let alive = true;
|
|
351
352
|
setReady(false);
|
|
352
353
|
setSelectedId(null);
|
|
354
|
+
setFocusedBoundaryId(null);
|
|
353
355
|
// Compound process frames: each box is parented to exactly one process
|
|
354
356
|
// group (its majority process); process member-boxes to their own
|
|
355
357
|
// process; the rest stays root-level. Exclusive assignment by
|
|
@@ -514,6 +516,9 @@ function Inner({
|
|
|
514
516
|
|
|
515
517
|
const onNodeClick = useCallback(
|
|
516
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;
|
|
517
522
|
const next = selectedId === node.id ? null : node.id;
|
|
518
523
|
setSelectedId(next);
|
|
519
524
|
onSelectFrame?.(next);
|
|
@@ -521,6 +526,37 @@ function Inner({
|
|
|
521
526
|
[selectedId, onSelectFrame],
|
|
522
527
|
);
|
|
523
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
|
+
|
|
524
560
|
const displayNodes = useMemo(
|
|
525
561
|
() =>
|
|
526
562
|
nodes.map((n) => ({
|
|
@@ -571,10 +607,12 @@ function Inner({
|
|
|
571
607
|
className={GRAPH_CANVAS_CLASS}
|
|
572
608
|
{...GRAPH_NAV_PROPS}
|
|
573
609
|
onNodeClick={onNodeClick}
|
|
610
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
574
611
|
onNodeMouseEnter={(_e, node) => setHoveredId(node.id)}
|
|
575
612
|
onNodeMouseLeave={() => setHoveredId(null)}
|
|
576
613
|
onPaneClick={() => {
|
|
577
614
|
setSelectedId(null);
|
|
615
|
+
setFocusedBoundaryId(null);
|
|
578
616
|
onSelectFrame?.(null);
|
|
579
617
|
}}
|
|
580
618
|
proOptions={{ hideAttribution: true }}
|
|
@@ -1169,6 +1169,21 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1169
1169
|
[onFileSelect],
|
|
1170
1170
|
);
|
|
1171
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
|
+
|
|
1172
1187
|
const onOpenFileFromWalkthrough = useCallback(
|
|
1173
1188
|
(file: string, opts?: SubsystemOpenFileOptions) => {
|
|
1174
1189
|
setFileOverlay({ file, startLine: opts?.startLine });
|
|
@@ -1539,18 +1554,6 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1539
1554
|
[expandedWalkthroughs, fitOverview, zoomOnWalkthroughFocus],
|
|
1540
1555
|
);
|
|
1541
1556
|
|
|
1542
|
-
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
1543
|
-
// the declaration panel's file link (toggle + tree sync, no start line).
|
|
1544
|
-
useEffect(() => {
|
|
1545
|
-
SUBSYSTEM_CALLBACKS.onOpenFile = (componentAlias: string) => {
|
|
1546
|
-
const comp = components.find((c) => c.alias === componentAlias);
|
|
1547
|
-
if (comp?.file) onOpenDeclarationFile(comp.file);
|
|
1548
|
-
};
|
|
1549
|
-
return () => {
|
|
1550
|
-
SUBSYSTEM_CALLBACKS.onOpenFile = undefined;
|
|
1551
|
-
};
|
|
1552
|
-
}, [components, onOpenDeclarationFile]);
|
|
1553
|
-
|
|
1554
1557
|
// Detail-panel links: related-name clicks select the matching component —
|
|
1555
1558
|
// resolved by alias, name, or symbol (call labels may carry a trailing `()`).
|
|
1556
1559
|
const resolveRelatedComponent = useCallback(
|
|
@@ -1996,6 +1999,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1996
1999
|
className={GRAPH_CANVAS_CLASS}
|
|
1997
2000
|
{...GRAPH_NAV_PROPS}
|
|
1998
2001
|
onNodeClick={onNodeClick}
|
|
2002
|
+
onNodeDoubleClick={onNodeDoubleClick}
|
|
1999
2003
|
onEdgeClick={onEdgeClick}
|
|
2000
2004
|
onNodesChange={onNodesChange}
|
|
2001
2005
|
onEdgesChange={onEdgesChange}
|
|
@@ -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,
|
|
@@ -887,6 +889,18 @@ describe('module badge labels', () => {
|
|
|
887
889
|
});
|
|
888
890
|
});
|
|
889
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
|
+
|
|
890
904
|
describe('reorderWalkthroughs', () => {
|
|
891
905
|
const wts: SubsystemWalkthrough[] = [
|
|
892
906
|
{ id: 'a', title: 'A', steps: [] },
|
package/src/subsystem/model.ts
CHANGED
|
@@ -663,6 +663,18 @@ export function moduleMinWidthForBadge(path: string): number {
|
|
|
663
663
|
);
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Min frame width so a **non-collapsing** boundary badge (process / package)
|
|
668
|
+
* fits inside with padding at both ends. Process badges are centred on the top
|
|
669
|
+
* edge and never collapse, so the full label must clear `MODULE_BADGE_INSET` on
|
|
670
|
+
* each side; ELK receives this as the frame's `minWidth`.
|
|
671
|
+
*/
|
|
672
|
+
export function boundaryMinWidthForBadge(label: string): number {
|
|
673
|
+
return Math.ceil(
|
|
674
|
+
moduleBadgeWidth(label) + MODULE_BADGE_INSET * 2 + MODULE_FRAME_BORDER,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
666
678
|
/** React Flow id for a package (repo) boundary group node. */
|
|
667
679
|
export function packageGroupNodeId(packageKey: string): string {
|
|
668
680
|
return `package:${packageKey}`;
|
|
@@ -1637,7 +1649,13 @@ export async function buildSubsystemGraph(
|
|
|
1637
1649
|
id: g.id,
|
|
1638
1650
|
memberIds: g.memberAliases,
|
|
1639
1651
|
parentId: g.parentId,
|
|
1640
|
-
|
|
1652
|
+
// Modules collapse a long path to `first/…/last`, so they only need
|
|
1653
|
+
// room for the collapsed badge. Processes/packages never collapse:
|
|
1654
|
+
// size the frame to hold the full centered label plus padding.
|
|
1655
|
+
minWidth:
|
|
1656
|
+
g.region.kind === 'module'
|
|
1657
|
+
? moduleMinWidthForBadge(g.region.label)
|
|
1658
|
+
: boundaryMinWidthForBadge(g.region.label),
|
|
1641
1659
|
})),
|
|
1642
1660
|
// Honour `showSingletonFrames`: a one-member process or module is
|
|
1643
1661
|
// still a real boundary. `buildBoundaryLayoutGroups` already made
|
|
@@ -1697,7 +1715,12 @@ export async function buildSubsystemGraph(
|
|
|
1697
1715
|
id: g.id,
|
|
1698
1716
|
type: 'subsystem-group',
|
|
1699
1717
|
position: { x: 0, y: 0 },
|
|
1700
|
-
width:
|
|
1718
|
+
width: Math.max(
|
|
1719
|
+
400,
|
|
1720
|
+
g.region.kind === 'module'
|
|
1721
|
+
? moduleMinWidthForBadge(g.region.label)
|
|
1722
|
+
: boundaryMinWidthForBadge(g.region.label),
|
|
1723
|
+
),
|
|
1701
1724
|
height: 300,
|
|
1702
1725
|
...(g.parentId ? { parentId: g.parentId } : {}),
|
|
1703
1726
|
data: { region: g.region },
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
storageBadgeLabel,
|
|
28
28
|
storageBadgeColor,
|
|
29
29
|
deriveNameFromSymbol,
|
|
30
|
-
BADGE_EDGE_INSET,
|
|
31
30
|
nodeMinWidthForBadges,
|
|
32
31
|
MODULE_BADGE_INSET,
|
|
33
32
|
moduleBadgeLabel,
|
|
@@ -70,9 +69,6 @@ function breakWords(s: string): string {
|
|
|
70
69
|
export interface SubsystemGraphCallbacks {
|
|
71
70
|
/** Click a component — open its file/entry point. */
|
|
72
71
|
onSelect?: (componentAlias: string) => void;
|
|
73
|
-
/** Click the filename badge — open that component's file in the drawer
|
|
74
|
-
* (same path as clicking the file link in the declaration panel). */
|
|
75
|
-
onOpenFile?: (componentAlias: string) => void;
|
|
76
72
|
/** Click an edge (or its label) — select the relationship. */
|
|
77
73
|
onEdgeSelect?: (edgeId: string) => void;
|
|
78
74
|
/** Hover a component (null on leave) — associates it with the file tree. */
|
|
@@ -121,6 +117,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
121
117
|
const callbacks = useSubsystemCallbacks();
|
|
122
118
|
const { data, selected, width: nodeWidth, height: nodeHeight } = props;
|
|
123
119
|
const c = data.component;
|
|
120
|
+
const [hover, setHover] = useState(false);
|
|
124
121
|
// Construct owns node color, derived from the active Pierre syntax theme —
|
|
125
122
|
// the same palette the declaration panel and file drawer render with. Role
|
|
126
123
|
// shows as the hover badge, not as color — for now; a role glyph/accent may
|
|
@@ -129,7 +126,6 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
129
126
|
// Left badge: framework brand (e.g. React cyan) when the label is a
|
|
130
127
|
// framework stereotype; otherwise the same construct color as the border.
|
|
131
128
|
const badgeColor = constructBadgeColor(c) ?? color;
|
|
132
|
-
const [hover, setHover] = useState(false);
|
|
133
129
|
const configuredMax = callbacks.maxNodeWidth;
|
|
134
130
|
const maxWidth = configuredMax ?? 300;
|
|
135
131
|
// `symbol` is the source of truth; `name` is derived from it consistently.
|
|
@@ -145,6 +141,11 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
145
141
|
// Set while a file is open in the drawer: true → spotlight, false → dim,
|
|
146
142
|
// absent (no file open) → neutral.
|
|
147
143
|
const fileMatch = data.fileMatch as boolean | undefined;
|
|
144
|
+
// Node fill lifts on hover as the clickability affordance; badges share it
|
|
145
|
+
// so they read as tabs on the same surface rather than floating patches.
|
|
146
|
+
const nodeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
147
|
+
const hoverBg =
|
|
148
|
+
theme.colors.backgroundHover ?? theme.colors.backgroundTertiary ?? nodeBg;
|
|
148
149
|
// Border: proposed uses the goldenrod accent (dashed); construct color stays
|
|
149
150
|
// on the left badge. File-open spotlight still wins with primary.
|
|
150
151
|
const borderColor = fileMatch
|
|
@@ -159,6 +160,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
159
160
|
const isExternal = c.construct === 'external';
|
|
160
161
|
const nodeRadius = isExternal ? 0 : 8;
|
|
161
162
|
const badgeRadius = isExternal ? 0 : 4;
|
|
163
|
+
// The border thickens on selection / file-match. Absolutely-positioned
|
|
164
|
+
// badges are laid out from the padding box (inside the border), so a raw
|
|
165
|
+
// `top` / `left` would slide with the border. Anchor them to the border box
|
|
166
|
+
// instead, offsetting by the border width. Side badges sit flush with the
|
|
167
|
+
// node's left / right edge (`-borderW`); the top reference stays at -9.
|
|
168
|
+
const borderW = isSelected || fileMatch ? 4 : 2;
|
|
169
|
+
const badgeTop = -9 - borderW;
|
|
170
|
+
const badgeEdge = -borderW;
|
|
162
171
|
|
|
163
172
|
return (
|
|
164
173
|
<div
|
|
@@ -187,15 +196,15 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
187
196
|
maxWidth,
|
|
188
197
|
padding: '6px 10px',
|
|
189
198
|
borderRadius: nodeRadius,
|
|
190
|
-
background:
|
|
199
|
+
background: hover ? hoverBg : nodeBg,
|
|
191
200
|
// Selected / file-matched nodes get a thicker border. Proposed nodes
|
|
192
201
|
// use a dashed goldenrod border; left construct badge keeps construct color.
|
|
193
|
-
border: `${
|
|
202
|
+
border: `${borderW}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
|
|
194
203
|
boxShadow: fileMatch
|
|
195
204
|
? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
|
|
196
205
|
: '0 1px 4px rgba(0,0,0,0.25)',
|
|
197
206
|
opacity: fileMatch === false || data.dimmed === true ? 0.18 : 1,
|
|
198
|
-
transition: 'opacity 150ms ease',
|
|
207
|
+
transition: 'opacity 150ms ease, background-color 120ms ease',
|
|
199
208
|
cursor: 'pointer',
|
|
200
209
|
fontFamily: theme.fonts.body,
|
|
201
210
|
}}
|
|
@@ -207,8 +216,8 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
207
216
|
<div
|
|
208
217
|
style={{
|
|
209
218
|
position: 'absolute',
|
|
210
|
-
top:
|
|
211
|
-
left:
|
|
219
|
+
top: badgeTop,
|
|
220
|
+
left: badgeEdge,
|
|
212
221
|
zIndex: 1,
|
|
213
222
|
fontFamily: theme.fonts.monospace,
|
|
214
223
|
fontSize: theme.fontSizes[1],
|
|
@@ -217,7 +226,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
217
226
|
lineHeight: '17px',
|
|
218
227
|
whiteSpace: 'nowrap',
|
|
219
228
|
color: badgeColor,
|
|
220
|
-
background:
|
|
229
|
+
background: nodeBg,
|
|
221
230
|
border: `2px solid ${badgeColor}`,
|
|
222
231
|
borderRadius: badgeRadius,
|
|
223
232
|
padding: '2px 8px',
|
|
@@ -226,15 +235,18 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
226
235
|
{constructBadgeLabel(c)}
|
|
227
236
|
</div>
|
|
228
237
|
|
|
229
|
-
{/*
|
|
238
|
+
{/* Secondary badge cluster — proposed wins over role when both are set;
|
|
230
239
|
store storage (retention backing) sits beside, since it is
|
|
231
|
-
orthogonal to topology. Persistent; pointer-events pass through.
|
|
240
|
+
orthogonal to topology. Persistent; pointer-events pass through.
|
|
241
|
+
Sits astride the bottom edge, centered (mirroring the top construct
|
|
242
|
+
badge's straddle), so the top edge stays free for the construct tag. */}
|
|
232
243
|
{(topRightLabel != null || storageLabel != null) && (
|
|
233
244
|
<div
|
|
234
245
|
style={{
|
|
235
246
|
position: 'absolute',
|
|
236
|
-
|
|
237
|
-
|
|
247
|
+
bottom: badgeTop,
|
|
248
|
+
left: '50%',
|
|
249
|
+
transform: 'translateX(-50%)',
|
|
238
250
|
zIndex: 1,
|
|
239
251
|
display: 'flex',
|
|
240
252
|
gap: 6,
|
|
@@ -250,7 +262,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
250
262
|
textTransform: 'uppercase',
|
|
251
263
|
lineHeight: '17px',
|
|
252
264
|
color: storageColor,
|
|
253
|
-
background:
|
|
265
|
+
background: nodeBg,
|
|
254
266
|
border: `2px solid ${storageColor}`,
|
|
255
267
|
borderRadius: badgeRadius,
|
|
256
268
|
padding: '2px 8px',
|
|
@@ -268,7 +280,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
268
280
|
textTransform: 'uppercase',
|
|
269
281
|
lineHeight: '17px',
|
|
270
282
|
color: topRightColor,
|
|
271
|
-
background:
|
|
283
|
+
background: nodeBg,
|
|
272
284
|
border: `2px solid ${topRightColor}`,
|
|
273
285
|
borderRadius: badgeRadius,
|
|
274
286
|
padding: '2px 8px',
|
|
@@ -280,41 +292,6 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
280
292
|
</div>
|
|
281
293
|
)}
|
|
282
294
|
|
|
283
|
-
{/* Filename badge — a strip across the bottom border, shown on hover
|
|
284
|
-
and pinned while this node's file is open in the drawer (fileMatch).
|
|
285
|
-
Mirrors the construct/role tab badges; hidden for fileless nodes. */}
|
|
286
|
-
{(hover || fileMatch === true) && c.file ? (
|
|
287
|
-
<div
|
|
288
|
-
style={{
|
|
289
|
-
position: 'absolute',
|
|
290
|
-
bottom: -14,
|
|
291
|
-
left: '50%',
|
|
292
|
-
transform: 'translateX(-50%)',
|
|
293
|
-
zIndex: 1,
|
|
294
|
-
fontFamily: theme.fonts.monospace,
|
|
295
|
-
fontSize: theme.fontSizes[1],
|
|
296
|
-
letterSpacing: 0.5,
|
|
297
|
-
lineHeight: '17px',
|
|
298
|
-
whiteSpace: 'nowrap',
|
|
299
|
-
// filename stays neutral — the construct color belongs to the
|
|
300
|
-
// borders and badges, not the file name
|
|
301
|
-
color: theme.colors.text ?? theme.colors.textSecondary,
|
|
302
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
303
|
-
border: `2px solid ${color}`,
|
|
304
|
-
borderRadius: badgeRadius,
|
|
305
|
-
padding: '2px 8px',
|
|
306
|
-
cursor: 'pointer',
|
|
307
|
-
}}
|
|
308
|
-
onClick={(e) => {
|
|
309
|
-
// Open the file directly; don't also toggle node selection.
|
|
310
|
-
e.stopPropagation();
|
|
311
|
-
callbacks.onOpenFile?.(c.alias);
|
|
312
|
-
}}
|
|
313
|
-
>
|
|
314
|
-
{c.file.split('/').pop()}
|
|
315
|
-
</div>
|
|
316
|
-
) : null}
|
|
317
|
-
|
|
318
295
|
{/* Purpose is no longer shown as a hover tooltip below the node — it
|
|
319
296
|
lives in the declaration panel and the graphify detail payload. */}
|
|
320
297
|
|
|
@@ -454,12 +431,13 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
454
431
|
} : undefined}
|
|
455
432
|
style={{
|
|
456
433
|
position: 'absolute',
|
|
457
|
-
// Process:
|
|
458
|
-
// frame border)
|
|
459
|
-
// Module/package are centred astride the
|
|
460
|
-
// badges. Always set left/transform
|
|
461
|
-
// group DOM nodes and `undefined`
|
|
462
|
-
|
|
434
|
+
// Process: the tab sits flush over the frame's top border (`top:
|
|
435
|
+
// -2` — the frame border is 2px) and carries its own full border, so
|
|
436
|
+
// the two read as one piece. Module/package are centred astride the
|
|
437
|
+
// top edge like component badges. Always set left/transform
|
|
438
|
+
// explicitly — React Flow reuses group DOM nodes and `undefined`
|
|
439
|
+
// does not clear a prior value.
|
|
440
|
+
top: isProcess ? -2 : 0,
|
|
463
441
|
left: isProcess ? '50%' : 12,
|
|
464
442
|
transform: isProcess ? 'translateX(-50%)' : 'translateY(-50%)',
|
|
465
443
|
zIndex: canToggle ? 10 : undefined,
|
|
@@ -475,7 +453,11 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
475
453
|
cursor: canToggle ? 'pointer' : undefined,
|
|
476
454
|
// The trailing halo squares the rounded corners back off in the fill
|
|
477
455
|
// colour, so an edge crossing a corner can't show through the notch.
|
|
478
|
-
|
|
456
|
+
// Square (process) badges have no notch, and the halo would erase the
|
|
457
|
+
// frame border they now sit flush on, so they skip it.
|
|
458
|
+
boxShadow: isProcess
|
|
459
|
+
? undefined
|
|
460
|
+
: `${canToggle && hover ? `0 2px 10px ${color}55, ` : ''}0 0 0 1.5px ${badgeBg}`,
|
|
479
461
|
fontFamily: theme.fonts.monospace,
|
|
480
462
|
fontSize: theme.fontSizes[3],
|
|
481
463
|
fontWeight: 700,
|
|
@@ -483,7 +465,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
483
465
|
color,
|
|
484
466
|
background: badgeBg,
|
|
485
467
|
border: `2px solid ${color}`,
|
|
486
|
-
borderTopWidth:
|
|
468
|
+
borderTopWidth: 2,
|
|
487
469
|
borderRadius: badgeRadius,
|
|
488
470
|
padding: '3px 9px',
|
|
489
471
|
whiteSpace: 'nowrap',
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -642,7 +642,10 @@ export async function computeElkLayout(
|
|
|
642
642
|
const compoundLayoutOptions: LayoutOptions = {
|
|
643
643
|
'elk.algorithm': 'layered',
|
|
644
644
|
'elk.direction': direction,
|
|
645
|
-
|
|
645
|
+
// `top` is the header band a boundary badge sits in. It has to clear the
|
|
646
|
+
// badge (~28px tall, and a nested frame's badge can overhang its own top
|
|
647
|
+
// edge by ~13px) plus a gap before the first child.
|
|
648
|
+
'elk.padding': '[top=64,left=24,bottom=24,right=24]',
|
|
646
649
|
'elk.spacing.nodeNode': '40',
|
|
647
650
|
};
|
|
648
651
|
|