@principal-ai/subsystems-react 0.53.1 → 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.
@@ -11,19 +11,25 @@ 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, useRef, 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
17
  import { Box, Minimize2 } from 'lucide-react';
18
- import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
18
+ import { absoluteBoundsOf, computeElkLayout, flowPositionOf, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
19
19
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
20
20
  import { deriveC4Groups, protocolColor } from './c4';
21
21
  import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
22
22
  import { TechMark, technologyBrand } from './techIcons';
23
23
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
24
24
  function C4NodeView(props) {
25
- const { element, selected, componentCount, onExpand, open, settled } = props.data;
25
+ const { element, selected, componentCount, onExpand, open, settled, growFrom, frameRect } = props.data;
26
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);
27
33
  // The card takes its box from the React Flow node rather than from its own
28
34
  // `nodeSize`, so on the opened container ELK's fitted size drives it — and
29
35
  // because the element persists across the swap, the card's own width/height
@@ -32,9 +38,16 @@ function C4NodeView(props) {
32
38
  // While grown it is taken out of flow: the node box already carries the layout
33
39
  // (and is what sibling containers and edges are positioned against), so the
34
40
  // card must not compete with it.
35
- return (_jsx("div", { style: grown
36
- ? { position: 'absolute', top: 0, left: 0, width: props.width, height: props.height }
37
- : undefined, children: _jsx(C4NodeCard, { node: element, selected: selected, componentCount: componentCount ?? 0, onExpand: onExpand, width: props.width, height: props.height, showDescription: settled !== false, handles: _jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", position: Position.Left, style: { opacity: 0 } }), _jsx(Handle, { type: "source", position: Position.Right, style: { opacity: 0 } })] }) }) }));
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 } })] }) }) }));
38
51
  }
39
52
  /**
40
53
  * Edge-label overlay.
@@ -115,36 +128,38 @@ function C4GroupView(props) {
115
128
  const onCollapse = props.data.onCollapse;
116
129
  const brand = props.data.brand;
117
130
  const { growFrom, frameRect } = props.data;
118
- // FLIP the real frame: start it transformed back onto the box's rect, then
119
- // release to identity so it grows into place. `transform-origin: top left`
120
- // makes the scale anchor match the absolute rects.
121
- const [grown, setGrown] = useState(!growFrom || !frameRect);
122
- useEffect(() => {
123
- if (!growFrom || !frameRect)
124
- return;
125
- setGrown(false);
126
- const raf = requestAnimationFrame(() => setGrown(true));
127
- return () => cancelAnimationFrame(raf);
128
- }, [growFrom, frameRect]);
129
- const dx = growFrom && frameRect ? growFrom.x - frameRect.x : 0;
130
- const dy = growFrom && frameRect ? growFrom.y - frameRect.y : 0;
131
- const sx = growFrom && frameRect && frameRect.width ? growFrom.width / frameRect.width : 1;
132
- const sy = growFrom && frameRect && frameRect.height ? growFrom.height / frameRect.height : 1;
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);
133
137
  // A container frame shows its technology mark (React/Bun/Node…) so the
134
138
  // boundary says what it is built with, same as the node it wraps. The system
135
139
  // frame has no single technology, so it keeps a neutral `Box`.
136
140
  const FrameIcon = isSystem || !brand ? Box : undefined;
137
141
  return (_jsx("div", { style: {
138
- width: '100%',
139
- 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(', '),
140
156
  boxSizing: 'border-box',
141
157
  borderRadius: 12,
142
158
  border: `2px dashed ${color}`,
143
159
  background: 'transparent',
144
160
  pointerEvents: 'none',
145
161
  transformOrigin: 'top left',
146
- transform: grown ? 'none' : `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
147
- transition: grown ? `transform ${MORPH_MS}ms ease-out` : 'none',
162
+ transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
148
163
  }, children: _jsxs("span", { style: {
149
164
  position: 'absolute',
150
165
  top: 0,
@@ -197,6 +212,128 @@ const edgeTypes = {
197
212
  /** Drill-down animation durations, in ms. */
198
213
  const MORPH_MS = 420;
199
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
+ }
200
337
  /** The container's rect in a laid-out state: its box node, or its frame bounds. */
201
338
  function containerRect(layout, id) {
202
339
  if (!layout)
@@ -214,6 +351,41 @@ function containerRect(layout, id) {
214
351
  height: node.height ?? 0,
215
352
  };
216
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
+ }
217
389
  function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }) {
218
390
  const { theme } = useTheme();
219
391
  const { fitView } = useReactFlow();
@@ -228,9 +400,10 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
228
400
  /** Prefetched layouts, keyed by open-container id ('' = closed). */
229
401
  const layoutCache = useRef(new Map());
230
402
  /**
231
- * False while the drill-down grow/shrink is in flight. It gates both halves of
232
- * the reveal: the opened container's components stay hidden, and every card
233
- * drops its description — returning only once the box settles.
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.
234
407
  *
235
408
  * Driven by one timer keyed to MORPH_MS rather than `transitionend`: the grow
236
409
  * is the card's own width/height transition *and* the system frame's
@@ -242,21 +415,29 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
242
415
  const paintedRef = useRef(false);
243
416
  /** The in-flight settle timer; cancelled if the open state changes again. */
244
417
  const settleTimer = useRef(null);
418
+ /** The post-morph re-frame; see `CAMERA_AFTER_MORPH_MS`. */
419
+ const cameraTimer = useRef(null);
245
420
  /** The open id rendered last, so a change can morph between the two layouts. */
246
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]);
247
430
  // Fit the camera AFTER the new nodes are committed (when `ready` flips true),
248
- // so `fitView` measures the real geometry. Fit the SYSTEM frame explicitly:
249
- // on a drill-down the drawn nodes are just the opened container's contents,
250
- // so fitting them would zoom to the container, not the whole system.
431
+ // so `fitView` measures the real geometry.
251
432
  useEffect(() => {
252
433
  if (!ready || !pendingFitRef.current)
253
434
  return;
254
435
  pendingFitRef.current = false;
255
- fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
256
- }, [ready, model.system.id, fitView]);
436
+ fitSystem();
437
+ }, [ready, fitSystem]);
257
438
  /** Called once the new layout is committed, from the layout effect. */
258
- const afterLayout = () => {
259
- pendingFitRef.current = true;
439
+ const afterLayout = (grows) => {
440
+ pendingFitRef.current = shouldRefitCamera(grows);
260
441
  };
261
442
  const layoutKey = useMemo(() => [
262
443
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
@@ -327,39 +508,10 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
327
508
  },
328
509
  };
329
510
  });
330
- // Every frame this layout draws: the system boundary, plus a container frame per
331
- // container whose components are in view (the component view leans on these).
332
- // The *opened* container's frame is dropped — it is drawn as a grown card
333
- // instead, as an ELK parent below.
334
- const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter((g) => g.id !== openId);
335
- const groups = frames.map((g) => ({
336
- id: g.id,
337
- memberIds: g.memberIds,
338
- ...(g.parentId ? { parentId: g.parentId } : {}),
339
- }));
340
- // Opened: the container becomes a real ELK parent so it is sized to hold
341
- // its components — the "layout B" the closed layout transitions to. Only in
342
- // the container view: that is where a container is a card, and where the
343
- // drill-down is offered.
344
- const openMembers = model.view === 'container' && openId
345
- ? model.nodes
346
- .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
347
- .map((n) => n.id)
348
- : [];
349
- if (openId && openMembers.length > 0) {
350
- groups.push({
351
- id: openId,
352
- memberIds: openMembers,
353
- // Never narrower than the closed card, so opening only ever grows the box.
354
- minWidth: NODE_W,
355
- // Room for the container's own label slot + technology row.
356
- padTop: OPEN_HEADER_PAD,
357
- });
358
- }
511
+ const { frames, groups } = c4GroupDefs(model, openId);
359
512
  // Bands come from the *frames* only. The opened container is an ELK parent,
360
513
  // not a band — treating it as one would push it into its own layer, away from
361
- // the containers it belongs beside (and, the system frame being flat, drag
362
- // that frame's union across the gap).
514
+ // the containers it belongs beside.
363
515
  const frameIds = frames.map((g) => g.id);
364
516
  const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
365
517
  const partitionByNode = new Map(model.nodes.map((n) => {
@@ -370,12 +522,6 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
370
522
  const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
371
523
  return [n.id, 1 + band];
372
524
  }));
373
- // The compound parent needs its band stated explicitly — partitioning is
374
- // activated on the root, and an unset parent falls to partition 0 (the
375
- // people band), which would land the opened container outside the frame.
376
- const openGroup = openId ? groups.find((g) => g.id === openId) : undefined;
377
- if (openId && openGroup)
378
- openGroup.partition = partitionByNode.get(openId) ?? 1;
379
525
  const drawnIds = new Set(rfNodes.map((n) => n.id));
380
526
  const rfEdges = model.edges
381
527
  .filter((e) => drawnIds.has(e.source) && drawnIds.has(e.target))
@@ -399,11 +545,22 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
399
545
  */
400
546
  const layoutFor = async (openId, inputs) => {
401
547
  const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
402
- // Flatten every frame *except* the opened container. The system frame is
403
- // decorative and flattens (one spacing, no nested edge runs); the opened
404
- // container has to be a real ELK parent so ELK sizes it to hold its nested
405
- // components — that fitted box is "layout B". Both passes are precomputed,
406
- // so the drill-down is a swap between two ELK results, not a reflow at click.
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.
407
564
  const result = await computeElkLayout(rfNodes, rfEdges, {
408
565
  direction: 'RIGHT',
409
566
  preserveNodePositions: false,
@@ -413,7 +570,7 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
413
570
  nodeSpacing: gutter * 2,
414
571
  edgeNodeSpacing: gutter,
415
572
  interLayerSpacing: 40,
416
- flatGroups: openId ? new Set(groups.map((g) => g.id).filter((id) => id !== openId)) : true,
573
+ flatGroups: model.view === 'component' ? true : new Set(),
417
574
  partitionByNode,
418
575
  });
419
576
  const positioned = new Map(result.nodes.map((n) => [n.id, n]));
@@ -425,12 +582,15 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
425
582
  labelPositions.set(e.id, pointAlongPath(pts, 0.5));
426
583
  return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
427
584
  });
428
- const builtGroups = new Set(result.groupBounds.keys());
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());
429
589
  // The opened container comes back from ELK as a parent, so it lands in
430
590
  // `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
431
591
  // from the shells; keep it in `groupBounds` so nested children can take
432
592
  // their coordinates relative to it.
433
- const shellBounds = new Map(result.groupBounds);
593
+ const shellBounds = new Map(groupBounds);
434
594
  if (openId)
435
595
  shellBounds.delete(openId);
436
596
  const groupById = new Map(frames.map((g) => [g.id, g]));
@@ -443,22 +603,26 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
443
603
  }
444
604
  return d;
445
605
  };
446
- const shellAbs = result.groupBounds;
447
- const shellOrigin = (id) => {
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) => {
448
611
  if (!id)
449
612
  return { x: 0, y: 0 };
450
- const b = shellAbs.get(id);
451
- return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
613
+ const r = absBoundsOf(id);
614
+ return r ? { x: r.x, y: r.y } : { x: 0, y: 0 };
452
615
  };
453
616
  // Only the system frame is emitted today (the drill-down's container frames
454
617
  // became grown cards), but the mapping stays keyed off the derived group so a
455
618
  // container frame can come back without reshaping this.
456
- const shells = [...shellBounds.entries()]
457
- .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
458
- .map(([id, b]) => {
619
+ const shells = [...shellBounds.keys()]
620
+ .sort((a, b) => depthOf(a) - depthOf(b))
621
+ .map((id) => {
459
622
  const def = groupById.get(id);
460
623
  const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
461
- const origin = shellOrigin(parentId);
624
+ const origin = parentOriginOf(parentId);
625
+ const b = absBoundsOf(id) ?? { x: 0, y: 0, width: 0, height: 0 };
462
626
  const element = model.nodes.find((n) => n.id === id);
463
627
  const frameColor = def?.kind === 'system'
464
628
  ? (theme.colors.text ?? '#888')
@@ -488,8 +652,6 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
488
652
  };
489
653
  });
490
654
  const leaves = rfNodes.map((n) => {
491
- const p = positioned.get(n.id);
492
- const abs = p ? p.position : n.position;
493
655
  let parentId;
494
656
  if (n.data && typeof n.data === 'object') {
495
657
  const modelNode = n.data.element;
@@ -504,29 +666,39 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
504
666
  if (candidate && builtGroups.has(candidate))
505
667
  parentId = candidate;
506
668
  }
507
- const origin = shellOrigin(parentId);
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;
508
674
  // The opened container is an ELK parent, so its box is whatever ELK fitted
509
675
  // to the nested components — `nodeSize` only knows the closed card.
510
- const fitted = openId === n.id ? result.groupBounds.get(openId) : undefined;
676
+ const fitted = openId === n.id ? groupBounds.get(openId) : undefined;
511
677
  const node = {
512
678
  ...n,
513
- position: { x: abs.x - origin.x, y: abs.y - origin.y },
679
+ position: { x: abs.x, y: abs.y },
514
680
  ...(fitted ? { width: fitted.width, height: fitted.height } : {}),
515
681
  };
516
682
  return (parentId ? { ...node, parentId } : { ...node, parentId: undefined });
517
683
  });
518
- return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds: result.groupBounds };
684
+ // `groupBounds`, not `result.groupBounds`: the rebased one. `Rendered` readers
685
+ // all assume absolute.
686
+ return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds };
519
687
  };
520
688
  /**
521
- * Stamp each growing frame's span onto its shell node, so its view can FLIP
522
- * from the old rect to its own. `grows` is keyed by frame id, flow-space.
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.
523
691
  */
524
692
  const injectGrow = (nodes, grows) => {
525
693
  if (!grows || grows.size === 0)
526
694
  return nodes;
527
695
  return nodes.map((n) => {
528
696
  const g = grows.get(n.id);
529
- return n.type === 'c4-group' && g
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'
530
702
  ? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
531
703
  : n;
532
704
  });
@@ -536,7 +708,7 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
536
708
  setRfEdges(r.edges);
537
709
  setLabelPositions(r.labelPositions);
538
710
  setReady(true);
539
- afterLayout();
711
+ afterLayout(grows);
540
712
  };
541
713
  // Render the current open state. Uses the prefetched layout when present
542
714
  // (instant swap), else computes it now.
@@ -553,12 +725,28 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
553
725
  if (settleTimer.current)
554
726
  clearTimeout(settleTimer.current);
555
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);
556
737
  }
557
738
  paintedRef.current = true;
558
739
  /**
559
- * Every frame whose rect changes between the previous and new layouts, so
560
- * all boundaries can grow/shrink together (container frames *and* the
561
- * system frame). Empty when nothing changed (e.g. the initial paint).
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.
562
750
  */
563
751
  const growsFor = (next) => {
564
752
  if (prevOpen === openId)
@@ -567,7 +755,13 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
567
755
  if (!prevLayout)
568
756
  return null;
569
757
  const grows = new Map();
570
- const ids = new Set([...prevLayout.groupBounds.keys(), ...next.groupBounds.keys()]);
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
+ ]);
571
765
  for (const id of ids) {
572
766
  const fromRect = containerRect(prevLayout, id);
573
767
  const to = containerRect(next, id);
@@ -607,7 +801,9 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
607
801
  setNodes(inputs.rfNodes.map((n) => ({ ...n, parentId: undefined })));
608
802
  setRfEdges(inputs.rfEdges);
609
803
  setReady(true);
610
- afterLayout();
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);
611
807
  });
612
808
  return () => {
613
809
  alive = false;
@@ -615,6 +811,12 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
615
811
  clearTimeout(settleTimer.current);
616
812
  settleTimer.current = null;
617
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
+ }
618
820
  };
619
821
  // eslint-disable-next-line react-hooks/exhaustive-deps
620
822
  }, [layoutKey]);
@@ -677,7 +879,14 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
677
879
  data: { ...n.data, selected: n.id === selectedId, settled },
678
880
  };
679
881
  }), [nodes, selectedId, settled, openContainerId]);
680
- 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) => {
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) => {
681
890
  if (node.type !== 'c4-node')
682
891
  return;
683
892
  const next = node.id === selectedId ? null : node.id;
@@ -690,7 +899,7 @@ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter
690
899
  // Pin the flow to its container. The label overlay is a sibling sized
691
900
  // `inset: 0`, so the two must share an origin or `viewport.y` (measured
692
901
  // in the flow's own box) lands the chips off vertically.
693
- 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: {
694
903
  position: 'absolute',
695
904
  inset: 0,
696
905
  display: 'flex',