@principal-ai/subsystems-react 0.37.5 → 0.37.6

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 (37) 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/WalkthroughsPanel.d.ts +2 -0
  14. package/dist/subsystem/WalkthroughsPanel.d.ts.map +1 -1
  15. package/dist/subsystem/WalkthroughsPanel.js +114 -48
  16. package/dist/subsystem/WalkthroughsPanel.js.map +1 -1
  17. package/dist/subsystem/model.js +6 -6
  18. package/dist/subsystem/toC4.d.ts +95 -0
  19. package/dist/subsystem/toC4.d.ts.map +1 -0
  20. package/dist/subsystem/toC4.js +196 -0
  21. package/dist/subsystem/toC4.js.map +1 -0
  22. package/dist/subsystem/walkthroughBrief.d.ts +18 -0
  23. package/dist/subsystem/walkthroughBrief.d.ts.map +1 -0
  24. package/dist/subsystem/walkthroughBrief.js +34 -0
  25. package/dist/subsystem/walkthroughBrief.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/index.ts +6 -1
  28. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +104 -0
  29. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +6 -0
  30. package/src/subsystem/C4Graph.tsx +465 -0
  31. package/src/subsystem/WalkthroughsPanel.test.tsx +146 -0
  32. package/src/subsystem/WalkthroughsPanel.tsx +163 -60
  33. package/src/subsystem/model.ts +6 -6
  34. package/src/subsystem/toC4.test.ts +121 -0
  35. package/src/subsystem/toC4.ts +277 -0
  36. package/src/subsystem/walkthroughBrief.test.ts +57 -0
  37. 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
+ }
@@ -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
+ });