@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
@@ -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>
@@ -1032,17 +1032,17 @@ export function isWalkthroughMechanism(
1032
1032
  }
1033
1033
 
1034
1034
  export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
1035
- calls: '#4ec9b0', // teal
1035
+ calls: '#22c55e', // green
1036
1036
  extends: '#b48ead', // purple
1037
1037
  inherits: '#9b6fd0', // purple
1038
1038
  implements: '#c586c0', // magenta
1039
1039
  mixes_in: '#d474a8', // pink-magenta
1040
1040
  uses: '#e3b341', // gold
1041
1041
  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
1042
+ references: '#3b82f6', // blue
1043
+ feeds: '#4ec9b0', // teal — data-flow into a processor
1044
+ produces: '#a78bfa', // violet — emits an output type
1045
+ writes: '#e8853a', // orange — mutates retained state
1046
1046
  reads: '#0ea5e9', // sky — pulls from retained state
1047
1047
  watches: '#9ca3af', // gray — observes, owns nothing
1048
1048
  'registers-into': '#ff6b35', // orange
@@ -1186,7 +1186,7 @@ export function packageColor(name: string): string {
1186
1186
  * are the only surface using it, but pick a distinct value if roles ever
1187
1187
  * take over node borders. */
1188
1188
  export const ROLE_COLOR: Record<SubsystemComponentRole, string> = {
1189
- entry: '#ff6b35', // orange — boundary element
1189
+ entry: '#c0c0c0', // silver — boundary element
1190
1190
  service: '#0893d2', // blue — external system
1191
1191
  };
1192
1192
 
@@ -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
+ });