@principal-ai/subsystems-react 0.37.5 → 0.37.7

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 (49) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +3 -0
  6. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -0
  7. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +2 -0
  8. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -0
  9. package/dist/subsystem/C4Graph.d.ts +22 -0
  10. package/dist/subsystem/C4Graph.d.ts.map +1 -0
  11. package/dist/subsystem/C4Graph.js +305 -0
  12. package/dist/subsystem/C4Graph.js.map +1 -0
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  14. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  15. package/dist/subsystem/SubsystemComponentGraph.js +7 -3
  16. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  17. package/dist/subsystem/WalkthroughsPanel.d.ts +2 -0
  18. package/dist/subsystem/WalkthroughsPanel.d.ts.map +1 -1
  19. package/dist/subsystem/WalkthroughsPanel.js +114 -48
  20. package/dist/subsystem/WalkthroughsPanel.js.map +1 -1
  21. package/dist/subsystem/model.d.ts +2 -0
  22. package/dist/subsystem/model.d.ts.map +1 -1
  23. package/dist/subsystem/model.js +6 -6
  24. package/dist/subsystem/model.js.map +1 -1
  25. package/dist/subsystem/nodes.d.ts.map +1 -1
  26. package/dist/subsystem/nodes.js +2 -1
  27. package/dist/subsystem/nodes.js.map +1 -1
  28. package/dist/subsystem/toC4.d.ts +95 -0
  29. package/dist/subsystem/toC4.d.ts.map +1 -0
  30. package/dist/subsystem/toC4.js +196 -0
  31. package/dist/subsystem/toC4.js.map +1 -0
  32. package/dist/subsystem/walkthroughBrief.d.ts +18 -0
  33. package/dist/subsystem/walkthroughBrief.d.ts.map +1 -0
  34. package/dist/subsystem/walkthroughBrief.js +34 -0
  35. package/dist/subsystem/walkthroughBrief.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/index.ts +6 -1
  38. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +104 -0
  39. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +6 -0
  40. package/src/subsystem/C4Graph.tsx +465 -0
  41. package/src/subsystem/SubsystemComponentGraph.tsx +15 -3
  42. package/src/subsystem/WalkthroughsPanel.test.tsx +146 -0
  43. package/src/subsystem/WalkthroughsPanel.tsx +163 -60
  44. package/src/subsystem/model.ts +8 -6
  45. package/src/subsystem/nodes.tsx +2 -1
  46. package/src/subsystem/toC4.test.ts +121 -0
  47. package/src/subsystem/toC4.ts +277 -0
  48. package/src/subsystem/walkthroughBrief.test.ts +57 -0
  49. package/src/subsystem/walkthroughBrief.ts +40 -0
@@ -13,7 +13,7 @@
13
13
  import { useCallback, useEffect, useRef, useState } from 'react';
14
14
  import type { DragEvent as ReactDragEvent } from 'react';
15
15
  import { useTheme } from '@principal-ade/industry-theme';
16
- import { ChevronDown, GripVertical, Pause, Play } from 'lucide-react';
16
+ import { Check, ChevronDown, Copy, GripVertical, Pause, Play } from 'lucide-react';
17
17
  import {
18
18
  PROPOSED_COLOR,
19
19
  reorderTargetIndex,
@@ -21,10 +21,17 @@ import {
21
21
  walkthroughStepGraphEdgeId,
22
22
  type SubsystemWalkthrough,
23
23
  } from './model';
24
+ import { buildStepBrief } from './walkthroughBrief';
24
25
 
25
26
  /** Pause (ms) between steps when a walkthrough autoplays. */
26
27
  export const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
27
28
 
29
+ /** How long a step's copy button flashes its "copied" checkmark (ms). */
30
+ export const STEP_COPY_FEEDBACK_MS = 2000;
31
+
32
+ /** Square size (px) of a walkthrough header's Play/Collapse control. */
33
+ const WALKTHROUGH_CONTROL_SIZE = 34;
34
+
28
35
  /** Drag state handed to a row's grip while a reorder is in flight. */
29
36
  interface WalkthroughReorderHandle {
30
37
  index: number;
@@ -93,6 +100,13 @@ function WalkthroughFlow({
93
100
  const [gripHover, setGripHover] = useState(false);
94
101
  const [hoveredStep, setHoveredStep] = useState<number | null>(null);
95
102
  const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
103
+ // Per-step copy affordance: revealed while a row is hovered or holds focus.
104
+ // `focusedStep` tracks focus-within (button or its copy control) so the icon
105
+ // stays put while the pointer or focus moves between them.
106
+ const [focusedStep, setFocusedStep] = useState<number | null>(null);
107
+ const [copyHoverStep, setCopyHoverStep] = useState<number | null>(null);
108
+ const [copiedStep, setCopiedStep] = useState<number | null>(null);
109
+ const copyTimerRef = useRef<number | null>(null);
96
110
  // Autoplay: stepping through the flow's steps with a pause between each.
97
111
  const [playing, setPlaying] = useState(false);
98
112
  const playTimerRef = useRef<number | null>(null);
@@ -109,6 +123,38 @@ function WalkthroughFlow({
109
123
  // eslint-disable-next-line react-hooks/exhaustive-deps
110
124
  useEffect(() => () => stopPlaying(), []);
111
125
 
126
+ const copyStep = useCallback(
127
+ (stepIndex: number) => {
128
+ const brief = buildStepBrief(walkthrough, stepIndex);
129
+ if (brief.length === 0) return;
130
+ const clipboard = navigator.clipboard;
131
+ if (!clipboard) return;
132
+ void clipboard
133
+ .writeText(brief)
134
+ .then(() => {
135
+ setCopiedStep(stepIndex);
136
+ if (copyTimerRef.current != null) {
137
+ window.clearTimeout(copyTimerRef.current);
138
+ }
139
+ copyTimerRef.current = window.setTimeout(
140
+ () => setCopiedStep(null),
141
+ STEP_COPY_FEEDBACK_MS,
142
+ );
143
+ })
144
+ .catch(() => {
145
+ // best-effort — show nothing when the clipboard is unavailable
146
+ });
147
+ },
148
+ [walkthrough],
149
+ );
150
+
151
+ useEffect(
152
+ () => () => {
153
+ if (copyTimerRef.current != null) window.clearTimeout(copyTimerRef.current);
154
+ },
155
+ [],
156
+ );
157
+
112
158
  // Native HTML5 drag suppresses mouse events, so a row hovered when the drag
113
159
  // began never gets its mouseleave — its hover styling would stick after the
114
160
  // drop. Reset every row's hover whenever a drag starts or ends.
@@ -172,7 +218,8 @@ function WalkthroughFlow({
172
218
  display: 'flex',
173
219
  alignItems: 'center',
174
220
  gap: 4,
175
- padding: '0 16px',
221
+ height: WALKTHROUGH_CONTROL_SIZE,
222
+ padding: '0 0 0 16px',
176
223
  background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
177
224
  transition: 'background 120ms ease',
178
225
  }}
@@ -198,7 +245,7 @@ function WalkthroughFlow({
198
245
  display: 'flex',
199
246
  alignItems: 'center',
200
247
  minWidth: 0,
201
- padding: '10px 0',
248
+ padding: 0,
202
249
  border: 'none',
203
250
  background: 'transparent',
204
251
  textAlign: 'left',
@@ -236,11 +283,11 @@ function WalkthroughFlow({
236
283
  alignItems: 'center',
237
284
  justifyContent: 'center',
238
285
  flexShrink: 0,
239
- width: 22,
240
- height: 22,
286
+ alignSelf: 'stretch',
287
+ width: WALKTHROUGH_CONTROL_SIZE,
241
288
  padding: 0,
242
289
  border: 'none',
243
- borderRadius: 4,
290
+ borderRadius: 0,
244
291
  background: playing || playHover ? theme.colors.border : 'transparent',
245
292
  color: playing || playHover ? theme.colors.text : muted,
246
293
  cursor: 'pointer',
@@ -265,11 +312,11 @@ function WalkthroughFlow({
265
312
  alignItems: 'center',
266
313
  justifyContent: 'center',
267
314
  flexShrink: 0,
268
- width: 22,
269
- height: 22,
315
+ alignSelf: 'stretch',
316
+ width: WALKTHROUGH_CONTROL_SIZE,
270
317
  padding: 0,
271
318
  border: 'none',
272
- borderRadius: 4,
319
+ borderRadius: 0,
273
320
  background: collapseHover ? theme.colors.border : 'transparent',
274
321
  color: collapseHover ? theme.colors.text : muted,
275
322
  cursor: 'pointer',
@@ -298,7 +345,7 @@ function WalkthroughFlow({
298
345
  display: 'inline-flex',
299
346
  alignItems: 'center',
300
347
  flexShrink: 0,
301
- marginRight: -8,
348
+ marginRight: 8,
302
349
  padding: '2px 0',
303
350
  color: gripHover || reorder.isDragging ? theme.colors.text : muted,
304
351
  cursor: reorder.isDragging ? 'grabbing' : 'grab',
@@ -320,65 +367,121 @@ function WalkthroughFlow({
320
367
  {walkthrough.steps.map((step, i) => {
321
368
  const stepActive = active !== null && active.stepIndex === i;
322
369
  const proposed = stepProposed(step.from, step.to);
370
+ const revealed =
371
+ stepActive || hoveredStep === i || focusedStep === i || copiedStep === i;
372
+ const copied = copiedStep === i;
373
+ const copyHovered = copyHoverStep === i;
323
374
  return (
324
- <button
375
+ <div
325
376
  key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
326
- ref={(el) => {
327
- stepButtonRefs.current[i] = el;
328
- }}
329
- type="button"
330
- title={proposed ? 'Step touches a proposed component' : undefined}
331
- onMouseEnter={() => {
332
- setHoveredStep(i);
333
- onHoverStep(walkthrough, i);
334
- }}
335
- onClick={() => onFocusStep(walkthrough, i)}
336
- style={{
337
- display: 'flex',
338
- alignItems: 'center',
339
- gap: 8,
340
- minWidth: 0,
341
- padding: '8px 8px 8px 17px',
342
- textAlign: 'left',
343
- borderRadius: 0,
344
- border: 'none',
345
- outline: 'none',
346
- background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
347
- cursor: 'pointer',
348
- transition: 'background 120ms ease',
377
+ style={{ position: 'relative' }}
378
+ onFocus={() => setFocusedStep(i)}
379
+ onBlur={(e) => {
380
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
381
+ setFocusedStep((cur) => (cur === i ? null : cur));
382
+ }
349
383
  }}
350
384
  >
351
- <span
352
- style={{
353
- flexShrink: 0,
354
- width: 14,
355
- fontSize: theme.fontSizes[0],
356
- fontFamily: theme.fonts.monospace,
357
- fontWeight: proposed ? 700 : undefined,
358
- color: proposed
359
- ? PROPOSED_COLOR
360
- : stepActive
361
- ? theme.colors.text
362
- : muted,
385
+ <button
386
+ ref={(el) => {
387
+ stepButtonRefs.current[i] = el;
363
388
  }}
364
- >
365
- {i + 1}
366
- </span>
367
- <span
389
+ type="button"
390
+ title={proposed ? 'Step touches a proposed component' : undefined}
391
+ onMouseEnter={() => {
392
+ setHoveredStep(i);
393
+ onHoverStep(walkthrough, i);
394
+ }}
395
+ onClick={() => onFocusStep(walkthrough, i)}
368
396
  style={{
369
- flex: 1,
397
+ display: 'flex',
398
+ alignItems: 'center',
399
+ gap: 8,
370
400
  minWidth: 0,
371
- overflow: 'hidden',
372
- textOverflow: 'ellipsis',
373
- whiteSpace: 'nowrap',
374
- fontSize: theme.fontSizes[1],
375
- fontFamily: theme.fonts.monospace,
376
- color: proposed ? PROPOSED_COLOR : theme.colors.text,
401
+ width: '100%',
402
+ padding: '8px 36px 8px 17px',
403
+ textAlign: 'left',
404
+ borderRadius: 0,
405
+ border: 'none',
406
+ outline: 'none',
407
+ background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
408
+ cursor: 'pointer',
409
+ transition: 'background 120ms ease',
377
410
  }}
378
411
  >
379
- {step.symbol}
380
- </span>
381
- </button>
412
+ <span
413
+ style={{
414
+ flexShrink: 0,
415
+ width: 14,
416
+ fontSize: theme.fontSizes[0],
417
+ fontFamily: theme.fonts.monospace,
418
+ fontWeight: proposed ? 700 : undefined,
419
+ color: proposed
420
+ ? PROPOSED_COLOR
421
+ : stepActive
422
+ ? theme.colors.text
423
+ : muted,
424
+ }}
425
+ >
426
+ {i + 1}
427
+ </span>
428
+ <span
429
+ style={{
430
+ flex: 1,
431
+ minWidth: 0,
432
+ overflow: 'hidden',
433
+ textOverflow: 'ellipsis',
434
+ whiteSpace: 'nowrap',
435
+ fontSize: theme.fontSizes[1],
436
+ fontFamily: theme.fonts.monospace,
437
+ color: proposed ? PROPOSED_COLOR : theme.colors.text,
438
+ }}
439
+ >
440
+ {step.symbol}
441
+ </span>
442
+ </button>
443
+ {revealed && (
444
+ <button
445
+ type="button"
446
+ aria-label={`Copy step ${i + 1} of ${walkthrough.title} for an agent`}
447
+ title={copied ? 'Copied' : 'Copy this step for an agent'}
448
+ onMouseDown={(e) => e.preventDefault()}
449
+ onMouseEnter={() => setCopyHoverStep(i)}
450
+ onMouseLeave={() =>
451
+ setCopyHoverStep((cur) => (cur === i ? null : cur))
452
+ }
453
+ onClick={() => copyStep(i)}
454
+ style={{
455
+ position: 'absolute',
456
+ right: 0,
457
+ top: 0,
458
+ bottom: 0,
459
+ display: 'inline-flex',
460
+ alignItems: 'center',
461
+ justifyContent: 'center',
462
+ width: 'auto',
463
+ aspectRatio: '1 / 1',
464
+ padding: 0,
465
+ border: 'none',
466
+ borderRadius: 0,
467
+ background: copied
468
+ ? 'rgba(16,185,129,0.12)'
469
+ : copyHovered
470
+ ? theme.colors.border
471
+ : 'transparent',
472
+ color: copied ? '#10b981' : copyHovered ? theme.colors.text : muted,
473
+ cursor: 'pointer',
474
+ transition: 'background 120ms ease, color 120ms ease',
475
+ }}
476
+ >
477
+ {copied ? (
478
+ <Check size={12} strokeWidth={2} />
479
+ ) : (
480
+ <Copy size={12} strokeWidth={2} />
481
+ )}
482
+ </button>
483
+ )}
484
+ </div>
382
485
  );
383
486
  })}
384
487
  </div>
@@ -963,6 +963,8 @@ export interface SubsystemGroupNodeData extends Record<string, unknown> {
963
963
  region: SubsystemProcessRegion;
964
964
  /** True while the region's members are dimmed by flow focus. */
965
965
  dimmed?: boolean;
966
+ /** Host-supplied frame color override; falls back to the derived color. */
967
+ color?: string;
966
968
  }
967
969
 
968
970
  export type SubsystemGraphNode =
@@ -1032,17 +1034,17 @@ export function isWalkthroughMechanism(
1032
1034
  }
1033
1035
 
1034
1036
  export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
1035
- calls: '#4ec9b0', // teal
1037
+ calls: '#22c55e', // green
1036
1038
  extends: '#b48ead', // purple
1037
1039
  inherits: '#9b6fd0', // purple
1038
1040
  implements: '#c586c0', // magenta
1039
1041
  mixes_in: '#d474a8', // pink-magenta
1040
1042
  uses: '#e3b341', // gold
1041
1043
  method: '#c586c0', // magenta
1042
- references: '#e07a5f', // terracotta
1043
- feeds: '#22c55e', // green — data-flow into a processor
1044
- produces: '#e07a5f', // terracotta — emits an output type
1045
- writes: '#2f9e44', // deep green — mutates retained state
1044
+ references: '#3b82f6', // blue
1045
+ feeds: '#4ec9b0', // teal — data-flow into a processor
1046
+ produces: '#a78bfa', // violet — emits an output type
1047
+ writes: '#e8853a', // orange — mutates retained state
1046
1048
  reads: '#0ea5e9', // sky — pulls from retained state
1047
1049
  watches: '#9ca3af', // gray — observes, owns nothing
1048
1050
  'registers-into': '#ff6b35', // orange
@@ -1186,7 +1188,7 @@ export function packageColor(name: string): string {
1186
1188
  * are the only surface using it, but pick a distinct value if roles ever
1187
1189
  * take over node borders. */
1188
1190
  export const ROLE_COLOR: Record<SubsystemComponentRole, string> = {
1189
- entry: '#ff6b35', // orange — boundary element
1191
+ entry: '#c0c0c0', // silver — boundary element
1190
1192
  service: '#0893d2', // blue — external system
1191
1193
  };
1192
1194
 
@@ -359,7 +359,8 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
359
359
  selected?: boolean;
360
360
  };
361
361
  const region = data.region;
362
- const color = packageColor(region?.key ?? 'process');
362
+ // Explicit host override wins over the derived (hash-based) frame color.
363
+ const color = data.color ?? packageColor(region?.key ?? 'process');
363
364
  const dimmed = data.dimmed === true;
364
365
  const hidden = (data as { hidden?: boolean }).hidden === true;
365
366
  // Label is the region identity alone (path / process key / owner/name).
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ toC4,
4
+ deriveRepoKey,
5
+ labelFromPurl,
6
+ isGroundedComponent,
7
+ } from './toC4';
8
+ import type { SubsystemModelDocument } from './model';
9
+
10
+ const REPO = 'pkg:github/acme/repo';
11
+
12
+ const doc: SubsystemModelDocument = {
13
+ components: [
14
+ { alias: 'a', name: 'Alpha', construct: 'function', file: 'src/a.ts', purl: `${REPO}#src/a.ts`, process: 'p1' },
15
+ { alias: 'b', name: 'Beta', construct: 'function', file: 'src/b.ts', purl: `${REPO}#src/b.ts`, process: 'p1' },
16
+ { alias: 's', name: 'Cache', construct: 'store', file: 'src/s.ts', purl: `${REPO}#src/s.ts`, process: 'p1' },
17
+ { alias: 'c', name: 'Gamma', construct: 'function', file: 'src/c.ts', purl: `${REPO}#src/c.ts`, process: 'p2' },
18
+ { alias: 'x', name: 'Gist API', construct: 'external', file: '', purl: 'external:api.github.com/gists' },
19
+ { alias: 'agent', name: 'Maintenance agent', construct: 'custom_entity', file: '', purl: 'external' },
20
+ ],
21
+ relations: [
22
+ { id: 'r1', from: 'a', to: 'b', relationType: 'method' }, // intra p1
23
+ { id: 'r2', from: 'a', to: 'c', relationType: 'references' },
24
+ { id: 'r3', from: 'b', to: 'c', relationType: 'references' },
25
+ { id: 'r4', from: 'a', to: 'x', relationType: 'references' },
26
+ ],
27
+ walkthroughs: [
28
+ {
29
+ id: 'w1',
30
+ title: 'flow',
31
+ steps: [
32
+ { from: 'b', to: 'c', mechanism: 'calls', file: 'src/b.ts', line: 1, purl: `${REPO}#src/b.ts`, symbol: 'Beta' },
33
+ { from: 'a', to: 'c', mechanism: 'calls', file: 'src/a.ts', line: 2, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
34
+ { from: 'a', to: 'agent', mechanism: 'calls', file: 'src/a.ts', line: 3, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
35
+ ],
36
+ },
37
+ ],
38
+ };
39
+
40
+ describe('helpers', () => {
41
+ test('deriveRepoKey ignores external purls', () => {
42
+ expect(deriveRepoKey(doc)).toBe(REPO);
43
+ });
44
+
45
+ test('labelFromPurl trims scheme and keeps owner/name', () => {
46
+ expect(labelFromPurl('pkg:github/acme/repo#src/a.ts')).toBe('acme/repo');
47
+ expect(labelFromPurl('external:api.github.com/gists')).toBe('api.github.com/gists');
48
+ });
49
+
50
+ test('isGroundedComponent excludes externals and entities', () => {
51
+ expect(isGroundedComponent(doc.components[0]!)).toBe(true);
52
+ expect(isGroundedComponent(doc.components[4]!)).toBe(false);
53
+ expect(isGroundedComponent(doc.components[5]!)).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe('container view', () => {
58
+ const model = toC4(doc, { view: 'container', systemLabel: 'Acme' });
59
+
60
+ test('system is the repo, containers are processes', () => {
61
+ expect(model.system.id).toBe(`system:${REPO}`);
62
+ expect(model.system.label).toBe('Acme');
63
+ expect(model.nodes.map((n) => n.id).sort()).toEqual(
64
+ [`container:p1`, `container:p2`, `external:external:api.github.com/gists`, `actor:agent`].sort(),
65
+ );
66
+ });
67
+
68
+ test('container members and store flag', () => {
69
+ const p1 = model.nodes.find((n) => n.id === 'container:p1')!;
70
+ expect(p1.members.sort()).toEqual(['a', 'b', 's']);
71
+ expect(p1.isStore).toBe(true);
72
+ });
73
+
74
+ test('one system group holds the container nodes', () => {
75
+ expect(model.groups).toHaveLength(1);
76
+ expect(model.groups[0]!.kind).toBe('system');
77
+ expect(model.groups[0]!.memberIds.sort()).toEqual(['container:p1', 'container:p2']);
78
+ });
79
+
80
+ test('edges roll up and intra-container edges drop', () => {
81
+ const byId = Object.fromEntries(model.edges.map((e) => [e.id, e]));
82
+ // a->b is intra-p1 — dropped.
83
+ expect(model.edges.some((e) => e.source === e.target)).toBe(false);
84
+ const rel = byId['relationship:container:p1\u0000container:p2']!;
85
+ expect(rel.kind).toBe('relationship');
86
+ expect(rel.count).toBe(2); // r2 + r3
87
+ const flow = byId['flow:container:p1\u0000container:p2']!;
88
+ expect(flow.count).toBe(2); // a->c + b->c
89
+ expect(byId['relationship:container:p1\u0000external:external:api.github.com/gists']).toBeDefined();
90
+ expect(byId['flow:container:p1\u0000actor:agent']).toBeDefined();
91
+ expect(model.edges).toHaveLength(4);
92
+ });
93
+ });
94
+
95
+ describe('component view', () => {
96
+ const model = toC4(doc, { view: 'component' });
97
+
98
+ test('components parent to their container; externals stay root', () => {
99
+ const a = model.nodes.find((n) => n.id === 'component:a')!;
100
+ expect(a.parentId).toBe('container:p1');
101
+ const c = model.nodes.find((n) => n.id === 'component:c')!;
102
+ expect(c.parentId).toBe('container:p2');
103
+ expect(model.nodes.map((n) => n.id).sort()).toEqual(
104
+ ['component:a', 'component:b', 'component:s', 'component:c', 'external:external:api.github.com/gists', 'actor:agent'].sort(),
105
+ );
106
+ });
107
+
108
+ test('nested groups: containers nest in the system', () => {
109
+ const p1 = model.groups.find((g) => g.id === 'container:p1')!;
110
+ expect(p1.parentId).toBe(`system:${REPO}`);
111
+ expect(p1.memberIds.sort()).toEqual(['component:a', 'component:b', 'component:s']);
112
+ const system = model.groups.find((g) => g.kind === 'system')!;
113
+ expect(system.memberIds.sort()).toEqual(['container:p1', 'container:p2']);
114
+ });
115
+
116
+ test('intra-container but cross-component edges survive', () => {
117
+ // a->b (same container, different components) survives at component level.
118
+ expect(model.edges.some((e) => e.source === 'component:a' && e.target === 'component:b')).toBe(true);
119
+ expect(model.edges.some((e) => e.source === e.target)).toBe(false);
120
+ });
121
+ });