@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
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import type { ComponentType, CSSProperties, ReactNode } from 'react';
15
16
  import {
16
17
  Edge,
17
18
  EdgeProps,
@@ -33,6 +34,9 @@ import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLa
33
34
  import { deriveC4Groups, protocolColor, type C4Group } from './c4';
34
35
  import {
35
36
  C4NodeCard,
37
+ COMPONENT_FOOTER_PAD,
38
+ COMPONENT_HEADER_PAD,
39
+ COMPONENT_W,
36
40
  NODE_H,
37
41
  NODE_W,
38
42
  OPEN_FOOTER_PAD,
@@ -44,12 +48,131 @@ import {
44
48
  import { TechMark, technologyBrand } from './techIcons';
45
49
  import type { TechBrand } from './techIcons';
46
50
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
47
- import type { C4Element, C4Model } from './c4';
51
+ import type { C4Component, C4Edge, C4Element, C4Model, C4TrailRef } from './c4';
48
52
  import { openContainerSet, openStateKey } from './c4';
53
+ import {
54
+ EDGE_DIM_ALPHA,
55
+ hexWithAlpha,
56
+ SubsystemCallbacksProvider,
57
+ SubsystemComponentNode,
58
+ SubsystemEdge,
59
+ } from './nodes';
60
+ import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
61
+ import {
62
+ constructBadgeLabel,
63
+ convertSubsystemToEdges,
64
+ convertSubsystemToNodes,
65
+ deriveNameFromSymbol,
66
+ edgeColor,
67
+ MECHANISM_DESCRIPTIONS,
68
+ rightBadgeLabel,
69
+ } from './model';
70
+ import type { SubsystemComponent, SubsystemEdgeProvenance, SubsystemTrail } from './model';
71
+ import { generateDeclarationString } from './formatDeclaration';
72
+ import { componentColor } from '../pierre/constructColors';
73
+ import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
74
+
75
+ /**
76
+ * The construct-level subgraph a C4 container hosts when it is opened — the
77
+ * component graph's own material (subsystem components + trail steps), so the
78
+ * constructs render as real `subsystem-component` nodes and `subsystem-edge`
79
+ * edges rather than a summary. Host-resolved and keyed by container id, so the
80
+ * C4 view stays free of the document.
81
+ *
82
+ * The hierarchy it expresses is container → C4 component → construct: a
83
+ * construct whose alias an authored component claims nests under that
84
+ * component (which grows into an ELK parent); one no component claims nests
85
+ * directly under the container.
86
+ */
87
+ export interface C4ConstructGraph {
88
+ /** The document components to draw inside this container. */
89
+ components: readonly SubsystemComponent[];
90
+ /** The trails whose steps become the construct edges (restrict to this container). */
91
+ trails: readonly SubsystemTrail[];
92
+ /** Construct alias → the authored C4 component element id that claims it. */
93
+ componentOf?: ReadonlyMap<string, string>;
94
+ }
49
95
 
50
96
  export interface C4GraphProps {
51
97
  model: C4Model;
52
98
  onSelectNode?: (id: string | null) => void;
99
+ /**
100
+ * Click an edge. Fires with the rolled-up line (and its `trailRefs`
101
+ * provenance when a builder supplied it), or `null` when the selection is
102
+ * cleared — pane click, or clicking a node. This is the drill-down
103
+ * affordance: the host switches the component graph to the contributing
104
+ * trails.
105
+ */
106
+ onSelectEdge?: (edge: C4Edge | null) => void;
107
+ /**
108
+ * Click a trail row in the edge inspector. Hands the ref back so the host
109
+ * can deep-link the component graph to that trail — e.g. via
110
+ * `SubsystemComponentGraph.initialTrailId`. Without this the refs render
111
+ * inert.
112
+ */
113
+ onTrailClick?: (ref: C4TrailRef) => void;
114
+ /**
115
+ * Element ids to fade — everything the focused trail does NOT touch. The
116
+ * host computes the set (trail step aliases → owning element via its
117
+ * membership map); this graph renders the named elements (and the lines
118
+ * between them) at low opacity so the players in the trail stay bright.
119
+ * `null` (or absent) renders everything at full weight.
120
+ */
121
+ dimmedElementIds?: ReadonlySet<string> | null;
122
+ /**
123
+ * Edge ids to fade beyond the endpoint rule — the host's line-level
124
+ * relevance set (edges that do NOT carry the focused trail / hovered step).
125
+ * A step entirely internal to a container folds into no line, so the host
126
+ * can dim every edge even though lit containers remain. Unioned with the
127
+ * own both-endpoints-dimmed rule; the selected edge never dims.
128
+ */
129
+ dimmedEdgeIds?: ReadonlySet<string> | null;
130
+ /**
131
+ * Element ids to remove from the canvas entirely — the focused trail's cast,
132
+ * the C4 analogue of the component graph's opened-flow hide. Unlike
133
+ * `dimmedElementIds` (faded but still drawn), these boxes are not rendered at
134
+ * all, and a line touching one is hidden with it. `null`/absent draws
135
+ * everything. Hover previews dim; an expanded trail hides non-members.
136
+ */
137
+ hiddenElementIds?: ReadonlySet<string> | null;
138
+ /** Edge ids to remove entirely — lines the focused trail does not fold. */
139
+ hiddenEdgeIds?: ReadonlySet<string> | null;
140
+ /**
141
+ * Subsystem-component aliases to fade beyond the container rule — the
142
+ * construct-level spotlight for a hovered step. A construct whose alias is in
143
+ * the set dims even when its container is lit. Absent → constructs dim only
144
+ * with their container.
145
+ */
146
+ dimmedConstructIds?: ReadonlySet<string> | null;
147
+ /** Subsystem edge ids to fade — the construct-level counterpart of `dimmedEdgeIds`. */
148
+ dimmedConstructEdgeIds?: ReadonlySet<string> | null;
149
+ /**
150
+ * The construct-level subgraph to draw inside each opened container — the
151
+ * component graph's subsystem nodes and edges, nested container → C4
152
+ * component → construct. The host resolves it from the model document and the
153
+ * focused trail (restricting edges to the container; a construct edge that
154
+ * crosses containers stays a C4 container-level line). Absent → the container
155
+ * opens as before, with no constructs.
156
+ */
157
+ constructGraphByContainer?: ReadonlyMap<string, C4ConstructGraph>;
158
+ /**
159
+ * The step under persistent focus — the host sets it from a trails-panel step
160
+ * click. The C4 canvas opens a code drawer showing that step's implementation
161
+ * (the trail viewer when supplied, else the file at its line). `null` → no
162
+ * drawer.
163
+ */
164
+ focusedStep?: { trailId: string; stepIndex: number } | null;
165
+ /** Close the step drawer — the host clears `focusedStep`. */
166
+ onCloseStep?: () => void;
167
+ /** Host code viewer for a step's file — the drawer's content. */
168
+ renderFileViewer?: (
169
+ file: string,
170
+ opts?: { startLine?: number; endLine?: number },
171
+ ) => ReactNode;
172
+ /** Host trail viewer; when supplied it replaces the file viewer for a step. */
173
+ renderTrailViewer?: (ctx: { trail: SubsystemTrail; stepIndex: number | null }) => ReactNode;
174
+ /** Suppress the drawer entirely (embeds). */
175
+ hideDrawer?: boolean;
53
176
  /**
54
177
  * Drill-down: add `id` to `model.openContainerIds`, or remove it if it is
55
178
  * already open. One verb for both directions, because with several containers
@@ -102,11 +225,35 @@ function C4NodeView(
102
225
  /** FLIP: absolute flow rects — where this card was, and where it now is. */
103
226
  growFrom?: FlowRect;
104
227
  frameRect?: FlowRect;
228
+ /**
229
+ * True while the host's focused trail does not touch this element — the
230
+ * "players stay bright, the rest fades" emphasis layer. Applied to the
231
+ * whole card (badges and all), never to a single badge on it.
232
+ */
233
+ dimmed?: boolean;
234
+ /** Arriving in this layout — fades in. */
235
+ entering?: boolean;
236
+ /** Gone from this layout — kept for the morph, fades out. */
237
+ leaving?: boolean;
238
+ /** The move/grow waits this long for the fade to finish, in ms. */
239
+ moveDelayMs?: number;
105
240
  }>
106
241
  >,
107
242
  ) {
108
- const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } =
109
- props.data;
243
+ const {
244
+ element,
245
+ selected,
246
+ componentCount,
247
+ onExpand,
248
+ open,
249
+ settled,
250
+ growFrom,
251
+ frameRect,
252
+ dimmed,
253
+ entering,
254
+ leaving,
255
+ moveDelayMs = 0,
256
+ } = props.data;
110
257
  const grown = open === true;
111
258
 
112
259
  // Position-only FLIP. The card resizes through its own `width`/`height`
@@ -115,6 +262,11 @@ function C4NodeView(
115
262
  // transition, so its resize is the card's own and nothing competes with it.
116
263
  const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
117
264
  const sliding = flipping && (dx !== 0 || dy !== 0);
265
+ const fade = useFade(entering, leaving);
266
+ const transitions = [
267
+ ...(fade.transition ? [fade.transition] : []),
268
+ ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
269
+ ];
118
270
  // The card takes its box from the React Flow node rather than from its own
119
271
  // `nodeSize`, so on the opened container ELK's fitted size drives it — and
120
272
  // because the element persists across the swap, the card's own width/height
@@ -133,24 +285,37 @@ function C4NodeView(
133
285
  // compose without either fighting the other.
134
286
  transformOrigin: 'top left',
135
287
  transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
136
- transition: sliding && placed ? `transform ${MORPH_MS}ms ease-out` : 'none',
288
+ transition: transitions.length > 0 ? transitions.join(', ') : 'none',
289
+ opacity: fade.opacity,
137
290
  }}
138
291
  >
139
- <C4NodeCard
140
- node={element}
141
- selected={selected}
142
- componentCount={componentCount ?? 0}
143
- onExpand={onExpand}
144
- width={props.width}
145
- height={props.height}
146
- showDescription={showDescriptionFor(open, settled)}
147
- handles={
148
- <>
149
- <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
150
- <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
151
- </>
152
- }
153
- />
292
+ <div
293
+ style={{
294
+ // The dim is a view concern, deliberately below the FLIP transform
295
+ // (which owns position) so the two never fight. The whole card fades —
296
+ // if we ever want to keep a badge lit, that is a different element.
297
+ opacity: dimmed ? 0.22 : 1,
298
+ transition: 'opacity 220ms ease',
299
+ }}
300
+ >
301
+ <C4NodeCard
302
+ node={element}
303
+ selected={selected}
304
+ componentCount={componentCount ?? 0}
305
+ onExpand={onExpand}
306
+ width={props.width}
307
+ height={props.height}
308
+ showDescription={showDescriptionFor(open, settled)}
309
+ transitionDelayMs={moveDelayMs}
310
+ sizeTransitionMs={MORPH_MS}
311
+ handles={
312
+ <>
313
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
314
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
315
+ </>
316
+ }
317
+ />
318
+ </div>
154
319
  </div>
155
320
  );
156
321
  }
@@ -166,9 +331,15 @@ function C4NodeView(
166
331
  function C4EdgeLabels({
167
332
  positions,
168
333
  edges,
334
+ selectedEdgeId,
335
+ dimmedEdgeIds,
336
+ hiddenEdgeIds,
169
337
  }: {
170
338
  positions: Map<string, { x: number; y: number }>;
171
339
  edges: C4Model['edges'];
340
+ selectedEdgeId?: string | null;
341
+ dimmedEdgeIds?: ReadonlySet<string> | null;
342
+ hiddenEdgeIds?: ReadonlySet<string> | null;
172
343
  }) {
173
344
  const { theme } = useTheme();
174
345
  const viewport = useViewport();
@@ -188,8 +359,11 @@ function C4EdgeLabels({
188
359
  {[...positions].map(([id, p]) => {
189
360
  const edge = byId.get(id);
190
361
  if (!edge) return null;
362
+ if (hiddenEdgeIds?.has(edge.id)) return null;
191
363
  const color = protocolColor(edge.protocol);
192
364
  const text = edge.protocol ?? edge.label;
365
+ const selected = edge.id === selectedEdgeId;
366
+ const dimmed = dimmedEdgeIds?.has(edge.id) === true;
193
367
  return (
194
368
  <div
195
369
  key={id}
@@ -206,6 +380,145 @@ function C4EdgeLabels({
206
380
  height: EDGE_LABEL_HEIGHT,
207
381
  boxSizing: 'border-box',
208
382
  padding: '7px 8px',
383
+ // Fades with its line, on the same beat.
384
+ opacity: dimmed ? 0.25 : 1,
385
+ transition: 'opacity 220ms ease',
386
+ }}
387
+ >
388
+ <span
389
+ aria-hidden
390
+ style={{
391
+ position: 'absolute',
392
+ inset: 0,
393
+ borderRadius: 4,
394
+ border: `0.5px solid ${color}`,
395
+ background: surface,
396
+ // The selected chip echoes the edge's heavier stroke.
397
+ outline: selected ? `1.5px solid ${color}` : 'none',
398
+ }}
399
+ />
400
+ <span
401
+ style={{
402
+ position: 'relative',
403
+ maxWidth: '100%',
404
+ overflow: 'hidden',
405
+ textOverflow: 'ellipsis',
406
+ whiteSpace: 'nowrap',
407
+ fontSize: EDGE_LABEL_FONT_SIZE,
408
+ lineHeight: 1,
409
+ fontFamily: theme.fonts.monospace,
410
+ fontWeight: selected ? 700 : 500,
411
+ color,
412
+ }}
413
+ >
414
+ {text}
415
+ </span>
416
+ </div>
417
+ );
418
+ })}
419
+ </div>
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Fallback drawer body when the host supplies no file/trail viewer: the step's
425
+ * seam — `from → to`, mechanism, site, symbol, and any annotation. A host with a
426
+ * real code viewer passes `renderFileViewer` / `renderTrailViewer` instead.
427
+ */
428
+ function StepDrawerFallback({ step }: { step: SubsystemTrail['steps'][number] }) {
429
+ const { theme } = useTheme();
430
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
431
+ return (
432
+ <div
433
+ style={{
434
+ padding: 12,
435
+ display: 'flex',
436
+ flexDirection: 'column',
437
+ gap: 6,
438
+ fontFamily: theme.fonts.monospace,
439
+ fontSize: theme.fontSizes[0],
440
+ }}
441
+ >
442
+ <div style={{ color: theme.colors.text }}>
443
+ {step.from} → {step.to}
444
+ </div>
445
+ <div style={{ color: muted }}>
446
+ {step.mechanism} · {step.file}:{step.span?.startLine ?? '?'}
447
+ </div>
448
+ {step.symbol && <div style={{ color: muted }}>{step.symbol}</div>}
449
+ {step.annotation && (
450
+ <div style={{ color: theme.colors.text, fontFamily: theme.fonts.body, marginTop: 4 }}>
451
+ {step.annotation}
452
+ </div>
453
+ )}
454
+ </div>
455
+ );
456
+ }
457
+
458
+ /**
459
+ * Construct-edge label overlay — the mechanism chips for the reused subsystem
460
+ * edges.
461
+ *
462
+ * `SubsystemEdge` draws only the line; the component graph renders its mechanism
463
+ * labels in a separate overlay. So the C4 canvas supplies one too, from the ELK
464
+ * label anchors stamped onto each edge's data (`labelX/labelY`).
465
+ */
466
+ function SubsystemEdgeLabels({ edges }: { edges: Edge[] }) {
467
+ const { theme } = useTheme();
468
+ const viewport = useViewport();
469
+ const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
470
+ return (
471
+ <div
472
+ style={{
473
+ position: 'absolute',
474
+ inset: 0,
475
+ overflow: 'hidden',
476
+ pointerEvents: 'none',
477
+ zIndex: 5,
478
+ }}
479
+ >
480
+ {edges.map((e) => {
481
+ const d = e.data as
482
+ | {
483
+ mechanism?: string;
484
+ provenance?: SubsystemEdgeProvenance;
485
+ labelX?: number;
486
+ labelY?: number;
487
+ dimmed?: boolean;
488
+ }
489
+ | undefined;
490
+ if (d?.labelX == null || d?.labelY == null) return null;
491
+ const mechanism = d.mechanism ?? 'uses';
492
+ const text = mechanism;
493
+ // The same hue the component graph gives its mechanism chips, so a verb
494
+ // reads the same colour on either canvas.
495
+ const color = edgeColor({ mechanism, provenance: d.provenance });
496
+ const labelTitle =
497
+ d.provenance === 'graphify'
498
+ ? 'Derived from graphify (static symbol graph)'
499
+ : MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] === false
500
+ ? 'Not directly verifiable with graphify'
501
+ : undefined;
502
+ return (
503
+ <div
504
+ key={e.id}
505
+ title={labelTitle}
506
+ style={{
507
+ position: 'absolute',
508
+ left: d.labelX * viewport.zoom + viewport.x,
509
+ top: d.labelY * viewport.zoom + viewport.y,
510
+ transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
511
+ transformOrigin: 'center center',
512
+ display: 'flex',
513
+ alignItems: 'center',
514
+ justifyContent: 'center',
515
+ width: estimateEdgeLabelWidth(text),
516
+ height: EDGE_LABEL_HEIGHT,
517
+ boxSizing: 'border-box',
518
+ padding: '7px 8px',
519
+ // Fades with its line, on the same beat.
520
+ opacity: d.dimmed === true ? 0.15 : 1,
521
+ transition: 'opacity 220ms ease',
209
522
  }}
210
523
  >
211
524
  <span
@@ -248,20 +561,38 @@ function C4EdgeLabels({
248
561
  * the same `elkPathPoints` this renders. React Flow's built-in edge would curve
249
562
  * between handles while the chip sat on ELK's orthogonal route, which is why
250
563
  * labels read as "off the line".
564
+ *
565
+ * Two strokes: an invisible wide hit path (the drawn line is 1px, hopeless to
566
+ * click) and the visible one. The selection emphasis is a heavier stroke on
567
+ * the same geometry, so the selected line reads without redrawing the route.
251
568
  */
252
- function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?: string }>>) {
569
+ function C4EdgeView({
570
+ data,
571
+ markerEnd,
572
+ }: EdgeProps<Edge<{ path?: string; color?: string; selected?: boolean; dimmed?: boolean }>>) {
253
573
  const path = data?.path ?? '';
254
574
  if (!path) return null;
575
+ const selected = data?.selected === true;
576
+ const dimmed = data?.dimmed === true;
255
577
  return (
256
- <path
257
- d={path}
258
- fill="none"
259
- stroke={data?.color}
260
- strokeWidth={1}
261
- strokeDasharray="6 4"
262
- markerEnd={markerEnd}
263
- style={{ pointerEvents: 'none' }}
264
- />
578
+ <>
579
+ <path
580
+ d={path}
581
+ fill="none"
582
+ stroke="transparent"
583
+ strokeWidth={14}
584
+ style={{ pointerEvents: 'auto', cursor: 'pointer' }}
585
+ />
586
+ <path
587
+ d={path}
588
+ fill="none"
589
+ stroke={data?.color}
590
+ strokeWidth={selected ? 2.5 : 1}
591
+ strokeDasharray="6 4"
592
+ markerEnd={markerEnd}
593
+ style={{ pointerEvents: 'none', opacity: dimmed ? 0.18 : 1, transition: 'opacity 220ms ease' }}
594
+ />
595
+ </>
265
596
  );
266
597
  }
267
598
 
@@ -276,6 +607,12 @@ function C4GroupView(
276
607
  /** FLIP: absolute flow rects — where to grow from, and the frame's own. */
277
608
  growFrom?: FlowRect;
278
609
  frameRect?: FlowRect;
610
+ /** Arriving in this layout — fades in. */
611
+ entering?: boolean;
612
+ /** Gone from this layout — kept for the morph, fades out. */
613
+ leaving?: boolean;
614
+ /** The move/grow waits this long for the fade to finish, in ms. */
615
+ moveDelayMs?: number;
279
616
  }>
280
617
  >,
281
618
  ) {
@@ -284,7 +621,7 @@ function C4GroupView(
284
621
  const isSystem = props.data.kind === 'system';
285
622
  const onCollapse = props.data.onCollapse;
286
623
  const brand = props.data.brand;
287
- const { growFrom, frameRect } = props.data;
624
+ const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data;
288
625
 
289
626
  // Position-only FLIP, same as the cards. The frame resizes through its own
290
627
  // width/height transition below, so scaling here would fight it *and* distort
@@ -292,6 +629,7 @@ function C4GroupView(
292
629
  // label chip's border and text scale with it.
293
630
  const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
294
631
  const sliding = flipping && (dx !== 0 || dy !== 0);
632
+ const fade = useFade(entering, leaving);
295
633
 
296
634
  // A container frame shows its technology mark (React/Bun/Node…) so the
297
635
  // boundary says what it is built with, same as the node it wraps. The system
@@ -310,10 +648,12 @@ function C4GroupView(
310
648
  // Size is always transitioned; the transform only while sliding, and
311
649
  // `transform: none` needs no transition.
312
650
  transition: [
313
- 'width 420ms ease-out',
314
- 'height 420ms ease-out',
315
- ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out`] : []),
651
+ `width ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
652
+ `height ${MORPH_MS}ms ease-out ${moveDelayMs}ms`,
653
+ ...(fade.transition ? [fade.transition] : []),
654
+ ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
316
655
  ].join(', '),
656
+ opacity: fade.opacity,
317
657
  boxSizing: 'border-box',
318
658
  borderRadius: 12,
319
659
  border: `2px dashed ${color}`,
@@ -387,19 +727,68 @@ function C4GroupView(
387
727
  );
388
728
  }
389
729
 
730
+ /**
731
+ * The component graph's own node, wrapped in the C4 canvas's FLIP so a construct
732
+ * glides to its new slot when the layout reflows. On its own it would only mount
733
+ * at the destination — a jump. Position-only, matching the C4 cards.
734
+ */
735
+ const SubsystemNode = SubsystemComponentNode as unknown as ComponentType<NodeProps>;
736
+
737
+ function C4SubsystemNodeView(props: NodeProps) {
738
+ const { growFrom, frameRect, entering, leaving, moveDelayMs = 0 } = props.data as {
739
+ growFrom?: FlowRect;
740
+ frameRect?: FlowRect;
741
+ entering?: boolean;
742
+ leaving?: boolean;
743
+ moveDelayMs?: number;
744
+ };
745
+ const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
746
+ const sliding = flipping && (dx !== 0 || dy !== 0);
747
+ const fade = useFade(entering, leaving);
748
+ const transitions = [
749
+ ...(fade.transition ? [fade.transition] : []),
750
+ ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out ${moveDelayMs}ms`] : []),
751
+ ];
752
+ return (
753
+ <div
754
+ style={{
755
+ transformOrigin: 'top left',
756
+ transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
757
+ transition: transitions.length > 0 ? transitions.join(', ') : 'none',
758
+ opacity: fade.opacity,
759
+ }}
760
+ >
761
+ <SubsystemNode {...props} />
762
+ </div>
763
+ );
764
+ }
765
+
390
766
  const nodeTypes = {
391
767
  'c4-node': C4NodeView,
392
768
  'c4-group': C4GroupView,
769
+ // The component graph's own renderers, reused for the constructs a container
770
+ // hosts. They are context-light (ThemeProvider + a React Flow ancestor), which
771
+ // is why they can ride this canvas instead of a nested one.
772
+ 'subsystem-component': C4SubsystemNodeView as never,
393
773
  };
394
774
  const edgeTypes = {
395
775
  'c4-edge': C4EdgeView,
776
+ 'subsystem-edge': SubsystemEdge as never,
396
777
  };
397
778
 
398
779
  /** Drill-down animation durations, in ms. */
399
780
  const MORPH_MS = 420;
400
781
  const CAMERA_MS = 520;
401
782
  /**
402
- * When to re-frame the camera after a drill-down: the morph, plus a beat.
783
+ * How long cards arriving or leaving take to fade, in ms. The morph is
784
+ * sequenced: the fade runs first, and the surviving cards wait it out before
785
+ * they move and grow (`moveDelayMs` below). So the reveal clock is
786
+ * `FADE_MS + MORPH_MS`, not just `MORPH_MS`.
787
+ */
788
+ const FADE_MS = 200;
789
+ /**
790
+ * When to re-frame the camera after a drill-down: the sequenced morph (fade,
791
+ * then move), plus a beat.
403
792
  *
404
793
  * The extra 60ms is not padding for looks. `fitView` reads the system frame's
405
794
  * *measured* size, and that frame's own height transition runs on the same
@@ -407,7 +796,7 @@ const CAMERA_MS = 520;
407
796
  * height that never existed. The beat is for React Flow's resize observer to
408
797
  * report the final geometry.
409
798
  */
410
- const CAMERA_AFTER_MORPH_MS = MORPH_MS + 60;
799
+ const CAMERA_AFTER_MORPH_MS = FADE_MS + MORPH_MS + 60;
411
800
 
412
801
  /** A fully laid-out open state: the RF nodes/edges and label anchors. */
413
802
  interface Rendered {
@@ -418,10 +807,50 @@ interface Rendered {
418
807
  }
419
808
 
420
809
  /** Flow-space rect of a box node or a frame, for the drill-down morph. */
421
- interface FlowRect { x: number; y: number; width: number; height: number }
810
+ export interface FlowRect { x: number; y: number; width: number; height: number }
422
811
 
423
812
  /** A frame's growth for the drill-down morph: its flow rect before → after. */
424
- interface FrameGrow { from: FlowRect; to: FlowRect }
813
+ export interface FrameGrow { from: FlowRect; to: FlowRect }
814
+
815
+ /**
816
+ * A node's rect in **absolute** flow space, walking the parent chain.
817
+ *
818
+ * `groupBounds` is already absolute; a leaf's `position` is relative to its
819
+ * parent, so each ancestor's origin is added in turn.
820
+ *
821
+ * The FLIP needs absolute rects, not parent-relative ones. React Flow places a
822
+ * node at its new absolute position (parent origin + relative position) and
823
+ * moves children along with the parent's *wrapper* — while our FLIP transform
824
+ * lives on the node's inner div, which the children do not inherit. So a node's
825
+ * own transform must cover its full absolute delta; rebasing it to the parent
826
+ * would leave it short by exactly the parent's movement (the "subtle drift"
827
+ * before a leaving box disappears).
828
+ */
829
+ export function absoluteRectOf(
830
+ layout: {
831
+ nodes: ReadonlyArray<{
832
+ id: string;
833
+ position: { x: number; y: number };
834
+ parentId?: string;
835
+ width?: number | null;
836
+ height?: number | null;
837
+ }>;
838
+ groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
839
+ },
840
+ id: string,
841
+ ): FlowRect | null {
842
+ const frame = layout.groupBounds.get(id);
843
+ if (frame) return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
844
+ const node = layout.nodes.find((n) => n.id === id);
845
+ if (!node) return null;
846
+ const origin = node.parentId ? absoluteRectOf(layout, node.parentId) : null;
847
+ return {
848
+ x: (origin?.x ?? 0) + node.position.x,
849
+ y: (origin?.y ?? 0) + node.position.y,
850
+ width: node.width ?? 0,
851
+ height: node.height ?? 0,
852
+ };
853
+ }
425
854
 
426
855
  /**
427
856
  * Whether committing a layout should also move the camera.
@@ -473,9 +902,9 @@ export function showDescriptionFor(open: boolean | undefined, settled: boolean |
473
902
  * `result.groupBounds` is absolute for a root-level group and *parent-relative*
474
903
  * for a nested one — two spaces in one map, which is the same trap as
475
904
  * `result.nodes`. The opened container is nested (inside the system frame), and
476
- * `containerRect` reads this map before the node box, so an unrebased entry would
477
- * be handed to the morph offset by the frame's origin: the container would glide
478
- * to a place it was never drawn.
905
+ * `absoluteRectOf` reads this map before the node box, so an unrebased entry
906
+ * would be handed to the morph offset by the frame's origin: the container would
907
+ * glide to a place it was never drawn.
479
908
  */
480
909
  export function rebaseGroupBoundsAbsolute(
481
910
  result: Pick<ElkLayoutResult, 'absoluteRects' | 'groupBounds'>,
@@ -509,6 +938,7 @@ export function rebaseGroupBoundsAbsolute(
509
938
  export function c4GroupDefs(
510
939
  model: C4Model,
511
940
  openIds?: readonly string[],
941
+ constructGraphByContainer?: ReadonlyMap<string, C4ConstructGraph>,
512
942
  ): { frames: C4Group[]; groups: CompoundGroupDef[] } {
513
943
  // Normalized here rather than trusted from the caller. This function's output is
514
944
  // cached under the open *set*, so a repeated id would emit the same group twice
@@ -542,10 +972,26 @@ export function c4GroupDefs(
542
972
  const openMembers = model.nodes
543
973
  .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
544
974
  .map((n) => n.id);
545
- if (openMembers.length === 0) continue;
975
+ // The constructs this container hosts, grouped under the authored component
976
+ // that claims each (which grows into its own ELK parent below); a construct
977
+ // no component claims nests directly under the container.
978
+ const cg = constructGraphByContainer?.get(openId);
979
+ const byComponent = new Map<string, string[]>();
980
+ const orphans: string[] = [];
981
+ for (const c of cg?.components ?? []) {
982
+ const owner = cg?.componentOf?.get(c.alias);
983
+ if (owner) {
984
+ const list = byComponent.get(owner) ?? [];
985
+ list.push(c.alias);
986
+ byComponent.set(owner, list);
987
+ } else {
988
+ orphans.push(c.alias);
989
+ }
990
+ }
991
+ if (openMembers.length === 0 && orphans.length === 0) continue;
546
992
  groups.push({
547
993
  id: openId,
548
- memberIds: openMembers,
994
+ memberIds: [...openMembers, ...orphans],
549
995
  // INSIDE the system frame, not beside it. The boundary *is* the system, so
550
996
  // a sibling here would draw the opened container outside the very boundary
551
997
  // it belongs to.
@@ -565,27 +1011,30 @@ export function c4GroupDefs(
565
1011
  // knows which one a bare member id means.
566
1012
  drawnAsCard: true,
567
1013
  });
1014
+ // A component that claims constructs grows into an ELK parent as well, so
1015
+ // its constructs nest inside it (container → component → construct) rather
1016
+ // than sitting beside it.
1017
+ for (const [componentId, memberIds] of byComponent) {
1018
+ if (memberIds.length === 0) continue;
1019
+ groups.push({
1020
+ id: componentId,
1021
+ memberIds,
1022
+ parentId: openId,
1023
+ minWidth: COMPONENT_W,
1024
+ // A component card's header is its label slot + padding; it has no
1025
+ // technology row and no collapse affordance.
1026
+ padTop: COMPONENT_HEADER_PAD,
1027
+ padBottom: COMPONENT_FOOTER_PAD,
1028
+ padX: OPEN_SIDE_PAD,
1029
+ drawnAsCard: true,
1030
+ });
1031
+ }
568
1032
  }
569
1033
  }
570
1034
 
571
1035
  return { frames, groups };
572
1036
  }
573
1037
 
574
- /** The container's rect in a laid-out state: its box node, or its frame bounds. */
575
- function containerRect(layout: Rendered | undefined, id: string): FlowRect | null {
576
- if (!layout) return null;
577
- const frame = layout.groupBounds.get(id);
578
- if (frame) return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
579
- const node = layout.nodes.find((n) => n.id === id);
580
- if (!node) return null;
581
- return {
582
- x: node.position.x,
583
- y: node.position.y,
584
- width: node.width ?? 0,
585
- height: node.height ?? 0,
586
- };
587
- }
588
-
589
1038
  /**
590
1039
  * FLIP between two absolute flow rects: render transformed back onto `from`,
591
1040
  * then release onto `to`.
@@ -621,6 +1070,23 @@ function useFlip(from: FlowRect | undefined, to: FlowRect | undefined) {
621
1070
  };
622
1071
  }
623
1072
 
1073
+ /**
1074
+ * Opacity for a card arriving or leaving the layout. An arriving card mounts
1075
+ * hidden and fades in; a leaving card is kept (out of the new layout) for the
1076
+ * morph, then fades out — a removal reads as a fade, not a pop.
1077
+ */
1078
+ function useFade(entering?: boolean, leaving?: boolean) {
1079
+ const animate = entering === true || leaving === true;
1080
+ const [shown, setShown] = useState(entering !== true);
1081
+ useEffect(() => {
1082
+ if (!animate) return;
1083
+ const raf = requestAnimationFrame(() => setShown(leaving !== true));
1084
+ return () => cancelAnimationFrame(raf);
1085
+ }, [animate, entering, leaving]);
1086
+ if (!animate) return { opacity: 1, transition: undefined as string | undefined };
1087
+ return { opacity: shown ? 1 : 0, transition: `opacity ${FADE_MS}ms ease` };
1088
+ }
1089
+
624
1090
  /**
625
1091
  * Whether a nested component is withheld while the diagram is mid-morph.
626
1092
  *
@@ -646,14 +1112,23 @@ export function hidesComponentDuringMorph(
646
1112
  return morphTargets.has(owner);
647
1113
  }
648
1114
 
649
- function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
650
- const { theme } = useTheme();
651
- const { fitView } = useReactFlow();
1115
+ function Inner({ model, onSelectNode, onSelectEdge, onTrailClick, onToggleContainer, onOpenContainer, onCloseContainer, gutter = 28, dimmedElementIds, dimmedEdgeIds, hiddenElementIds, hiddenEdgeIds, dimmedConstructIds, dimmedConstructEdgeIds, constructGraphByContainer, focusedStep, onCloseStep, renderFileViewer, renderTrailViewer, hideDrawer = false }: C4GraphProps) {
1116
+ const { theme, mode } = useTheme();
1117
+ const { fitView, fitBounds } = useReactFlow();
652
1118
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
653
1119
  const [nodes, setNodes] = useState<Node[]>([]);
1120
+ /** Latest rendered nodes, for the step-focus camera fit without re-firing on
1121
+ * every layout change. */
1122
+ const nodesRef = useRef<Node[]>([]);
1123
+ nodesRef.current = nodes;
654
1124
  const [rfEdges, setRfEdges] = useState<Edge[]>([]);
655
1125
  const [ready, setReady] = useState(false);
656
1126
  const [selectedId, setSelectedId] = useState<string | null>(null);
1127
+ const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
1128
+ // The construct (subsystem component alias) whose functionality (declaration
1129
+ // + purpose) is open. Its own panel, so opening a construct never disturbs the
1130
+ // node/edge inspector.
1131
+ const [selectedConstructAlias, setSelectedConstructAlias] = useState<string | null>(null);
657
1132
  const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
658
1133
  () => new Map(),
659
1134
  );
@@ -682,7 +1157,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
682
1157
  if (cache.size <= LAYOUT_CACHE_MAX) break;
683
1158
  // Never evict what is on screen: a re-layout of the current state mid-morph
684
1159
  // would swap the geometry out from under the transition.
685
- if (oldest === openStateKey(openIds)) continue;
1160
+ if (oldest === currentCacheKeyRef.current) continue;
686
1161
  cache.delete(oldest);
687
1162
  }
688
1163
  };
@@ -704,12 +1179,29 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
704
1179
  const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
705
1180
  /** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
706
1181
  const cameraTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
1182
+ /** Drops cards that were kept only to fade out. */
1183
+ const leaverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
1184
+ /**
1185
+ * The dim set of the *previous* render, read when a card starts leaving so it
1186
+ * fades from the opacity it actually had. A hover preview dims the non-cast
1187
+ * boxes; clicking the trail clears the hover, so without this a leaving box
1188
+ * would brighten to full before fading.
1189
+ */
1190
+ const prevDimmedRef = useRef<ReadonlySet<string> | null>(null);
707
1191
  /**
708
1192
  * The open set rendered last, so a change can morph between the two layouts.
709
1193
  * The array, not just its key, because the morph needs to know *which*
710
1194
  * containers changed — see `morphTargetsRef`.
711
1195
  */
712
1196
  const prevOpenRef = useRef<string[]>([]);
1197
+ /**
1198
+ * The layout-cache key rendered last, and the one on screen now. The key
1199
+ * carries the construct counts too (a card's box grows with its rows), so a
1200
+ * focus change that keeps the same containers open still misses the cache and
1201
+ * recomputes — and the morph reads the previous entry by its own key.
1202
+ */
1203
+ const prevCacheKeyRef = useRef('');
1204
+ const currentCacheKeyRef = useRef('');
713
1205
  /**
714
1206
  * Containers whose open state is changing *right now* — the symmetric
715
1207
  * difference of the previous and next open sets.
@@ -787,16 +1279,44 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
787
1279
  const openIds = useMemo(() => openContainerSet(model), [model]);
788
1280
  const openKey = openStateKey(openIds);
789
1281
 
790
- const layoutKey = useMemo(
1282
+ // The constructs a container hosts change its layout, so a focus shift that
1283
+ // swaps them (same containers open) must recompute — keyed here or the stale
1284
+ // box would stand.
1285
+ const constructKey = useMemo(
1286
+ () =>
1287
+ constructGraphByContainer
1288
+ ? [...constructGraphByContainer.entries()]
1289
+ .map(
1290
+ ([id, cg]) =>
1291
+ `${id}\0${cg.components.map((c) => c.alias).sort().join(',')}`,
1292
+ )
1293
+ .sort()
1294
+ .join('\n')
1295
+ : '',
1296
+ [constructGraphByContainer],
1297
+ );
1298
+
1299
+ // The model's own shape — which boxes and lines exist. Part of the cache key
1300
+ // so a host that filters the model (e.g. down to a focused trail's cast)
1301
+ // re-lays-out instead of hitting a stale layout for the same open set.
1302
+ const modelKey = useMemo(
791
1303
  () =>
792
1304
  [
793
1305
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
794
1306
  ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
795
- `open:${openKey}`,
796
1307
  ].join('\n'),
797
- [model, openKey],
1308
+ [model],
1309
+ );
1310
+
1311
+ const layoutKey = useMemo(
1312
+ () => `${modelKey}\nopen:${openKey}\nconstructs:${constructKey}`,
1313
+ [modelKey, openKey, constructKey],
798
1314
  );
799
1315
 
1316
+ /** The identity of one laid-out state — open set + constructs + model shape. */
1317
+ const stateKey = (ids: readonly string[]) =>
1318
+ `${openStateKey(ids)}|${constructKey}|${modelKey}`;
1319
+
800
1320
  /**
801
1321
  * Build the React Flow inputs for a given open state — everything ELK needs,
802
1322
  * independent of whether a layout has been run yet. Pure over `openIds`, so it
@@ -822,6 +1342,21 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
822
1342
  if (parent) childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
823
1343
  }
824
1344
 
1345
+ // Authored components that claim constructs grow into ELK parents too, so
1346
+ // they are drawn as grown cards (absolute box, no description) exactly like
1347
+ // an opened container.
1348
+ const groupedComponentIds = new Set<string>();
1349
+ if (model.view === 'container') {
1350
+ for (const openId of openIds) {
1351
+ const cg = constructGraphByContainer?.get(openId);
1352
+ if (!cg) continue;
1353
+ for (const c of cg.components) {
1354
+ const owner = cg.componentOf?.get(c.alias);
1355
+ if (owner) groupedComponentIds.add(owner);
1356
+ }
1357
+ }
1358
+ }
1359
+
825
1360
  const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
826
1361
  const size = nodeSize(n);
827
1362
  // A component of an opened container nests under *its own* container (a
@@ -853,11 +1388,44 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
853
1388
  ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && canToggle
854
1389
  ? { onExpand: () => toggle(n.id), open: opened.has(n.id) }
855
1390
  : {}),
1391
+ // A component grown to hold its constructs is a drawn-as-card group:
1392
+ // no description, absolute box, sized by ELK.
1393
+ ...(n.kind === 'component' && groupedComponentIds.has(n.id) ? { open: true } : {}),
856
1394
  },
857
1395
  };
858
1396
  });
859
1397
 
860
- const { frames, groups } = c4GroupDefs(model, openIds);
1398
+ // Constructs: the component graph's own nodes and edges, nested inside their
1399
+ // opened container (under the authored component that claims each). Built
1400
+ // per container so an edge never crosses containers — a construct edge that
1401
+ // would is left to the C4 container-level line.
1402
+ const constructEdges: Edge[] = [];
1403
+ if (model.view === 'container') {
1404
+ for (const openId of openIds) {
1405
+ const cg = constructGraphByContainer?.get(openId);
1406
+ if (!cg || cg.components.length === 0) continue;
1407
+ const subNodes = convertSubsystemToNodes({
1408
+ components: [...cg.components],
1409
+ trails: [...cg.trails],
1410
+ });
1411
+ for (const sn of subNodes) {
1412
+ const owner = cg.componentOf?.get(sn.id) ?? openId;
1413
+ rfNodes.push({
1414
+ ...sn,
1415
+ parentId: owner,
1416
+ zIndex: 3,
1417
+ // `c4Owner` is the container this construct belongs to, read by the
1418
+ // morph/dim pass; it is not part of the node's own render.
1419
+ data: { ...sn.data, c4Owner: openId },
1420
+ });
1421
+ }
1422
+ constructEdges.push(
1423
+ ...convertSubsystemToEdges({ components: [...cg.components], trails: [...cg.trails] }),
1424
+ );
1425
+ }
1426
+ }
1427
+
1428
+ const { frames, groups } = c4GroupDefs(model, openIds, constructGraphByContainer);
861
1429
 
862
1430
  // Bands come from the *frames* only. The opened container is an ELK parent,
863
1431
  // not a band — treating it as one would push it into its own layer, away from
@@ -888,6 +1456,12 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
888
1456
  data: { edge: e, color, path: '' },
889
1457
  };
890
1458
  });
1459
+ // Construct edges attach only when both endpoints are drawn — a step to an
1460
+ // external, or to a construct in another container, is dropped here and left
1461
+ // to the C4 container-level line.
1462
+ for (const e of constructEdges) {
1463
+ if (drawnIds.has(e.source) && drawnIds.has(e.target)) rfEdges.push(e);
1464
+ }
891
1465
 
892
1466
  return { rfNodes, rfEdges, groups, partitionByNode, frames };
893
1467
  };
@@ -936,20 +1510,30 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
936
1510
  const withPaths = rfEdges.map((e) => {
937
1511
  const pts = result.edgePathPoints?.get(e.id);
938
1512
  if (!pts || pts.length === 0) return e;
1513
+ const path = pointsToSmoothPath(pts, 12);
1514
+ // The component graph's edge renderer reads `elkPath` (and its label
1515
+ // overlay reads `labelX/labelY`); the C4 renderer reads `path`. Set each
1516
+ // so the right renderer draws its own route and its own label anchor.
1517
+ if (e.type === 'subsystem-edge') {
1518
+ const mid = pointAlongPath(pts, 0.5);
1519
+ return { ...e, data: { ...e.data, elkPath: path, labelX: mid.x, labelY: mid.y } };
1520
+ }
939
1521
  labelPositions.set(e.id, pointAlongPath(pts, 0.5));
940
- return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
1522
+ return { ...e, data: { ...e.data, path } };
941
1523
  });
942
1524
 
943
1525
  // `result.groupBounds` mixes coordinate spaces; everything downstream here
944
1526
  // reads it as absolute, so normalize it once at the boundary.
945
1527
  const groupBounds = rebaseGroupBoundsAbsolute(result);
946
1528
  const builtGroups = new Set(groupBounds.keys());
947
- // The opened containers come back from ELK as parents, so they land in
948
- // `groupBounds` — but each is drawn as a grown *card*, not a frame. Drop them
949
- // all from the shells; keep them in `groupBounds` so their nested children can
1529
+ // Every group drawn as a grown *card* (opened containers, and components
1530
+ // grown to hold their constructs) comes back from ELK as a parent, so it
1531
+ // lands in `groupBounds` — but it is drawn as a card, not a frame. Drop each
1532
+ // from the shells; keep them in `groupBounds` so their nested children can
950
1533
  // take their coordinates relative to them.
1534
+ const drawnAsCardIds = new Set(groups.filter((g) => g.drawnAsCard).map((g) => g.id));
951
1535
  const shellBounds = new Map(groupBounds);
952
- for (const openId of openIds) shellBounds.delete(openId);
1536
+ for (const id of drawnAsCardIds) shellBounds.delete(id);
953
1537
 
954
1538
  const groupById = new Map(frames.map((g) => [g.id, g]));
955
1539
  const depthOf = (id: string): number => {
@@ -1013,28 +1597,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1013
1597
  });
1014
1598
 
1015
1599
  const leaves = rfNodes.map((n) => {
1600
+ // The parent is whatever `buildInputs` already stamped on the node — a
1601
+ // component's container, or a construct's component/container — kept only
1602
+ // when ELK actually built that group (a closed container is not one). This
1603
+ // covers the construct nodes too, whose data carries no `element`.
1016
1604
  let parentId: string | undefined;
1017
- if (n.data && typeof n.data === 'object') {
1018
- const modelNode = (n.data as { element?: C4Element }).element;
1019
- // A component nests under its own container when that container is open —
1020
- // the sibling containers and out-of-boundary nodes in the same layout must
1021
- // not. Reads the component's own owner rather than a single open id,
1022
- // because several containers can be open at once.
1023
- const owner =
1024
- modelNode?.kind === 'component' ? (modelNode.container ?? modelNode.parentId) : undefined;
1025
- const nested = owner && opened.has(owner) ? owner : undefined;
1026
- const candidate = nested ?? modelNode?.parentId;
1027
- if (candidate && builtGroups.has(candidate)) parentId = candidate;
1028
- }
1605
+ if (n.parentId && builtGroups.has(n.parentId)) parentId = n.parentId;
1029
1606
  // `flowPositionOf` covers every node ELK placed; the fallback is for the
1030
1607
  // pathological case of a node ELK did not return at all.
1031
1608
  const abs =
1032
1609
  flowPositionOf(result, n.id, parentId) ??
1033
1610
  positioned.get(n.id)?.position ??
1034
1611
  n.position;
1035
- // An opened container is an ELK parent, so its box is whatever ELK fitted
1036
- // to the nested components — `nodeSize` only knows the closed card.
1037
- const fitted = opened.has(n.id) ? groupBounds.get(n.id) : undefined;
1612
+ // A grown card (an opened container, or a component holding its
1613
+ // constructs) is an ELK parent, so its box is whatever ELK fitted to its
1614
+ // nested children — `nodeSize` only knows the closed card.
1615
+ const fitted = drawnAsCardIds.has(n.id) ? groupBounds.get(n.id) : undefined;
1038
1616
  const node = {
1039
1617
  ...n,
1040
1618
  position: { x: abs.x, y: abs.y },
@@ -1056,17 +1634,40 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1056
1634
  if (!grows || grows.size === 0) return nodes;
1057
1635
  return nodes.map((n) => {
1058
1636
  const g = grows.get(n.id);
1059
- // A child is hidden until the morph settles, and its absolute position also
1060
- // moves with its parent — one transform on the parent covers both.
1061
- if (!g || n.parentId) return n;
1062
- return n.type === 'c4-group' || n.type === 'c4-node'
1063
- ? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
1064
- : n;
1637
+ if (!g) return n;
1638
+ if (n.type !== 'c4-group' && n.type !== 'c4-node' && n.type !== 'subsystem-component') {
1639
+ return n;
1640
+ }
1641
+ // Absolute rects for every node — root or nested. `useFlip` turns them into
1642
+ // the absolute delta the node's inner div must travel; React Flow has
1643
+ // already moved the wrapper (and its children) to the new absolute slot.
1644
+ return { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } };
1065
1645
  });
1066
1646
  };
1067
1647
 
1068
- const renderLayout = (r: Rendered, grows: Map<string, FrameGrow> | null) => {
1069
- setNodes(injectGrow(r.nodes, grows));
1648
+ const renderLayout = (
1649
+ r: Rendered,
1650
+ grows: Map<string, FrameGrow> | null,
1651
+ morph?: { prevNodeIds: Set<string>; leavers: Node[]; moveDelayMs: number },
1652
+ ) => {
1653
+ // Mark cards that were not in the previous layout so they fade in; keep the
1654
+ // previous layout's orphans (leavers) so they fade out instead of popping.
1655
+ // Every card also carries the move delay, so the survivors' glide and grow
1656
+ // wait for the fade to finish (the sequenced morph).
1657
+ const moveDelayMs = morph?.moveDelayMs ?? 0;
1658
+ const marked = morph
1659
+ ? r.nodes.map((n) => {
1660
+ const base = morph.prevNodeIds.has(n.id)
1661
+ ? n
1662
+ : { ...n, data: { ...n.data, entering: true } };
1663
+ return { ...base, data: { ...base.data, moveDelayMs } };
1664
+ })
1665
+ : r.nodes;
1666
+ const leavers = (morph?.leavers ?? []).map((n) => ({
1667
+ ...n,
1668
+ data: { ...n.data, moveDelayMs },
1669
+ }));
1670
+ setNodes([...injectGrow(marked, grows), ...leavers]);
1070
1671
  setRfEdges(r.edges);
1071
1672
  setLabelPositions(r.labelPositions);
1072
1673
  setReady(true);
@@ -1077,33 +1678,34 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1077
1678
  // (instant swap), else computes it now.
1078
1679
  useEffect(() => {
1079
1680
  let alive = true;
1080
- setReady(false);
1681
+ // Blank the canvas only for the very first paint. A relayout keeps the
1682
+ // previous layout on screen until the new one lands — otherwise every trail
1683
+ // pick (which changes the open set and the construct graph, so the cache
1684
+ // misses) flashed the "Laying out…" overlay.
1685
+ if (!paintedRef.current) setReady(false);
1081
1686
  setSelectedId(null);
1082
1687
  const openKeyNow = openStateKey(openIds);
1688
+ const cacheKeyNow = stateKey(openIds);
1689
+ currentCacheKeyRef.current = cacheKeyNow;
1083
1690
  const prevOpen = prevOpenRef.current;
1691
+ const prevCacheKey = prevCacheKeyRef.current;
1692
+ // Captured now, not inside `applyLayout`: the layout may compute async, and
1693
+ // the tracking effect below clears the ref before that runs. This is the dim
1694
+ // set of the render just gone (the hover preview).
1695
+ const prevDimmed = prevDimmedRef.current;
1084
1696
  const inputs = buildInputs(openIds);
1085
1697
 
1086
- // Only a real open-state change animates; the first paint is already at rest.
1698
+ // The containers whose open state changed *this* tick — the only ones whose
1699
+ // components genuinely arrive or leave. Recorded as a ref rather than state:
1700
+ // it is read during the next render (to decide what to hide) but must not
1701
+ // itself schedule a render.
1087
1702
  if (paintedRef.current && openStateKey(prevOpen) !== openKeyNow) {
1088
- // The containers in one set and not the other are the ones whose components
1089
- // actually arrive or leave. Recorded as a ref rather than state: it is read
1090
- // during the next render (to decide what to hide) but must not itself
1091
- // schedule a render.
1092
1703
  const before = new Set(prevOpen);
1093
1704
  const after = new Set(openIds);
1094
1705
  morphTargetsRef.current = new Set([
1095
1706
  ...prevOpen.filter((id) => !after.has(id)),
1096
1707
  ...openIds.filter((id) => !before.has(id)),
1097
1708
  ]);
1098
- setSettled(false);
1099
- if (settleTimer.current) clearTimeout(settleTimer.current);
1100
- settleTimer.current = setTimeout(() => {
1101
- setSettled(true);
1102
- // The morph is over. Clearing the targets matters: an unrelated re-render
1103
- // afterwards must not find a container still marked mid-morph and hide
1104
- // components that are merely sitting still.
1105
- morphTargetsRef.current = new Set();
1106
- }, MORPH_MS);
1107
1709
  // Re-frame once the grow has landed, not while it is running. The drill-down
1108
1710
  // more than doubles the boundary, so the extent the camera was fitted to no
1109
1711
  // longer contains the content — but moving *during* the morph compounds with
@@ -1112,6 +1714,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1112
1714
  // out the morph is the honest way to get both.
1113
1715
  if (cameraTimer.current) clearTimeout(cameraTimer.current);
1114
1716
  cameraTimer.current = setTimeout(fitSystem, CAMERA_AFTER_MORPH_MS);
1717
+ } else {
1718
+ morphTargetsRef.current = new Set();
1115
1719
  }
1116
1720
  paintedRef.current = true;
1117
1721
 
@@ -1120,28 +1724,28 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1120
1724
  * boundaries *and* cards grow/shrink and slide together. Empty when nothing
1121
1725
  * changed (e.g. the initial paint).
1122
1726
  *
1123
- * Frames alone are not enough: ELK re-lays out the whole diagram, so every
1124
- * root card moves too. Without a card in this set it keeps its new slot the
1125
- * instant the layout swaps — a teleport, which is the most visible part of a
1126
- * bad morph. Child nodes are excluded: a transform on them would only fight
1127
- * the parent's, which carries the same movement.
1727
+ * This covers *nested* cards too (a container inside the system frame, a
1728
+ * component inside a grown container). They are React Flow children, so the
1729
+ * FLIP rects are rebased to their parent's origin in `injectGrow` — a
1730
+ * transform on a child then composes with the parent's instead of fighting
1731
+ * it. Without this, filtering the model to a trail's cast reflowed the
1732
+ * surviving containers and they teleported to their new slots.
1128
1733
  */
1129
1734
  const growsFor = (next: Rendered): Map<string, FrameGrow> | null => {
1130
- if (openStateKey(prevOpen) === openKeyNow) return null;
1131
- const prevLayout = layoutCache.current.get(openStateKey(prevOpen));
1735
+ if (!paintedRef.current) return null;
1736
+ const prevLayout = layoutCache.current.get(prevCacheKey);
1132
1737
  if (!prevLayout) return null;
1133
1738
  const grows = new Map<string, FrameGrow>();
1134
- const rootCardIds = (l: Rendered) =>
1135
- l.nodes.filter((n) => !n.parentId).map((n) => n.id);
1739
+ const cardIds = (l: Rendered) => l.nodes.map((n) => n.id);
1136
1740
  const ids = new Set([
1137
1741
  ...prevLayout.groupBounds.keys(),
1138
1742
  ...next.groupBounds.keys(),
1139
- ...rootCardIds(prevLayout),
1140
- ...rootCardIds(next),
1743
+ ...cardIds(prevLayout),
1744
+ ...cardIds(next),
1141
1745
  ]);
1142
1746
  for (const id of ids) {
1143
- const fromRect = containerRect(prevLayout, id);
1144
- const to = containerRect(next, id);
1747
+ const fromRect = absoluteRectOf(prevLayout, id);
1748
+ const to = absoluteRectOf(next, id);
1145
1749
  if (!fromRect || !to) continue;
1146
1750
  if (
1147
1751
  fromRect.x === to.x &&
@@ -1156,10 +1760,77 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1156
1760
  return grows.size > 0 ? grows : null;
1157
1761
  };
1158
1762
 
1159
- const cached = layoutCache.current.get(openKeyNow);
1160
- if (cached) {
1763
+ // Commit a new layout: keep the survivors gliding (FLIP), fade in cards the
1764
+ // previous layout did not have, and hold the previous layout's orphans for
1765
+ // one morph so they fade out rather than pop. Then advance the refs.
1766
+ const applyLayout = (r: Rendered) => {
1767
+ const prevLayout = layoutCache.current.get(prevCacheKey);
1768
+ const span = growsFor(r);
1769
+ const prevNodeIds = new Set((prevLayout?.nodes ?? []).map((n) => n.id));
1770
+ const nextIds = new Set(r.nodes.map((n) => n.id));
1771
+ // A leaving card is rendered as a *root* node pinned to its old absolute
1772
+ // rect. Nested under its old parent it would ride that parent's React Flow
1773
+ // wrapper — which moves the instant the new layout commits — and drift
1774
+ // before it faded. As a root it stays put and just fades.
1775
+ const leavers: Node[] = prevLayout
1776
+ ? prevLayout.nodes
1777
+ .filter((n) => !nextIds.has(n.id))
1778
+ .map((n) => {
1779
+ const rect = absoluteRectOf(prevLayout, n.id);
1780
+ // Keep the dim it had while hovered, so the fade starts from the
1781
+ // faded opacity instead of flashing back to full first.
1782
+ const el = (n.data as { element?: C4Element } | undefined)?.element;
1783
+ const wasDimmed = el ? prevDimmed?.has(el.id) === true : false;
1784
+ return {
1785
+ ...n,
1786
+ parentId: undefined,
1787
+ position: rect ? { x: rect.x, y: rect.y } : n.position,
1788
+ ...(rect ? { width: rect.width, height: rect.height } : {}),
1789
+ data: { ...n.data, leaving: true, ...(wasDimmed ? { dimmed: true } : {}) },
1790
+ };
1791
+ })
1792
+ : [];
1793
+ // Only *root* fades (a container arriving or leaving) sequence the move.
1794
+ // Nested cards (components, constructs) are withheld by the morph gate
1795
+ // while their container grows, so their entering/leaving is not a fade the
1796
+ // reader sees — delaying the grow for them would just add a pause.
1797
+ const rootEntering = prevLayout
1798
+ ? r.nodes.filter((n) => !n.parentId && !prevNodeIds.has(n.id)).length
1799
+ : 0;
1800
+ const rootLeaving = leavers.filter((n) => !n.parentId).length;
1801
+ const moveDelayMs = rootEntering > 0 || rootLeaving > 0 ? FADE_MS : 0;
1802
+ // The edges are withheld for the whole sequenced morph — fade, then move,
1803
+ // then content. The morphing containers' components are withheld too (see
1804
+ // `morphTargetsRef`).
1805
+ if (span) {
1806
+ setSettled(false);
1807
+ if (settleTimer.current) clearTimeout(settleTimer.current);
1808
+ settleTimer.current = setTimeout(() => {
1809
+ setSettled(true);
1810
+ morphTargetsRef.current = new Set();
1811
+ }, moveDelayMs + MORPH_MS);
1812
+ }
1813
+ if (leaverTimer.current) clearTimeout(leaverTimer.current);
1814
+ if (leavers.length > 0) {
1815
+ const leavingIds = new Set(leavers.map((n) => n.id));
1816
+ // A leaver only fades; it does not move, so it is dropped when the fade
1817
+ // ends, just before the survivors start to glide.
1818
+ leaverTimer.current = setTimeout(() => {
1819
+ setNodes((prev) => prev.filter((n) => !leavingIds.has(n.id)));
1820
+ }, FADE_MS + 40);
1821
+ }
1161
1822
  prevOpenRef.current = openIds;
1162
- renderLayout(cached, growsFor(cached));
1823
+ prevCacheKeyRef.current = cacheKeyNow;
1824
+ renderLayout(
1825
+ r,
1826
+ span,
1827
+ prevLayout ? { prevNodeIds, leavers, moveDelayMs } : undefined,
1828
+ );
1829
+ };
1830
+
1831
+ const cached = layoutCache.current.get(cacheKeyNow);
1832
+ if (cached) {
1833
+ applyLayout(cached);
1163
1834
  return () => {
1164
1835
  alive = false;
1165
1836
  };
@@ -1167,10 +1838,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1167
1838
  void layoutFor(openIds, inputs)
1168
1839
  .then((r) => {
1169
1840
  if (!alive) return;
1170
- cacheLayout(openKeyNow, r);
1171
- const span = growsFor(r);
1172
- prevOpenRef.current = openIds;
1173
- renderLayout(r, span);
1841
+ cacheLayout(cacheKeyNow, r);
1842
+ applyLayout(r);
1174
1843
  })
1175
1844
  .catch((err) => {
1176
1845
  if (!alive) return;
@@ -1194,10 +1863,22 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1194
1863
  clearTimeout(cameraTimer.current);
1195
1864
  cameraTimer.current = null;
1196
1865
  }
1866
+ if (leaverTimer.current) {
1867
+ clearTimeout(leaverTimer.current);
1868
+ leaverTimer.current = null;
1869
+ }
1197
1870
  };
1198
1871
  // eslint-disable-next-line react-hooks/exhaustive-deps
1199
1872
  }, [layoutKey]);
1200
1873
 
1874
+ // Remember this render's dim set for the *next* commit's leavers. Declared
1875
+ // after the layout effect so it runs after it: the layout effect reads the
1876
+ // value the previous render left here (a hover dim), before this overwrites
1877
+ // it with the now-cleared set.
1878
+ useEffect(() => {
1879
+ prevDimmedRef.current = dimmedElementIds ?? null;
1880
+ });
1881
+
1201
1882
  // After the first paint, prefetch the opened layout for every container, so a
1202
1883
  // click swaps to a cached result instead of waiting on ELK.
1203
1884
  useEffect(() => {
@@ -1222,7 +1903,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1222
1903
  void (async () => {
1223
1904
  for (const target of targets) {
1224
1905
  if (!alive) return;
1225
- const key = openStateKey(target);
1906
+ const key = stateKey(target);
1226
1907
  if (layoutCache.current.has(key)) continue;
1227
1908
  try {
1228
1909
  const r = await layoutFor(target, buildInputs(target));
@@ -1239,11 +1920,93 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1239
1920
  // eslint-disable-next-line react-hooks/exhaustive-deps
1240
1921
  }, [layoutKey, model.view]);
1241
1922
 
1923
+ /** Open a construct's functionality, or close it when re-clicked. */
1924
+ const selectConstruct = useCallback((alias: string) => {
1925
+ setSelectedConstructAlias((cur) => (cur === alias ? null : alias));
1926
+ }, []);
1927
+
1928
+ // Scoped to this canvas so the reused component nodes dispatch into this
1929
+ // graph's selection instead of a mounted component graph's module callbacks.
1930
+ const subsystemCallbacks = useMemo(() => ({ onSelect: selectConstruct }), [selectConstruct]);
1931
+
1242
1932
  const selected = useMemo(
1243
1933
  () => model.nodes.find((n) => n.id === selectedId) ?? null,
1244
1934
  [model, selectedId],
1245
1935
  );
1246
1936
 
1937
+ /** The rolled-up line under selection, when it is an edge. */
1938
+ const selectedEdge = useMemo(
1939
+ () => model.edges.find((e) => e.id === selectedEdgeId) ?? null,
1940
+ [model, selectedEdgeId],
1941
+ );
1942
+
1943
+ /**
1944
+ * The step under persistent focus, resolved from the construct graph's trails
1945
+ * (the drawer shows its implementation). The host owns the selection.
1946
+ */
1947
+ const focusedStepInfo = useMemo(() => {
1948
+ if (!focusedStep) return null;
1949
+ for (const cg of constructGraphByContainer?.values() ?? []) {
1950
+ const trail = cg.trails.find((t) => t.id === focusedStep.trailId);
1951
+ if (!trail) continue;
1952
+ const step = trail.steps[focusedStep.stepIndex];
1953
+ if (!step) continue;
1954
+ return { trail, step, stepIndex: focusedStep.stepIndex };
1955
+ }
1956
+ return null;
1957
+ }, [focusedStep, constructGraphByContainer]);
1958
+
1959
+ // Frame the focused step's constructs, the way the component graph frames a
1960
+ // focused step's edge: box the `from`/`to` node rects (absolute) and fit.
1961
+ // Waits out the drawer's height transition so the fit uses the reduced canvas.
1962
+ useEffect(() => {
1963
+ if (!focusedStepInfo) return;
1964
+ const rects: FlowRect[] = [];
1965
+ for (const id of [focusedStepInfo.step.from, focusedStepInfo.step.to]) {
1966
+ const r = absoluteRectOf({ nodes: nodesRef.current, groupBounds: new Map() }, id);
1967
+ if (r) rects.push(r);
1968
+ }
1969
+ if (rects.length === 0) return;
1970
+ const minX = Math.min(...rects.map((r) => r.x));
1971
+ const minY = Math.min(...rects.map((r) => r.y));
1972
+ const maxX = Math.max(...rects.map((r) => r.x + r.width));
1973
+ const maxY = Math.max(...rects.map((r) => r.y + r.height));
1974
+ const bounds = {
1975
+ x: minX,
1976
+ y: minY,
1977
+ width: Math.max(1, maxX - minX),
1978
+ height: Math.max(1, maxY - minY),
1979
+ };
1980
+ const timer = setTimeout(() => {
1981
+ requestAnimationFrame(() => fitBounds(bounds, { padding: 0.2, duration: MORPH_MS }));
1982
+ }, FILE_DRAWER_HEIGHT_MS + 20);
1983
+ return () => clearTimeout(timer);
1984
+ // `nodesRef` is a ref; `settled` re-fires once the constructs have landed.
1985
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1986
+ }, [focusedStepInfo, settled, fitBounds]);
1987
+
1988
+ /**
1989
+ * The construct under selection, folded to what the functionality panel
1990
+ * shows. Resolved across every container's construct graph (aliases are unique
1991
+ * in the document, so at most one matches).
1992
+ */
1993
+ const selectedConstruct = useMemo(() => {
1994
+ if (!selectedConstructAlias) return null;
1995
+ for (const cg of constructGraphByContainer?.values() ?? []) {
1996
+ const c = cg.components.find((x) => x.alias === selectedConstructAlias);
1997
+ if (!c) continue;
1998
+ return {
1999
+ component: c,
2000
+ name: deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype),
2001
+ badge: constructBadgeLabel(c),
2002
+ color: componentColor(c, resolvePierreSyntaxThemeName(mode)),
2003
+ role: rightBadgeLabel(c),
2004
+ declaration: generateDeclarationString(c),
2005
+ };
2006
+ }
2007
+ return null;
2008
+ }, [selectedConstructAlias, constructGraphByContainer, mode]);
2009
+
1247
2010
  /**
1248
2011
  * The components authored inside the selected element, if it is a container.
1249
2012
  * These are `C4Component`s whose `container` names the selected id (or, once
@@ -1254,7 +2017,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1254
2017
  () =>
1255
2018
  selected
1256
2019
  ? model.nodes.filter(
1257
- (n) =>
2020
+ (n): n is C4Component =>
1258
2021
  n.kind === 'component' &&
1259
2022
  (n.container === selected.id || n.parentId === selected.id),
1260
2023
  )
@@ -1262,33 +2025,93 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1262
2025
  [model, selected],
1263
2026
  );
1264
2027
 
2028
+ /**
2029
+ * A selected container's member aliases, resolved from the construct graph
2030
+ * (a container's members are derived, not stored — see `C4ElementBase`). Only
2031
+ * populated when the container hosts focused constructs.
2032
+ */
2033
+ const selectedContainerMembers = useMemo(
2034
+ () =>
2035
+ selected?.kind === 'container'
2036
+ ? (constructGraphByContainer?.get(selected.id)?.components ?? []).map((c) => c.alias)
2037
+ : [],
2038
+ [selected, constructGraphByContainer],
2039
+ );
2040
+
1265
2041
  /** The containers open for drill-down, normalized. */
1266
2042
  const openContainerIds = useMemo(() => new Set(openIds), [openIds]);
1267
2043
 
1268
2044
  const displayNodes = useMemo(
1269
2045
  () =>
1270
2046
  nodes.map((n) => {
2047
+ // A card kept only to fade out is never hidden or dimmed — it is on its
2048
+ // way off screen and must stay visible for the fade.
2049
+ if ((n.data as { leaving?: boolean } | undefined)?.leaving) return n;
2050
+ // Constructs are the component graph's own nodes. They ride the morph
2051
+ // gate of the container that hosts them (they arrive with its grow) and
2052
+ // dim with it; the node reads `isSelected` from data because it stops
2053
+ // its own click propagation.
2054
+ if (n.type === 'subsystem-component') {
2055
+ const owner = (n.data as { c4Owner?: string } | undefined)?.c4Owner;
2056
+ const hidden = hidesComponentDuringMorph(
2057
+ owner,
2058
+ openContainerIds,
2059
+ morphTargetsRef.current,
2060
+ settled,
2061
+ );
2062
+ // Dim with its container, or on its own when the host spotlights a
2063
+ // step's constructs (the component graph's per-component behaviour).
2064
+ const ownerDimmed = dimmedElementIds && owner ? dimmedElementIds.has(owner) : false;
2065
+ const stepDimmed = dimmedConstructIds?.has(n.id) === true;
2066
+ return {
2067
+ ...n,
2068
+ ...(hidden ? { hidden: true } : {}),
2069
+ data: {
2070
+ ...n.data,
2071
+ dimmed: ownerDimmed || stepDimmed,
2072
+ isSelected: n.id === selectedConstructAlias,
2073
+ },
2074
+ };
2075
+ }
1271
2076
  if (n.type !== 'c4-node') return n;
1272
2077
  const element = (n.data as { element?: C4Element } | undefined)?.element;
1273
2078
  const owner = element?.kind === 'component' ? (element.container ?? element.parentId) : undefined;
1274
- // `hidesComponentDuringMorph` owns the rule; see there for why it is
1275
- // per-container rather than global.
1276
- const hidden = hidesComponentDuringMorph(
1277
- owner,
1278
- openContainerIds,
1279
- morphTargetsRef.current,
1280
- settled,
1281
- );
2079
+ // `hidesComponentDuringMorph` owns the morph rule; see there for why it
2080
+ // is per-container rather than global. A host `hiddenElementIds` (the
2081
+ // focused-trail cast) removes the box outright.
2082
+ const hidden =
2083
+ hidesComponentDuringMorph(
2084
+ owner,
2085
+ openContainerIds,
2086
+ morphTargetsRef.current,
2087
+ settled,
2088
+ ) || (element ? hiddenElementIds?.has(element.id) === true : false);
1282
2089
  return {
1283
2090
  ...n,
1284
2091
  ...(hidden ? { hidden: true } : {}),
1285
- data: { ...n.data, selected: n.id === selectedId, settled },
2092
+ data: {
2093
+ ...n.data,
2094
+ selected: n.id === selectedId,
2095
+ settled,
2096
+ dimmed: dimmedElementIds
2097
+ ? (element ? dimmedElementIds.has(element.id) : false)
2098
+ : false,
2099
+ },
1286
2100
  };
1287
2101
  }),
1288
2102
  // `morphTargetsRef` is a ref, so it cannot be a dependency; `settled` covers
1289
2103
  // it, because the two always change together — targets are written in the same
1290
2104
  // tick that clears `settled`, and cleared in the same tick that restores it.
1291
- [nodes, selectedId, settled, openContainerIds],
2105
+ [
2106
+ nodes,
2107
+ selectedId,
2108
+ settled,
2109
+ openContainerIds,
2110
+ dimmedElementIds,
2111
+ dimmedConstructIds,
2112
+ hiddenElementIds,
2113
+ selectedConstructAlias,
2114
+ ],
1292
2115
  );
1293
2116
 
1294
2117
  /**
@@ -1296,15 +2119,112 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1296
2119
  * gliding between the two slots the old lines would visibly point at the wrong
1297
2120
  * boxes. Hiding them for the morph is honest and cheap; interpolating the
1298
2121
  * polylines to match is not.
2122
+ *
2123
+ * The dim set is the union of
2124
+ * (a) the own endpoint rule — a line between two dimmed boxes fades with
2125
+ * them, a line into a lit container stays bright on its own; and
2126
+ * (b) the host's line-relevance set — edges that don't carry the focused
2127
+ * trail / hovered step (a step internal to a container folds into no
2128
+ * line at all, so even lines out of the lit container fade).
2129
+ *
2130
+ * The selected edge never dims: it is the inspector's subject.
1299
2131
  */
2132
+ const dimmedEdgeIdSet = useMemo(() => {
2133
+ if (!dimmedElementIds && !dimmedEdgeIds) return null;
2134
+ const out = new Set<string>();
2135
+ if (dimmedElementIds) {
2136
+ for (const e of rfEdges) {
2137
+ if (dimmedElementIds.has(e.source) && dimmedElementIds.has(e.target)) out.add(e.id);
2138
+ }
2139
+ }
2140
+ if (dimmedEdgeIds) for (const id of dimmedEdgeIds) out.add(id);
2141
+ if (selectedEdgeId) out.delete(selectedEdgeId);
2142
+ return out;
2143
+ }, [dimmedElementIds, dimmedEdgeIds, rfEdges, selectedEdgeId]);
2144
+
2145
+ const hiddenEdgeIdSet = useMemo(() => {
2146
+ if (!hiddenElementIds && !hiddenEdgeIds) return null;
2147
+ const out = new Set<string>();
2148
+ if (hiddenElementIds) {
2149
+ // Either endpoint gone → the line has nothing to attach to, so it goes
2150
+ // with the box (unlike the dim rule, which needs both endpoints dimmed).
2151
+ for (const e of rfEdges) {
2152
+ if (hiddenElementIds.has(e.source) || hiddenElementIds.has(e.target)) out.add(e.id);
2153
+ }
2154
+ }
2155
+ if (hiddenEdgeIds) for (const id of hiddenEdgeIds) out.add(id);
2156
+ return out;
2157
+ }, [hiddenElementIds, hiddenEdgeIds, rfEdges]);
2158
+
1300
2159
  const displayEdges = useMemo(
1301
- () => (settled ? rfEdges : rfEdges.map((e) => ({ ...e, hidden: true }))),
1302
- [rfEdges, settled],
2160
+ () =>
2161
+ rfEdges.map((e) => {
2162
+ const hidden = !settled || hiddenEdgeIdSet?.has(e.id) === true;
2163
+ const dimmed =
2164
+ dimmedEdgeIdSet?.has(e.id) === true ||
2165
+ dimmedConstructEdgeIds?.has(e.id) === true;
2166
+ // The arrowhead is a shared SVG marker: dimming the stroke (path opacity
2167
+ // or colour alpha) leaves it at full colour. Clone the marker with a
2168
+ // dimmed colour, as the component graph does.
2169
+ const markerEnd = e.markerEnd;
2170
+ const nextMarker =
2171
+ dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
2172
+ ? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
2173
+ : markerEnd;
2174
+ return {
2175
+ ...e,
2176
+ ...(hidden ? { hidden: true } : {}),
2177
+ markerEnd: nextMarker,
2178
+ data: {
2179
+ ...e.data,
2180
+ selected: e.id === selectedEdgeId,
2181
+ dimmed,
2182
+ },
2183
+ };
2184
+ }),
2185
+ [rfEdges, settled, selectedEdgeId, dimmedEdgeIdSet, hiddenEdgeIdSet, dimmedConstructEdgeIds],
1303
2186
  );
1304
2187
 
1305
- return (
1306
- <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
2188
+ /** The construct edges, for their mechanism-label overlay (carries the dim flag). */
2189
+ const subsystemLabelEdges = useMemo(
2190
+ () => displayEdges.filter((e) => e.type === 'subsystem-edge'),
2191
+ [displayEdges],
2192
+ );
2193
+
2194
+ // The step drawer: open while a step is focused and a viewer is not hidden.
2195
+ const drawerOpen = focusedStepInfo != null && !hideDrawer;
2196
+ const drawerTitle = focusedStepInfo
2197
+ ? renderTrailViewer
2198
+ ? focusedStepInfo.trail.title
2199
+ : focusedStepInfo.step.file
2200
+ : null;
2201
+ const drawerProgress =
2202
+ focusedStepInfo && focusedStepInfo.trail.steps.length > 1
2203
+ ? { index: focusedStepInfo.stepIndex, total: focusedStepInfo.trail.steps.length }
2204
+ : null;
2205
+
2206
+ // The selection panel floats over the canvas in the top-right corner. A host
2207
+ // that wants the panel (or anything else) beside the graph wraps the graph in
2208
+ // `GraphWithSidebar` itself, so the graph stays overlay-only.
2209
+ const selectionPanelStyle: CSSProperties = {
2210
+ position: 'absolute',
2211
+ top: 12,
2212
+ right: 12,
2213
+ // Above the edge-label overlay (zIndex 5), or chips paint over the panel.
2214
+ zIndex: 10,
2215
+ width: 280,
2216
+ maxHeight: 'calc(100% - 24px)',
2217
+ overflowY: 'auto',
2218
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
2219
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
2220
+ borderRadius: 8,
2221
+ padding: '10px 12px',
2222
+ };
2223
+
2224
+ const canvasRoot = (
2225
+ <>
1307
2226
  <GraphLayerStyle />
2227
+ <SubsystemCallbacksProvider value={subsystemCallbacks}>
1308
2228
  <ReactFlow
1309
2229
  nodes={displayNodes}
1310
2230
  edges={displayEdges}
@@ -1316,11 +2236,27 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1316
2236
  if (node.type !== 'c4-node') return;
1317
2237
  const next = node.id === selectedId ? null : node.id;
1318
2238
  setSelectedId(next);
2239
+ setSelectedEdgeId(null);
2240
+ setSelectedConstructAlias(null);
1319
2241
  onSelectNode?.(next);
2242
+ onSelectEdge?.(null);
2243
+ }}
2244
+ onEdgeClick={(_e, edge) => {
2245
+ const next = edge.id === selectedEdgeId ? null : edge.id;
2246
+ setSelectedEdgeId(next);
2247
+ // An edge selection replaces the node panel — the two inspectors
2248
+ // sharing the corner would fight for it.
2249
+ setSelectedId(null);
2250
+ setSelectedConstructAlias(null);
2251
+ onSelectNode?.(null);
2252
+ onSelectEdge?.(next ? (model.edges.find((e) => e.id === next) ?? null) : null);
1320
2253
  }}
1321
2254
  onPaneClick={() => {
1322
2255
  setSelectedId(null);
2256
+ setSelectedEdgeId(null);
2257
+ setSelectedConstructAlias(null);
1323
2258
  onSelectNode?.(null);
2259
+ onSelectEdge?.(null);
1324
2260
  }}
1325
2261
  proOptions={{ hideAttribution: true }}
1326
2262
  // Pin the flow to its container. The label overlay is a sibling sized
@@ -1330,12 +2266,16 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1330
2266
  >
1331
2267
  <GraphChrome />
1332
2268
  </ReactFlow>
2269
+ </SubsystemCallbacksProvider>
1333
2270
  {/* Gated on `settled`, like the edges themselves: a chip is anchored to the
1334
2271
  *settled* midpoint of its edge, so showing one while the cards glide leaves
1335
2272
  it pointing at the space between two layouts. It rejoins them, the nested
1336
2273
  components and the descriptions in one reveal. */}
1337
2274
  {ready && settled && labelPositions.size > 0 && (
1338
- <C4EdgeLabels positions={labelPositions} edges={model.edges} />
2275
+ <C4EdgeLabels positions={labelPositions} edges={model.edges} selectedEdgeId={selectedEdgeId} dimmedEdgeIds={dimmedEdgeIdSet} hiddenEdgeIds={hiddenEdgeIdSet} />
2276
+ )}
2277
+ {ready && settled && subsystemLabelEdges.length > 0 && (
2278
+ <SubsystemEdgeLabels edges={subsystemLabelEdges} />
1339
2279
  )}
1340
2280
  {!ready && (
1341
2281
  <div
@@ -1354,24 +2294,12 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1354
2294
  Laying out C4 view…
1355
2295
  </div>
1356
2296
  )}
2297
+ </>
2298
+ );
2299
+ const selectionPanels = (
2300
+ <>
1357
2301
  {selected && (
1358
- <div
1359
- style={{
1360
- position: 'absolute',
1361
- top: 12,
1362
- right: 12,
1363
- // Above the edge-label overlay (zIndex 5), or chips paint over the
1364
- // panel.
1365
- zIndex: 10,
1366
- width: 280,
1367
- maxHeight: 'calc(100% - 24px)',
1368
- overflowY: 'auto',
1369
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
1370
- border: `1px solid ${theme.colors.border ?? '#333'}`,
1371
- borderRadius: 8,
1372
- padding: '10px 12px',
1373
- }}
1374
- >
2302
+ <div style={selectionPanelStyle}>
1375
2303
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1376
2304
  <span style={{ fontFamily: theme.fonts.monospace, fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
1377
2305
  {selected.label}
@@ -1476,7 +2404,7 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1476
2404
  whiteSpace: 'nowrap',
1477
2405
  }}
1478
2406
  >
1479
- {'technology' in c && c.technology ? c.technology : c.kind}
2407
+ {c.technology || c.kind}
1480
2408
  {(c.members ?? []).length > 0 ? ` · ${(c.members ?? []).length} member${(c.members ?? []).length === 1 ? '' : 's'}` : ''}
1481
2409
  </span>
1482
2410
  </div>
@@ -1508,8 +2436,8 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1508
2436
  >
1509
2437
  Components
1510
2438
  </span>
1511
- {(selected.members ?? []).length > 0 ? (
1512
- (selected.members ?? []).map((alias) => (
2439
+ {selectedContainerMembers.length > 0 ? (
2440
+ selectedContainerMembers.map((alias) => (
1513
2441
  <span
1514
2442
  key={alias}
1515
2443
  title={alias}
@@ -1540,6 +2468,290 @@ function Inner({ model, onSelectNode, onToggleContainer, onOpenContainer, onClos
1540
2468
  )}
1541
2469
  </div>
1542
2470
  )}
2471
+
2472
+ {selectedEdge && ready && (
2473
+ <div style={selectionPanelStyle}>
2474
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
2475
+ <span
2476
+ aria-hidden
2477
+ style={{
2478
+ width: 8,
2479
+ height: 8,
2480
+ borderRadius: '50%',
2481
+ background: protocolColor(selectedEdge.protocol),
2482
+ flexShrink: 0,
2483
+ }}
2484
+ />
2485
+ <span
2486
+ style={{
2487
+ fontFamily: theme.fonts.monospace,
2488
+ fontWeight: 600,
2489
+ color: theme.colors.text,
2490
+ flex: 1,
2491
+ minWidth: 0,
2492
+ overflow: 'hidden',
2493
+ textOverflow: 'ellipsis',
2494
+ whiteSpace: 'nowrap',
2495
+ }}
2496
+ >
2497
+ {selectedEdge.label}
2498
+ </span>
2499
+ <button
2500
+ type="button"
2501
+ onClick={() => {
2502
+ setSelectedEdgeId(null);
2503
+ onSelectEdge?.(null);
2504
+ }}
2505
+ aria-label="Clear selection"
2506
+ style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
2507
+ >
2508
+ ×
2509
+ </button>
2510
+ </div>
2511
+
2512
+ {/* The rollup row: what this line folded up. The protocol color the
2513
+ card already carries is enough — this is the count + verbs. */}
2514
+ <div
2515
+ style={{
2516
+ display: 'flex',
2517
+ alignItems: 'center',
2518
+ gap: 6,
2519
+ flexWrap: 'wrap',
2520
+ marginTop: 6,
2521
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
2522
+ paddingTop: 6,
2523
+ }}
2524
+ >
2525
+ {selectedEdge.mechanisms.map((m) => (
2526
+ <span
2527
+ key={m}
2528
+ style={{
2529
+ fontFamily: theme.fonts.monospace,
2530
+ fontSize: theme.fontSizes[0],
2531
+ color: theme.colors.text,
2532
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
2533
+ borderRadius: 4,
2534
+ padding: '1px 6px',
2535
+ }}
2536
+ >
2537
+ {m}
2538
+ </span>
2539
+ ))}
2540
+ <span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }}>
2541
+ {selectedEdge.count} {selectedEdge.count === 1 ? 'step' : 'steps'}
2542
+ </span>
2543
+ </div>
2544
+
2545
+ {/* The drill-down list: the trails this line rolls up, each handed to
2546
+ the host for a deep-link into the component graph. */}
2547
+ <div
2548
+ style={{
2549
+ display: 'flex',
2550
+ flexDirection: 'column',
2551
+ gap: 4,
2552
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
2553
+ paddingTop: 6,
2554
+ marginTop: 6,
2555
+ }}
2556
+ >
2557
+ <span
2558
+ style={{
2559
+ fontFamily: theme.fonts.monospace,
2560
+ fontSize: theme.fontSizes[0],
2561
+ color: muted,
2562
+ textTransform: 'uppercase',
2563
+ letterSpacing: 0.5,
2564
+ }}
2565
+ >
2566
+ Backed by trails
2567
+ </span>
2568
+ {(selectedEdge.trailRefs ?? []).length > 0 ? (
2569
+ (selectedEdge.trailRefs ?? []).map((ref) => (
2570
+ <button
2571
+ key={ref.trailId}
2572
+ type="button"
2573
+ onClick={() => onTrailClick?.(ref)}
2574
+ disabled={!onTrailClick}
2575
+ title={
2576
+ onTrailClick
2577
+ ? `Focus this trail in the component graph`
2578
+ : 'Trail drill-down needs onTrailClick'
2579
+ }
2580
+ style={{
2581
+ display: 'flex',
2582
+ alignItems: 'center',
2583
+ gap: 6,
2584
+ textAlign: 'left',
2585
+ border: 'none',
2586
+ background: 'transparent',
2587
+ padding: 0,
2588
+ cursor: onTrailClick ? 'pointer' : 'default',
2589
+ fontFamily: theme.fonts.monospace,
2590
+ fontSize: theme.fontSizes[0],
2591
+ }}
2592
+ >
2593
+ <span style={{ color: theme.colors.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
2594
+ {ref.trailId}
2595
+ </span>
2596
+ <span style={{ color: muted, flexShrink: 0 }}>
2597
+ {ref.stepIndices.length} {ref.stepIndices.length === 1 ? 'step' : 'steps'}
2598
+ {ref.stepIndices.length > 0 ? ` · #${ref.stepIndices.map((i) => i + 1).join(', #')}` : ''}
2599
+ </span>
2600
+ </button>
2601
+ ))
2602
+ ) : (
2603
+ <span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted }}>
2604
+ Authored line — no trail provenance yet
2605
+ </span>
2606
+ )}
2607
+ </div>
2608
+ </div>
2609
+ )}
2610
+
2611
+ {/* Construct functionality — a construct row's declaration + purpose,
2612
+ opened from a card. Its own bottom-left panel so it never fights the
2613
+ top-right node/edge inspector for the corner. */}
2614
+ {selectedConstruct && (
2615
+ <div
2616
+ style={{
2617
+ ...selectionPanelStyle,
2618
+ top: 'auto',
2619
+ bottom: 12,
2620
+ left: 12,
2621
+ right: 'auto',
2622
+ width: 320,
2623
+ maxHeight: 'calc(50% - 18px)',
2624
+ }}
2625
+ >
2626
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
2627
+ <span
2628
+ aria-hidden
2629
+ style={{
2630
+ width: 8,
2631
+ height: 8,
2632
+ borderRadius: '50%',
2633
+ background: selectedConstruct.color,
2634
+ flexShrink: 0,
2635
+ }}
2636
+ />
2637
+ <span
2638
+ style={{
2639
+ fontFamily: theme.fonts.monospace,
2640
+ fontWeight: 600,
2641
+ color: theme.colors.text,
2642
+ flex: 1,
2643
+ minWidth: 0,
2644
+ overflow: 'hidden',
2645
+ textOverflow: 'ellipsis',
2646
+ whiteSpace: 'nowrap',
2647
+ }}
2648
+ >
2649
+ {selectedConstruct.name}
2650
+ </span>
2651
+ <span
2652
+ style={{
2653
+ flexShrink: 0,
2654
+ fontFamily: theme.fonts.monospace,
2655
+ fontSize: theme.fontSizes[0],
2656
+ color: muted,
2657
+ textTransform: 'uppercase',
2658
+ letterSpacing: 0.4,
2659
+ }}
2660
+ >
2661
+ {selectedConstruct.badge}
2662
+ </span>
2663
+ <button
2664
+ type="button"
2665
+ onClick={() => setSelectedConstructAlias(null)}
2666
+ aria-label="Clear construct"
2667
+ style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
2668
+ >
2669
+ ×
2670
+ </button>
2671
+ </div>
2672
+ {selectedConstruct.component.purpose && (
2673
+ <div
2674
+ style={{
2675
+ marginTop: 6,
2676
+ fontFamily: theme.fonts.body,
2677
+ fontSize: theme.fontSizes[0],
2678
+ color: theme.colors.text,
2679
+ lineHeight: 1.4,
2680
+ }}
2681
+ >
2682
+ {selectedConstruct.component.purpose}
2683
+ </div>
2684
+ )}
2685
+ {selectedConstruct.declaration && (
2686
+ <pre
2687
+ style={{
2688
+ marginTop: 6,
2689
+ marginBottom: 0,
2690
+ padding: '6px 8px',
2691
+ borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
2692
+ background: theme.colors.background ?? 'transparent',
2693
+ color: theme.colors.text,
2694
+ fontFamily: theme.fonts.monospace,
2695
+ fontSize: theme.fontSizes[0],
2696
+ lineHeight: 1.45,
2697
+ whiteSpace: 'pre-wrap',
2698
+ overflowWrap: 'anywhere',
2699
+ }}
2700
+ >
2701
+ {selectedConstruct.declaration}
2702
+ </pre>
2703
+ )}
2704
+ <div
2705
+ style={{
2706
+ marginTop: 6,
2707
+ fontFamily: theme.fonts.monospace,
2708
+ fontSize: theme.fontSizes[0],
2709
+ color: muted,
2710
+ }}
2711
+ >
2712
+ {selectedConstruct.component.alias}
2713
+ {selectedConstruct.role ? ` · ${selectedConstruct.role}` : ''}
2714
+ {selectedConstruct.component.proposed ? ' · proposed' : ''}
2715
+ </div>
2716
+ </div>
2717
+ )}
2718
+ </>
2719
+ );
2720
+ return (
2721
+ <div
2722
+ style={{
2723
+ position: 'relative',
2724
+ width: '100%',
2725
+ height: '100%',
2726
+ display: 'flex',
2727
+ flexDirection: 'column',
2728
+ background: theme.colors.background,
2729
+ }}
2730
+ >
2731
+ <div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
2732
+ {canvasRoot}
2733
+ {selectionPanels}
2734
+ </div>
2735
+ <FileDrawer
2736
+ title={drawerOpen ? drawerTitle : null}
2737
+ onClose={() => onCloseStep?.()}
2738
+ hidden={hideDrawer}
2739
+ progress={drawerProgress}
2740
+ >
2741
+ {drawerOpen && focusedStepInfo
2742
+ ? renderTrailViewer
2743
+ ? renderTrailViewer({
2744
+ trail: focusedStepInfo.trail,
2745
+ stepIndex: focusedStepInfo.stepIndex,
2746
+ })
2747
+ : renderFileViewer
2748
+ ? renderFileViewer(focusedStepInfo.step.file, {
2749
+ startLine: focusedStepInfo.step.span?.startLine,
2750
+ endLine: focusedStepInfo.step.span?.endLine,
2751
+ })
2752
+ : <StepDrawerFallback step={focusedStepInfo.step} />
2753
+ : null}
2754
+ </FileDrawer>
1543
2755
  </div>
1544
2756
  );
1545
2757
  }