@principal-ai/subsystems-react 0.53.0 → 0.53.2

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 (34) hide show
  1. package/dist/subsystem/C4Graph.d.ts +91 -1
  2. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  3. package/dist/subsystem/C4Graph.js +672 -180
  4. package/dist/subsystem/C4Graph.js.map +1 -1
  5. package/dist/subsystem/C4NodeCard.d.ts +36 -1
  6. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  7. package/dist/subsystem/C4NodeCard.js +45 -10
  8. package/dist/subsystem/C4NodeCard.js.map +1 -1
  9. package/dist/subsystem/IssueList.d.ts.map +1 -1
  10. package/dist/subsystem/IssueList.js +14 -5
  11. package/dist/subsystem/IssueList.js.map +1 -1
  12. package/dist/subsystem/c4.d.ts +27 -3
  13. package/dist/subsystem/c4.d.ts.map +1 -1
  14. package/dist/subsystem/c4.js +48 -0
  15. package/dist/subsystem/c4.js.map +1 -1
  16. package/dist/utils/elkLayout.d.ts +68 -8
  17. package/dist/utils/elkLayout.d.ts.map +1 -1
  18. package/dist/utils/elkLayout.js +95 -14
  19. package/dist/utils/elkLayout.js.map +1 -1
  20. package/package.json +2 -1
  21. package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
  22. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
  23. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
  24. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +132 -0
  25. package/src/subsystem/C4Graph.groups.test.ts +217 -0
  26. package/src/subsystem/C4Graph.morph.test.tsx +208 -0
  27. package/src/subsystem/C4Graph.tsx +803 -226
  28. package/src/subsystem/C4NodeCard.test.ts +6 -3
  29. package/src/subsystem/C4NodeCard.tsx +92 -20
  30. package/src/subsystem/IssueList.category.test.ts +39 -0
  31. package/src/subsystem/IssueList.tsx +16 -5
  32. package/src/subsystem/c4.ts +74 -3
  33. package/src/utils/elkLayout.test.ts +131 -0
  34. package/src/utils/elkLayout.ts +133 -16
@@ -11,18 +11,43 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
11
11
  * `GRAPH_NAV_PROPS` chrome. Deliberately small — no measurement passes, no
12
12
  * drawers, no trail playback.
13
13
  */
14
- import { useEffect, useMemo, useState } from 'react';
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
  import { Handle, MarkerType, Position, ReactFlow, ReactFlowProvider, useReactFlow, useViewport, } from '@xyflow/react';
16
16
  import { useTheme } from '@principal-ade/industry-theme';
17
- import { Box, LayoutGrid } from 'lucide-react';
18
- import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
17
+ import { Box, Minimize2 } from 'lucide-react';
18
+ import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
19
19
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
20
- import { protocolColor } from './c4';
21
- import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
20
+ import { deriveC4Groups, protocolColor } from './c4';
21
+ import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
22
+ import { TechMark, technologyBrand } from './techIcons';
22
23
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
23
24
  function C4NodeView(props) {
24
- const { element, selected, componentCount } = props.data;
25
- return (_jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, handles: _jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", position: Position.Left, style: { opacity: 0 } }), _jsx(Handle, { type: "source", position: Position.Right, style: { opacity: 0 } })] }) }));
25
+ const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } = props.data;
26
+ const grown = open === true;
27
+ // Position-only FLIP. The card resizes through its own `width`/`height`
28
+ // transition, so scaling here would distort the text and fight that transition.
29
+ // A card that only changed *size* has nothing to slide — no transform and no
30
+ // transition, so its resize is the card's own and nothing competes with it.
31
+ const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
32
+ const sliding = flipping && (dx !== 0 || dy !== 0);
33
+ // The card takes its box from the React Flow node rather than from its own
34
+ // `nodeSize`, so on the opened container ELK's fitted size drives it — and
35
+ // because the element persists across the swap, the card's own width/height
36
+ // transition *is* the grow. No FLIP needed, and no text distortion.
37
+ //
38
+ // While grown it is taken out of flow: the node box already carries the layout
39
+ // (and is what sibling containers and edges are positioned against), so the
40
+ // card must not compete with it.
41
+ return (_jsx("div", { style: {
42
+ ...(grown
43
+ ? { position: 'absolute', top: 0, left: 0, width: props.width, height: props.height }
44
+ : {}),
45
+ // Position only — the card's own transition owns the size, so these two
46
+ // compose without either fighting the other.
47
+ transformOrigin: 'top left',
48
+ transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
49
+ transition: sliding && placed ? `transform ${MORPH_MS}ms ease-out` : 'none',
50
+ }, children: _jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, onExpand: onExpand, width: props.width, height: props.height, showDescription: showDescriptionFor(open, settled), handles: _jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", position: Position.Left, style: { opacity: 0 } }), _jsx(Handle, { type: "source", position: Position.Right, style: { opacity: 0 } })] }) }) }));
26
51
  }
27
52
  /**
28
53
  * Edge-label overlay.
@@ -100,17 +125,41 @@ function C4GroupView(props) {
100
125
  const { theme } = useTheme();
101
126
  const color = props.data.color;
102
127
  const isSystem = props.data.kind === 'system';
103
- // A system frame is the box we draw; a container frame groups components
104
- // inside one. Boxes for each, so the badge says which level you are looking at.
105
- const FrameIcon = isSystem ? Box : LayoutGrid;
128
+ const onCollapse = props.data.onCollapse;
129
+ const brand = props.data.brand;
130
+ const { growFrom, frameRect } = props.data;
131
+ // Position-only FLIP, same as the cards. The frame resizes through its own
132
+ // width/height transition below, so scaling here would fight it *and* distort
133
+ // what it draws: a scaled dashed border changes weight as it grows, and the
134
+ // label chip's border and text scale with it.
135
+ const { flipping, placed, dx, dy } = useFlip(growFrom, frameRect);
136
+ const sliding = flipping && (dx !== 0 || dy !== 0);
137
+ // A container frame shows its technology mark (React/Bun/Node…) so the
138
+ // boundary says what it is built with, same as the node it wraps. The system
139
+ // frame has no single technology, so it keeps a neutral `Box`.
140
+ const FrameIcon = isSystem || !brand ? Box : undefined;
106
141
  return (_jsx("div", { style: {
107
- width: '100%',
108
- height: '100%',
142
+ // ELK's own fitted size, as numbers — the same trick the cards use. At
143
+ // `100%` this element inherits the React Flow node box, which snaps to
144
+ // each new layout, and only a scale could disguise that. Transitioning the
145
+ // numbers grows and shrinks the boundary for real, in both directions, and
146
+ // keeps the dashed border a constant weight throughout.
147
+ width: props.width ?? '100%',
148
+ height: props.height ?? '100%',
149
+ // Size is always transitioned; the transform only while sliding, and
150
+ // `transform: none` needs no transition.
151
+ transition: [
152
+ 'width 420ms ease-out',
153
+ 'height 420ms ease-out',
154
+ ...(sliding && placed ? [`transform ${MORPH_MS}ms ease-out`] : []),
155
+ ].join(', '),
109
156
  boxSizing: 'border-box',
110
157
  borderRadius: 12,
111
158
  border: `2px dashed ${color}`,
112
159
  background: 'transparent',
113
160
  pointerEvents: 'none',
161
+ transformOrigin: 'top left',
162
+ transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
114
163
  }, children: _jsxs("span", { style: {
115
164
  position: 'absolute',
116
165
  top: 0,
@@ -134,7 +183,24 @@ function C4GroupView(props) {
134
183
  padding: '4px 10px',
135
184
  boxSizing: 'border-box',
136
185
  whiteSpace: 'nowrap',
137
- }, children: [_jsx(FrameIcon, { size: 13, strokeWidth: 2.25, "aria-hidden": true }), props.data.label] }) }));
186
+ }, children: [FrameIcon ? (_jsx(FrameIcon, { size: 13, strokeWidth: 2.25, "aria-hidden": true })) : brand ? (_jsx(TechMark, { brand: brand, size: 14 })) : null, props.data.label, onCollapse && (_jsx("button", { type: "button", onClick: (e) => {
187
+ e.stopPropagation();
188
+ onCollapse();
189
+ }, "aria-label": "Collapse", style: {
190
+ pointerEvents: 'auto',
191
+ display: 'inline-flex',
192
+ alignItems: 'center',
193
+ justifyContent: 'center',
194
+ width: 18,
195
+ height: 18,
196
+ marginLeft: 2,
197
+ padding: 0,
198
+ border: 'none',
199
+ background: 'transparent',
200
+ color,
201
+ cursor: 'pointer',
202
+ lineHeight: 1,
203
+ }, children: _jsx(Minimize2, { size: 12, strokeWidth: 2.5 }) }))] }) }));
138
204
  }
139
205
  const nodeTypes = {
140
206
  'c4-node': C4NodeView,
@@ -143,7 +209,184 @@ const nodeTypes = {
143
209
  const edgeTypes = {
144
210
  'c4-edge': C4EdgeView,
145
211
  };
146
- function Inner({ model, title, onSelectNode, gutter = 28 }) {
212
+ /** Drill-down animation durations, in ms. */
213
+ const MORPH_MS = 420;
214
+ const CAMERA_MS = 520;
215
+ /**
216
+ * When to re-frame the camera after a drill-down: the morph, plus a beat.
217
+ *
218
+ * The extra 60ms is not padding for looks. `fitView` reads the system frame's
219
+ * *measured* size, and that frame's own height transition runs on the same
220
+ * MORPH_MS clock — so asking on the same tick reads it mid-flight and fits to a
221
+ * height that never existed. The beat is for React Flow's resize observer to
222
+ * report the final geometry.
223
+ */
224
+ const CAMERA_AFTER_MORPH_MS = MORPH_MS + 60;
225
+ /**
226
+ * Whether committing a layout should also move the camera.
227
+ *
228
+ * A morph — a commit that has rects to FLIP from — must not. Every element
229
+ * already on screen is sliding from the rect the reader last saw it at, and that
230
+ * anchoring *is* the morph; a viewport move on top of it makes each card's
231
+ * on-screen path the sum of two independent animations, so nothing lands where
232
+ * the FLIP said it would. `CAMERA_MS` also outlasts `MORPH_MS`, so the camera
233
+ * would still be drifting for 100ms after the cards had settled — which is what
234
+ * reads as the cards "still moving".
235
+ *
236
+ * A commit with no `grows` is a genuine change: the first paint, a new system, a
237
+ * different element set, or the grid fallback after an ELK failure. There is no
238
+ * continuity to preserve there and the content may genuinely not be on screen, so
239
+ * fit.
240
+ *
241
+ * An *empty* map fits for the same reason — no element is anchoring, so there is
242
+ * nothing a camera move could contradict. Testing the size rather than the
243
+ * identity keeps that total over the whole input domain instead of leaning on a
244
+ * caller convention (`growsFor` happens to hand back `null` rather than an empty
245
+ * map, but nothing forces it to).
246
+ *
247
+ * @param grows the rects to animate from, exactly as `injectGrow` received them.
248
+ */
249
+ export function shouldRefitCamera(grows) {
250
+ return !grows || grows.size === 0;
251
+ }
252
+ /**
253
+ * Whether a card shows its description line.
254
+ *
255
+ * Off for the whole morph: the card is resizing under it, so the line would slide
256
+ * across a moving box.
257
+ *
258
+ * And off for good once the container is grown, which is the half that is easy to
259
+ * get wrong. `settled` alone is not enough — the line comes back the moment the
260
+ * grow finishes, straight into the space the components now occupy. ELK reserves
261
+ * header chrome only (`padTop`), never a description, so the returning line does
262
+ * not push the internals down; it lands on top of the first component.
263
+ */
264
+ export function showDescriptionFor(open, settled) {
265
+ return settled === true && open !== true;
266
+ }
267
+ /**
268
+ * `groupBounds` with every entry in absolute flow coordinates.
269
+ *
270
+ * `result.groupBounds` is absolute for a root-level group and *parent-relative*
271
+ * for a nested one — two spaces in one map, which is the same trap as
272
+ * `result.nodes`. The opened container is nested (inside the system frame), and
273
+ * `containerRect` reads this map before the node box, so an unrebased entry would
274
+ * be handed to the morph offset by the frame's origin: the container would glide
275
+ * to a place it was never drawn.
276
+ */
277
+ export function rebaseGroupBoundsAbsolute(result) {
278
+ const rebased = new Map();
279
+ for (const [id, b] of result.groupBounds) {
280
+ // A flat frame has no node of its own, so `absoluteRects` has no entry and
281
+ // `absoluteBoundsOf` falls back to the entry — already absolute, since it was
282
+ // synthesized from absolute member bounds.
283
+ const abs = absoluteBoundsOf(result, id) ?? b;
284
+ rebased.set(id, { ...b, x: abs.x, y: abs.y });
285
+ }
286
+ return rebased;
287
+ }
288
+ /**
289
+ * The compound groups one open state hands to ELK: the frames it draws, and the
290
+ * group defs that make ELK lay them out.
291
+ *
292
+ * Exported because the nesting here is the load-bearing part and it is invisible
293
+ * from the rendered output. The opened container sits *inside* the system frame, so
294
+ * the frame is the origin its contents are laid out from — as a sibling, or as a
295
+ * flat union, the frame has no stable corner and the whole boundary slides as
296
+ * ELK re-places its members.
297
+ */
298
+ export function c4GroupDefs(model, openId) {
299
+ // Every frame this layout draws: the system boundary, plus a container frame per
300
+ // container whose components are in view (the component view leans on these).
301
+ // The *opened* container's frame is dropped — it is drawn as a grown card
302
+ // instead, as an ELK parent below.
303
+ const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter((g) => g.id !== openId);
304
+ const groups = frames.map((g) => ({
305
+ id: g.id,
306
+ memberIds: g.memberIds,
307
+ ...(g.parentId ? { parentId: g.parentId } : {}),
308
+ }));
309
+ // Opened: the container becomes a real ELK parent so it is sized to hold its
310
+ // components — the "layout B" the closed layout transitions to. Only in the
311
+ // container view: that is where a container is a card, and where the drill-down
312
+ // is offered.
313
+ const openMembers = model.view === 'container' && openId
314
+ ? model.nodes
315
+ .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
316
+ .map((n) => n.id)
317
+ : [];
318
+ if (openId && openMembers.length > 0) {
319
+ groups.push({
320
+ id: openId,
321
+ memberIds: openMembers,
322
+ // INSIDE the system frame, not beside it. The boundary *is* the system, so a
323
+ // sibling here would draw the opened container outside the very boundary it
324
+ // belongs to.
325
+ parentId: model.system.id,
326
+ // Never narrower than the closed card, so opening only ever grows the box.
327
+ minWidth: NODE_W,
328
+ // Room for the container's own label slot + technology row.
329
+ padTop: OPEN_HEADER_PAD,
330
+ // Drawn as a card, laid out as a group — both are needed, and only ELK knows
331
+ // which one a bare member id means.
332
+ drawnAsCard: true,
333
+ });
334
+ }
335
+ return { frames, groups };
336
+ }
337
+ /** The container's rect in a laid-out state: its box node, or its frame bounds. */
338
+ function containerRect(layout, id) {
339
+ if (!layout)
340
+ return null;
341
+ const frame = layout.groupBounds.get(id);
342
+ if (frame)
343
+ return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
344
+ const node = layout.nodes.find((n) => n.id === id);
345
+ if (!node)
346
+ return null;
347
+ return {
348
+ x: node.position.x,
349
+ y: node.position.y,
350
+ width: node.width ?? 0,
351
+ height: node.height ?? 0,
352
+ };
353
+ }
354
+ /**
355
+ * FLIP between two absolute flow rects: render transformed back onto `from`,
356
+ * then release onto `to`.
357
+ *
358
+ * The step back **must** happen while rendering, not in an effect. An effect runs
359
+ * after the browser has already painted, so the element is painted at its new
360
+ * position for one frame and only then jumps back — a visible teleport, which is
361
+ * the exact thing this hook exists to remove. Adjusting state during render lets
362
+ * React apply the transform in the same commit, so the new position is never
363
+ * painted untransformed.
364
+ *
365
+ * The rects are keyed by value: `injectGrow` mints fresh objects each layout, so
366
+ * comparing identity would re-fire the whole morph on unrelated re-renders.
367
+ */
368
+ function useFlip(from, to) {
369
+ const key = from && to ? `${from.x},${from.y},${to.x},${to.y}` : '';
370
+ const [lastKey, setLastKey] = useState(key);
371
+ const [placed, setPlaced] = useState(!key);
372
+ if (key !== lastKey) {
373
+ setLastKey(key);
374
+ setPlaced(false);
375
+ }
376
+ useEffect(() => {
377
+ if (!key)
378
+ return;
379
+ const raf = requestAnimationFrame(() => setPlaced(true));
380
+ return () => cancelAnimationFrame(raf);
381
+ }, [key]);
382
+ return {
383
+ flipping: key !== '',
384
+ placed,
385
+ dx: from && to ? from.x - to.x : 0,
386
+ dy: from && to ? from.y - to.y : 0,
387
+ };
388
+ }
389
+ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }) {
147
390
  const { theme } = useTheme();
148
391
  const { fitView } = useReactFlow();
149
392
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -152,22 +395,73 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
152
395
  const [ready, setReady] = useState(false);
153
396
  const [selectedId, setSelectedId] = useState(null);
154
397
  const [labelPositions, setLabelPositions] = useState(() => new Map());
398
+ /** Set by the layout effect; consumed by the `ready` effect to run the fit. */
399
+ const pendingFitRef = useRef(false);
400
+ /** Prefetched layouts, keyed by open-container id ('' = closed). */
401
+ const layoutCache = useRef(new Map());
402
+ /**
403
+ * False while the drill-down grow/shrink is in flight. It gates the whole reveal:
404
+ * the opened container's components stay hidden, every card drops its
405
+ * description, and the edges and their labels are withheld — all returning
406
+ * together once the box settles.
407
+ *
408
+ * Driven by one timer keyed to MORPH_MS rather than `transitionend`: the grow
409
+ * is the card's own width/height transition *and* the system frame's
410
+ * transform, so a DOM event would fire twice per open and gate on the wrong
411
+ * property.
412
+ */
413
+ const [settled, setSettled] = useState(true);
414
+ /** Set once the first layout has rendered, so only a real change animates. */
415
+ const paintedRef = useRef(false);
416
+ /** The in-flight settle timer; cancelled if the open state changes again. */
417
+ const settleTimer = useRef(null);
418
+ /** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
419
+ const cameraTimer = useRef(null);
420
+ /** The open id rendered last, so a change can morph between the two layouts. */
421
+ const prevOpenRef = useRef(null);
422
+ /**
423
+ * Frame the whole system. Fit the SYSTEM node explicitly rather than the drawn
424
+ * nodes: on a drill-down those are just the opened container and its components,
425
+ * so fitting them would zoom to the container instead of the system.
426
+ */
427
+ const fitSystem = useCallback(() => {
428
+ fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
429
+ }, [fitView, model.system.id]);
430
+ // Fit the camera AFTER the new nodes are committed (when `ready` flips true),
431
+ // so `fitView` measures the real geometry.
432
+ useEffect(() => {
433
+ if (!ready || !pendingFitRef.current)
434
+ return;
435
+ pendingFitRef.current = false;
436
+ fitSystem();
437
+ }, [ready, fitSystem]);
438
+ /** Called once the new layout is committed, from the layout effect. */
439
+ const afterLayout = (grows) => {
440
+ pendingFitRef.current = shouldRefitCamera(grows);
441
+ };
155
442
  const layoutKey = useMemo(() => [
156
443
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
157
- ...model.groups.map((g) => `${g.id}\0${g.parentId ?? ''}\0${g.memberIds.length}`).sort(),
158
444
  ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
445
+ `open:${model.openContainerId ?? ''}`,
159
446
  ].join('\n'), [model]);
160
- useEffect(() => {
161
- let alive = true;
162
- setReady(false);
163
- setSelectedId(null);
447
+ /**
448
+ * Build the React Flow inputs for a given open state — everything ELK needs,
449
+ * independent of whether a layout has been run yet. Pure over `openId`, so it
450
+ * serves both the rendered state and the background prefetch.
451
+ */
452
+ const buildInputs = (openId) => {
164
453
  // Draw only the elements for this view. A container diagram shows the
165
454
  // runtime units (containers, externals, people); components belong to the
166
- // component view. Components are still carried on the model (see the side
167
- // panel), just not drawn here.
168
- const inView = (n) => model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
169
- // How many components each container groups — computed once here because
170
- // the card only receives its own element, not the model.
455
+ // component view — or to a container that has been opened.
456
+ const inView = (n) => {
457
+ if (model.view === 'component')
458
+ return n.kind !== 'container';
459
+ if (n.kind === 'component')
460
+ return (n.container ?? n.parentId) === openId;
461
+ // A container is always drawn — it is a card either way. Opened, it grows
462
+ // and nests its components; closed, it is the bare box.
463
+ return true;
464
+ };
171
465
  const childComponentCount = new Map();
172
466
  for (const n of model.nodes) {
173
467
  if (n.kind !== 'component')
@@ -176,193 +470,386 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
176
470
  if (parent)
177
471
  childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
178
472
  }
179
- // Layout against the true card size, so ELK's ports sit on the card's
180
- // border — an inflated box leaves the line starting short of the card edge.
181
- // The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
182
473
  const rfNodes = model.nodes.filter(inView).map((n, i) => {
183
474
  const size = nodeSize(n);
475
+ // A component of the opened container nests under it (a React Flow child),
476
+ // so edges between components can be drawn later. Everything else keeps its
477
+ // authored parentId.
478
+ const nested = n.kind === 'component' && openId ? openId : undefined;
479
+ const parentId = nested ?? n.parentId;
184
480
  return {
185
481
  id: n.id,
186
482
  type: 'c4-node',
187
- // Fallback grid pitch uses the container box — the largest, so the
188
- // pre-layout positions never overlap. ELK replaces them on success.
189
483
  position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
190
484
  width: size.width,
191
485
  height: size.height,
192
- ...(n.parentId ? { parentId: n.parentId } : {}),
193
- data: { element: n, selected: false, componentCount: childComponentCount.get(n.id) ?? 0 },
486
+ ...(parentId ? { parentId } : {}),
487
+ // Nested cards are separate node divs in the viewport, so they must be
488
+ // told to paint above the grown container they sit inside — DOM order
489
+ // follows the model, not the containment.
490
+ ...(nested ? { zIndex: 2 } : {}),
491
+ data: {
492
+ element: n,
493
+ selected: false,
494
+ componentCount: childComponentCount.get(n.id) ?? 0,
495
+ // A container with components opens; the same affordance closes it
496
+ // again, so the toggle lives in one place.
497
+ ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && onOpenContainer
498
+ ? {
499
+ onExpand: () => {
500
+ if (n.id === openId)
501
+ onCloseContainer?.();
502
+ else
503
+ onOpenContainer?.(n.id);
504
+ },
505
+ open: n.id === openId,
506
+ }
507
+ : {}),
508
+ },
194
509
  };
195
510
  });
196
- const groups = model.groups.map((g) => ({
197
- id: g.id,
198
- memberIds: g.memberIds,
199
- ...(g.parentId ? { parentId: g.parentId } : {}),
200
- }));
201
- // Band nodes so a flat layout still reads as C4 and frames can't interleave.
202
- //
203
- // Flat mode has no ELK parents, so without bands ELK mixes members of
204
- // different frames across the whole graph — their synthesized bounding
205
- // boxes then overlap. A partition per frame keeps each frame's members
206
- // contiguous; people lead, externals trail.
207
- //
208
- // Order (ascending = earlier in the layout direction): people, then each
209
- // frame in model order, then external systems.
210
- const frameIds = model.groups.map((g) => g.id);
511
+ const { frames, groups } = c4GroupDefs(model, openId);
512
+ // Bands come from the *frames* only. The opened container is an ELK parent,
513
+ // not a band — treating it as one would push it into its own layer, away from
514
+ // the containers it belongs beside.
515
+ const frameIds = frames.map((g) => g.id);
211
516
  const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
212
517
  const partitionByNode = new Map(model.nodes.map((n) => {
213
518
  if (n.kind === 'person')
214
519
  return [n.id, 0];
215
520
  if (n.kind === 'external-system')
216
521
  return [n.id, frameIds.length + 1];
217
- // A node inside a frame shares its frame's band; a frame-less node
218
- // (a container at container view) gets its own by id order.
219
522
  const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
220
523
  return [n.id, 1 + band];
221
524
  }));
222
- const rfEdges = model.edges.map((e) => {
223
- // Colour keys off the protocol, not the trail verb: on a container
224
- // diagram the transport is what a reader distinguishes at a glance.
525
+ const drawnIds = new Set(rfNodes.map((n) => n.id));
526
+ const rfEdges = model.edges
527
+ .filter((e) => drawnIds.has(e.source) && drawnIds.has(e.target))
528
+ .map((e) => {
225
529
  const color = protocolColor(e.protocol);
226
530
  return {
227
531
  id: e.id,
228
532
  type: 'c4-edge',
229
533
  source: e.source,
230
534
  target: e.target,
231
- // The reserved label box and the chip must agree on size, so ELK is
232
- // given the same text the chip draws (protocol first, then label). The
233
- // chip itself is the C4EdgeLabels overlay, not this field.
234
535
  label: e.protocol ?? e.label,
235
536
  markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
236
537
  data: { edge: e, color, path: '' },
237
538
  };
238
539
  });
239
- void computeElkLayout(rfNodes, rfEdges, {
540
+ return { rfNodes, rfEdges, groups, partitionByNode, frames };
541
+ };
542
+ /**
543
+ * Run ELK for one open state and turn the result into React Flow nodes /
544
+ * edges. The heavy step the prefetch caches.
545
+ */
546
+ const layoutFor = async (openId, inputs) => {
547
+ const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
548
+ // The system boundary is a REAL ELK parent: its containers lay out *inside* it,
549
+ // so ELK owns both the frame's origin and its size. That is what holds the
550
+ // boundary's top-left corner steady between the two layouts, which is the whole
551
+ // point of the drill-down. Measured, not assumed — with the frame as a parent,
552
+ // ELK holds the frame at (12,12) and the opened container at (12,202) across
553
+ // both layouts, the container growing 150 -> 556 *downward* while its siblings
554
+ // reflow around it.
555
+ //
556
+ // Synthesized as a flat union instead, the boundary had no position of its own:
557
+ // it inherited its members' top edge, so it slid 206px the moment ELK re-placed
558
+ // one of them, and the FLIP dutifully animated the slide. A union means we own
559
+ // the boundary's geometry; a parent means ELK does.
560
+ //
561
+ // The component view keeps every frame flat. Its container frames nest *inside*
562
+ // the system frame, and flattening a group whose parent is a real group would
563
+ // leave that parent holding a reference to a group ELK never sees.
564
+ const result = await computeElkLayout(rfNodes, rfEdges, {
240
565
  direction: 'RIGHT',
241
566
  preserveNodePositions: false,
242
567
  groups,
243
568
  keepSingletonGroups: true,
244
569
  edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
245
- // The gutter that used to inflate each node box is now real spacing, so
246
- // the ports still sit on the card edge.
247
570
  nodeSpacing: gutter * 2,
248
571
  edgeNodeSpacing: gutter,
249
- // Between-layer/between-partition gap. This is what keeps the external
250
- // band clear of the system frame's padding — ELK gives partitions zero
251
- // default separation. It stacks on top of ELK's reserved label layer, so
252
- // it is kept modest.
253
572
  interLayerSpacing: 40,
254
- // Draw the system frame as a decoration around the boxes rather than an
255
- // ELK parent. Nesting made in-frame edges inherit the frame's padding and
256
- // spacing, so they ran longer than a root-level edge touching the same
257
- // nodes; flattening gives every edge one spacing.
258
- flatGroups: true,
573
+ flatGroups: model.view === 'component' ? true : new Set(),
259
574
  partitionByNode,
260
- })
261
- .then((result) => {
262
- if (!alive)
263
- return;
264
- const positioned = new Map(result.nodes.map((n) => [n.id, n]));
265
- // Draw each edge along ELK's routed polyline and place its label from
266
- // the same points — one geometry for the line and the chip.
267
- const labelPositions = new Map();
268
- const withPaths = rfEdges.map((e) => {
269
- const pts = result.edgePathPoints?.get(e.id);
270
- if (!pts || pts.length === 0)
271
- return e;
272
- labelPositions.set(e.id, pointAlongPath(pts, 0.5));
273
- // Rounded corners on ELK's orthogonal route — gentler than raw right
274
- // angles, and still the exact polyline the label is placed on.
275
- return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
276
- });
277
- setLabelPositions(labelPositions);
278
- const builtGroups = new Set(result.groupBounds.keys());
279
- const groupById = new Map(model.groups.map((g) => [g.id, g]));
280
- const depthOf = (id) => {
281
- let d = 0;
282
- let g = groupById.get(id);
283
- while (g?.parentId) {
284
- d += 1;
285
- g = groupById.get(g.parentId);
286
- }
287
- return d;
575
+ });
576
+ const positioned = new Map(result.nodes.map((n) => [n.id, n]));
577
+ const labelPositions = new Map();
578
+ const withPaths = rfEdges.map((e) => {
579
+ const pts = result.edgePathPoints?.get(e.id);
580
+ if (!pts || pts.length === 0)
581
+ return e;
582
+ labelPositions.set(e.id, pointAlongPath(pts, 0.5));
583
+ return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
584
+ });
585
+ // `result.groupBounds` mixes coordinate spaces; everything downstream here
586
+ // reads it as absolute, so normalize it once at the boundary.
587
+ const groupBounds = rebaseGroupBoundsAbsolute(result);
588
+ const builtGroups = new Set(groupBounds.keys());
589
+ // The opened container comes back from ELK as a parent, so it lands in
590
+ // `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
591
+ // from the shells; keep it in `groupBounds` so nested children can take
592
+ // their coordinates relative to it.
593
+ const shellBounds = new Map(groupBounds);
594
+ if (openId)
595
+ shellBounds.delete(openId);
596
+ const groupById = new Map(frames.map((g) => [g.id, g]));
597
+ const depthOf = (id) => {
598
+ let d = 0;
599
+ let g = groupById.get(id);
600
+ while (g?.parentId) {
601
+ d += 1;
602
+ g = groupById.get(g.parentId);
603
+ }
604
+ return d;
605
+ };
606
+ // Node positions must live in the same space as the edges and the frames:
607
+ // absolute. `flowPositionOf` does the one conversion (and the shells below use
608
+ // the same absolute origin), so nodes, edges and frames cannot disagree.
609
+ const absBoundsOf = (id) => absoluteBoundsOf(result, id);
610
+ const parentOriginOf = (id) => {
611
+ if (!id)
612
+ return { x: 0, y: 0 };
613
+ const r = absBoundsOf(id);
614
+ return r ? { x: r.x, y: r.y } : { x: 0, y: 0 };
615
+ };
616
+ // Only the system frame is emitted today (the drill-down's container frames
617
+ // became grown cards), but the mapping stays keyed off the derived group so a
618
+ // container frame can come back without reshaping this.
619
+ const shells = [...shellBounds.keys()]
620
+ .sort((a, b) => depthOf(a) - depthOf(b))
621
+ .map((id) => {
622
+ const def = groupById.get(id);
623
+ const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
624
+ const origin = parentOriginOf(parentId);
625
+ const b = absBoundsOf(id) ?? { x: 0, y: 0, width: 0, height: 0 };
626
+ const element = model.nodes.find((n) => n.id === id);
627
+ const frameColor = def?.kind === 'system'
628
+ ? (theme.colors.text ?? '#888')
629
+ : element
630
+ ? nodeStyle(element, theme).color
631
+ : (theme.colors.accent ?? theme.colors.info);
632
+ return {
633
+ id,
634
+ type: 'c4-group',
635
+ position: { x: b.x - origin.x, y: b.y - origin.y },
636
+ width: Math.max(200, b.width),
637
+ height: Math.max(120, b.height),
638
+ draggable: false,
639
+ selectable: false,
640
+ ...(parentId ? { parentId } : {}),
641
+ data: {
642
+ label: def?.label ?? id,
643
+ kind: def?.kind ?? 'container',
644
+ color: frameColor,
645
+ ...(def?.kind !== 'system' && element
646
+ ? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
647
+ : {}),
648
+ ...(def?.kind === 'container' && openId === id && onCloseContainer
649
+ ? { onCollapse: () => onCloseContainer?.() }
650
+ : {}),
651
+ },
288
652
  };
289
- // Shell positions from flat synthesis are absolute. React Flow reads a
290
- // child's `position` as relative to its parent, so a shell nested in
291
- // another shell must be made relative to it.
292
- const shellAbs = new Map(result.groupBounds);
293
- const shellOrigin = (id) => {
294
- if (!id)
295
- return { x: 0, y: 0 };
296
- const b = shellAbs.get(id);
297
- return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
653
+ });
654
+ const leaves = rfNodes.map((n) => {
655
+ let parentId;
656
+ if (n.data && typeof n.data === 'object') {
657
+ const modelNode = n.data.element;
658
+ // Only the opened container's own components nest under it — the sibling
659
+ // containers and out-of-boundary nodes in the same layout must not.
660
+ const nested = modelNode?.kind === 'component' &&
661
+ openId &&
662
+ (modelNode.container ?? modelNode.parentId) === openId
663
+ ? openId
664
+ : undefined;
665
+ const candidate = nested ?? modelNode?.parentId;
666
+ if (candidate && builtGroups.has(candidate))
667
+ parentId = candidate;
668
+ }
669
+ // `flowPositionOf` covers every node ELK placed; the fallback is for the
670
+ // pathological case of a node ELK did not return at all.
671
+ const abs = flowPositionOf(result, n.id, parentId) ??
672
+ positioned.get(n.id)?.position ??
673
+ n.position;
674
+ // The opened container is an ELK parent, so its box is whatever ELK fitted
675
+ // to the nested components — `nodeSize` only knows the closed card.
676
+ const fitted = openId === n.id ? groupBounds.get(openId) : undefined;
677
+ const node = {
678
+ ...n,
679
+ position: { x: abs.x, y: abs.y },
680
+ ...(fitted ? { width: fitted.width, height: fitted.height } : {}),
298
681
  };
299
- const shells = [...result.groupBounds.entries()]
300
- .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
301
- .map(([id, b]) => {
302
- const def = groupById.get(id);
303
- const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
304
- const origin = shellOrigin(parentId);
305
- // A container frame borrows its container node's border colour, so a
306
- // frame and the box it wraps read as the same thing. Falls back to
307
- // the accent when no element matches (e.g. a hand-authored group id).
308
- const element = model.nodes.find((n) => n.id === id);
309
- const frameColor = def?.kind === 'system'
310
- ? (theme.colors.text ?? '#888')
311
- : element
312
- ? nodeStyle(element, theme).color
313
- : (theme.colors.accent ?? theme.colors.info);
314
- return {
315
- id,
316
- type: 'c4-group',
317
- position: { x: b.x - origin.x, y: b.y - origin.y },
318
- width: Math.max(200, b.width),
319
- height: Math.max(120, b.height),
320
- draggable: false,
321
- selectable: false,
322
- ...(parentId ? { parentId } : {}),
323
- data: {
324
- label: def?.label ?? id,
325
- kind: def?.kind ?? 'container',
326
- color: frameColor,
327
- },
328
- };
329
- });
330
- const leaves = rfNodes.map((n) => {
331
- const p = positioned.get(n.id);
332
- // Absolute flow position from the flat layout.
333
- const abs = p ? p.position : n.position;
334
- // Re-parent to the shell this node sits in, if any: a component's
335
- // container, or a container's system frame. React Flow positions a
336
- // child relative to its parent, so subtract the shell origin.
337
- let parentId;
338
- if (n.data && typeof n.data === 'object') {
339
- const modelNode = n.data.element;
340
- if (modelNode?.parentId && builtGroups.has(modelNode.parentId))
341
- parentId = modelNode.parentId;
682
+ return (parentId ? { ...node, parentId } : { ...node, parentId: undefined });
683
+ });
684
+ // `groupBounds`, not `result.groupBounds`: the rebased one. `Rendered` readers
685
+ // all assume absolute.
686
+ return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds };
687
+ };
688
+ /**
689
+ * Stamp each growing element's span onto its node, so its view can FLIP from the
690
+ * old rect to its own. `grows` is keyed by element id, flow-space.
691
+ */
692
+ const injectGrow = (nodes, grows) => {
693
+ if (!grows || grows.size === 0)
694
+ return nodes;
695
+ return nodes.map((n) => {
696
+ const g = grows.get(n.id);
697
+ // A child is hidden until the morph settles, and its absolute position also
698
+ // moves with its parent — one transform on the parent covers both.
699
+ if (!g || n.parentId)
700
+ return n;
701
+ return n.type === 'c4-group' || n.type === 'c4-node'
702
+ ? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
703
+ : n;
704
+ });
705
+ };
706
+ const renderLayout = (r, grows) => {
707
+ setNodes(injectGrow(r.nodes, grows));
708
+ setRfEdges(r.edges);
709
+ setLabelPositions(r.labelPositions);
710
+ setReady(true);
711
+ afterLayout(grows);
712
+ };
713
+ // Render the current open state. Uses the prefetched layout when present
714
+ // (instant swap), else computes it now.
715
+ useEffect(() => {
716
+ let alive = true;
717
+ setReady(false);
718
+ setSelectedId(null);
719
+ const openId = model.openContainerId ?? null;
720
+ const prevOpen = prevOpenRef.current;
721
+ const inputs = buildInputs(openId);
722
+ // Only a real open-state change animates; the first paint is already at rest.
723
+ if (paintedRef.current && prevOpen !== openId) {
724
+ setSettled(false);
725
+ if (settleTimer.current)
726
+ clearTimeout(settleTimer.current);
727
+ settleTimer.current = setTimeout(() => setSettled(true), MORPH_MS);
728
+ // Re-frame once the grow has landed, not while it is running. The drill-down
729
+ // more than doubles the boundary, so the extent the camera was fitted to no
730
+ // longer contains the content — but moving *during* the morph compounds with
731
+ // the FLIP glide, and every card then travels the sum of two animations
732
+ // instead of the one the FLIP specified (see `shouldRefitCamera`). Waiting
733
+ // out the morph is the honest way to get both.
734
+ if (cameraTimer.current)
735
+ clearTimeout(cameraTimer.current);
736
+ cameraTimer.current = setTimeout(fitSystem, CAMERA_AFTER_MORPH_MS);
737
+ }
738
+ paintedRef.current = true;
739
+ /**
740
+ * Every element whose rect changes between the previous and new layouts, so
741
+ * boundaries *and* cards grow/shrink and slide together. Empty when nothing
742
+ * changed (e.g. the initial paint).
743
+ *
744
+ * Frames alone are not enough: ELK re-lays out the whole diagram, so every
745
+ * root card moves too. Without a card in this set it keeps its new slot the
746
+ * instant the layout swaps — a teleport, which is the most visible part of a
747
+ * bad morph. Child nodes are excluded: they are hidden until the box settles,
748
+ * so there is nothing to animate, and a transform on them would only fight
749
+ * the parent's.
750
+ */
751
+ const growsFor = (next) => {
752
+ if (prevOpen === openId)
753
+ return null;
754
+ const prevLayout = layoutCache.current.get(prevOpen ?? '');
755
+ if (!prevLayout)
756
+ return null;
757
+ const grows = new Map();
758
+ const rootCardIds = (l) => l.nodes.filter((n) => !n.parentId).map((n) => n.id);
759
+ const ids = new Set([
760
+ ...prevLayout.groupBounds.keys(),
761
+ ...next.groupBounds.keys(),
762
+ ...rootCardIds(prevLayout),
763
+ ...rootCardIds(next),
764
+ ]);
765
+ for (const id of ids) {
766
+ const fromRect = containerRect(prevLayout, id);
767
+ const to = containerRect(next, id);
768
+ if (!fromRect || !to)
769
+ continue;
770
+ if (fromRect.x === to.x &&
771
+ fromRect.y === to.y &&
772
+ fromRect.width === to.width &&
773
+ fromRect.height === to.height) {
774
+ continue;
342
775
  }
343
- const origin = shellOrigin(parentId);
344
- const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
345
- return (parentId ? { ...node, parentId } : { ...node, parentId: undefined });
346
- });
347
- setNodes([...shells, ...leaves]);
348
- setRfEdges(withPaths);
349
- setReady(true);
350
- requestAnimationFrame(() => fitView({ padding: 0.15 }));
776
+ grows.set(id, { from: fromRect, to });
777
+ }
778
+ return grows.size > 0 ? grows : null;
779
+ };
780
+ const cached = layoutCache.current.get(openId ?? '');
781
+ if (cached) {
782
+ prevOpenRef.current = openId;
783
+ renderLayout(cached, growsFor(cached));
784
+ return () => {
785
+ alive = false;
786
+ };
787
+ }
788
+ void layoutFor(openId, inputs)
789
+ .then((r) => {
790
+ if (!alive)
791
+ return;
792
+ layoutCache.current.set(openId ?? '', r);
793
+ const span = growsFor(r);
794
+ prevOpenRef.current = openId;
795
+ renderLayout(r, span);
351
796
  })
352
797
  .catch((err) => {
353
798
  if (!alive)
354
799
  return;
355
800
  console.warn('[c4-graph] ELK layout failed, using grid:', err);
356
- setNodes(rfNodes);
357
- setRfEdges(rfEdges);
801
+ setNodes(inputs.rfNodes.map((n) => ({ ...n, parentId: undefined })));
802
+ setRfEdges(inputs.rfEdges);
358
803
  setReady(true);
359
- requestAnimationFrame(() => fitView({ padding: 0.15 }));
804
+ // A grid's geometry is arbitrary and nothing FLIPped from the previous
805
+ // layout, so this commit genuinely needs a fit.
806
+ afterLayout(null);
360
807
  });
361
808
  return () => {
362
809
  alive = false;
810
+ if (settleTimer.current) {
811
+ clearTimeout(settleTimer.current);
812
+ settleTimer.current = null;
813
+ }
814
+ // Same for the re-frame: it outlives the effect that armed it, and firing
815
+ // into an unmounted React Flow instance is a wasted camera move at best.
816
+ if (cameraTimer.current) {
817
+ clearTimeout(cameraTimer.current);
818
+ cameraTimer.current = null;
819
+ }
363
820
  };
364
821
  // eslint-disable-next-line react-hooks/exhaustive-deps
365
822
  }, [layoutKey]);
823
+ // After the first paint, prefetch the opened layout for every container, so a
824
+ // click swaps to a cached result instead of waiting on ELK.
825
+ useEffect(() => {
826
+ if (model.view !== 'container')
827
+ return;
828
+ let alive = true;
829
+ const containers = model.nodes.filter((n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id));
830
+ void (async () => {
831
+ for (const c of containers) {
832
+ if (!alive)
833
+ return;
834
+ if (layoutCache.current.has(c.id))
835
+ continue;
836
+ try {
837
+ const inputs = buildInputs(c.id);
838
+ const r = await layoutFor(c.id, inputs);
839
+ if (!alive)
840
+ return;
841
+ layoutCache.current.set(c.id, r);
842
+ }
843
+ catch {
844
+ // Prefetch is best-effort; a miss just falls back to compute-on-click.
845
+ }
846
+ }
847
+ })();
848
+ return () => {
849
+ alive = false;
850
+ };
851
+ // eslint-disable-next-line react-hooks/exhaustive-deps
852
+ }, [layoutKey, model.view]);
366
853
  const selected = useMemo(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
367
854
  /**
368
855
  * The components authored inside the selected element, if it is a container.
@@ -374,14 +861,32 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
374
861
  ? model.nodes.filter((n) => n.kind === 'component' &&
375
862
  (n.container === selected.id || n.parentId === selected.id))
376
863
  : [], [model, selected]);
377
- const displayNodes = useMemo(() => nodes.map((n) => n.type === 'c4-node'
378
- ? { ...n, data: { ...n.data, selected: n.id === selectedId } }
379
- : n), [nodes, selectedId]);
380
- // What the current view draws: runtime units at container level, components
381
- // at component level. Mirrors the `inView` filter used for the nodes.
382
- const runtime = model.nodes.filter((n) => model.view === 'component' ? n.kind === 'component' : n.kind === 'container').length;
383
- const outside = model.nodes.filter((n) => n.kind === 'external-system' || n.kind === 'person').length;
384
- return (_jsxs("div", { style: { position: 'relative', width: '100%', height: '100%', background: theme.colors.background }, children: [_jsx(GraphLayerStyle, {}), _jsx(ReactFlow, { nodes: displayNodes, edges: rfEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS, ...GRAPH_NAV_PROPS, onNodeClick: (_e, node) => {
864
+ /** The container currently opened for drill-down, if any. */
865
+ const openContainerId = model.openContainerId ?? null;
866
+ const displayNodes = useMemo(() => nodes.map((n) => {
867
+ if (n.type !== 'c4-node')
868
+ return n;
869
+ const element = n.data?.element;
870
+ // The nested components are already positioned inside the grown card,
871
+ // but they only *appear* once the grow settles — otherwise they are
872
+ // squeezed and clipped mid-animation.
873
+ const nested = element?.kind === 'component' &&
874
+ openContainerId != null &&
875
+ (element.container ?? element.parentId) === openContainerId;
876
+ return {
877
+ ...n,
878
+ ...(nested ? { hidden: !settled } : {}),
879
+ data: { ...n.data, selected: n.id === selectedId, settled },
880
+ };
881
+ }), [nodes, selectedId, settled, openContainerId]);
882
+ /**
883
+ * An edge's polyline is routed for the *settled* layout, so while the cards are
884
+ * gliding between the two slots the old lines would visibly point at the wrong
885
+ * boxes. Hiding them for the morph is honest and cheap; interpolating the
886
+ * polylines to match is not.
887
+ */
888
+ const displayEdges = useMemo(() => (settled ? rfEdges : rfEdges.map((e) => ({ ...e, hidden: true }))), [rfEdges, settled]);
889
+ return (_jsxs("div", { style: { position: 'relative', width: '100%', height: '100%', background: theme.colors.background }, children: [_jsx(GraphLayerStyle, {}), _jsx(ReactFlow, { nodes: displayNodes, edges: displayEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS, ...GRAPH_NAV_PROPS, onNodeClick: (_e, node) => {
385
890
  if (node.type !== 'c4-node')
386
891
  return;
387
892
  const next = node.id === selectedId ? null : node.id;
@@ -394,7 +899,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
394
899
  // Pin the flow to its container. The label overlay is a sibling sized
395
900
  // `inset: 0`, so the two must share an origin or `viewport.y` (measured
396
901
  // in the flow's own box) lands the chips off vertically.
397
- style: { width: '100%', height: '100%' }, children: _jsx(GraphChrome, {}) }), ready && labelPositions.size > 0 && (_jsx(C4EdgeLabels, { positions: labelPositions, edges: model.edges })), !ready && (_jsx("div", { style: {
902
+ style: { width: '100%', height: '100%' }, children: _jsx(GraphChrome, {}) }), ready && settled && labelPositions.size > 0 && (_jsx(C4EdgeLabels, { positions: labelPositions, edges: model.edges })), !ready && (_jsx("div", { style: {
398
903
  position: 'absolute',
399
904
  inset: 0,
400
905
  display: 'flex',
@@ -404,20 +909,7 @@ function Inner({ model, title, onSelectNode, gutter = 28 }) {
404
909
  color: muted,
405
910
  fontFamily: theme.fonts.monospace,
406
911
  fontSize: theme.fontSizes[0],
407
- }, children: "Laying out C4 view\u2026" })), title && (_jsxs("div", { style: {
408
- position: 'absolute',
409
- top: 12,
410
- left: 12,
411
- // Above the edge-label overlay (zIndex 5).
412
- zIndex: 10,
413
- fontSize: theme.fontSizes[2],
414
- fontWeight: 600,
415
- color: theme.colors.text,
416
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
417
- border: `1px solid ${theme.colors.border ?? '#333'}`,
418
- borderRadius: 6,
419
- padding: '4px 10px',
420
- }, children: [title, _jsxs("span", { style: { fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, marginLeft: 8 }, children: [model.view, " view \u00B7 ", runtime, " ", model.view === 'container' ? 'containers' : 'components', " \u00B7 ", outside, " outside"] })] })), selected && (_jsxs("div", { style: {
912
+ }, children: "Laying out C4 view\u2026" })), selected && (_jsxs("div", { style: {
421
913
  position: 'absolute',
422
914
  top: 12,
423
915
  right: 12,