@principal-ai/subsystems-react 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/index.d.ts +2 -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/aggregateFixture.d.ts +5 -0
  6. package/dist/stories/Subsystem/ComponentGraph/aggregateFixture.d.ts.map +1 -0
  7. package/dist/stories/Subsystem/ComponentGraph/aggregateFixture.js +4 -0
  8. package/dist/stories/Subsystem/ComponentGraph/aggregateFixture.js.map +1 -0
  9. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts +5 -0
  10. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts.map +1 -0
  11. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js +4 -0
  12. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js.map +1 -0
  13. package/dist/stories/Subsystem/ComponentGraph/composedFixture.d.ts +5 -0
  14. package/dist/stories/Subsystem/ComponentGraph/composedFixture.d.ts.map +1 -0
  15. package/dist/stories/Subsystem/ComponentGraph/composedFixture.js +4 -0
  16. package/dist/stories/Subsystem/ComponentGraph/composedFixture.js.map +1 -0
  17. package/dist/subsystem/SubsystemAggregateGraph.d.ts +70 -0
  18. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -0
  19. package/dist/subsystem/SubsystemAggregateGraph.js +385 -0
  20. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -0
  21. package/dist/subsystem/model.d.ts +7 -0
  22. package/dist/subsystem/model.d.ts.map +1 -1
  23. package/dist/subsystem/model.js +65 -1
  24. package/dist/subsystem/model.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/index.ts +8 -0
  27. package/src/stories/Subsystem/ComponentDeclarationAudit.stories.tsx +13 -13
  28. package/src/stories/Subsystem/ComponentGraph/AggregateFrameGraph.stories.tsx +61 -0
  29. package/src/stories/Subsystem/ComponentGraph/AggregateGraph.stories.tsx +61 -0
  30. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +17 -17
  31. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +2 -2
  32. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +3 -3
  33. package/src/stories/Subsystem/ComponentGraph/ComposedGraph.stories.tsx +73 -0
  34. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +6 -6
  35. package/src/stories/Subsystem/ComponentGraph/CustomEntities.stories.tsx +8 -8
  36. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +11 -11
  37. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +6 -6
  38. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +10 -10
  39. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +6 -6
  40. package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +17 -17
  41. package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +16 -16
  42. package/src/stories/Subsystem/ComponentGraph/Packages.stories.tsx +12 -12
  43. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +1 -1
  44. package/src/stories/Subsystem/ComponentGraph/Proposed.stories.tsx +10 -10
  45. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +69 -69
  46. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +27 -27
  47. package/src/stories/Subsystem/ComponentGraph/aggregateFixture.ts +8 -0
  48. package/src/stories/Subsystem/ComponentGraph/aggregateViewFixture.ts +8 -0
  49. package/src/stories/Subsystem/ComponentGraph/composedFixture.ts +9 -0
  50. package/src/subsystem/SubsystemAggregateGraph.tsx +607 -0
  51. package/src/subsystem/model.test.ts +63 -0
  52. package/src/subsystem/model.ts +65 -1
@@ -0,0 +1,607 @@
1
+ /**
2
+ * SubsystemAggregateGraph — frame-level overview canvas.
3
+ *
4
+ * Same stack and interaction language as SubsystemComponentGraph
5
+ * (React Flow + ELK + custom nodes + select/hover), but purpose-built for
6
+ * composed views: nodes are process / module frames (plus one ungrouped
7
+ * bucket), edges are the deduped walkthrough flows between them.
8
+ *
9
+ * Deliberately leaner than the component graph: fixed node sizes (no
10
+ * measurement passes), flat ELK layout (no compound groups, so the
11
+ * double-parent failure mode is structurally impossible), no edge labels,
12
+ * no walkthrough playback, no drawers. Frame detail (members, models)
13
+ * renders in a side panel.
14
+ */
15
+
16
+ import { useCallback, useEffect, useMemo, useState } from 'react';
17
+ import {
18
+ Edge,
19
+ Handle,
20
+ MarkerType,
21
+ Node,
22
+ NodeProps,
23
+ Position,
24
+ ReactFlow,
25
+ ReactFlowProvider,
26
+ useReactFlow,
27
+ } from '@xyflow/react';
28
+ import { useTheme } from '@principal-ade/industry-theme';
29
+ import { computeElkLayout } from '../utils/elkLayout';
30
+ import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
31
+
32
+ export interface AggregateFrameMember {
33
+ alias: string;
34
+ name: string;
35
+ file: string;
36
+ construct: string;
37
+ models: string[];
38
+ }
39
+
40
+ export interface AggregateFrameNode {
41
+ id: string;
42
+ kind: 'module' | 'process' | 'external' | 'ungrouped';
43
+ key: string;
44
+ label: string;
45
+ /**
46
+ * Enclosing boundary: process code frames nest in their process frame,
47
+ * every external frame in the single external boundary. Absent =
48
+ * root-level (module with no process, ungrouped).
49
+ */
50
+ group?: { key: string; kind: 'process' | 'external' };
51
+ members: AggregateFrameMember[];
52
+ models: string[];
53
+ }
54
+
55
+ export interface AggregateFrameEdge {
56
+ from: string;
57
+ to: string;
58
+ mechanisms: string[];
59
+ walkthroughIds: string[];
60
+ steps: number;
61
+ /** Real member endpoint behind a hub-routed edge (see aggregate). */
62
+ source?: string;
63
+ target?: string;
64
+ }
65
+
66
+ /** Intake/outtake hub for a multi-frame boundary. */
67
+ export interface AggregateHub {
68
+ id: string;
69
+ group: { key: string; kind: 'process' | 'external' };
70
+ kind: 'intake' | 'outtake';
71
+ label: string;
72
+ }
73
+
74
+ export interface SubsystemAggregateGraphProps {
75
+ frames: AggregateFrameNode[];
76
+ edges: AggregateFrameEdge[];
77
+ /** Intake/outtake hubs; rendered as compact pills inside their boundary. */
78
+ hubs?: AggregateHub[];
79
+ title?: string;
80
+ onSelectFrame?: (id: string | null) => void;
81
+ onOpenFile?: (path: string) => void;
82
+ }
83
+
84
+ const NODE_W = 232;
85
+ const NODE_H = 78;
86
+
87
+ function edgeColor(mechanism: string | undefined, fallback: string): string {
88
+ if (!mechanism) return fallback;
89
+ return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
90
+ }
91
+
92
+ function AggregateFrameGroupView(
93
+ props: NodeProps<Node<{ label: string; kind: 'process' | 'external' }>>,
94
+ ) {
95
+ const { theme } = useTheme();
96
+ const external = props.data.kind === 'external';
97
+ const border = external
98
+ ? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
99
+ : (theme.colors.border ?? '#333');
100
+ return (
101
+ <div
102
+ style={{
103
+ width: '100%',
104
+ height: '100%',
105
+ boxSizing: 'border-box',
106
+ borderRadius: 12,
107
+ border: `2px ${external ? 'solid' : 'dashed'} ${border}`,
108
+ background: 'transparent',
109
+ pointerEvents: 'none',
110
+ }}
111
+ >
112
+ <span
113
+ style={{
114
+ position: 'absolute',
115
+ top: -11,
116
+ left: 12,
117
+ fontFamily: theme.fonts.monospace,
118
+ fontSize: theme.fontSizes[1],
119
+ letterSpacing: 0.5,
120
+ textTransform: 'uppercase',
121
+ color: external
122
+ ? border
123
+ : (theme.colors.textMuted ?? theme.colors.textSecondary),
124
+ background: theme.colors.background,
125
+ padding: '0 8px',
126
+ whiteSpace: 'nowrap',
127
+ }}
128
+ >
129
+ {props.data.label}
130
+ </span>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ const HUB_W = 96;
136
+ const HUB_H = 30;
137
+
138
+ function AggregateHubNodeView(
139
+ props: NodeProps<Node<{ hub: AggregateHub }>>,
140
+ ) {
141
+ const { theme } = useTheme();
142
+ const { hub } = props.data;
143
+ const color =
144
+ hub.group.kind === 'external'
145
+ ? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
146
+ : (theme.colors.border ?? '#333');
147
+ return (
148
+ <div
149
+ style={{
150
+ width: HUB_W,
151
+ height: HUB_H,
152
+ boxSizing: 'border-box',
153
+ display: 'flex',
154
+ alignItems: 'center',
155
+ justifyContent: 'center',
156
+ borderRadius: HUB_H / 2,
157
+ border: `1px solid ${color}`,
158
+ background: theme.colors.background,
159
+ color: theme.colors.textMuted ?? theme.colors.textSecondary,
160
+ fontFamily: theme.fonts.monospace,
161
+ fontSize: theme.fontSizes[0],
162
+ letterSpacing: 0.4,
163
+ whiteSpace: 'nowrap',
164
+ overflow: 'hidden',
165
+ textOverflow: 'ellipsis',
166
+ }}
167
+ title={hub.label}
168
+ >
169
+ {hub.kind === 'intake' ? '▸ in' : 'out ▸'}
170
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
171
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function AggregateFrameNodeView(
177
+ props: NodeProps<
178
+ Node<{ frame: AggregateFrameNode; selected: boolean; dimmed: boolean; nested?: boolean }>
179
+ >,
180
+ ) {
181
+ const { theme } = useTheme();
182
+ const { data } = props;
183
+ const f = data.frame;
184
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
185
+ // Process buckets read as sub-regions, not entities: dashed boundary
186
+ // chrome and a flat fill, mirroring the group frames. Externals are real
187
+ // systems — solid chrome like other nodes, just without a module tag.
188
+ // Module/ungrouped boxes keep solid node chrome.
189
+ const boundaryStyled = f.kind === 'process';
190
+ // A process box nested inside its own process boundary would otherwise
191
+ // repeat the boundary's name — it holds that process's module-less
192
+ // members, so it says so. Root-level boxes keep their key for context.
193
+ // External boxes never nest in a same-named frame (their boundary is the
194
+ // shared `external` one), so they always keep their system name.
195
+ const title = f.kind === 'process' && data.nested ? 'unframed' : f.label;
196
+ const fullTitle =
197
+ f.kind === 'process' && data.nested ? `${f.label} · unframed members` : f.label;
198
+ return (
199
+ <div
200
+ style={{
201
+ width: NODE_W,
202
+ height: NODE_H,
203
+ boxSizing: 'border-box',
204
+ display: 'flex',
205
+ flexDirection: 'column',
206
+ justifyContent: 'center',
207
+ gap: 2,
208
+ padding: '8px 12px',
209
+ borderRadius: 8,
210
+ // The unframed bucket paints the canvas background rather than
211
+ // staying transparent, so routed edges don't show through its label.
212
+ background: boundaryStyled
213
+ ? theme.colors.background
214
+ : (theme.colors.backgroundSecondary ?? theme.colors.background),
215
+ border: `2px ${boundaryStyled ? 'dashed' : 'solid'} ${
216
+ data.selected ? theme.colors.primary : (theme.colors.border ?? '#333')
217
+ }`,
218
+ boxShadow: boundaryStyled ? 'none' : '0 1px 4px rgba(0,0,0,0.25)',
219
+ opacity: data.dimmed ? 0.35 : 1,
220
+ transition: 'opacity 120ms ease, border-color 120ms ease',
221
+ cursor: 'pointer',
222
+ fontFamily: theme.fonts.body,
223
+ }}
224
+ >
225
+ {f.kind === 'module' && (
226
+ <span
227
+ style={{
228
+ fontFamily: theme.fonts.monospace,
229
+ fontSize: theme.fontSizes[0],
230
+ letterSpacing: 0.5,
231
+ textTransform: 'uppercase',
232
+ color: theme.colors.accent ?? theme.colors.info,
233
+ whiteSpace: 'nowrap',
234
+ overflow: 'hidden',
235
+ textOverflow: 'ellipsis',
236
+ }}
237
+ >
238
+ module
239
+ </span>
240
+ )}
241
+ <span
242
+ style={{
243
+ fontWeight: 600,
244
+ fontSize: theme.fontSizes[2],
245
+ color: theme.colors.text,
246
+ whiteSpace: 'nowrap',
247
+ overflow: 'hidden',
248
+ textOverflow: 'ellipsis',
249
+ }}
250
+ title={fullTitle}
251
+ >
252
+ {title}
253
+ </span>
254
+ <span
255
+ style={{
256
+ fontFamily: theme.fonts.monospace,
257
+ fontSize: theme.fontSizes[0],
258
+ color: muted,
259
+ whiteSpace: 'nowrap',
260
+ overflow: 'hidden',
261
+ textOverflow: 'ellipsis',
262
+ }}
263
+ >
264
+ {/* Boxes describe their contents (constructs), not their container —
265
+ the frame boundary already says which process/module this is. */}
266
+ {describeConstructBreakdown(f.members.map((m) => m.construct))}
267
+ {f.models.length > 0 ? ` · ${f.models.length} model${f.models.length === 1 ? '' : 's'}` : ''}
268
+ </span>
269
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
270
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
271
+ </div>
272
+ );
273
+ }
274
+
275
+ const nodeTypes = {
276
+ 'aggregate-frame': AggregateFrameNodeView,
277
+ 'aggregate-frame-group': AggregateFrameGroupView,
278
+ 'aggregate-hub': AggregateHubNodeView,
279
+ };
280
+
281
+ function Inner({
282
+ frames,
283
+ edges,
284
+ hubs = [],
285
+ title,
286
+ onSelectFrame,
287
+ onOpenFile,
288
+ }: SubsystemAggregateGraphProps) {
289
+ const { theme } = useTheme();
290
+ const { fitView } = useReactFlow();
291
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
292
+ const [nodes, setNodes] = useState<Node[]>([]);
293
+ const [rfEdges, setRfEdges] = useState<Edge[]>([]);
294
+ const [ready, setReady] = useState(false);
295
+ const [selectedId, setSelectedId] = useState<string | null>(null);
296
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
297
+
298
+ const layoutKey = useMemo(
299
+ () =>
300
+ [
301
+ ...frames.map((f) => f.id).sort(),
302
+ ...edges.map((e) => `${e.from}\0${e.to}\0${e.steps}`).sort(),
303
+ ].join('\n'),
304
+ [frames, edges, hubs],
305
+ );
306
+
307
+ useEffect(() => {
308
+ let alive = true;
309
+ setReady(false);
310
+ setSelectedId(null);
311
+ // Compound process frames: each box is parented to exactly one process
312
+ // group (its majority process); process member-boxes to their own
313
+ // process; the rest stays root-level. Exclusive assignment by
314
+ // construction — the double-parent failure mode cannot occur here.
315
+ const groupIdFor = (kind: 'process' | 'external', key: string) =>
316
+ `frame:${kind}:${key}`;
317
+ const groupDefs = new Map<string, { id: string; kind: 'process' | 'external'; key: string }>();
318
+ for (const f of frames) {
319
+ if (!f.group) continue;
320
+ const id = groupIdFor(f.group.kind, f.group.key);
321
+ if (!groupDefs.has(id)) groupDefs.set(id, { id, ...f.group });
322
+ }
323
+ const rfNodes: Node[] = [
324
+ ...frames.map((f, i) => ({
325
+ id: f.id,
326
+ type: 'aggregate-frame',
327
+ // Pre-ELK grid slot; replaced by ELK positions below.
328
+ position: { x: (i % 4) * (NODE_W + 60), y: Math.floor(i / 4) * (NODE_H + 40) },
329
+ width: NODE_W,
330
+ height: NODE_H,
331
+ ...(f.group ? { parentId: groupIdFor(f.group.kind, f.group.key) } : {}),
332
+ data: { frame: f, selected: false, dimmed: false },
333
+ })),
334
+ ...hubs.map((h) => ({
335
+ id: h.id,
336
+ type: 'aggregate-hub',
337
+ position: { x: 0, y: 0 },
338
+ width: HUB_W,
339
+ height: HUB_H,
340
+ parentId: groupIdFor(h.group.kind, h.group.key),
341
+ data: { hub: h },
342
+ })),
343
+ ];
344
+ const groups = [...groupDefs.values()].map((g) => ({
345
+ id: g.id,
346
+ memberIds: [
347
+ ...frames
348
+ .filter((f) => f.group?.kind === g.kind && f.group?.key === g.key)
349
+ .map((f) => f.id),
350
+ ...hubs
351
+ .filter((h) => h.group.kind === g.kind && h.group.key === g.key)
352
+ .map((h) => h.id),
353
+ ],
354
+ }));
355
+ const rfEdges: Edge[] = edges
356
+ .filter((e) => e.from !== e.to)
357
+ .map((e) => ({
358
+ id: `${e.from}\0${e.to}`,
359
+ source: e.from,
360
+ target: e.to,
361
+ style: {
362
+ stroke: edgeColor(e.mechanisms[0], muted),
363
+ strokeWidth: 1 + Math.min(e.steps, 6) * 0.5,
364
+ },
365
+ markerEnd: {
366
+ type: MarkerType.ArrowClosed,
367
+ color: edgeColor(e.mechanisms[0], muted),
368
+ width: 20,
369
+ height: 20,
370
+ },
371
+ data: { edge: e },
372
+ }));
373
+ void computeElkLayout(rfNodes, rfEdges, {
374
+ direction: 'RIGHT',
375
+ // Reposition (never keep the placeholder grid) and lay out compound
376
+ // process frames.
377
+ preserveNodePositions: false,
378
+ groups,
379
+ })
380
+ .then((result) => {
381
+ if (!alive) return;
382
+ const positioned = new Map(result.nodes.map((n) => [n.id, n]));
383
+ const builtGroups = new Set(result.groupBounds.keys());
384
+ const shells: Node[] = [];
385
+ for (const [id, b] of result.groupBounds) {
386
+ const def = groupDefs.get(id);
387
+ shells.push({
388
+ id,
389
+ type: 'aggregate-frame-group',
390
+ position: { x: b.x, y: b.y },
391
+ width: Math.max(200, b.width),
392
+ height: Math.max(120, b.height),
393
+ draggable: false,
394
+ selectable: false,
395
+ data: {
396
+ label: def?.key ?? id,
397
+ kind: def?.kind ?? 'process',
398
+ },
399
+ });
400
+ }
401
+ // Clear leaf parentIds that point at groups ELK dropped.
402
+ const leaves = rfNodes.map((n) => {
403
+ const p = positioned.get(n.id);
404
+ const node = p ? { ...n, position: { x: p.position.x, y: p.position.y } } : n;
405
+ const parentId = (node as { parentId?: string }).parentId;
406
+ if (parentId && !builtGroups.has(parentId)) {
407
+ const { parentId: _drop, ...rest } = node as { parentId?: string } & Node;
408
+ return rest as Node;
409
+ }
410
+ return node;
411
+ });
412
+ setNodes([...shells, ...leaves]);
413
+ setRfEdges(rfEdges);
414
+ setReady(true);
415
+ requestAnimationFrame(() => fitView({ padding: 0.15 }));
416
+ })
417
+ .catch((err) => {
418
+ if (!alive) return;
419
+ console.warn('[subsystem-aggregate-graph] ELK layout failed, using grid:', err);
420
+ setNodes(rfNodes);
421
+ setRfEdges(rfEdges);
422
+ setReady(true);
423
+ requestAnimationFrame(() => fitView({ padding: 0.15 }));
424
+ });
425
+ return () => {
426
+ alive = false;
427
+ };
428
+ // eslint-disable-next-line react-hooks/exhaustive-deps
429
+ }, [layoutKey]);
430
+
431
+ const neighborIds = useMemo(() => {
432
+ const focus = hoveredId ?? selectedId;
433
+ if (!focus) return null;
434
+ const set = new Set<string>([focus]);
435
+ for (const e of edges) {
436
+ if (e.from === focus) set.add(e.to);
437
+ if (e.to === focus) set.add(e.from);
438
+ }
439
+ return set;
440
+ }, [hoveredId, selectedId, edges]);
441
+
442
+ const selected = selectedId ? (frames.find((f) => f.id === selectedId) ?? null) : null;
443
+
444
+ const onNodeClick = useCallback(
445
+ (_e: unknown, node: Node) => {
446
+ const next = selectedId === node.id ? null : node.id;
447
+ setSelectedId(next);
448
+ onSelectFrame?.(next);
449
+ },
450
+ [selectedId, onSelectFrame],
451
+ );
452
+
453
+ const displayNodes = useMemo(
454
+ () =>
455
+ nodes.map((n) => ({
456
+ ...n,
457
+ data: {
458
+ ...(n.data as object),
459
+ selected: selectedId === n.id,
460
+ dimmed: neighborIds ? !neighborIds.has(n.id) : false,
461
+ // Surviving parentId means ELK built this node's process frame —
462
+ // the node renders nested, so a process box drops its redundant name.
463
+ nested: (n as { parentId?: string }).parentId != null,
464
+ },
465
+ })),
466
+ [nodes, selectedId, neighborIds],
467
+ );
468
+
469
+ const displayEdges = useMemo(
470
+ () =>
471
+ rfEdges.map((e) => {
472
+ const dimmed =
473
+ neighborIds != null &&
474
+ !(neighborIds.has(e.source) && neighborIds.has(e.target));
475
+ return {
476
+ ...e,
477
+ style: { ...(e.style as object), opacity: dimmed ? 0.12 : 0.9 },
478
+ };
479
+ }),
480
+ [rfEdges, neighborIds],
481
+ );
482
+
483
+ return (
484
+ <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
485
+ <ReactFlow
486
+ nodes={displayNodes}
487
+ edges={displayEdges}
488
+ nodeTypes={nodeTypes}
489
+ onNodeClick={onNodeClick}
490
+ onNodeMouseEnter={(_e, node) => setHoveredId(node.id)}
491
+ onNodeMouseLeave={() => setHoveredId(null)}
492
+ onPaneClick={() => {
493
+ setSelectedId(null);
494
+ onSelectFrame?.(null);
495
+ }}
496
+ proOptions={{ hideAttribution: true }}
497
+ minZoom={0.2}
498
+ />
499
+ {!ready && (
500
+ <div
501
+ style={{
502
+ position: 'absolute',
503
+ inset: 0,
504
+ display: 'flex',
505
+ alignItems: 'center',
506
+ justifyContent: 'center',
507
+ background: theme.colors.background,
508
+ color: muted,
509
+ fontFamily: theme.fonts.monospace,
510
+ fontSize: theme.fontSizes[0],
511
+ }}
512
+ >
513
+ Laying out frames…
514
+ </div>
515
+ )}
516
+ {title && (
517
+ <div
518
+ style={{
519
+ position: 'absolute',
520
+ top: 12,
521
+ left: 12,
522
+ fontSize: theme.fontSizes[2],
523
+ fontWeight: 600,
524
+ color: theme.colors.text,
525
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
526
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
527
+ borderRadius: 6,
528
+ padding: '4px 10px',
529
+ }}
530
+ >
531
+ {title}
532
+ </div>
533
+ )}
534
+ {selected && (
535
+ <div
536
+ style={{
537
+ position: 'absolute',
538
+ top: 12,
539
+ right: 12,
540
+ width: 280,
541
+ maxHeight: 'calc(100% - 24px)',
542
+ overflowY: 'auto',
543
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
544
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
545
+ borderRadius: 8,
546
+ padding: '10px 12px',
547
+ }}
548
+ >
549
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
550
+ <span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
551
+ {selected.label}
552
+ </span>
553
+ <button
554
+ type="button"
555
+ onClick={() => {
556
+ setSelectedId(null);
557
+ onSelectFrame?.(null);
558
+ }}
559
+ aria-label="Clear selection"
560
+ style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
561
+ >
562
+ ×
563
+ </button>
564
+ </div>
565
+ <div style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, margin: '4px 0 8px' }}>
566
+ {selected.kind} · {selected.members.length} member{selected.members.length === 1 ? '' : 's'}
567
+ {selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
568
+ </div>
569
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
570
+ {selected.members.map((m) => (
571
+ <button
572
+ key={m.alias}
573
+ type="button"
574
+ title={`${m.alias}${m.file ? ` — ${m.file}` : ''}`}
575
+ disabled={!m.file || !onOpenFile}
576
+ onClick={() => m.file && onOpenFile?.(m.file)}
577
+ style={{
578
+ border: 'none',
579
+ background: 'transparent',
580
+ color: m.file ? theme.colors.text : muted,
581
+ cursor: m.file && onOpenFile ? 'pointer' : 'default',
582
+ fontFamily: theme.fonts.monospace,
583
+ fontSize: theme.fontSizes[0],
584
+ textAlign: 'left',
585
+ padding: '2px 0',
586
+ overflow: 'hidden',
587
+ textOverflow: 'ellipsis',
588
+ whiteSpace: 'nowrap',
589
+ }}
590
+ >
591
+ {m.alias}
592
+ </button>
593
+ ))}
594
+ </div>
595
+ </div>
596
+ )}
597
+ </div>
598
+ );
599
+ }
600
+
601
+ export function SubsystemAggregateGraph(props: SubsystemAggregateGraphProps) {
602
+ return (
603
+ <ReactFlowProvider>
604
+ <Inner {...props} />
605
+ </ReactFlowProvider>
606
+ );
607
+ }
@@ -28,6 +28,7 @@ import {
28
28
  formatPurl,
29
29
  packageColor,
30
30
  subsystemGraphLayoutKey,
31
+ describeConstructBreakdown,
31
32
  } from './model';
32
33
  import type { SubsystemComponent, SubsystemComponentEdge } from './model';
33
34
 
@@ -626,6 +627,52 @@ describe('subsystem graph model', () => {
626
627
  expect(groups.filter((g) => g.region.kind === 'package')).toHaveLength(1);
627
628
  });
628
629
 
630
+ test('mixed-process module members live only in the module frame', () => {
631
+ // Same file framed under two processes (composed-model vocabulary drift):
632
+ // the module cannot nest, so its members must not also sit directly in
633
+ // the process frames — ELK throws on double-parented leaves.
634
+ const groups = buildBoundaryLayoutGroups({
635
+ components: [
636
+ { alias: 'a1', name: 'a1', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', module: 'src/a.ts', process: 'app/host' },
637
+ { alias: 'a2', name: 'a2', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', module: 'src/a.ts', process: 'app/renderer' },
638
+ { alias: 'b1', name: 'b1', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
639
+ ],
640
+ });
641
+ const leafHits = new Map<string, string[]>();
642
+ for (const g of groups) {
643
+ for (const id of g.memberAliases) {
644
+ // Nested group ids legitimately appear in parents; only leaves count.
645
+ if (groups.some((h) => h.id === id)) continue;
646
+ leafHits.set(id, [...(leafHits.get(id) ?? []), g.id]);
647
+ }
648
+ }
649
+ for (const [leaf, owners] of leafHits) {
650
+ expect(`${leaf} in ${owners.length} groups: ${owners.join(',')}`).toBe(`${leaf} in 1 groups: ${owners[0]}`);
651
+ }
652
+ const mod = groups.find((g) => g.region.kind === 'module');
653
+ expect(mod?.memberAliases).toEqual(['a1', 'a2']);
654
+ });
655
+
656
+ test('module-less leaf in a mixed-package process lives only in the process frame', () => {
657
+ const groups = buildBoundaryLayoutGroups({
658
+ components: [
659
+ { alias: 'x', name: 'x', construct: 'function', file: 'x.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
660
+ { alias: 'y', name: 'y', construct: 'function', file: 'y.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
661
+ { alias: 'z', name: 'z', construct: 'function', file: 'z.ts', purl: 'pkg:github/other/lib', process: 'app/host' },
662
+ ],
663
+ });
664
+ const leafHits = new Map<string, string[]>();
665
+ for (const g of groups) {
666
+ for (const id of g.memberAliases) {
667
+ if (groups.some((h) => h.id === id)) continue;
668
+ leafHits.set(id, [...(leafHits.get(id) ?? []), g.id]);
669
+ }
670
+ }
671
+ for (const [leaf, owners] of leafHits) {
672
+ expect(owners).toHaveLength(1);
673
+ }
674
+ });
675
+
629
676
  test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
630
677
  const base = doc;
631
678
  const withRef = {
@@ -681,6 +728,22 @@ describe('isConstructsOnlyModel', () => {
681
728
  });
682
729
  });
683
730
 
731
+ describe('describeConstructBreakdown', () => {
732
+ test('counts by construct, most common first', () => {
733
+ expect(describeConstructBreakdown(['function', 'class', 'function'])).toBe(
734
+ '2 functions · 1 class',
735
+ );
736
+ });
737
+
738
+ test('ignores blank constructs and handles empties', () => {
739
+ expect(describeConstructBreakdown(['', ' ', 'store'])).toBe('1 store');
740
+ expect(describeConstructBreakdown([])).toBe('empty');
741
+ expect(describeConstructBreakdown(['interface', 'type_alias', 'enum', 'custom_entity'])).toBe(
742
+ '1 custom_entity · 1 enum · 1 interface · +1 more',
743
+ );
744
+ });
745
+ });
746
+
684
747
  describe('module badge labels', () => {
685
748
  test('moduleBadgeLabel keeps the full path when it fits the module width', () => {
686
749
  expect(moduleBadgeLabel('src/main.ts', 1000)).toBe('src/main.ts');