@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/subsystems-react",
3
- "version": "0.36.0",
3
+ "version": "0.36.1",
4
4
  "description": "React UI for Subsystem Models — graph, Pierre code views, and session-event feeds",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- ['analysis', 'layers', 'references'],
109
- ['use-drawings', 'analysis', 'references'],
110
- ['analysis', 'node-comp', 'references'],
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
- // Two deployment units + one unframed outsider: host and renderer each get a
25
- // dashed process frame; the service sits outside every boundary.
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
- ].map((c, i) =>
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: [] },
@@ -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
- minWidth: g.region.kind === 'module' ? moduleMinWidthForBadge(g.region.label) : undefined,
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: 400,
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 },
@@ -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: theme.colors.backgroundSecondary ?? theme.colors.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: `${isSelected || fileMatch ? 4 : 2}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
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: -11,
211
- left: BADGE_EDGE_INSET,
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: theme.colors.backgroundSecondary ?? theme.colors.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
- {/* Right badge cluster — proposed wins over role when both are set;
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
- top: -11,
237
- right: BADGE_EDGE_INSET,
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: theme.colors.backgroundSecondary ?? theme.colors.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: theme.colors.backgroundSecondary ?? theme.colors.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: badge fill starts with the process fill (inside the
458
- // frame border); no top badge border so widths don't fight.
459
- // Module/package are centred astride the top edge like component
460
- // badges. Always set left/transform explicitly — React Flow reuses
461
- // group DOM nodes and `undefined` does not clear a prior value.
462
- top: 0,
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
- boxShadow: `${canToggle && hover ? `0 2px 10px ${color}55, ` : ''}0 0 0 1.5px ${badgeBg}`,
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: isProcess ? 0 : 2,
468
+ borderTopWidth: 2,
487
469
  borderRadius: badgeRadius,
488
470
  padding: '3px 9px',
489
471
  whiteSpace: 'nowrap',
@@ -642,7 +642,10 @@ export async function computeElkLayout(
642
642
  const compoundLayoutOptions: LayoutOptions = {
643
643
  'elk.algorithm': 'layered',
644
644
  'elk.direction': direction,
645
- 'elk.padding': '[top=48,left=24,bottom=24,right=24]',
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