@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
@@ -0,0 +1,465 @@
1
+ /**
2
+ * C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
3
+ *
4
+ * Prototype test case: feed it a `C4Model` from `toC4()` and it draws the
5
+ * system as a compound frame, its containers (or components) as boxes, and
6
+ * externals/actors outside the system. Static `relations` render as solid
7
+ * edges; `walkthrough` hops render as dashed flow edges. Click a box to list
8
+ * the source components it rolled up.
9
+ *
10
+ * Same interaction language as the other graphs: React Flow + ELK + the shared
11
+ * `GRAPH_NAV_PROPS` chrome. Deliberately small — no measurement passes, no
12
+ * drawers, no walkthrough playback.
13
+ */
14
+
15
+ import { useEffect, useMemo, useState } from 'react';
16
+ import {
17
+ Edge,
18
+ Handle,
19
+ MarkerType,
20
+ Node,
21
+ NodeProps,
22
+ Position,
23
+ ReactFlow,
24
+ ReactFlowProvider,
25
+ useReactFlow,
26
+ } from '@xyflow/react';
27
+ import { useTheme } from '@principal-ade/industry-theme';
28
+ import { computeElkLayout } from '../utils/elkLayout';
29
+ import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
30
+ import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
31
+ import type { C4Model, C4Node } from './toC4';
32
+
33
+ export interface C4GraphProps {
34
+ model: C4Model;
35
+ title?: string;
36
+ onSelectNode?: (id: string | null) => void;
37
+ }
38
+
39
+ const NODE_W = 230;
40
+ const NODE_H = 80;
41
+
42
+ function baseName(path: string): string {
43
+ const parts = path.split('/').filter(Boolean);
44
+ return parts[parts.length - 1] ?? path;
45
+ }
46
+
47
+ function edgeColor(mechanism: string | undefined, fallback: string): string {
48
+ if (!mechanism) return fallback;
49
+ return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
50
+ }
51
+
52
+ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
53
+ const { theme } = useTheme();
54
+ const { node, selected } = props.data;
55
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
56
+
57
+ const kindColor =
58
+ node.kind === 'external'
59
+ ? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
60
+ : node.kind === 'actor'
61
+ ? (theme.colors.accent ?? theme.colors.info)
62
+ : (theme.colors.border ?? '#333');
63
+
64
+ const tag = node.kind === 'container' ? 'container' : node.kind === 'external' ? 'external system' : node.kind === 'actor' ? 'actor' : 'component';
65
+
66
+ const subtitle =
67
+ node.kind === 'component'
68
+ ? `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`
69
+ : `${describeConstructBreakdown(node.constructs)}${
70
+ node.members.length > 0 ? ` · ${node.members.length} component${node.members.length === 1 ? '' : 's'}` : ''
71
+ }`;
72
+
73
+ return (
74
+ <div
75
+ style={{
76
+ width: NODE_W,
77
+ height: NODE_H,
78
+ boxSizing: 'border-box',
79
+ display: 'flex',
80
+ flexDirection: 'column',
81
+ justifyContent: 'center',
82
+ gap: 2,
83
+ padding: '8px 12px',
84
+ borderRadius: node.kind === 'actor' ? 20 : 8,
85
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
86
+ border: `2px ${node.kind === 'component' ? 'solid' : node.kind === 'container' ? 'solid' : 'dashed'} ${
87
+ selected ? theme.colors.primary : kindColor
88
+ }`,
89
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
90
+ cursor: 'pointer',
91
+ fontFamily: theme.fonts.body,
92
+ }}
93
+ >
94
+ <span
95
+ style={{
96
+ fontFamily: theme.fonts.monospace,
97
+ fontSize: theme.fontSizes[0],
98
+ letterSpacing: 0.5,
99
+ textTransform: 'uppercase',
100
+ color: kindColor,
101
+ whiteSpace: 'nowrap',
102
+ overflow: 'hidden',
103
+ textOverflow: 'ellipsis',
104
+ }}
105
+ >
106
+ {tag}
107
+ </span>
108
+ <span
109
+ style={{
110
+ fontWeight: 600,
111
+ fontSize: theme.fontSizes[2],
112
+ color: theme.colors.text,
113
+ whiteSpace: 'nowrap',
114
+ overflow: 'hidden',
115
+ textOverflow: 'ellipsis',
116
+ }}
117
+ title={node.label}
118
+ >
119
+ {node.label}
120
+ </span>
121
+ <span
122
+ style={{
123
+ fontFamily: theme.fonts.monospace,
124
+ fontSize: theme.fontSizes[0],
125
+ color: muted,
126
+ whiteSpace: 'nowrap',
127
+ overflow: 'hidden',
128
+ textOverflow: 'ellipsis',
129
+ }}
130
+ title={node.members.join(', ')}
131
+ >
132
+ {subtitle}
133
+ </span>
134
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
135
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
136
+ </div>
137
+ );
138
+ }
139
+
140
+ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
141
+ const { theme } = useTheme();
142
+ const color = props.data.color;
143
+ return (
144
+ <div
145
+ style={{
146
+ width: '100%',
147
+ height: '100%',
148
+ boxSizing: 'border-box',
149
+ borderRadius: 12,
150
+ border: `2px dashed ${color}`,
151
+ background: 'transparent',
152
+ pointerEvents: 'none',
153
+ }}
154
+ >
155
+ <span
156
+ style={{
157
+ position: 'absolute',
158
+ top: 0,
159
+ transform: 'translateY(-50%)',
160
+ left: 12,
161
+ zIndex: 1,
162
+ fontFamily: theme.fonts.monospace,
163
+ fontSize: theme.fontSizes[2],
164
+ fontWeight: 600,
165
+ letterSpacing: 0.5,
166
+ textTransform: 'uppercase',
167
+ lineHeight: '18px',
168
+ color,
169
+ background: theme.colors.background,
170
+ boxShadow: `0 0 0 1.5px ${theme.colors.background}`,
171
+ border: `2px solid ${color}`,
172
+ borderRadius: 4,
173
+ padding: '4px 10px',
174
+ boxSizing: 'border-box',
175
+ whiteSpace: 'nowrap',
176
+ }}
177
+ >
178
+ {props.data.label}
179
+ </span>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ const nodeTypes = {
185
+ 'c4-node': C4NodeView,
186
+ 'c4-group': C4GroupView,
187
+ };
188
+
189
+ function Inner({ model, title, onSelectNode }: C4GraphProps) {
190
+ const { theme } = useTheme();
191
+ const { fitView } = useReactFlow();
192
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
193
+ const [nodes, setNodes] = useState<Node[]>([]);
194
+ const [rfEdges, setRfEdges] = useState<Edge[]>([]);
195
+ const [ready, setReady] = useState(false);
196
+ const [selectedId, setSelectedId] = useState<string | null>(null);
197
+
198
+ const layoutKey = useMemo(
199
+ () =>
200
+ [
201
+ ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
202
+ ...model.groups.map((g) => `${g.id}\0${g.parentId ?? ''}\0${g.memberIds.length}`).sort(),
203
+ ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
204
+ ].join('\n'),
205
+ [model],
206
+ );
207
+
208
+ useEffect(() => {
209
+ let alive = true;
210
+ setReady(false);
211
+ setSelectedId(null);
212
+
213
+ const rfNodes: Node[] = model.nodes.map((n, i) => ({
214
+ id: n.id,
215
+ type: 'c4-node',
216
+ position: { x: (i % 4) * (NODE_W + 60), y: Math.floor(i / 4) * (NODE_H + 40) },
217
+ width: NODE_W,
218
+ height: NODE_H,
219
+ ...(n.parentId ? { parentId: n.parentId } : {}),
220
+ data: { node: n, selected: false },
221
+ }));
222
+
223
+ const groups = model.groups.map((g) => ({
224
+ id: g.id,
225
+ memberIds: g.memberIds,
226
+ ...(g.parentId ? { parentId: g.parentId } : {}),
227
+ }));
228
+
229
+ const rfEdges: Edge[] = model.edges.map((e) => {
230
+ const flow = e.kind === 'flow';
231
+ const color = flow ? edgeColor(e.mechanisms[0], muted) : (theme.colors.border ?? '#666');
232
+ return {
233
+ id: e.id,
234
+ source: e.source,
235
+ target: e.target,
236
+ label: e.label,
237
+ labelShowBg: false,
238
+ labelStyle: { fill: muted, fontSize: theme.fontSizes[0], fontFamily: theme.fonts.monospace },
239
+ style: {
240
+ stroke: color,
241
+ strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
242
+ ...(flow ? { strokeDasharray: '6 4' } : {}),
243
+ },
244
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
245
+ data: { edge: e },
246
+ };
247
+ });
248
+
249
+ void computeElkLayout(rfNodes, rfEdges, {
250
+ direction: 'RIGHT',
251
+ preserveNodePositions: false,
252
+ groups,
253
+ keepSingletonGroups: true,
254
+ })
255
+ .then((result) => {
256
+ if (!alive) return;
257
+ const positioned = new Map(result.nodes.map((n) => [n.id, n]));
258
+ const builtGroups = new Set(result.groupBounds.keys());
259
+ const groupById = new Map(model.groups.map((g) => [g.id, g]));
260
+
261
+ const depthOf = (id: string): number => {
262
+ let d = 0;
263
+ let g = groupById.get(id);
264
+ while (g?.parentId) {
265
+ d += 1;
266
+ g = groupById.get(g.parentId);
267
+ }
268
+ return d;
269
+ };
270
+
271
+ const shells: Node[] = [...result.groupBounds.entries()]
272
+ .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
273
+ .map(([id, b]) => {
274
+ const def = groupById.get(id);
275
+ const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
276
+ return {
277
+ id,
278
+ type: 'c4-group',
279
+ position: { x: b.x, y: b.y },
280
+ width: Math.max(200, b.width),
281
+ height: Math.max(120, b.height),
282
+ draggable: false,
283
+ selectable: false,
284
+ ...(parentId ? { parentId } : {}),
285
+ data: {
286
+ label: def?.label ?? id,
287
+ kind: def?.kind ?? 'container',
288
+ color: def?.kind === 'system' ? (theme.colors.text ?? '#888') : (theme.colors.accent ?? theme.colors.info),
289
+ },
290
+ };
291
+ });
292
+
293
+ const leaves = rfNodes.map((n) => {
294
+ const p = positioned.get(n.id);
295
+ const node = p ? { ...n, position: { x: p.position.x, y: p.position.y } } : n;
296
+ const parentId = (node as { parentId?: string }).parentId;
297
+ if (parentId && !builtGroups.has(parentId)) {
298
+ const { parentId: _drop, ...rest } = node as { parentId?: string } & Node;
299
+ return rest as Node;
300
+ }
301
+ return node;
302
+ });
303
+
304
+ setNodes([...shells, ...leaves]);
305
+ setRfEdges(rfEdges);
306
+ setReady(true);
307
+ requestAnimationFrame(() => fitView({ padding: 0.15 }));
308
+ })
309
+ .catch((err) => {
310
+ if (!alive) return;
311
+ console.warn('[c4-graph] ELK layout failed, using grid:', err);
312
+ setNodes(rfNodes);
313
+ setRfEdges(rfEdges);
314
+ setReady(true);
315
+ requestAnimationFrame(() => fitView({ padding: 0.15 }));
316
+ });
317
+
318
+ return () => {
319
+ alive = false;
320
+ };
321
+ // eslint-disable-next-line react-hooks/exhaustive-deps
322
+ }, [layoutKey]);
323
+
324
+ const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
325
+
326
+ const displayNodes = useMemo(
327
+ () =>
328
+ nodes.map((n) =>
329
+ n.type === 'c4-node'
330
+ ? { ...n, data: { ...n.data, selected: n.id === selectedId } }
331
+ : n,
332
+ ),
333
+ [nodes, selectedId],
334
+ );
335
+
336
+ const runtime = model.nodes.filter((n) => n.kind === 'container' || n.kind === 'component').length;
337
+ const outside = model.nodes.filter((n) => n.kind === 'external' || n.kind === 'actor').length;
338
+
339
+ return (
340
+ <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
341
+ <GraphLayerStyle />
342
+ <ReactFlow
343
+ nodes={displayNodes}
344
+ edges={rfEdges}
345
+ nodeTypes={nodeTypes}
346
+ className={GRAPH_CANVAS_CLASS}
347
+ {...GRAPH_NAV_PROPS}
348
+ onNodeClick={(_e, node) => {
349
+ if (node.type !== 'c4-node') return;
350
+ const next = node.id === selectedId ? null : node.id;
351
+ setSelectedId(next);
352
+ onSelectNode?.(next);
353
+ }}
354
+ onPaneClick={() => {
355
+ setSelectedId(null);
356
+ onSelectNode?.(null);
357
+ }}
358
+ proOptions={{ hideAttribution: true }}
359
+ >
360
+ <GraphChrome />
361
+ </ReactFlow>
362
+ {!ready && (
363
+ <div
364
+ style={{
365
+ position: 'absolute',
366
+ inset: 0,
367
+ display: 'flex',
368
+ alignItems: 'center',
369
+ justifyContent: 'center',
370
+ background: theme.colors.background,
371
+ color: muted,
372
+ fontFamily: theme.fonts.monospace,
373
+ fontSize: theme.fontSizes[0],
374
+ }}
375
+ >
376
+ Laying out C4 view…
377
+ </div>
378
+ )}
379
+ {title && (
380
+ <div
381
+ style={{
382
+ position: 'absolute',
383
+ top: 12,
384
+ left: 12,
385
+ fontSize: theme.fontSizes[2],
386
+ fontWeight: 600,
387
+ color: theme.colors.text,
388
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
389
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
390
+ borderRadius: 6,
391
+ padding: '4px 10px',
392
+ }}
393
+ >
394
+ {title}
395
+ <span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, marginLeft: 8 }}>
396
+ {model.view} view · {runtime} {model.view === 'container' ? 'containers' : 'components'} · {outside} outside
397
+ </span>
398
+ </div>
399
+ )}
400
+ {selected && (
401
+ <div
402
+ style={{
403
+ position: 'absolute',
404
+ top: 12,
405
+ right: 12,
406
+ width: 280,
407
+ maxHeight: 'calc(100% - 24px)',
408
+ overflowY: 'auto',
409
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
410
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
411
+ borderRadius: 8,
412
+ padding: '10px 12px',
413
+ }}
414
+ >
415
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
416
+ <span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
417
+ {selected.label}
418
+ </span>
419
+ <button
420
+ type="button"
421
+ onClick={() => {
422
+ setSelectedId(null);
423
+ onSelectNode?.(null);
424
+ }}
425
+ aria-label="Clear selection"
426
+ style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
427
+ >
428
+ ×
429
+ </button>
430
+ </div>
431
+ <div style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, margin: '4px 0 8px' }}>
432
+ {selected.kind} · {selected.members.length} component{selected.members.length === 1 ? '' : 's'}
433
+ {selected.models && selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
434
+ </div>
435
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
436
+ {selected.members.map((alias) => (
437
+ <span
438
+ key={alias}
439
+ title={alias}
440
+ style={{
441
+ fontFamily: theme.fonts.monospace,
442
+ fontSize: theme.fontSizes[0],
443
+ color: theme.colors.text,
444
+ overflow: 'hidden',
445
+ textOverflow: 'ellipsis',
446
+ whiteSpace: 'nowrap',
447
+ }}
448
+ >
449
+ {alias}
450
+ </span>
451
+ ))}
452
+ </div>
453
+ </div>
454
+ )}
455
+ </div>
456
+ );
457
+ }
458
+
459
+ export function C4Graph(props: C4GraphProps) {
460
+ return (
461
+ <ReactFlowProvider>
462
+ <Inner {...props} />
463
+ </ReactFlowProvider>
464
+ );
465
+ }
@@ -345,6 +345,14 @@ export interface SubsystemComponentGraphProps {
345
345
  * where the user left off. Omit to keep the state purely in-memory.
346
346
  */
347
347
  persistKey?: string;
348
+ /**
349
+ * Explicit frame colors for boundary regions, keyed by region key (the
350
+ * component's `process` / `module` value, or the package purl key). A key
351
+ * present here overrides the library's derived `packageColor`. Hosts use this
352
+ * to pin the colors a given surface cares about (e.g. a marketing hero)
353
+ * instead of accepting the hash. Unmapped regions keep the derived color.
354
+ */
355
+ boundaryColors?: Record<string, string>;
348
356
  }
349
357
 
350
358
  const nodeTypes: NodeTypes = {
@@ -449,7 +457,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
449
457
  measured: { w: number; h: number } | null;
450
458
  }
451
459
 
452
- function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, persistKey }: InnerProps) {
460
+ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, persistKey }: InnerProps) {
453
461
  const { theme } = useTheme();
454
462
  const { fitView } = useReactFlow();
455
463
  const viewport = useViewport();
@@ -984,7 +992,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
984
992
  // Boundary frames follow their members: hidden when no member is
985
993
  // visible, dimmed when members are dimmed. Never selectable.
986
994
  if (n.type === 'subsystem-group') {
987
- const memberAliases = ((n.data as { region?: { memberAliases?: string[] } } | undefined)?.region?.memberAliases) ?? [];
995
+ const region = (n.data as { region?: { key?: string; memberAliases?: string[] } } | undefined)?.region;
996
+ const memberAliases = region?.memberAliases ?? [];
988
997
  const vis = flowElementVisibility({
989
998
  inOpened: memberAliases.some((alias) => openedNodeIds?.has(alias) === true),
990
999
  inSelected: memberAliases.some((alias) => brightNodeIds?.has(alias) === true),
@@ -995,12 +1004,15 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
995
1004
  ? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
996
1005
  : vis.dimmed;
997
1006
  const hidden = vis.hidden;
1007
+ // Host override wins over the library's derived frame color.
1008
+ const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
998
1009
  return {
999
1010
  ...n,
1000
1011
  hidden,
1001
1012
  selectable: false,
1002
1013
  data: {
1003
1014
  ...(n.data as object),
1015
+ ...(color != null && { color }),
1004
1016
  ...(dimmed && { dimmed: true }),
1005
1017
  },
1006
1018
  };
@@ -1031,7 +1043,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1031
1043
  },
1032
1044
  };
1033
1045
  });
1034
- }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds]);
1046
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, boundaryColors]);
1035
1047
 
1036
1048
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
1037
1049
  const baseEdgesKey = useMemo(
@@ -0,0 +1,146 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { Window } from 'happy-dom';
3
+ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react/pure';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import {
6
+ STEP_COPY_FEEDBACK_MS,
7
+ WalkthroughsPanel,
8
+ type WalkthroughsPanelProps,
9
+ } from './WalkthroughsPanel';
10
+ import type { SubsystemWalkthrough } from './model';
11
+ import { buildStepBrief } from './walkthroughBrief';
12
+
13
+ // Bun's happy-dom test environment needs `@happy-dom/global-registrator`, which
14
+ // isn't installed here, so wire the DOM primitives React DOM + Testing Library
15
+ // need before any test renders.
16
+ const dom = new Window();
17
+ const globals = globalThis as unknown as Record<string, unknown>;
18
+ globals.window = dom;
19
+ globals.document = dom.document;
20
+ globals.navigator = dom.navigator;
21
+ globals.HTMLElement = dom.HTMLElement;
22
+ globals.Node = dom.Node;
23
+ globals.Element = dom.Element;
24
+ globals.Event = dom.Event;
25
+ globals.MouseEvent = dom.MouseEvent;
26
+ globals.FocusEvent = dom.FocusEvent;
27
+ globals.KeyboardEvent = dom.KeyboardEvent;
28
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
29
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
30
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
31
+ globals.localStorage = dom.localStorage;
32
+ globals.sessionStorage = dom.sessionStorage;
33
+ globals.matchMedia = dom.matchMedia.bind(dom);
34
+
35
+
36
+ const walkthroughs: SubsystemWalkthrough[] = [
37
+ {
38
+ id: 'auth-flow',
39
+ title: 'Auth flow',
40
+ steps: [
41
+ {
42
+ from: 'ui',
43
+ to: 'api',
44
+ mechanism: 'calls',
45
+ file: 'src/ui/login.tsx',
46
+ line: 42,
47
+ purl: 'pkg:github/acme/app',
48
+ symbol: 'Login.submit',
49
+ },
50
+ ],
51
+ },
52
+ ];
53
+
54
+ const writeText = mock(() => Promise.resolve());
55
+
56
+ beforeEach(() => {
57
+ writeText.mockClear();
58
+ Object.defineProperty(navigator, 'clipboard', {
59
+ value: { writeText },
60
+ configurable: true,
61
+ });
62
+ });
63
+
64
+ afterEach(cleanup);
65
+
66
+ function renderPanel(overrides: Partial<WalkthroughsPanelProps> = {}) {
67
+ const props: WalkthroughsPanelProps = {
68
+ walkthroughs,
69
+ expandedWalkthroughs: new Set(['auth-flow']),
70
+ focusedWalkthroughId: null,
71
+ focusedStepIndex: null,
72
+ hoveredWalkthroughStep: null,
73
+ onToggleCollapsed: () => {},
74
+ onFocusFlow: () => {},
75
+ onClearFocus: () => {},
76
+ onFocusStep: () => {},
77
+ onHoverStep: () => {},
78
+ onHoverFlow: () => {},
79
+ onLeaveStep: () => {},
80
+ ...overrides,
81
+ };
82
+ return render(
83
+ <ThemeProvider theme={defaultEditorTheme}>
84
+ <WalkthroughsPanel {...props} />
85
+ </ThemeProvider>,
86
+ );
87
+ }
88
+
89
+ const COPY_LABEL = 'Copy step 1 of Auth flow for an agent';
90
+
91
+ describe('WalkthroughsPanel step copy', () => {
92
+ test('copy icon is hidden until the step row is hovered', () => {
93
+ const { getByText, queryByLabelText } = renderPanel();
94
+ expect(queryByLabelText(COPY_LABEL)).toBeNull();
95
+ const row = getByText('Login.submit').closest('button')!;
96
+ fireEvent.mouseEnter(row);
97
+ expect(queryByLabelText(COPY_LABEL)).not.toBeNull();
98
+ });
99
+
100
+ test('copy icon is revealed on keyboard focus', () => {
101
+ const { getByText, queryByLabelText } = renderPanel();
102
+ const row = getByText('Login.submit').closest('button')!;
103
+ fireEvent.focus(row);
104
+ expect(queryByLabelText(COPY_LABEL)).not.toBeNull();
105
+ });
106
+
107
+ test('clicking copies the step brief and flashes a copied state', async () => {
108
+ const { getByText, getByLabelText } = renderPanel();
109
+ fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
110
+ const copy = getByLabelText(COPY_LABEL);
111
+ fireEvent.mouseDown(copy);
112
+ fireEvent.click(copy);
113
+ await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
114
+ expect(writeText).toHaveBeenCalledWith(buildStepBrief(walkthroughs[0]!, 0));
115
+ expect(copy.getAttribute('title')).toBe('Copied');
116
+ });
117
+
118
+ test('hovering the row still focuses the step on click', () => {
119
+ const onFocusStep = mock(() => {});
120
+ const { getByText } = renderPanel({ onFocusStep });
121
+ const row = getByText('Login.submit').closest('button')!;
122
+ fireEvent.mouseEnter(row);
123
+ fireEvent.click(row);
124
+ expect(onFocusStep).toHaveBeenCalledWith(walkthroughs[0]!, 0);
125
+ });
126
+
127
+ test('the copied checkmark clears after the feedback window', async () => {
128
+ const { getByText, getByLabelText } = renderPanel();
129
+ fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
130
+ const copy = getByLabelText(COPY_LABEL);
131
+ fireEvent.click(copy);
132
+ await waitFor(() => expect(copy.getAttribute('title')).toBe('Copied'));
133
+ await new Promise((r) => setTimeout(r, STEP_COPY_FEEDBACK_MS + 20));
134
+ await waitFor(() => expect(copy.getAttribute('title')).not.toBe('Copied'));
135
+ });
136
+
137
+ test('a failed copy shows no copied state', async () => {
138
+ writeText.mockImplementationOnce(() => Promise.reject(new Error('denied')));
139
+ const { getByText, getByLabelText } = renderPanel();
140
+ fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
141
+ const copy = getByLabelText(COPY_LABEL);
142
+ fireEvent.click(copy);
143
+ await new Promise((r) => setTimeout(r, 10));
144
+ expect(copy.getAttribute('title')).toBe('Copy this step for an agent');
145
+ });
146
+ });