@principal-ai/subsystems-react 0.53.6 → 0.55.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 (110) hide show
  1. package/dist/index.d.ts +8 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  6. package/dist/pierre/PierreTrailCodeView.js +7 -5
  7. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  8. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  9. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +1 -2
  10. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  11. package/dist/subsystem/C4Graph.d.ts +144 -9
  12. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  13. package/dist/subsystem/C4Graph.js +924 -148
  14. package/dist/subsystem/C4Graph.js.map +1 -1
  15. package/dist/subsystem/C4NodeCard.d.ts +22 -1
  16. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  17. package/dist/subsystem/C4NodeCard.js +10 -2
  18. package/dist/subsystem/C4NodeCard.js.map +1 -1
  19. package/dist/subsystem/ComponentDeclaration.d.ts +5 -4
  20. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  21. package/dist/subsystem/ComponentDeclaration.js +12 -4
  22. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  23. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  24. package/dist/subsystem/ConstructsCatalog.js +10 -7
  25. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  26. package/dist/subsystem/GraphSidebar.d.ts +71 -0
  27. package/dist/subsystem/GraphSidebar.d.ts.map +1 -0
  28. package/dist/subsystem/GraphSidebar.js +145 -0
  29. package/dist/subsystem/GraphSidebar.js.map +1 -0
  30. package/dist/subsystem/GraphWithSidebar.d.ts +24 -0
  31. package/dist/subsystem/GraphWithSidebar.d.ts.map +1 -0
  32. package/dist/subsystem/GraphWithSidebar.js +25 -0
  33. package/dist/subsystem/GraphWithSidebar.js.map +1 -0
  34. package/dist/subsystem/IssueList.d.ts +4 -5
  35. package/dist/subsystem/IssueList.d.ts.map +1 -1
  36. package/dist/subsystem/IssueList.js +33 -28
  37. package/dist/subsystem/IssueList.js.map +1 -1
  38. package/dist/subsystem/SubsystemComponentGraph.d.ts +33 -0
  39. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  40. package/dist/subsystem/SubsystemComponentGraph.js +367 -414
  41. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  42. package/dist/subsystem/TrailStepsNav.d.ts +48 -0
  43. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -0
  44. package/dist/subsystem/TrailStepsNav.js +189 -0
  45. package/dist/subsystem/TrailStepsNav.js.map +1 -0
  46. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  47. package/dist/subsystem/TrailsPanel.js +3 -4
  48. package/dist/subsystem/TrailsPanel.js.map +1 -1
  49. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts +151 -0
  50. package/dist/subsystem/__fixtures__/c4Drilldown.d.ts.map +1 -0
  51. package/dist/subsystem/__fixtures__/c4Drilldown.js +783 -0
  52. package/dist/subsystem/__fixtures__/c4Drilldown.js.map +1 -0
  53. package/dist/subsystem/c4.d.ts +39 -8
  54. package/dist/subsystem/c4.d.ts.map +1 -1
  55. package/dist/subsystem/c4.js.map +1 -1
  56. package/dist/subsystem/declarationRef.d.ts +26 -18
  57. package/dist/subsystem/declarationRef.d.ts.map +1 -1
  58. package/dist/subsystem/declarationRef.js +9 -4
  59. package/dist/subsystem/declarationRef.js.map +1 -1
  60. package/dist/subsystem/model.d.ts +19 -1
  61. package/dist/subsystem/model.d.ts.map +1 -1
  62. package/dist/subsystem/model.js +12 -2
  63. package/dist/subsystem/model.js.map +1 -1
  64. package/dist/subsystem/nodes.d.ts.map +1 -1
  65. package/dist/subsystem/nodes.js +24 -4
  66. package/dist/subsystem/nodes.js.map +1 -1
  67. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  68. package/dist/subsystem/trailBrief.js +6 -1
  69. package/dist/subsystem/trailBrief.js.map +1 -1
  70. package/package.json +3 -3
  71. package/src/index.ts +9 -2
  72. package/src/pierre/PierreTrailCodeView.tsx +9 -13
  73. package/src/stories/Subsystem/C4Graph/C4ToComponentDrill.stories.tsx +496 -0
  74. package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +7 -7
  75. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +19 -25
  76. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +8 -10
  77. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +40 -33
  78. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +6 -6
  79. package/src/stories/Subsystem/ComponentGraph/TrailAutoplay.stories.tsx +7 -7
  80. package/src/stories/Subsystem/ComponentGraph/Trails.stories.tsx +27 -30
  81. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +1 -2
  82. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +10 -13
  83. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +3 -4
  84. package/src/stories/Subsystem/IssueIcons.stories.tsx +65 -0
  85. package/src/stories/Subsystem/Sidebar/GraphSidebar.stories.tsx +206 -0
  86. package/src/subsystem/C4Graph.groups.test.ts +96 -2
  87. package/src/subsystem/C4Graph.tsx +1377 -165
  88. package/src/subsystem/C4NodeCard.test.ts +1 -2
  89. package/src/subsystem/C4NodeCard.tsx +24 -2
  90. package/src/subsystem/ComponentDeclaration.tsx +18 -8
  91. package/src/subsystem/ConstructsCatalog.tsx +23 -8
  92. package/src/subsystem/GraphSidebar.test.tsx +152 -0
  93. package/src/subsystem/GraphSidebar.tsx +300 -0
  94. package/src/subsystem/GraphWithSidebar.test.tsx +78 -0
  95. package/src/subsystem/GraphWithSidebar.tsx +60 -0
  96. package/src/subsystem/IssueList.tsx +34 -29
  97. package/src/subsystem/SubsystemComponentGraph.tsx +290 -376
  98. package/src/subsystem/TrailStepsNav.test.tsx +179 -0
  99. package/src/subsystem/TrailStepsNav.tsx +372 -0
  100. package/src/subsystem/TrailsPanel.tsx +3 -4
  101. package/src/subsystem/__fixtures__/c4Drilldown.test.ts +338 -0
  102. package/src/subsystem/__fixtures__/c4Drilldown.ts +847 -0
  103. package/src/subsystem/c4.ts +41 -8
  104. package/src/subsystem/declarationRef.ts +28 -19
  105. package/src/subsystem/model.test.ts +32 -12
  106. package/src/subsystem/model.ts +30 -1
  107. package/src/subsystem/nodes.group.test.tsx +82 -0
  108. package/src/subsystem/nodes.tsx +30 -2
  109. package/src/subsystem/trailBrief.test.ts +2 -2
  110. package/src/subsystem/trailBrief.ts +6 -1
@@ -18,12 +18,18 @@ import { Box, Minimize2 } from 'lucide-react';
18
18
  import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
19
19
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
20
20
  import { deriveC4Groups, protocolColor } from './c4';
21
- import { C4NodeCard, NODE_H, NODE_W, OPEN_FOOTER_PAD, OPEN_HEADER_PAD, OPEN_SIDE_PAD, nodeSize, nodeStyle, } from './C4NodeCard';
21
+ import { C4NodeCard, COMPONENT_FOOTER_PAD, COMPONENT_HEADER_PAD, COMPONENT_W, NODE_H, NODE_W, OPEN_FOOTER_PAD, OPEN_HEADER_PAD, OPEN_SIDE_PAD, nodeSize, nodeStyle, } from './C4NodeCard';
22
22
  import { TechMark, technologyBrand } from './techIcons';
23
23
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
24
24
  import { openContainerSet, openStateKey } from './c4';
25
+ import { EDGE_DIM_ALPHA, hexWithAlpha, SubsystemCallbacksProvider, SubsystemComponentNode, SubsystemEdge, } from './nodes';
26
+ import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
27
+ import { constructBadgeLabel, convertSubsystemToEdges, convertSubsystemToNodes, deriveNameFromSymbol, edgeColor, MECHANISM_DESCRIPTIONS, rightBadgeLabel, } from './model';
28
+ import { generateDeclarationString } from './formatDeclaration';
29
+ import { componentColor } from '../pierre/constructColors';
30
+ import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
25
31
  function C4NodeView(props) {
26
- const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } = props.data;
32
+ const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect, dimmed, entering, leaving, moveDelayMs = 0, } = props.data;
27
33
  const grown = open === true;
28
34
  // Position-only FLIP. The card resizes through its own `width`/`height`
29
35
  // transition, so scaling here would distort the text and fight that transition.
@@ -31,6 +37,11 @@ function C4NodeView(props) {
31
37
  // transition, so its resize is the card's own and nothing competes with it.
32
38
  const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
33
39
  const sliding = flipping && (dx !== 0 || dy !== 0);
40
+ const fade = useFade(entering, leaving);
41
+ const transitions = [
42
+ ...(fade.transition ? [fade.transition] : []),
43
+ ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
44
+ ];
34
45
  // The card takes its box from the React Flow node rather than from its own
35
46
  // `nodeSize`, so on the opened container ELK's fitted size drives it — and
36
47
  // because the element persists across the swap, the card's own width/height
@@ -47,8 +58,15 @@ function C4NodeView(props) {
47
58
  // compose without either fighting the other.
48
59
  transformOrigin: 'top left',
49
60
  transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
50
- transition: sliding && placed ? `transform ${MORPH_MS}ms ease-out` : 'none',
51
- }, children: _jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, onExpand: onExpand, width: props.width, height: props.height, showDescription: showDescriptionFor(open, settled), handles: _jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", position: Position.Left, style: { opacity: 0 } }), _jsx(Handle, { type: "source", position: Position.Right, style: { opacity: 0 } })] }) }) }));
61
+ transition: transitions.length > 0 ? transitions.join(', ') : 'none',
62
+ opacity: fade.opacity,
63
+ }, children: _jsx("div", { style: {
64
+ // The dim is a view concern, deliberately below the FLIP transform
65
+ // (which owns position) so the two never fight. The whole card fades —
66
+ // if we ever want to keep a badge lit, that is a different element.
67
+ opacity: dimmed ? 0.22 : 1,
68
+ transition: 'opacity 220ms ease',
69
+ }, children: _jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, onExpand: onExpand, width: props.width, height: props.height, showDescription: showDescriptionFor(open, settled), transitionDelayMs: moveDelayMs, sizeTransitionMs: MORPH_MS, handles: _jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", position: Position.Left, style: { opacity: 0 } }), _jsx(Handle, { type: "source", position: Position.Right, style: { opacity: 0 } })] }) }) }) }));
52
70
  }
53
71
  /**
54
72
  * Edge-label overlay.
@@ -58,7 +76,7 @@ function C4NodeView(props) {
58
76
  * never drift from the edges it sits on. Positions are ELK's computed label
59
77
  * anchors (`edgeLabelPositions`), in flow coordinates.
60
78
  */
61
- function C4EdgeLabels({ positions, edges, }) {
79
+ function C4EdgeLabels({ positions, edges, selectedEdgeId, dimmedEdgeIds, hiddenEdgeIds, }) {
62
80
  const { theme } = useTheme();
63
81
  const viewport = useViewport();
64
82
  const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
@@ -73,8 +91,12 @@ function C4EdgeLabels({ positions, edges, }) {
73
91
  const edge = byId.get(id);
74
92
  if (!edge)
75
93
  return null;
94
+ if (hiddenEdgeIds?.has(edge.id))
95
+ return null;
76
96
  const color = protocolColor(edge.protocol);
77
97
  const text = edge.protocol ?? edge.label;
98
+ const selected = edge.id === selectedEdgeId;
99
+ const dimmed = dimmedEdgeIds?.has(edge.id) === true;
78
100
  return (_jsxs("div", { style: {
79
101
  position: 'absolute',
80
102
  left: p.x * viewport.zoom + viewport.x,
@@ -88,12 +110,17 @@ function C4EdgeLabels({ positions, edges, }) {
88
110
  height: EDGE_LABEL_HEIGHT,
89
111
  boxSizing: 'border-box',
90
112
  padding: '7px 8px',
113
+ // Fades with its line, on the same beat.
114
+ opacity: dimmed ? 0.25 : 1,
115
+ transition: 'opacity 220ms ease',
91
116
  }, children: [_jsx("span", { "aria-hidden": true, style: {
92
117
  position: 'absolute',
93
118
  inset: 0,
94
119
  borderRadius: 4,
95
120
  border: `0.5px solid ${color}`,
96
121
  background: surface,
122
+ // The selected chip echoes the edge's heavier stroke.
123
+ outline: selected ? `1.5px solid ${color}` : 'none',
97
124
  } }), _jsx("span", { style: {
98
125
  position: 'relative',
99
126
  maxWidth: '100%',
@@ -103,11 +130,96 @@ function C4EdgeLabels({ positions, edges, }) {
103
130
  fontSize: EDGE_LABEL_FONT_SIZE,
104
131
  lineHeight: 1,
105
132
  fontFamily: theme.fonts.monospace,
106
- fontWeight: 500,
133
+ fontWeight: selected ? 700 : 500,
107
134
  color,
108
135
  }, children: text })] }, id));
109
136
  }) }));
110
137
  }
138
+ /**
139
+ * Fallback drawer body when the host supplies no file/trail viewer: the step's
140
+ * seam — `from → to`, mechanism, site, symbol, and any annotation. A host with a
141
+ * real code viewer passes `renderFileViewer` / `renderTrailViewer` instead.
142
+ */
143
+ function StepDrawerFallback({ step }) {
144
+ const { theme } = useTheme();
145
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
146
+ return (_jsxs("div", { style: {
147
+ padding: 12,
148
+ display: 'flex',
149
+ flexDirection: 'column',
150
+ gap: 6,
151
+ fontFamily: theme.fonts.monospace,
152
+ fontSize: theme.fontSizes[0],
153
+ }, children: [_jsxs("div", { style: { color: theme.colors.text }, children: [step.from, " \u2192 ", step.to] }), _jsxs("div", { style: { color: muted }, children: [step.mechanism, " \u00B7 ", step.file, ":", step.span?.startLine ?? '?'] }), step.symbol && _jsx("div", { style: { color: muted }, children: step.symbol }), step.annotation && (_jsx("div", { style: { color: theme.colors.text, fontFamily: theme.fonts.body, marginTop: 4 }, children: step.annotation }))] }));
154
+ }
155
+ /**
156
+ * Construct-edge label overlay — the mechanism chips for the reused subsystem
157
+ * edges.
158
+ *
159
+ * `SubsystemEdge` draws only the line; the component graph renders its mechanism
160
+ * labels in a separate overlay. So the C4 canvas supplies one too, from the ELK
161
+ * label anchors stamped onto each edge's data (`labelX/labelY`).
162
+ */
163
+ function SubsystemEdgeLabels({ edges }) {
164
+ const { theme } = useTheme();
165
+ const viewport = useViewport();
166
+ const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
167
+ return (_jsx("div", { style: {
168
+ position: 'absolute',
169
+ inset: 0,
170
+ overflow: 'hidden',
171
+ pointerEvents: 'none',
172
+ zIndex: 5,
173
+ }, children: edges.map((e) => {
174
+ const d = e.data;
175
+ if (d?.labelX == null || d?.labelY == null)
176
+ return null;
177
+ const mechanism = d.mechanism ?? 'uses';
178
+ const text = mechanism;
179
+ // The same hue the component graph gives its mechanism chips, so a verb
180
+ // reads the same colour on either canvas.
181
+ const color = edgeColor({ mechanism, provenance: d.provenance });
182
+ const labelTitle = d.provenance === 'graphify'
183
+ ? 'Derived from graphify (static symbol graph)'
184
+ : MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] === false
185
+ ? 'Not directly verifiable with graphify'
186
+ : undefined;
187
+ return (_jsxs("div", { title: labelTitle, style: {
188
+ position: 'absolute',
189
+ left: d.labelX * viewport.zoom + viewport.x,
190
+ top: d.labelY * viewport.zoom + viewport.y,
191
+ transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
192
+ transformOrigin: 'center center',
193
+ display: 'flex',
194
+ alignItems: 'center',
195
+ justifyContent: 'center',
196
+ width: estimateEdgeLabelWidth(text),
197
+ height: EDGE_LABEL_HEIGHT,
198
+ boxSizing: 'border-box',
199
+ padding: '7px 8px',
200
+ // Fades with its line, on the same beat.
201
+ opacity: d.dimmed === true ? 0.15 : 1,
202
+ transition: 'opacity 220ms ease',
203
+ }, children: [_jsx("span", { "aria-hidden": true, style: {
204
+ position: 'absolute',
205
+ inset: 0,
206
+ borderRadius: 4,
207
+ border: `0.5px solid ${color}`,
208
+ background: surface,
209
+ } }), _jsx("span", { style: {
210
+ position: 'relative',
211
+ maxWidth: '100%',
212
+ overflow: 'hidden',
213
+ textOverflow: 'ellipsis',
214
+ whiteSpace: 'nowrap',
215
+ fontSize: EDGE_LABEL_FONT_SIZE,
216
+ lineHeight: 1,
217
+ fontFamily: theme.fonts.monospace,
218
+ fontWeight: 500,
219
+ color,
220
+ }, children: text })] }, e.id));
221
+ }) }));
222
+ }
111
223
  /**
112
224
  * C4 edge — draws ELK's routed polyline, not React Flow's default bezier.
113
225
  *
@@ -115,12 +227,18 @@ function C4EdgeLabels({ positions, edges, }) {
115
227
  * the same `elkPathPoints` this renders. React Flow's built-in edge would curve
116
228
  * between handles while the chip sat on ELK's orthogonal route, which is why
117
229
  * labels read as "off the line".
230
+ *
231
+ * Two strokes: an invisible wide hit path (the drawn line is 1px, hopeless to
232
+ * click) and the visible one. The selection emphasis is a heavier stroke on
233
+ * the same geometry, so the selected line reads without redrawing the route.
118
234
  */
119
- function C4EdgeView({ data, markerEnd }) {
235
+ function C4EdgeView({ data, markerEnd, }) {
120
236
  const path = data?.path ?? '';
121
237
  if (!path)
122
238
  return null;
123
- return (_jsx("path", { d: path, fill: "none", stroke: data?.color, strokeWidth: 1, strokeDasharray: "6 4", markerEnd: markerEnd, style: { pointerEvents: 'none' } }));
239
+ const selected = data?.selected === true;
240
+ const dimmed = data?.dimmed === true;
241
+ return (_jsxs(_Fragment, { children: [_jsx("path", { d: path, fill: "none", stroke: "transparent", strokeWidth: 14, style: { pointerEvents: 'auto', cursor: 'pointer' } }), _jsx("path", { d: path, fill: "none", stroke: data?.color, strokeWidth: selected ? 2.5 : 1, strokeDasharray: "6 4", markerEnd: markerEnd, style: { pointerEvents: 'none', opacity: dimmed ? 0.18 : 1, transition: 'opacity 220ms ease' } })] }));
124
242
  }
125
243
  function C4GroupView(props) {
126
244
  const { theme } = useTheme();
@@ -128,13 +246,14 @@ function C4GroupView(props) {
128
246
  const isSystem = props.data.kind === 'system';
129
247
  const onCollapse = props.data.onCollapse;
130
248
  const brand = props.data.brand;
131
- const { growFrom, frameRect } = props.data;
249
+ const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data;
132
250
  // Position-only FLIP, same as the cards. The frame resizes through its own
133
251
  // width/height transition below, so scaling here would fight it *and* distort
134
252
  // what it draws: a scaled dashed border changes weight as it grows, and the
135
253
  // label chip's border and text scale with it.
136
254
  const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
137
255
  const sliding = flipping && (dx !== 0 || dy !== 0);
256
+ const fade = useFade(entering, leaving);
138
257
  // A container frame shows its technology mark (React/Bun/Node…) so the
139
258
  // boundary says what it is built with, same as the node it wraps. The system
140
259
  // frame has no single technology, so it keeps a neutral `Box`.
@@ -150,10 +269,12 @@ function C4GroupView(props) {
150
269
  // Size is always transitioned; the transform only while sliding, and
151
270
  // `transform: none` needs no transition.
152
271
  transition: [
153
- 'width 420ms ease-out',
154
- 'height 420ms ease-out',
155
- ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out`] : []),
272
+ `width ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
273
+ `height ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
274
+ ...(fade.transition ? [fade.transition] : []),
275
+ ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
156
276
  ].join(', '),
277
+ opacity: fade.opacity,
157
278
  boxSizing: 'border-box',
158
279
  borderRadius: 12,
159
280
  border: `2px dashed ${color}`,
@@ -203,18 +324,53 @@ function C4GroupView(props) {
203
324
  lineHeight: 1,
204
325
  }, children: _jsx(Minimize2, { size: 12, strokeWidth: 2.5 }) }))] }) }));
205
326
  }
327
+ /**
328
+ * The component graph's own node, wrapped in the C4 canvas's FLIP so a construct
329
+ * glides to its new slot when the layout reflows. On its own it would only mount
330
+ * at the destination — a jump. Position-only, matching the C4 cards.
331
+ */
332
+ const SubsystemNode = SubsystemComponentNode;
333
+ function C4SubsystemNodeView(props) {
334
+ const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data;
335
+ const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
336
+ const sliding = flipping && (dx !== 0 || dy !== 0);
337
+ const fade = useFade(entering, leaving);
338
+ const transitions = [
339
+ ...(fade.transition ? [fade.transition] : []),
340
+ ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
341
+ ];
342
+ return (_jsx("div", { style: {
343
+ transformOrigin: 'top left',
344
+ transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
345
+ transition: transitions.length > 0 ? transitions.join(', ') : 'none',
346
+ opacity: fade.opacity,
347
+ }, children: _jsx(SubsystemNode, { ...props }) }));
348
+ }
206
349
  const nodeTypes = {
207
350
  'c4-node': C4NodeView,
208
351
  'c4-group': C4GroupView,
352
+ // The component graph's own renderers, reused for the constructs a container
353
+ // hosts. They are context-light (ThemeProvider + a React Flow ancestor), which
354
+ // is why they can ride this canvas instead of a nested one.
355
+ 'subsystem-component': C4SubsystemNodeView,
209
356
  };
210
357
  const edgeTypes = {
211
358
  'c4-edge': C4EdgeView,
359
+ 'subsystem-edge': SubsystemEdge,
212
360
  };
213
361
  /** Drill-down animation durations, in ms. */
214
362
  const MORPH_MS = 420;
215
363
  const CAMERA_MS = 520;
216
364
  /**
217
- * When to re-frame the camera after a drill-down: the morph, plus a beat.
365
+ * How long cards arriving or leaving take to fade, in ms. The morph is
366
+ * sequenced: the fade runs first, and the surviving cards wait it out before
367
+ * they move and grow (`moveDelayMs` below). So the reveal clock is
368
+ * `FADE_MS + MORPH_MS`, not just `MORPH_MS`.
369
+ */
370
+ const FADE_MS = 200;
371
+ /**
372
+ * When to re-frame the camera after a drill-down: the sequenced morph (fade,
373
+ * then move), plus a beat.
218
374
  *
219
375
  * The extra 60ms is not padding for looks. `fitView` reads the system frame's
220
376
  * *measured* size, and that frame's own height transition runs on the same
@@ -222,7 +378,36 @@ const CAMERA_MS = 520;
222
378
  * height that never existed. The beat is for React Flow's resize observer to
223
379
  * report the final geometry.
224
380
  */
225
- const CAMERA_AFTER_MORPH_MS = MORPH_MS + 60;
381
+ const CAMERA_AFTER_MORPH_MS = FADE_MS + MORPH_MS + 60;
382
+ /**
383
+ * A node's rect in **absolute** flow space, walking the parent chain.
384
+ *
385
+ * `groupBounds` is already absolute; a leaf's `position` is relative to its
386
+ * parent, so each ancestor's origin is added in turn.
387
+ *
388
+ * The FLIP needs absolute rects, not parent-relative ones. React Flow places a
389
+ * node at its new absolute position (parent origin + relative position) and
390
+ * moves children along with the parent's *wrapper* — while our FLIP transform
391
+ * lives on the node's inner div, which the children do not inherit. So a node's
392
+ * own transform must cover its full absolute delta; rebasing it to the parent
393
+ * would leave it short by exactly the parent's movement (the "subtle drift"
394
+ * before a leaving box disappears).
395
+ */
396
+ export function absoluteRectOf(layout, id) {
397
+ const frame = layout.groupBounds.get(id);
398
+ if (frame)
399
+ return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
400
+ const node = layout.nodes.find((n) => n.id === id);
401
+ if (!node)
402
+ return null;
403
+ const origin = node.parentId ? absoluteRectOf(layout, node.parentId) : null;
404
+ return {
405
+ x: (origin?.x ?? 0) + node.position.x,
406
+ y: (origin?.y ?? 0) + node.position.y,
407
+ width: node.width ?? 0,
408
+ height: node.height ?? 0,
409
+ };
410
+ }
226
411
  /**
227
412
  * Whether committing a layout should also move the camera.
228
413
  *
@@ -271,9 +456,9 @@ export function showDescriptionFor(open, settled) {
271
456
  * `result.groupBounds` is absolute for a root-level group and *parent-relative*
272
457
  * for a nested one — two spaces in one map, which is the same trap as
273
458
  * `result.nodes`. The opened container is nested (inside the system frame), and
274
- * `containerRect` reads this map before the node box, so an unrebased entry would
275
- * be handed to the morph offset by the frame's origin: the container would glide
276
- * to a place it was never drawn.
459
+ * `absoluteRectOf` reads this map before the node box, so an unrebased entry
460
+ * would be handed to the morph offset by the frame's origin: the container would
461
+ * glide to a place it was never drawn.
277
462
  */
278
463
  export function rebaseGroupBoundsAbsolute(result) {
279
464
  const rebased = new Map();
@@ -301,7 +486,7 @@ export function rebaseGroupBoundsAbsolute(result) {
301
486
  * gives) purely so the group list — and therefore the layout — is deterministic
302
487
  * for a given set.
303
488
  */
304
- export function c4GroupDefs(model, openIds) {
489
+ export function c4GroupDefs(model, openIds, constructGraphByContainer) {
305
490
  // Normalized here rather than trusted from the caller. This function's output is
306
491
  // cached under the open *set*, so a repeated id would emit the same group twice
307
492
  // (ELK rejects a duplicate id) and a different order would produce a different
@@ -329,11 +514,28 @@ export function c4GroupDefs(model, openIds) {
329
514
  const openMembers = model.nodes
330
515
  .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
331
516
  .map((n) => n.id);
332
- if (openMembers.length === 0)
517
+ // The constructs this container hosts, grouped under the authored component
518
+ // that claims each (which grows into its own ELK parent below); a construct
519
+ // no component claims nests directly under the container.
520
+ const cg = constructGraphByContainer?.get(openId);
521
+ const byComponent = new Map();
522
+ const orphans = [];
523
+ for (const c of cg?.components ?? []) {
524
+ const owner = cg?.componentOf?.get(c.alias);
525
+ if (owner) {
526
+ const list = byComponent.get(owner) ?? [];
527
+ list.push(c.alias);
528
+ byComponent.set(owner, list);
529
+ }
530
+ else {
531
+ orphans.push(c.alias);
532
+ }
533
+ }
534
+ if (openMembers.length === 0 && orphans.length === 0)
333
535
  continue;
334
536
  groups.push({
335
537
  id: openId,
336
- memberIds: openMembers,
538
+ memberIds: [...openMembers, ...orphans],
337
539
  // INSIDE the system frame, not beside it. The boundary *is* the system, so
338
540
  // a sibling here would draw the opened container outside the very boundary
339
541
  // it belongs to.
@@ -353,27 +555,29 @@ export function c4GroupDefs(model, openIds) {
353
555
  // knows which one a bare member id means.
354
556
  drawnAsCard: true,
355
557
  });
558
+ // A component that claims constructs grows into an ELK parent as well, so
559
+ // its constructs nest inside it (container → component → construct) rather
560
+ // than sitting beside it.
561
+ for (const [componentId, memberIds] of byComponent) {
562
+ if (memberIds.length === 0)
563
+ continue;
564
+ groups.push({
565
+ id: componentId,
566
+ memberIds,
567
+ parentId: openId,
568
+ minWidth: COMPONENT_W,
569
+ // A component card's header is its label slot + padding; it has no
570
+ // technology row and no collapse affordance.
571
+ padTop: COMPONENT_HEADER_PAD,
572
+ padBottom: COMPONENT_FOOTER_PAD,
573
+ padX: OPEN_SIDE_PAD,
574
+ drawnAsCard: true,
575
+ });
576
+ }
356
577
  }
357
578
  }
358
579
  return { frames, groups };
359
580
  }
360
- /** The container's rect in a laid-out state: its box node, or its frame bounds. */
361
- function containerRect(layout, id) {
362
- if (!layout)
363
- return null;
364
- const frame = layout.groupBounds.get(id);
365
- if (frame)
366
- return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
367
- const node = layout.nodes.find((n) => n.id === id);
368
- if (!node)
369
- return null;
370
- return {
371
- x: node.position.x,
372
- y: node.position.y,
373
- width: node.width ?? 0,
374
- height: node.height ?? 0,
375
- };
376
- }
377
581
  /**
378
582
  * FLIP between two absolute flow rects: render transformed back onto `from`,
379
583
  * then release onto `to`.
@@ -409,6 +613,24 @@ function useFlip(from, to) {
409
613
  dy: from && to ? from.y - to.y : 0,
410
614
  };
411
615
  }
616
+ /**
617
+ * Opacity for a card arriving or leaving the layout. An arriving card mounts
618
+ * hidden and fades in; a leaving card is kept (out of the new layout) for the
619
+ * morph, then fades out — a removal reads as a fade, not a pop.
620
+ */
621
+ function useFade(entering, leaving) {
622
+ const animate = entering === true || leaving === true;
623
+ const [shown, setShown] = useState(entering !== true);
624
+ useEffect(() => {
625
+ if (!animate)
626
+ return;
627
+ const raf = requestAnimationFrame(() => setShown(leaving !== true));
628
+ return () => cancelAnimationFrame(raf);
629
+ }, [animate, entering, leaving]);
630
+ if (!animate)
631
+ return { opacity: 1, transition: undefined };
632
+ return { opacity: shown ? 1 : 0, transition: `opacity ${FADE_MS}ms ease` };
633
+ }
412
634
  /**
413
635
  * Whether a nested component is withheld while the diagram is mid-morph.
414
636
  *
@@ -429,14 +651,23 @@ export function hidesComponentDuringMorph(owner, openIds, morphTargets, settled)
429
651
  return false;
430
652
  return morphTargets.has(owner);
431
653
  }
432
- function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }) {
433
- const { theme } = useTheme();
434
- const { fitView } = useReactFlow();
654
+ function Inner({ model, onSelectNode, onSelectEdge, onTrailClick, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28, dimmedElementIds, dimmedEdgeIds, hiddenElementIds, hiddenEdgeIds, dimmedConstructIds, dimmedConstructEdgeIds, constructGraphByContainer, focusedStep, onCloseStep, renderFileViewer, renderTrailViewer, hideDrawer = false }) {
655
+ const { theme, mode } = useTheme();
656
+ const { fitView, fitBounds } = useReactFlow();
435
657
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
436
658
  const [nodes, setNodes] = useState([]);
659
+ /** Latest rendered nodes, for the step-focus camera fit without re-firing on
660
+ * every layout change. */
661
+ const nodesRef = useRef([]);
662
+ nodesRef.current = nodes;
437
663
  const [rfEdges, setRfEdges] = useState([]);
438
664
  const [ready, setReady] = useState(false);
439
665
  const [selectedId, setSelectedId] = useState(null);
666
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
667
+ // The construct (subsystem component alias) whose functionality (declaration
668
+ // + purpose) is open. Its own panel, so opening a construct never disturbs the
669
+ // node/edge inspector.
670
+ const [selectedConstructAlias, setSelectedConstructAlias] = useState(null);
440
671
  const [labelPositions, setLabelPositions] = useState(() => new Map());
441
672
  /** Set by the layout effect; consumed by the `ready` effect to run the fit. */
442
673
  const pendingFitRef = useRef(false);
@@ -464,7 +695,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
464
695
  break;
465
696
  // Never evict what is on screen: a re-layout of the current state mid-morph
466
697
  // would swap the geometry out from under the transition.
467
- if (oldest === openStateKey(openIds))
698
+ if (oldest === currentCacheKeyRef.current)
468
699
  continue;
469
700
  cache.delete(oldest);
470
701
  }
@@ -487,12 +718,29 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
487
718
  const settleTimer = useRef(null);
488
719
  /** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
489
720
  const cameraTimer = useRef(null);
721
+ /** Drops cards that were kept only to fade out. */
722
+ const leaverTimer = useRef(null);
723
+ /**
724
+ * The dim set of the *previous* render, read when a card starts leaving so it
725
+ * fades from the opacity it actually had. A hover preview dims the non-cast
726
+ * boxes; clicking the trail clears the hover, so without this a leaving box
727
+ * would brighten to full before fading.
728
+ */
729
+ const prevDimmedRef = useRef(null);
490
730
  /**
491
731
  * The open set rendered last, so a change can morph between the two layouts.
492
732
  * The array, not just its key, because the morph needs to know *which*
493
733
  * containers changed — see `morphTargetsRef`.
494
734
  */
495
735
  const prevOpenRef = useRef([]);
736
+ /**
737
+ * The layout-cache key rendered last, and the one on screen now. The key
738
+ * carries the construct counts too (a card's box grows with its rows), so a
739
+ * focus change that keeps the same containers open still misses the cache and
740
+ * recomputes — and the morph reads the previous entry by its own key.
741
+ */
742
+ const prevCacheKeyRef = useRef('');
743
+ const currentCacheKeyRef = useRef('');
496
744
  /**
497
745
  * Containers whose open state is changing *right now* — the symmetric
498
746
  * difference of the previous and next open sets.
@@ -564,11 +812,25 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
564
812
  // as a change.
565
813
  const openIds = useMemo(() => openContainerSet(model), [model]);
566
814
  const openKey = openStateKey(openIds);
567
- const layoutKey = useMemo(() => [
815
+ // The constructs a container hosts change its layout, so a focus shift that
816
+ // swaps them (same containers open) must recompute — keyed here or the stale
817
+ // box would stand.
818
+ const constructKey = useMemo(() => constructGraphByContainer
819
+ ? [...constructGraphByContainer.entries()]
820
+ .map(([id, cg]) => `${id}\0${cg.components.map((c) => c.alias).sort().join(',')}`)
821
+ .sort()
822
+ .join('\n')
823
+ : '', [constructGraphByContainer]);
824
+ // The model's own shape — which boxes and lines exist. Part of the cache key
825
+ // so a host that filters the model (e.g. down to a focused trail's cast)
826
+ // re-lays-out instead of hitting a stale layout for the same open set.
827
+ const modelKey = useMemo(() => [
568
828
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
569
829
  ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
570
- `open:${openKey}`,
571
- ].join('\n'), [model, openKey]);
830
+ ].join('\n'), [model]);
831
+ const layoutKey = useMemo(() => `${modelKey}\nopen:${openKey}\nconstructs:${constructKey}`, [modelKey, openKey, constructKey]);
832
+ /** The identity of one laid-out state — open set + constructs + model shape. */
833
+ const stateKey = (ids) => `${openStateKey(ids)}|${constructKey}|${modelKey}`;
572
834
  /**
573
835
  * Build the React Flow inputs for a given open state — everything ELK needs,
574
836
  * independent of whether a layout has been run yet. Pure over `openIds`, so it
@@ -596,6 +858,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
596
858
  if (parent)
597
859
  childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
598
860
  }
861
+ // Authored components that claim constructs grow into ELK parents too, so
862
+ // they are drawn as grown cards (absolute box, no description) exactly like
863
+ // an opened container.
864
+ const groupedComponentIds = new Set();
865
+ if (model.view === 'container') {
866
+ for (const openId of openIds) {
867
+ const cg = constructGraphByContainer?.get(openId);
868
+ if (!cg)
869
+ continue;
870
+ for (const c of cg.components) {
871
+ const owner = cg.componentOf?.get(c.alias);
872
+ if (owner)
873
+ groupedComponentIds.add(owner);
874
+ }
875
+ }
876
+ }
599
877
  const rfNodes = model.nodes.filter(inView).map((n, i) => {
600
878
  const size = nodeSize(n);
601
879
  // A component of an opened container nests under *its own* container (a
@@ -627,10 +905,41 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
627
905
  ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
628
906
  ? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
629
907
  : {}),
908
+ // A component grown to hold its constructs is a drawn-as-card group:
909
+ // no description, absolute box, sized by ELK.
910
+ ...(n.kind === 'component' && groupedComponentIds.has(n.id) ? { open: true } : {}),
630
911
  },
631
912
  };
632
913
  });
633
- const { frames, groups } = c4GroupDefs(model, openIds);
914
+ // Constructs: the component graph's own nodes and edges, nested inside their
915
+ // opened container (under the authored component that claims each). Built
916
+ // per container so an edge never crosses containers — a construct edge that
917
+ // would is left to the C4 container-level line.
918
+ const constructEdges = [];
919
+ if (model.view === 'container') {
920
+ for (const openId of openIds) {
921
+ const cg = constructGraphByContainer?.get(openId);
922
+ if (!cg || cg.components.length === 0)
923
+ continue;
924
+ const subNodes = convertSubsystemToNodes({
925
+ components: [...cg.components],
926
+ trails: [...cg.trails],
927
+ });
928
+ for (const sn of subNodes) {
929
+ const owner = cg.componentOf?.get(sn.id) ?? openId;
930
+ rfNodes.push({
931
+ ...sn,
932
+ parentId: owner,
933
+ zIndex: 3,
934
+ // `c4Owner` is the container this construct belongs to, read by the
935
+ // morph/dim pass; it is not part of the node's own render.
936
+ data: { ...sn.data, c4Owner: openId },
937
+ });
938
+ }
939
+ constructEdges.push(...convertSubsystemToEdges({ components: [...cg.components], trails: [...cg.trails] }));
940
+ }
941
+ }
942
+ const { frames, groups } = c4GroupDefs(model, openIds, constructGraphByContainer);
634
943
  // Bands come from the *frames* only. The opened container is an ELK parent,
635
944
  // not a band — treating it as one would push it into its own layer, away from
636
945
  // the containers it belongs beside.
@@ -659,6 +968,13 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
659
968
  data: { edge: e, color, path: '' },
660
969
  };
661
970
  });
971
+ // Construct edges attach only when both endpoints are drawn — a step to an
972
+ // external, or to a construct in another container, is dropped here and left
973
+ // to the C4 container-level line.
974
+ for (const e of constructEdges) {
975
+ if (drawnIds.has(e.source) && drawnIds.has(e.target))
976
+ rfEdges.push(e);
977
+ }
662
978
  return { rfNodes, rfEdges, groups, partitionByNode, frames };
663
979
  };
664
980
  /**
@@ -702,20 +1018,30 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
702
1018
  const pts = result.edgePathPoints?.get(e.id);
703
1019
  if (!pts || pts.length === 0)
704
1020
  return e;
1021
+ const path = pointsToSmoothPath(pts, 12);
1022
+ // The component graph's edge renderer reads `elkPath` (and its label
1023
+ // overlay reads `labelX/labelY`); the C4 renderer reads `path`. Set each
1024
+ // so the right renderer draws its own route and its own label anchor.
1025
+ if (e.type === 'subsystem-edge') {
1026
+ const mid = pointAlongPath(pts, 0.5);
1027
+ return { ...e, data: { ...e.data, elkPath: path, labelX: mid.x, labelY: mid.y } };
1028
+ }
705
1029
  labelPositions.set(e.id, pointAlongPath(pts, 0.5));
706
- return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
1030
+ return { ...e, data: { ...e.data, path } };
707
1031
  });
708
1032
  // `result.groupBounds` mixes coordinate spaces; everything downstream here
709
1033
  // reads it as absolute, so normalize it once at the boundary.
710
1034
  const groupBounds = rebaseGroupBoundsAbsolute(result);
711
1035
  const builtGroups = new Set(groupBounds.keys());
712
- // The opened containers come back from ELK as parents, so they land in
713
- // `groupBounds` — but each is drawn as a grown *card*, not a frame. Drop them
714
- // all from the shells; keep them in `groupBounds` so their nested children can
1036
+ // Every group drawn as a grown *card* (opened containers, and components
1037
+ // grown to hold their constructs) comes back from ELK as a parent, so it
1038
+ // lands in `groupBounds` — but it is drawn as a card, not a frame. Drop each
1039
+ // from the shells; keep them in `groupBounds` so their nested children can
715
1040
  // take their coordinates relative to them.
1041
+ const drawnAsCardIds = new Set(groups.filter((g) => g.drawnAsCard).map((g) => g.id));
716
1042
  const shellBounds = new Map(groupBounds);
717
- for (const openId of openIds)
718
- shellBounds.delete(openId);
1043
+ for (const id of drawnAsCardIds)
1044
+ shellBounds.delete(id);
719
1045
  const groupById = new Map(frames.map((g) => [g.id, g]));
720
1046
  const depthOf = (id) => {
721
1047
  let d = 0;
@@ -775,27 +1101,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
775
1101
  };
776
1102
  });
777
1103
  const leaves = rfNodes.map((n) => {
1104
+ // The parent is whatever `buildInputs` already stamped on the node — a
1105
+ // component's container, or a construct's component/container — kept only
1106
+ // when ELK actually built that group (a closed container is not one). This
1107
+ // covers the construct nodes too, whose data carries no `element`.
778
1108
  let parentId;
779
- if (n.data && typeof n.data === 'object') {
780
- const modelNode = n.data.element;
781
- // A component nests under its own container when that container is open —
782
- // the sibling containers and out-of-boundary nodes in the same layout must
783
- // not. Reads the component's own owner rather than a single open id,
784
- // because several containers can be open at once.
785
- const owner = modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
786
- const nested = owner && opened.has(owner) ? owner : undefined;
787
- const candidate = nested ?? modelNode?.parentId;
788
- if (candidate && builtGroups.has(candidate))
789
- parentId = candidate;
790
- }
1109
+ if (n.parentId && builtGroups.has(n.parentId))
1110
+ parentId = n.parentId;
791
1111
  // `flowPositionOf` covers every node ELK placed; the fallback is for the
792
1112
  // pathological case of a node ELK did not return at all.
793
1113
  const abs = flowPositionOf(result, n.id, parentId) ??
794
1114
  positioned.get(n.id)?.position ??
795
1115
  n.position;
796
- // An opened container is an ELK parent, so its box is whatever ELK fitted
797
- // to the nested components — `nodeSize` only knows the closed card.
798
- const fitted = opened.has(n.id) ? groupBounds.get(n.id) : undefined;
1116
+ // A grown card (an opened container, or a component holding its
1117
+ // constructs) is an ELK parent, so its box is whatever ELK fitted to its
1118
+ // nested children — `nodeSize` only knows the closed card.
1119
+ const fitted = drawnAsCardIds.has(n.id) ? groupBounds.get(n.id) : undefined;
799
1120
  const node = {
800
1121
  ...n,
801
1122
  position: { x: abs.x, y: abs.y },
@@ -816,17 +1137,36 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
816
1137
  return nodes;
817
1138
  return nodes.map((n) => {
818
1139
  const g = grows.get(n.id);
819
- // A child is hidden until the morph settles, and its absolute position also
820
- // moves with its parent — one transform on the parent covers both.
821
- if (!g || n.parentId)
1140
+ if (!g)
1141
+ return n;
1142
+ if (n.type !== 'c4-group' && n.type !== 'c4-node' && n.type !== 'subsystem-component') {
822
1143
  return n;
823
- return n.type === 'c4-group' || n.type === 'c4-node'
824
- ? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
825
- : n;
1144
+ }
1145
+ // Absolute rects for every node — root or nested. `useFlip` turns them into
1146
+ // the absolute delta the node's inner div must travel; React Flow has
1147
+ // already moved the wrapper (and its children) to the new absolute slot.
1148
+ return { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } };
826
1149
  });
827
1150
  };
828
- const renderLayout = (r, grows) => {
829
- setNodes(injectGrow(r.nodes, grows));
1151
+ const renderLayout = (r, grows, morph) => {
1152
+ // Mark cards that were not in the previous layout so they fade in; keep the
1153
+ // previous layout's orphans (leavers) so they fade out instead of popping.
1154
+ // Every card also carries the move delay, so the survivors' glide and grow
1155
+ // wait for the fade to finish (the sequenced morph).
1156
+ const moveDelayMs = morph?.moveDelayMs ?? 0;
1157
+ const marked = morph
1158
+ ? r.nodes.map((n) => {
1159
+ const base = morph.prevNodeIds.has(n.id)
1160
+ ? n
1161
+ : { ...n, data: { ...n.data, entering: true } };
1162
+ return { ...base, data: { ...base.data, moveDelayMs } };
1163
+ })
1164
+ : r.nodes;
1165
+ const leavers = (morph?.leavers ?? []).map((n) => ({
1166
+ ...n,
1167
+ data: { ...n.data, moveDelayMs },
1168
+ }));
1169
+ setNodes([...injectGrow(marked, grows), ...leavers]);
830
1170
  setRfEdges(r.edges);
831
1171
  setLabelPositions(r.labelPositions);
832
1172
  setReady(true);
@@ -836,33 +1176,34 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
836
1176
  // (instant swap), else computes it now.
837
1177
  useEffect(() => {
838
1178
  let alive = true;
839
- setReady(false);
1179
+ // Blank the canvas only for the very first paint. A relayout keeps the
1180
+ // previous layout on screen until the new one lands — otherwise every trail
1181
+ // pick (which changes the open set and the construct graph, so the cache
1182
+ // misses) flashed the "Laying out…" overlay.
1183
+ if (!paintedRef.current)
1184
+ setReady(false);
840
1185
  setSelectedId(null);
841
1186
  const openKeyNow = openStateKey(openIds);
1187
+ const cacheKeyNow = stateKey(openIds);
1188
+ currentCacheKeyRef.current = cacheKeyNow;
842
1189
  const prevOpen = prevOpenRef.current;
1190
+ const prevCacheKey = prevCacheKeyRef.current;
1191
+ // Captured now, not inside `applyLayout`: the layout may compute async, and
1192
+ // the tracking effect below clears the ref before that runs. This is the dim
1193
+ // set of the render just gone (the hover preview).
1194
+ const prevDimmed = prevDimmedRef.current;
843
1195
  const inputs = buildInputs(openIds);
844
- // Only a real open-state change animates; the first paint is already at rest.
1196
+ // The containers whose open state changed *this* tick — the only ones whose
1197
+ // components genuinely arrive or leave. Recorded as a ref rather than state:
1198
+ // it is read during the next render (to decide what to hide) but must not
1199
+ // itself schedule a render.
845
1200
  if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
846
- // The containers in one set and not the other are the ones whose components
847
- // actually arrive or leave. Recorded as a ref rather than state: it is read
848
- // during the next render (to decide what to hide) but must not itself
849
- // schedule a render.
850
1201
  const before = new Set(prevOpen);
851
1202
  const after = new Set(openIds);
852
1203
  morphTargetsRef.current = new Set([
853
1204
  ...prevOpen.filter((id) => !after.has(id)),
854
1205
  ...openIds.filter((id) => !before.has(id)),
855
1206
  ]);
856
- setSettled(false);
857
- if (settleTimer.current)
858
- clearTimeout(settleTimer.current);
859
- settleTimer.current = setTimeout(() => {
860
- setSettled(true);
861
- // The morph is over. Clearing the targets matters: an unrelated re-render
862
- // afterwards must not find a container still marked mid-morph and hide
863
- // components that are merely sitting still.
864
- morphTargetsRef.current = new Set();
865
- }, MORPH_MS);
866
1207
  // Re-frame once the grow has landed, not while it is running. The drill-down
867
1208
  // more than doubles the boundary, so the extent the camera was fitted to no
868
1209
  // longer contains the content — but moving *during* the morph compounds with
@@ -873,35 +1214,39 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
873
1214
  clearTimeout(cameraTimer.current);
874
1215
  cameraTimer.current = setTimeout(fitSystem, CAMERA_AFTER_MORPH_MS);
875
1216
  }
1217
+ else {
1218
+ morphTargetsRef.current = new Set();
1219
+ }
876
1220
  paintedRef.current = true;
877
1221
  /**
878
1222
  * Every element whose rect changes between the previous and new layouts, so
879
1223
  * boundaries *and* cards grow/shrink and slide together. Empty when nothing
880
1224
  * changed (e.g. the initial paint).
881
1225
  *
882
- * Frames alone are not enough: ELK re-lays out the whole diagram, so every
883
- * root card moves too. Without a card in this set it keeps its new slot the
884
- * instant the layout swaps — a teleport, which is the most visible part of a
885
- * bad morph. Child nodes are excluded: a transform on them would only fight
886
- * the parent's, which carries the same movement.
1226
+ * This covers *nested* cards too (a container inside the system frame, a
1227
+ * component inside a grown container). They are React Flow children, so the
1228
+ * FLIP rects are rebased to their parent's origin in `injectGrow` — a
1229
+ * transform on a child then composes with the parent's instead of fighting
1230
+ * it. Without this, filtering the model to a trail's cast reflowed the
1231
+ * surviving containers and they teleported to their new slots.
887
1232
  */
888
1233
  const growsFor = (next) => {
889
- if (openStateKey(prevOpen) === openKeyNow)
1234
+ if (!paintedRef.current)
890
1235
  return null;
891
- const prevLayout = layoutCache.current.get(openStateKey(prevOpen));
1236
+ const prevLayout = layoutCache.current.get(prevCacheKey);
892
1237
  if (!prevLayout)
893
1238
  return null;
894
1239
  const grows = new Map();
895
- const rootCardIds = (l) => l.nodes.filter((n) => !n.parentId).map((n) => n.id);
1240
+ const cardIds = (l) => l.nodes.map((n) => n.id);
896
1241
  const ids = new Set([
897
1242
  ...prevLayout.groupBounds.keys(),
898
1243
  ...next.groupBounds.keys(),
899
- ...rootCardIds(prevLayout),
900
- ...rootCardIds(next),
1244
+ ...cardIds(prevLayout),
1245
+ ...cardIds(next),
901
1246
  ]);
902
1247
  for (const id of ids) {
903
- const fromRect = containerRect(prevLayout, id);
904
- const to = containerRect(next, id);
1248
+ const fromRect = absoluteRectOf(prevLayout, id);
1249
+ const to = absoluteRectOf(next, id);
905
1250
  if (!fromRect || !to)
906
1251
  continue;
907
1252
  if (fromRect.x === to.x &&
@@ -914,10 +1259,74 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
914
1259
  }
915
1260
  return grows.size > 0 ? grows : null;
916
1261
  };
917
- const cached = layoutCache.current.get(openKeyNow);
918
- if (cached) {
1262
+ // Commit a new layout: keep the survivors gliding (FLIP), fade in cards the
1263
+ // previous layout did not have, and hold the previous layout's orphans for
1264
+ // one morph so they fade out rather than pop. Then advance the refs.
1265
+ const applyLayout = (r) => {
1266
+ const prevLayout = layoutCache.current.get(prevCacheKey);
1267
+ const span = growsFor(r);
1268
+ const prevNodeIds = new Set((prevLayout?.nodes ?? []).map((n) => n.id));
1269
+ const nextIds = new Set(r.nodes.map((n) => n.id));
1270
+ // A leaving card is rendered as a *root* node pinned to its old absolute
1271
+ // rect. Nested under its old parent it would ride that parent's React Flow
1272
+ // wrapper — which moves the instant the new layout commits — and drift
1273
+ // before it faded. As a root it stays put and just fades.
1274
+ const leavers = prevLayout
1275
+ ? prevLayout.nodes
1276
+ .filter((n) => !nextIds.has(n.id))
1277
+ .map((n) => {
1278
+ const rect = absoluteRectOf(prevLayout, n.id);
1279
+ // Keep the dim it had while hovered, so the fade starts from the
1280
+ // faded opacity instead of flashing back to full first.
1281
+ const el = n.data?.element;
1282
+ const wasDimmed = el ? prevDimmed?.has(el.id) === true : false;
1283
+ return {
1284
+ ...n,
1285
+ parentId: undefined,
1286
+ position: rect ? { x: rect.x, y: rect.y } : n.position,
1287
+ ...(rect ? { width: rect.width, height: rect.height } : {}),
1288
+ data: { ...n.data, leaving: true, ...(wasDimmed ? { dimmed: true } : {}) },
1289
+ };
1290
+ })
1291
+ : [];
1292
+ // Only *root* fades (a container arriving or leaving) sequence the move.
1293
+ // Nested cards (components, constructs) are withheld by the morph gate
1294
+ // while their container grows, so their entering/leaving is not a fade the
1295
+ // reader sees — delaying the grow for them would just add a pause.
1296
+ const rootEntering = prevLayout
1297
+ ? r.nodes.filter((n) => !n.parentId && !prevNodeIds.has(n.id)).length
1298
+ : 0;
1299
+ const rootLeaving = leavers.filter((n) => !n.parentId).length;
1300
+ const moveDelayMs = rootEntering > 0 || rootLeaving > 0 ? FADE_MS : 0;
1301
+ // The edges are withheld for the whole sequenced morph — fade, then move,
1302
+ // then content. The morphing containers' components are withheld too (see
1303
+ // `morphTargetsRef`).
1304
+ if (span) {
1305
+ setSettled(false);
1306
+ if (settleTimer.current)
1307
+ clearTimeout(settleTimer.current);
1308
+ settleTimer.current = setTimeout(() => {
1309
+ setSettled(true);
1310
+ morphTargetsRef.current = new Set();
1311
+ }, moveDelayMs + MORPH_MS);
1312
+ }
1313
+ if (leaverTimer.current)
1314
+ clearTimeout(leaverTimer.current);
1315
+ if (leavers.length > 0) {
1316
+ const leavingIds = new Set(leavers.map((n) => n.id));
1317
+ // A leaver only fades; it does not move, so it is dropped when the fade
1318
+ // ends, just before the survivors start to glide.
1319
+ leaverTimer.current = setTimeout(() => {
1320
+ setNodes((prev) => prev.filter((n) => !leavingIds.has(n.id)));
1321
+ }, FADE_MS + 40);
1322
+ }
919
1323
  prevOpenRef.current = openIds;
920
- renderLayout(cached, growsFor(cached));
1324
+ prevCacheKeyRef.current = cacheKeyNow;
1325
+ renderLayout(r, span, prevLayout ? { prevNodeIds, leavers, moveDelayMs } : undefined);
1326
+ };
1327
+ const cached = layoutCache.current.get(cacheKeyNow);
1328
+ if (cached) {
1329
+ applyLayout(cached);
921
1330
  return () => {
922
1331
  alive = false;
923
1332
  };
@@ -926,10 +1335,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
926
1335
  .then((r) => {
927
1336
  if (!alive)
928
1337
  return;
929
- cacheLayout(openKeyNow, r);
930
- const span = growsFor(r);
931
- prevOpenRef.current = openIds;
932
- renderLayout(r, span);
1338
+ cacheLayout(cacheKeyNow, r);
1339
+ applyLayout(r);
933
1340
  })
934
1341
  .catch((err) => {
935
1342
  if (!alive)
@@ -954,9 +1361,20 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
954
1361
  clearTimeout(cameraTimer.current);
955
1362
  cameraTimer.current = null;
956
1363
  }
1364
+ if (leaverTimer.current) {
1365
+ clearTimeout(leaverTimer.current);
1366
+ leaverTimer.current = null;
1367
+ }
957
1368
  };
958
1369
  // eslint-disable-next-line react-hooks/exhaustive-deps
959
1370
  }, [layoutKey]);
1371
+ // Remember this render's dim set for the *next* commit's leavers. Declared
1372
+ // after the layout effect so it runs after it: the layout effect reads the
1373
+ // value the previous render left here (a hover dim), before this overwrites
1374
+ // it with the now-cleared set.
1375
+ useEffect(() => {
1376
+ prevDimmedRef.current = dimmedElementIds ?? null;
1377
+ });
960
1378
  // After the first paint, prefetch the opened layout for every container, so a
961
1379
  // click swaps to a cached result instead of waiting on ELK.
962
1380
  useEffect(() => {
@@ -981,7 +1399,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
981
1399
  for (const target of targets) {
982
1400
  if (!alive)
983
1401
  return;
984
- const key = openStateKey(target);
1402
+ const key = stateKey(target);
985
1403
  if (layoutCache.current.has(key))
986
1404
  continue;
987
1405
  try {
@@ -1000,7 +1418,88 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1000
1418
  };
1001
1419
  // eslint-disable-next-line react-hooks/exhaustive-deps
1002
1420
  }, [layoutKey, model.view]);
1421
+ /** Open a construct's functionality, or close it when re-clicked. */
1422
+ const selectConstruct = useCallback((alias) => {
1423
+ setSelectedConstructAlias((cur) => (cur === alias ? null : alias));
1424
+ }, []);
1425
+ // Scoped to this canvas so the reused component nodes dispatch into this
1426
+ // graph's selection instead of a mounted component graph's module callbacks.
1427
+ const subsystemCallbacks = useMemo(() => ({ onSelect: selectConstruct }), [selectConstruct]);
1003
1428
  const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
1429
+ /** The rolled-up line under selection, when it is an edge. */
1430
+ const selectedEdge = useMemo(() => model.edges.find((e) => e.id === selectedEdgeId) ?? null, [model, selectedEdgeId]);
1431
+ /**
1432
+ * The step under persistent focus, resolved from the construct graph's trails
1433
+ * (the drawer shows its implementation). The host owns the selection.
1434
+ */
1435
+ const focusedStepInfo = useMemo(() => {
1436
+ if (!focusedStep)
1437
+ return null;
1438
+ for (const cg of constructGraphByContainer?.values() ?? []) {
1439
+ const trail = cg.trails.find((t) => t.id === focusedStep.trailId);
1440
+ if (!trail)
1441
+ continue;
1442
+ const step = trail.steps[focusedStep.stepIndex];
1443
+ if (!step)
1444
+ continue;
1445
+ return { trail, step, stepIndex: focusedStep.stepIndex };
1446
+ }
1447
+ return null;
1448
+ }, [focusedStep, constructGraphByContainer]);
1449
+ // Frame the focused step's constructs, the way the component graph frames a
1450
+ // focused step's edge: box the `from`/`to` node rects (absolute) and fit.
1451
+ // Waits out the drawer's height transition so the fit uses the reduced canvas.
1452
+ useEffect(() => {
1453
+ if (!focusedStepInfo)
1454
+ return;
1455
+ const rects = [];
1456
+ for (const id of [focusedStepInfo.step.from, focusedStepInfo.step.to]) {
1457
+ const r = absoluteRectOf({ nodes: nodesRef.current, groupBounds: new Map() }, id);
1458
+ if (r)
1459
+ rects.push(r);
1460
+ }
1461
+ if (rects.length === 0)
1462
+ return;
1463
+ const minX = Math.min(...rects.map((r) => r.x));
1464
+ const minY = Math.min(...rects.map((r) => r.y));
1465
+ const maxX = Math.max(...rects.map((r) => r.x + r.width));
1466
+ const maxY = Math.max(...rects.map((r) => r.y + r.height));
1467
+ const bounds = {
1468
+ x: minX,
1469
+ y: minY,
1470
+ width: Math.max(1, maxX - minX),
1471
+ height: Math.max(1, maxY - minY),
1472
+ };
1473
+ const timer = setTimeout(() => {
1474
+ requestAnimationFrame(() => fitBounds(bounds, { padding: 0.2, duration: MORPH_MS }));
1475
+ }, FILE_DRAWER_HEIGHT_MS + 20);
1476
+ return () => clearTimeout(timer);
1477
+ // `nodesRef` is a ref; `settled` re-fires once the constructs have landed.
1478
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1479
+ }, [focusedStepInfo, settled, fitBounds]);
1480
+ /**
1481
+ * The construct under selection, folded to what the functionality panel
1482
+ * shows. Resolved across every container's construct graph (aliases are unique
1483
+ * in the document, so at most one matches).
1484
+ */
1485
+ const selectedConstruct = useMemo(() => {
1486
+ if (!selectedConstructAlias)
1487
+ return null;
1488
+ for (const cg of constructGraphByContainer?.values() ?? []) {
1489
+ const c = cg.components.find((x) => x.alias === selectedConstructAlias);
1490
+ if (!c)
1491
+ continue;
1492
+ return {
1493
+ component: c,
1494
+ name: deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype),
1495
+ badge: constructBadgeLabel(c),
1496
+ color: componentColor(c, resolvePierreSyntaxThemeName(mode)),
1497
+ role: rightBadgeLabel(c),
1498
+ declaration: generateDeclarationString(c),
1499
+ };
1500
+ }
1501
+ return null;
1502
+ }, [selectedConstructAlias, constructGraphByContainer, mode]);
1004
1503
  /**
1005
1504
  * The components authored inside the selected element, if it is a container.
1006
1505
  * These are `C4Component`s whose `container` names the selected id (or, once
@@ -1011,47 +1510,205 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1011
1510
  ? model.nodes.filter((n) => n.kind === 'component' &&
1012
1511
  (n.container === selected.id || n.parentId === selected.id))
1013
1512
  : [], [model, selected]);
1513
+ /**
1514
+ * A selected container's member aliases, resolved from the construct graph
1515
+ * (a container's members are derived, not stored — see `C4ElementBase`). Only
1516
+ * populated when the container hosts focused constructs.
1517
+ */
1518
+ const selectedContainerMembers = useMemo(() => selected?.kind === 'container'
1519
+ ? (constructGraphByContainer?.get(selected.id)?.components ?? []).map((c) => c.alias)
1520
+ : [], [selected, constructGraphByContainer]);
1014
1521
  /** The containers open for drill-down, normalized. */
1015
1522
  const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
1016
1523
  const displayNodes = useMemo(() => nodes.map((n) => {
1524
+ // A card kept only to fade out is never hidden or dimmed — it is on its
1525
+ // way off screen and must stay visible for the fade.
1526
+ if (n.data?.leaving)
1527
+ return n;
1528
+ // Constructs are the component graph's own nodes. They ride the morph
1529
+ // gate of the container that hosts them (they arrive with its grow) and
1530
+ // dim with it; the node reads `isSelected` from data because it stops
1531
+ // its own click propagation.
1532
+ if (n.type === 'subsystem-component') {
1533
+ const owner = n.data?.c4Owner;
1534
+ const hidden = hidesComponentDuringMorph(owner, openContainerIds, morphTargetsRef.current, settled);
1535
+ // Dim with its container, or on its own when the host spotlights a
1536
+ // step's constructs (the component graph's per-component behaviour).
1537
+ const ownerDimmed = dimmedElementIds && owner ? dimmedElementIds.has(owner) : false;
1538
+ const stepDimmed = dimmedConstructIds?.has(n.id) === true;
1539
+ return {
1540
+ ...n,
1541
+ ...(hidden ? { hidden: true } : {}),
1542
+ data: {
1543
+ ...n.data,
1544
+ dimmed: ownerDimmed || stepDimmed,
1545
+ isSelected: n.id === selectedConstructAlias,
1546
+ },
1547
+ };
1548
+ }
1017
1549
  if (n.type !== 'c4-node')
1018
1550
  return n;
1019
1551
  const element = n.data?.element;
1020
1552
  const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
1021
- // `hidesComponentDuringMorph` owns the rule; see there for why it is
1022
- // per-container rather than global.
1023
- const hidden = hidesComponentDuringMorph(owner, openContainerIds, morphTargetsRef.current, settled);
1553
+ // `hidesComponentDuringMorph` owns the morph rule; see there for why it
1554
+ // is per-container rather than global. A host `hiddenElementIds` (the
1555
+ // focused-trail cast) removes the box outright.
1556
+ const hidden = hidesComponentDuringMorph(owner, openContainerIds, morphTargetsRef.current, settled) || (element ? hiddenElementIds?.has(element.id) === true : false);
1024
1557
  return {
1025
1558
  ...n,
1026
1559
  ...(hidden ? { hidden: true } : {}),
1027
- data: { ...n.data, selected: n.id === selectedId, settled },
1560
+ data: {
1561
+ ...n.data,
1562
+ selected: n.id === selectedId,
1563
+ settled,
1564
+ dimmed: dimmedElementIds
1565
+ ? (element ? dimmedElementIds.has(element.id) : false)
1566
+ : false,
1567
+ },
1028
1568
  };
1029
1569
  }),
1030
1570
  // `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
1031
1571
  // it, because the two always change together — targets are written in the same
1032
1572
  // tick that clears `settled`, and cleared in the same tick that restores it.
1033
- [nodes, selectedId, settled, openContainerIds]);
1573
+ [
1574
+ nodes,
1575
+ selectedId,
1576
+ settled,
1577
+ openContainerIds,
1578
+ dimmedElementIds,
1579
+ dimmedConstructIds,
1580
+ hiddenElementIds,
1581
+ selectedConstructAlias,
1582
+ ]);
1034
1583
  /**
1035
1584
  * An edge's polyline is routed for the *settled* layout, so while the cards are
1036
1585
  * gliding between the two slots the old lines would visibly point at the wrong
1037
1586
  * boxes. Hiding them for the morph is honest and cheap; interpolating the
1038
1587
  * polylines to match is not.
1588
+ *
1589
+ * The dim set is the union of
1590
+ * (a) the own endpoint rule — a line between two dimmed boxes fades with
1591
+ * them, a line into a lit container stays bright on its own; and
1592
+ * (b) the host's line-relevance set — edges that don't carry the focused
1593
+ * trail / hovered step (a step internal to a container folds into no
1594
+ * line at all, so even lines out of the lit container fade).
1595
+ *
1596
+ * The selected edge never dims: it is the inspector's subject.
1039
1597
  */
1040
- const displayEdges = useMemo(() => (settled ? rfEdges : rfEdges.map((e) => ({ ...e, hidden: true }))), [rfEdges, settled]);
1041
- return (_jsxs("div", { style: { position: 'relative', width: '100%', height: '100%', background: theme.colors.background }, children: [_jsx(GraphLayerStyle, {}), _jsx(ReactFlow, { nodes: displayNodes, edges: displayEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS, ...GRAPH_NAV_PROPS, onNodeClick: (_e, node) => {
1042
- if (node.type !== 'c4-node')
1043
- return;
1044
- const next = node.id === selectedId ? null : node.id;
1045
- setSelectedId(next);
1046
- onSelectNode?.(next);
1047
- }, onPaneClick: () => {
1048
- setSelectedId(null);
1049
- onSelectNode?.(null);
1050
- }, proOptions: { hideAttribution: true },
1051
- // Pin the flow to its container. The label overlay is a sibling sized
1052
- // `inset: 0`, so the two must share an origin or `viewport.y` (measured
1053
- // in the flow's own box) lands the chips off vertically.
1054
- style: { width: '100%', height: '100%' }, children: _jsx(GraphChrome, {}) }), ready && settled && labelPositions.size > 0 && (_jsx(C4EdgeLabels, { positions: labelPositions, edges: model.edges })), !ready && (_jsx("div", { style: {
1598
+ const dimmedEdgeIdSet = useMemo(() => {
1599
+ if (!dimmedElementIds && !dimmedEdgeIds)
1600
+ return null;
1601
+ const out = new Set();
1602
+ if (dimmedElementIds) {
1603
+ for (const e of rfEdges) {
1604
+ if (dimmedElementIds.has(e.source) && dimmedElementIds.has(e.target))
1605
+ out.add(e.id);
1606
+ }
1607
+ }
1608
+ if (dimmedEdgeIds)
1609
+ for (const id of dimmedEdgeIds)
1610
+ out.add(id);
1611
+ if (selectedEdgeId)
1612
+ out.delete(selectedEdgeId);
1613
+ return out;
1614
+ }, [dimmedElementIds, dimmedEdgeIds, rfEdges, selectedEdgeId]);
1615
+ const hiddenEdgeIdSet = useMemo(() => {
1616
+ if (!hiddenElementIds && !hiddenEdgeIds)
1617
+ return null;
1618
+ const out = new Set();
1619
+ if (hiddenElementIds) {
1620
+ // Either endpoint gone → the line has nothing to attach to, so it goes
1621
+ // with the box (unlike the dim rule, which needs both endpoints dimmed).
1622
+ for (const e of rfEdges) {
1623
+ if (hiddenElementIds.has(e.source) || hiddenElementIds.has(e.target))
1624
+ out.add(e.id);
1625
+ }
1626
+ }
1627
+ if (hiddenEdgeIds)
1628
+ for (const id of hiddenEdgeIds)
1629
+ out.add(id);
1630
+ return out;
1631
+ }, [hiddenElementIds, hiddenEdgeIds, rfEdges]);
1632
+ const displayEdges = useMemo(() => rfEdges.map((e) => {
1633
+ const hidden = !settled || hiddenEdgeIdSet?.has(e.id) === true;
1634
+ const dimmed = dimmedEdgeIdSet?.has(e.id) === true ||
1635
+ dimmedConstructEdgeIds?.has(e.id) === true;
1636
+ // The arrowhead is a shared SVG marker: dimming the stroke (path opacity
1637
+ // or colour alpha) leaves it at full colour. Clone the marker with a
1638
+ // dimmed colour, as the component graph does.
1639
+ const markerEnd = e.markerEnd;
1640
+ const nextMarker = dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
1641
+ ? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
1642
+ : markerEnd;
1643
+ return {
1644
+ ...e,
1645
+ ...(hidden ? { hidden: true } : {}),
1646
+ markerEnd: nextMarker,
1647
+ data: {
1648
+ ...e.data,
1649
+ selected: e.id === selectedEdgeId,
1650
+ dimmed,
1651
+ },
1652
+ };
1653
+ }), [rfEdges, settled, selectedEdgeId, dimmedEdgeIdSet, hiddenEdgeIdSet, dimmedConstructEdgeIds]);
1654
+ /** The construct edges, for their mechanism-label overlay (carries the dim flag). */
1655
+ const subsystemLabelEdges = useMemo(() => displayEdges.filter((e) => e.type === 'subsystem-edge'), [displayEdges]);
1656
+ // The step drawer: open while a step is focused and a viewer is not hidden.
1657
+ const drawerOpen = focusedStepInfo != null && !hideDrawer;
1658
+ const drawerTitle = focusedStepInfo
1659
+ ? renderTrailViewer
1660
+ ? focusedStepInfo.trail.title
1661
+ : focusedStepInfo.step.file
1662
+ : null;
1663
+ const drawerProgress = focusedStepInfo && focusedStepInfo.trail.steps.length > 1
1664
+ ? { index: focusedStepInfo.stepIndex, total: focusedStepInfo.trail.steps.length }
1665
+ : null;
1666
+ // The selection panel floats over the canvas in the top-right corner. A host
1667
+ // that wants the panel (or anything else) beside the graph wraps the graph in
1668
+ // `GraphWithSidebar` itself, so the graph stays overlay-only.
1669
+ const selectionPanelStyle = {
1670
+ position: 'absolute',
1671
+ top: 12,
1672
+ right: 12,
1673
+ // Above the edge-label overlay (zIndex 5), or chips paint over the panel.
1674
+ zIndex: 10,
1675
+ width: 280,
1676
+ maxHeight: 'calc(100% - 24px)',
1677
+ overflowY: 'auto',
1678
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
1679
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
1680
+ borderRadius: 8,
1681
+ padding: '10px 12px',
1682
+ };
1683
+ const canvasRoot = (_jsxs(_Fragment, { children: [_jsx(GraphLayerStyle, {}), _jsx(SubsystemCallbacksProvider, { value: subsystemCallbacks, children: _jsx(ReactFlow, { nodes: displayNodes, edges: displayEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS, ...GRAPH_NAV_PROPS, onNodeClick: (_e, node) => {
1684
+ if (node.type !== 'c4-node')
1685
+ return;
1686
+ const next = node.id === selectedId ? null : node.id;
1687
+ setSelectedId(next);
1688
+ setSelectedEdgeId(null);
1689
+ setSelectedConstructAlias(null);
1690
+ onSelectNode?.(next);
1691
+ onSelectEdge?.(null);
1692
+ }, onEdgeClick: (_e, edge) => {
1693
+ const next = edge.id === selectedEdgeId ? null : edge.id;
1694
+ setSelectedEdgeId(next);
1695
+ // An edge selection replaces the node panel — the two inspectors
1696
+ // sharing the corner would fight for it.
1697
+ setSelectedId(null);
1698
+ setSelectedConstructAlias(null);
1699
+ onSelectNode?.(null);
1700
+ onSelectEdge?.(next ? (model.edges.find((e) => e.id === next) ?? null) : null);
1701
+ }, onPaneClick: () => {
1702
+ setSelectedId(null);
1703
+ setSelectedEdgeId(null);
1704
+ setSelectedConstructAlias(null);
1705
+ onSelectNode?.(null);
1706
+ onSelectEdge?.(null);
1707
+ }, proOptions: { hideAttribution: true },
1708
+ // Pin the flow to its container. The label overlay is a sibling sized
1709
+ // `inset: 0`, so the two must share an origin or `viewport.y` (measured
1710
+ // in the flow's own box) lands the chips off vertically.
1711
+ style: { width: '100%', height: '100%' }, children: _jsx(GraphChrome, {}) }) }), ready && settled && labelPositions.size > 0 && (_jsx(C4EdgeLabels, { positions: labelPositions, edges: model.edges, selectedEdgeId: selectedEdgeId, dimmedEdgeIds: dimmedEdgeIdSet, hiddenEdgeIds: hiddenEdgeIdSet })), ready && settled && subsystemLabelEdges.length > 0 && (_jsx(SubsystemEdgeLabels, { edges: subsystemLabelEdges })), !ready && (_jsx("div", { style: {
1055
1712
  position: 'absolute',
1056
1713
  inset: 0,
1057
1714
  display: 'flex',
@@ -1061,21 +1718,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1061
1718
  color: muted,
1062
1719
  fontFamily: theme.fonts.monospace,
1063
1720
  fontSize: theme.fontSizes[0],
1064
- }, children: "Laying out C4 view\u2026" })), selected && (_jsxs("div", { style: {
1065
- position: 'absolute',
1066
- top: 12,
1067
- right: 12,
1068
- // Above the edge-label overlay (zIndex 5), or chips paint over the
1069
- // panel.
1070
- zIndex: 10,
1071
- width: 280,
1072
- maxHeight: 'calc(100% - 24px)',
1073
- overflowY: 'auto',
1074
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
1075
- border: `1px solid ${theme.colors.border ?? '#333'}`,
1076
- borderRadius: 8,
1077
- padding: '10px 12px',
1078
- }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { style: { fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: selected.label }), _jsx("button", { type: "button", onClick: () => {
1721
+ }, children: "Laying out C4 view\u2026" }))] }));
1722
+ const selectionPanels = (_jsxs(_Fragment, { children: [selected && (_jsxs("div", { style: selectionPanelStyle, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { style: { fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: selected.label }), _jsx("button", { type: "button", onClick: () => {
1079
1723
  setSelectedId(null);
1080
1724
  onSelectNode?.(null);
1081
1725
  }, "aria-label": "Clear selection", style: { border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }, children: "\u00D7" })] }), selected.kind !== 'person' &&
@@ -1121,7 +1765,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1121
1765
  overflow: 'hidden',
1122
1766
  textOverflow: 'ellipsis',
1123
1767
  whiteSpace: 'nowrap',
1124
- }, children: ['technology' in c && c.technology ? c.technology : c.kind, (c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''] })] }, c.id)))] })), selected.kind === 'container' && selectedComponents.length === 0 && (_jsxs("div", { style: {
1768
+ }, children: [c.technology || c.kind, (c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''] })] }, c.id)))] })), selected.kind === 'container' && selectedComponents.length === 0 && (_jsxs("div", { style: {
1125
1769
  display: 'flex',
1126
1770
  flexDirection: 'column',
1127
1771
  gap: 6,
@@ -1133,7 +1777,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1133
1777
  color: muted,
1134
1778
  textTransform: 'uppercase',
1135
1779
  letterSpacing: 0.5,
1136
- }, children: "Components" }), (selected.members ?? []).length > 0 ? ((selected.members ?? []).map((alias) => (_jsx("span", { title: alias, style: {
1780
+ }, children: "Components" }), selectedContainerMembers.length > 0 ? (selectedContainerMembers.map((alias) => (_jsx("span", { title: alias, style: {
1137
1781
  fontFamily: theme.fonts.monospace,
1138
1782
  fontSize: theme.fontSizes[0],
1139
1783
  color: theme.colors.text,
@@ -1144,7 +1788,139 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1144
1788
  fontFamily: theme.fonts.monospace,
1145
1789
  fontSize: theme.fontSizes[0],
1146
1790
  color: muted,
1147
- }, children: "No components" }))] }))] }))] }));
1791
+ }, children: "No components" }))] }))] })), selectedEdge && ready && (_jsxs("div", { style: selectionPanelStyle, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { "aria-hidden": true, style: {
1792
+ width: 8,
1793
+ height: 8,
1794
+ borderRadius: '50%',
1795
+ background: protocolColor(selectedEdge.protocol),
1796
+ flexShrink: 0,
1797
+ } }), _jsx("span", { style: {
1798
+ fontFamily: theme.fonts.monospace,
1799
+ fontWeight: 600,
1800
+ color: theme.colors.text,
1801
+ flex: 1,
1802
+ minWidth: 0,
1803
+ overflow: 'hidden',
1804
+ textOverflow: 'ellipsis',
1805
+ whiteSpace: 'nowrap',
1806
+ }, children: selectedEdge.label }), _jsx("button", { type: "button", onClick: () => {
1807
+ setSelectedEdgeId(null);
1808
+ onSelectEdge?.(null);
1809
+ }, "aria-label": "Clear selection", style: { border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }, children: "\u00D7" })] }), _jsxs("div", { style: {
1810
+ display: 'flex',
1811
+ alignItems: 'center',
1812
+ gap: 6,
1813
+ flexWrap: 'wrap',
1814
+ marginTop: 6,
1815
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
1816
+ paddingTop: 6,
1817
+ }, children: [selectedEdge.mechanisms.map((m) => (_jsx("span", { style: {
1818
+ fontFamily: theme.fonts.monospace,
1819
+ fontSize: theme.fontSizes[0],
1820
+ color: theme.colors.text,
1821
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
1822
+ borderRadius: 4,
1823
+ padding: '1px 6px',
1824
+ }, children: m }, m))), _jsxs("span", { style: { fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }, children: [selectedEdge.count, " ", selectedEdge.count === 1 ? 'step' : 'steps'] })] }), _jsxs("div", { style: {
1825
+ display: 'flex',
1826
+ flexDirection: 'column',
1827
+ gap: 4,
1828
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
1829
+ paddingTop: 6,
1830
+ marginTop: 6,
1831
+ }, children: [_jsx("span", { style: {
1832
+ fontFamily: theme.fonts.monospace,
1833
+ fontSize: theme.fontSizes[0],
1834
+ color: muted,
1835
+ textTransform: 'uppercase',
1836
+ letterSpacing: 0.5,
1837
+ }, children: "Backed by trails" }), (selectedEdge.trailRefs ?? []).length > 0 ? ((selectedEdge.trailRefs ?? []).map((ref) => (_jsxs("button", { type: "button", onClick: () => onTrailClick?.(ref), disabled: !onTrailClick, title: onTrailClick
1838
+ ? `Focus this trail in the component graph`
1839
+ : 'Trail drill-down needs onTrailClick', style: {
1840
+ display: 'flex',
1841
+ alignItems: 'center',
1842
+ gap: 6,
1843
+ textAlign: 'left',
1844
+ border: 'none',
1845
+ background: 'transparent',
1846
+ padding: 0,
1847
+ cursor: onTrailClick ? 'pointer' : 'default',
1848
+ fontFamily: theme.fonts.monospace,
1849
+ fontSize: theme.fontSizes[0],
1850
+ }, children: [_jsx("span", { style: { color: theme.colors.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: ref.trailId }), _jsxs("span", { style: { color: muted, flexShrink: 0 }, children: [ref.stepIndices.length, " ", ref.stepIndices.length === 1 ? 'step' : 'steps', ref.stepIndices.length > 0 ? ` · #${ref.stepIndices.map((i) => i + 1).join(', #')}` : ''] })] }, ref.trailId)))) : (_jsx("span", { style: { fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }, children: "Authored line \u2014 no trail provenance yet" }))] })] })), selectedConstruct && (_jsxs("div", { style: {
1851
+ ...selectionPanelStyle,
1852
+ top: 'auto',
1853
+ bottom: 12,
1854
+ left: 12,
1855
+ right: 'auto',
1856
+ width: 320,
1857
+ maxHeight: 'calc(50% - 18px)',
1858
+ }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { "aria-hidden": true, style: {
1859
+ width: 8,
1860
+ height: 8,
1861
+ borderRadius: '50%',
1862
+ background: selectedConstruct.color,
1863
+ flexShrink: 0,
1864
+ } }), _jsx("span", { style: {
1865
+ fontFamily: theme.fonts.monospace,
1866
+ fontWeight: 600,
1867
+ color: theme.colors.text,
1868
+ flex: 1,
1869
+ minWidth: 0,
1870
+ overflow: 'hidden',
1871
+ textOverflow: 'ellipsis',
1872
+ whiteSpace: 'nowrap',
1873
+ }, children: selectedConstruct.name }), _jsx("span", { style: {
1874
+ flexShrink: 0,
1875
+ fontFamily: theme.fonts.monospace,
1876
+ fontSize: theme.fontSizes[0],
1877
+ color: muted,
1878
+ textTransform: 'uppercase',
1879
+ letterSpacing: 0.4,
1880
+ }, children: selectedConstruct.badge }), _jsx("button", { type: "button", onClick: () => setSelectedConstructAlias(null), "aria-label": "Clear construct", style: { border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }, children: "\u00D7" })] }), selectedConstruct.component.purpose && (_jsx("div", { style: {
1881
+ marginTop: 6,
1882
+ fontFamily: theme.fonts.body,
1883
+ fontSize: theme.fontSizes[0],
1884
+ color: theme.colors.text,
1885
+ lineHeight: 1.4,
1886
+ }, children: selectedConstruct.component.purpose })), selectedConstruct.declaration && (_jsx("pre", { style: {
1887
+ marginTop: 6,
1888
+ marginBottom: 0,
1889
+ padding: '6px 8px',
1890
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
1891
+ background: theme.colors.background ?? 'transparent',
1892
+ color: theme.colors.text,
1893
+ fontFamily: theme.fonts.monospace,
1894
+ fontSize: theme.fontSizes[0],
1895
+ lineHeight: 1.45,
1896
+ whiteSpace: 'pre-wrap',
1897
+ overflowWrap: 'anywhere',
1898
+ }, children: selectedConstruct.declaration })), _jsxs("div", { style: {
1899
+ marginTop: 6,
1900
+ fontFamily: theme.fonts.monospace,
1901
+ fontSize: theme.fontSizes[0],
1902
+ color: muted,
1903
+ }, children: [selectedConstruct.component.alias, selectedConstruct.role ? ` · ${selectedConstruct.role}` : '', selectedConstruct.component.proposed ? ' · proposed' : ''] })] }))] }));
1904
+ return (_jsxs("div", { style: {
1905
+ position: 'relative',
1906
+ width: '100%',
1907
+ height: '100%',
1908
+ display: 'flex',
1909
+ flexDirection: 'column',
1910
+ background: theme.colors.background,
1911
+ }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minHeight: 0 }, children: [canvasRoot, selectionPanels] }), _jsx(FileDrawer, { title: drawerOpen ? drawerTitle : null, onClose: () => onCloseStep?.(), hidden: hideDrawer, progress: drawerProgress, children: drawerOpen && focusedStepInfo
1912
+ ? renderTrailViewer
1913
+ ? renderTrailViewer({
1914
+ trail: focusedStepInfo.trail,
1915
+ stepIndex: focusedStepInfo.stepIndex,
1916
+ })
1917
+ : renderFileViewer
1918
+ ? renderFileViewer(focusedStepInfo.step.file, {
1919
+ startLine: focusedStepInfo.step.span?.startLine,
1920
+ endLine: focusedStepInfo.step.span?.endLine,
1921
+ })
1922
+ : _jsx(StepDrawerFallback, { step: focusedStepInfo.step })
1923
+ : null })] }));
1148
1924
  }
1149
1925
  export function C4Graph(props) {
1150
1926
  return (_jsx(ReactFlowProvider, { children: _jsx(Inner, { ...props }) }));