@principal-ai/subsystems-react 0.52.0 → 0.53.1

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 (36) hide show
  1. package/dist/subsystem/C4Graph.d.ts +8 -1
  2. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  3. package/dist/subsystem/C4Graph.js +470 -155
  4. package/dist/subsystem/C4Graph.js.map +1 -1
  5. package/dist/subsystem/C4NodeCard.d.ts +42 -1
  6. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  7. package/dist/subsystem/C4NodeCard.js +75 -20
  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 +13 -4
  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/subsystem/model.d.ts +35 -245
  17. package/dist/subsystem/model.d.ts.map +1 -1
  18. package/dist/subsystem/model.js +11 -29
  19. package/dist/subsystem/model.js.map +1 -1
  20. package/dist/utils/elkLayout.d.ts +26 -2
  21. package/dist/utils/elkLayout.d.ts.map +1 -1
  22. package/dist/utils/elkLayout.js +40 -8
  23. package/dist/utils/elkLayout.js.map +1 -1
  24. package/package.json +4 -3
  25. package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
  26. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
  27. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
  28. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +131 -0
  29. package/src/subsystem/C4Graph.tsx +578 -198
  30. package/src/subsystem/C4NodeCard.test.ts +6 -3
  31. package/src/subsystem/C4NodeCard.tsx +158 -49
  32. package/src/subsystem/IssueList.category.test.ts +39 -0
  33. package/src/subsystem/IssueList.tsx +14 -3
  34. package/src/subsystem/c4.ts +74 -3
  35. package/src/subsystem/model.ts +62 -305
  36. package/src/utils/elkLayout.ts +60 -10
@@ -11,7 +11,7 @@
11
11
  * drawers, no trail playback.
12
12
  */
13
13
 
14
- import { useEffect, useMemo, useState } from 'react';
14
+ import { useEffect, useMemo, useRef, useState } from 'react';
15
15
  import {
16
16
  Edge,
17
17
  EdgeProps,
@@ -26,17 +26,28 @@ import {
26
26
  useViewport,
27
27
  } from '@xyflow/react';
28
28
  import { useTheme } from '@principal-ade/industry-theme';
29
+ import { Box, Minimize2 } from 'lucide-react';
29
30
  import { computeElkLayout, pointAlongPath, pointsToSmoothPath } from '../utils/elkLayout';
31
+ import type { CompoundGroupDef } from '../utils/elkLayout';
30
32
  import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, C4_LABEL_WIDTH, estimateEdgeLabelWidth } from '../utils/edgeLabel';
31
- import { protocolColor } from './c4';
32
- import { C4NodeCard, NODE_H, NODE_W, nodeSize } from './C4NodeCard';
33
+ import { deriveC4Groups, protocolColor } from './c4';
34
+ import { C4NodeCard, NODE_H, NODE_W, OPEN_HEADER_PAD, nodeSize, nodeStyle } from './C4NodeCard';
35
+ import { TechMark, technologyBrand } from './techIcons';
36
+ import type { TechBrand } from './techIcons';
33
37
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
34
38
  import type { C4Element, C4Model } from './c4';
35
39
 
36
40
  export interface C4GraphProps {
37
41
  model: C4Model;
38
- title?: string;
39
42
  onSelectNode?: (id: string | null) => void;
43
+ /**
44
+ * Drill-down. Called from the expand affordance on a container card; the host
45
+ * sets `openContainerId` on the model to swap the box for a frame with its
46
+ * components inside.
47
+ */
48
+ onOpenContainer?: (id: string) => void;
49
+ /** Called from the collapse affordance inside an opened container frame. */
50
+ onCloseContainer?: () => void;
40
51
  /**
41
52
  * Breathing room around each card, in flow px, applied as ELK spacing (not by
42
53
  * inflating the node box — that left the edge lines starting short of the
@@ -46,19 +57,57 @@ export interface C4GraphProps {
46
57
  gutter?: number;
47
58
  }
48
59
 
49
- function C4NodeView(props: NodeProps<Node<{ element: C4Element; selected: boolean }>>) {
50
- const { element, selected } = props.data;
60
+ function C4NodeView(
61
+ props: NodeProps<
62
+ Node<{
63
+ element: C4Element;
64
+ selected: boolean;
65
+ componentCount?: number;
66
+ onExpand?: () => void;
67
+ /** This container is the open one: ELK sized the box to hold its components. */
68
+ open?: boolean;
69
+ /**
70
+ * False while the drill-down grow/shrink is in flight. The description is
71
+ * hidden for the whole animation and only returns once the box settles.
72
+ */
73
+ settled?: boolean;
74
+ }>
75
+ >,
76
+ ) {
77
+ const { element, selected, componentCount, onExpand, open, settled } = props.data;
78
+ const grown = open === true;
79
+ // The card takes its box from the React Flow node rather than from its own
80
+ // `nodeSize`, so on the opened container ELK's fitted size drives it — and
81
+ // because the element persists across the swap, the card's own width/height
82
+ // transition *is* the grow. No FLIP needed, and no text distortion.
83
+ //
84
+ // While grown it is taken out of flow: the node box already carries the layout
85
+ // (and is what sibling containers and edges are positioned against), so the
86
+ // card must not compete with it.
51
87
  return (
52
- <C4NodeCard
53
- node={element}
54
- selected={selected}
55
- handles={
56
- <>
57
- <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
58
- <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
59
- </>
88
+ <div
89
+ style={
90
+ grown
91
+ ? { position: 'absolute', top: 0, left: 0, width: props.width, height: props.height }
92
+ : undefined
60
93
  }
61
- />
94
+ >
95
+ <C4NodeCard
96
+ node={element}
97
+ selected={selected}
98
+ componentCount={componentCount ?? 0}
99
+ onExpand={onExpand}
100
+ width={props.width}
101
+ height={props.height}
102
+ showDescription={settled !== false}
103
+ handles={
104
+ <>
105
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
106
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
107
+ </>
108
+ }
109
+ />
110
+ </div>
62
111
  );
63
112
  }
64
113
 
@@ -172,9 +221,46 @@ function C4EdgeView({ data, markerEnd }: EdgeProps<Edge<{ path?: string; color?:
172
221
  );
173
222
  }
174
223
 
175
- function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
224
+ function C4GroupView(
225
+ props: NodeProps<
226
+ Node<{
227
+ label: string;
228
+ kind: 'system' | 'container';
229
+ color: string;
230
+ onCollapse?: () => void;
231
+ brand?: TechBrand;
232
+ /** FLIP: absolute flow rects — where to grow from, and the frame's own. */
233
+ growFrom?: FlowRect;
234
+ frameRect?: FlowRect;
235
+ }>
236
+ >,
237
+ ) {
176
238
  const { theme } = useTheme();
177
239
  const color = props.data.color;
240
+ const isSystem = props.data.kind === 'system';
241
+ const onCollapse = props.data.onCollapse;
242
+ const brand = props.data.brand;
243
+ const { growFrom, frameRect } = props.data;
244
+
245
+ // FLIP the real frame: start it transformed back onto the box's rect, then
246
+ // release to identity so it grows into place. `transform-origin: top left`
247
+ // makes the scale anchor match the absolute rects.
248
+ const [grown, setGrown] = useState(!growFrom || !frameRect);
249
+ useEffect(() => {
250
+ if (!growFrom || !frameRect) return;
251
+ setGrown(false);
252
+ const raf = requestAnimationFrame(() => setGrown(true));
253
+ return () => cancelAnimationFrame(raf);
254
+ }, [growFrom, frameRect]);
255
+ const dx = growFrom && frameRect ? growFrom.x - frameRect.x : 0;
256
+ const dy = growFrom && frameRect ? growFrom.y - frameRect.y : 0;
257
+ const sx = growFrom && frameRect && frameRect.width ? growFrom.width / frameRect.width : 1;
258
+ const sy = growFrom && frameRect && frameRect.height ? growFrom.height / frameRect.height : 1;
259
+
260
+ // A container frame shows its technology mark (React/Bun/Node…) so the
261
+ // boundary says what it is built with, same as the node it wraps. The system
262
+ // frame has no single technology, so it keeps a neutral `Box`.
263
+ const FrameIcon = isSystem || !brand ? Box : undefined;
178
264
  return (
179
265
  <div
180
266
  style={{
@@ -185,6 +271,9 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
185
271
  border: `2px dashed ${color}`,
186
272
  background: 'transparent',
187
273
  pointerEvents: 'none',
274
+ transformOrigin: 'top left',
275
+ transform: grown ? 'none' : `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
276
+ transition: grown ? `transform ${MORPH_MS}ms ease-out` : 'none',
188
277
  }}
189
278
  >
190
279
  <span
@@ -194,6 +283,9 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
194
283
  transform: 'translateY(-50%)',
195
284
  left: 12,
196
285
  zIndex: 1,
286
+ display: 'inline-flex',
287
+ alignItems: 'center',
288
+ gap: 6,
197
289
  fontFamily: theme.fonts.monospace,
198
290
  fontSize: theme.fontSizes[2],
199
291
  fontWeight: 600,
@@ -210,7 +302,39 @@ function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'co
210
302
  whiteSpace: 'nowrap',
211
303
  }}
212
304
  >
305
+ {FrameIcon ? (
306
+ <FrameIcon size={13} strokeWidth={2.25} aria-hidden />
307
+ ) : brand ? (
308
+ <TechMark brand={brand} size={14} />
309
+ ) : null}
213
310
  {props.data.label}
311
+ {onCollapse && (
312
+ <button
313
+ type="button"
314
+ onClick={(e) => {
315
+ e.stopPropagation();
316
+ onCollapse();
317
+ }}
318
+ aria-label="Collapse"
319
+ style={{
320
+ pointerEvents: 'auto',
321
+ display: 'inline-flex',
322
+ alignItems: 'center',
323
+ justifyContent: 'center',
324
+ width: 18,
325
+ height: 18,
326
+ marginLeft: 2,
327
+ padding: 0,
328
+ border: 'none',
329
+ background: 'transparent',
330
+ color,
331
+ cursor: 'pointer',
332
+ lineHeight: 1,
333
+ }}
334
+ >
335
+ <Minimize2 size={12} strokeWidth={2.5} />
336
+ </button>
337
+ )}
214
338
  </span>
215
339
  </div>
216
340
  );
@@ -220,12 +344,44 @@ const nodeTypes = {
220
344
  'c4-node': C4NodeView,
221
345
  'c4-group': C4GroupView,
222
346
  };
223
-
224
347
  const edgeTypes = {
225
348
  'c4-edge': C4EdgeView,
226
349
  };
227
350
 
228
- function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
351
+ /** Drill-down animation durations, in ms. */
352
+ const MORPH_MS = 420;
353
+ const CAMERA_MS = 520;
354
+
355
+ /** A fully laid-out open state: the RF nodes/edges and label anchors. */
356
+ interface Rendered {
357
+ nodes: Node[];
358
+ edges: Edge[];
359
+ labelPositions: Map<string, { x: number; y: number }>;
360
+ groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
361
+ }
362
+
363
+ /** Flow-space rect of a box node or a frame, for the drill-down morph. */
364
+ interface FlowRect { x: number; y: number; width: number; height: number }
365
+
366
+ /** A frame's growth for the drill-down morph: its flow rect before → after. */
367
+ interface FrameGrow { from: FlowRect; to: FlowRect }
368
+
369
+ /** The container's rect in a laid-out state: its box node, or its frame bounds. */
370
+ function containerRect(layout: Rendered | undefined, id: string): FlowRect | null {
371
+ if (!layout) return null;
372
+ const frame = layout.groupBounds.get(id);
373
+ if (frame) return { x: frame.x, y: frame.y, width: frame.width, height: frame.height };
374
+ const node = layout.nodes.find((n) => n.id === id);
375
+ if (!node) return null;
376
+ return {
377
+ x: node.position.x,
378
+ y: node.position.y,
379
+ width: node.width ?? 0,
380
+ height: node.height ?? 0,
381
+ };
382
+ }
383
+
384
+ function Inner({ model, onSelectNode, onOpenContainer, onCloseContainer, gutter = 28 }: C4GraphProps) {
229
385
  const { theme } = useTheme();
230
386
  const { fitView } = useReactFlow();
231
387
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -236,213 +392,450 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
236
392
  const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
237
393
  () => new Map(),
238
394
  );
395
+ /** Set by the layout effect; consumed by the `ready` effect to run the fit. */
396
+ const pendingFitRef = useRef(false);
397
+ /** Prefetched layouts, keyed by open-container id ('' = closed). */
398
+ const layoutCache = useRef(new Map<string, Rendered>());
399
+ /**
400
+ * False while the drill-down grow/shrink is in flight. It gates both halves of
401
+ * the reveal: the opened container's components stay hidden, and every card
402
+ * drops its description — returning only once the box settles.
403
+ *
404
+ * Driven by one timer keyed to MORPH_MS rather than `transitionend`: the grow
405
+ * is the card's own width/height transition *and* the system frame's
406
+ * transform, so a DOM event would fire twice per open and gate on the wrong
407
+ * property.
408
+ */
409
+ const [settled, setSettled] = useState(true);
410
+ /** Set once the first layout has rendered, so only a real change animates. */
411
+ const paintedRef = useRef(false);
412
+ /** The in-flight settle timer; cancelled if the open state changes again. */
413
+ const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
414
+ /** The open id rendered last, so a change can morph between the two layouts. */
415
+ const prevOpenRef = useRef<string | null>(null);
416
+
417
+ // Fit the camera AFTER the new nodes are committed (when `ready` flips true),
418
+ // so `fitView` measures the real geometry. Fit the SYSTEM frame explicitly:
419
+ // on a drill-down the drawn nodes are just the opened container's contents,
420
+ // so fitting them would zoom to the container, not the whole system.
421
+ useEffect(() => {
422
+ if (!ready || !pendingFitRef.current) return;
423
+ pendingFitRef.current = false;
424
+ fitView({ padding: 0.15, duration: CAMERA_MS, maxZoom: 1, nodes: [{ id: model.system.id }] });
425
+ }, [ready, model.system.id, fitView]);
426
+
427
+ /** Called once the new layout is committed, from the layout effect. */
428
+ const afterLayout = () => {
429
+ pendingFitRef.current = true;
430
+ };
239
431
 
240
432
  const layoutKey = useMemo(
241
433
  () =>
242
434
  [
243
435
  ...model.nodes.map((n) => `${n.id}\0${n.parentId ?? ''}\0${n.label}`).sort(),
244
- ...model.groups.map((g) => `${g.id}\0${g.parentId ?? ''}\0${g.memberIds.length}`).sort(),
245
436
  ...model.edges.map((e) => `${e.id}\0${e.count}`).sort(),
437
+ `open:${model.openContainerId ?? ''}`,
246
438
  ].join('\n'),
247
439
  [model],
248
440
  );
249
441
 
250
- useEffect(() => {
251
- let alive = true;
252
- setReady(false);
253
- setSelectedId(null);
254
-
442
+ /**
443
+ * Build the React Flow inputs for a given open state — everything ELK needs,
444
+ * independent of whether a layout has been run yet. Pure over `openId`, so it
445
+ * serves both the rendered state and the background prefetch.
446
+ */
447
+ const buildInputs = (openId: string | null) => {
255
448
  // Draw only the elements for this view. A container diagram shows the
256
449
  // runtime units (containers, externals, people); components belong to the
257
- // component view. Components are still carried on the model (see the side
258
- // panel), just not drawn here.
259
- const inView = (n: C4Element): boolean =>
260
- model.view === 'component' ? n.kind !== 'container' : n.kind !== 'component';
261
-
262
- // Layout against the true card size, so ELK's ports sit on the card's
263
- // border — an inflated box leaves the line starting short of the card edge.
264
- // The `gutter` becomes ELK spacing instead (see `nodeSpacing` below).
450
+ // component view — or to a container that has been opened.
451
+ const inView = (n: C4Element): boolean => {
452
+ if (model.view === 'component') return n.kind !== 'container';
453
+ if (n.kind === 'component') return (n.container ?? n.parentId) === openId;
454
+ // A container is always drawn — it is a card either way. Opened, it grows
455
+ // and nests its components; closed, it is the bare box.
456
+ return true;
457
+ };
458
+
459
+ const childComponentCount = new Map<string, number>();
460
+ for (const n of model.nodes) {
461
+ if (n.kind !== 'component') continue;
462
+ const parent = n.container ?? n.parentId;
463
+ if (parent) childComponentCount.set(parent, (childComponentCount.get(parent) ?? 0) + 1);
464
+ }
465
+
265
466
  const rfNodes: Node[] = model.nodes.filter(inView).map((n, i) => {
266
467
  const size = nodeSize(n);
468
+ // A component of the opened container nests under it (a React Flow child),
469
+ // so edges between components can be drawn later. Everything else keeps its
470
+ // authored parentId.
471
+ const nested = n.kind === 'component' && openId ? openId : undefined;
472
+ const parentId = nested ?? n.parentId;
267
473
  return {
268
474
  id: n.id,
269
475
  type: 'c4-node',
270
- // Fallback grid pitch uses the container box — the largest, so the
271
- // pre-layout positions never overlap. ELK replaces them on success.
272
476
  position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
273
477
  width: size.width,
274
478
  height: size.height,
275
- ...(n.parentId ? { parentId: n.parentId } : {}),
276
- data: { element: n, selected: false },
479
+ ...(parentId ? { parentId } : {}),
480
+ // Nested cards are separate node divs in the viewport, so they must be
481
+ // told to paint above the grown container they sit inside — DOM order
482
+ // follows the model, not the containment.
483
+ ...(nested ? { zIndex: 2 } : {}),
484
+ data: {
485
+ element: n,
486
+ selected: false,
487
+ componentCount: childComponentCount.get(n.id) ?? 0,
488
+ // A container with components opens; the same affordance closes it
489
+ // again, so the toggle lives in one place.
490
+ ...(n.kind === 'container' && (childComponentCount.get(n.id) ?? 0) > 0 && onOpenContainer
491
+ ? {
492
+ onExpand: () => {
493
+ if (n.id === openId) onCloseContainer?.();
494
+ else onOpenContainer?.(n.id);
495
+ },
496
+ open: n.id === openId,
497
+ }
498
+ : {}),
499
+ },
277
500
  };
278
501
  });
279
502
 
280
- const groups = model.groups.map((g) => ({
503
+ // Every frame this layout draws: the system boundary, plus a container frame per
504
+ // container whose components are in view (the component view leans on these).
505
+ // The *opened* container's frame is dropped — it is drawn as a grown card
506
+ // instead, as an ELK parent below.
507
+ const frames = deriveC4Groups({ ...model, openContainerId: openId }).filter(
508
+ (g) => g.id !== openId,
509
+ );
510
+ const groups: CompoundGroupDef[] = frames.map((g) => ({
281
511
  id: g.id,
282
512
  memberIds: g.memberIds,
283
513
  ...(g.parentId ? { parentId: g.parentId } : {}),
284
514
  }));
285
515
 
286
- // Band nodes so a flat layout still reads as C4 and frames can't interleave.
287
- //
288
- // Flat mode has no ELK parents, so without bands ELK mixes members of
289
- // different frames across the whole graph — their synthesized bounding
290
- // boxes then overlap. A partition per frame keeps each frame's members
291
- // contiguous; people lead, externals trail.
292
- //
293
- // Order (ascending = earlier in the layout direction): people, then each
294
- // frame in model order, then external systems.
295
- const frameIds = model.groups.map((g) => g.id);
516
+ // Opened: the container becomes a real ELK parent so it is sized to hold
517
+ // its components — the "layout B" the closed layout transitions to. Only in
518
+ // the container view: that is where a container is a card, and where the
519
+ // drill-down is offered.
520
+ const openMembers =
521
+ model.view === 'container' && openId
522
+ ? model.nodes
523
+ .filter((n) => n.kind === 'component' && (n.container ?? n.parentId) === openId)
524
+ .map((n) => n.id)
525
+ : [];
526
+ if (openId && openMembers.length > 0) {
527
+ groups.push({
528
+ id: openId,
529
+ memberIds: openMembers,
530
+ // Never narrower than the closed card, so opening only ever grows the box.
531
+ minWidth: NODE_W,
532
+ // Room for the container's own label slot + technology row.
533
+ padTop: OPEN_HEADER_PAD,
534
+ });
535
+ }
536
+
537
+ // Bands come from the *frames* only. The opened container is an ELK parent,
538
+ // not a band — treating it as one would push it into its own layer, away from
539
+ // the containers it belongs beside (and, the system frame being flat, drag
540
+ // that frame's union across the gap).
541
+ const frameIds = frames.map((g) => g.id);
296
542
  const frameIndex = new Map(frameIds.map((id, i) => [id, i]));
297
543
  const partitionByNode = new Map<string, number>(
298
544
  model.nodes.map((n) => {
299
545
  if (n.kind === 'person') return [n.id, 0];
300
546
  if (n.kind === 'external-system') return [n.id, frameIds.length + 1];
301
- // A node inside a frame shares its frame's band; a frame-less node
302
- // (a container at container view) gets its own by id order.
303
547
  const band = n.parentId ? (frameIndex.get(n.parentId) ?? 0) : (frameIndex.get(n.id) ?? 0);
304
548
  return [n.id, 1 + band];
305
549
  }),
306
550
  );
551
+ // The compound parent needs its band stated explicitly — partitioning is
552
+ // activated on the root, and an unset parent falls to partition 0 (the
553
+ // people band), which would land the opened container outside the frame.
554
+ const openGroup = openId ? groups.find((g) => g.id === openId) : undefined;
555
+ if (openId && openGroup) openGroup.partition = partitionByNode.get(openId) ?? 1;
307
556
 
308
- const rfEdges: Edge[] = model.edges.map((e) => {
309
- // Colour keys off the protocol, not the trail verb: on a container
310
- // diagram the transport is what a reader distinguishes at a glance.
311
- const color = protocolColor(e.protocol);
312
- return {
313
- id: e.id,
314
- type: 'c4-edge',
315
- source: e.source,
316
- target: e.target,
317
- // The reserved label box and the chip must agree on size, so ELK is
318
- // given the same text the chip draws (protocol first, then label). The
319
- // chip itself is the C4EdgeLabels overlay, not this field.
320
- label: e.protocol ?? e.label,
321
- markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
322
- data: { edge: e, color, path: '' },
323
- };
324
- });
557
+ const drawnIds = new Set(rfNodes.map((n) => n.id));
558
+ const rfEdges: Edge[] = model.edges
559
+ .filter((e) => drawnIds.has(e.source) && drawnIds.has(e.target))
560
+ .map((e) => {
561
+ const color = protocolColor(e.protocol);
562
+ return {
563
+ id: e.id,
564
+ type: 'c4-edge',
565
+ source: e.source,
566
+ target: e.target,
567
+ label: e.protocol ?? e.label,
568
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
569
+ data: { edge: e, color, path: '' },
570
+ };
571
+ });
325
572
 
326
- void computeElkLayout(rfNodes, rfEdges, {
573
+ return { rfNodes, rfEdges, groups, partitionByNode, frames };
574
+ };
575
+
576
+ /**
577
+ * Run ELK for one open state and turn the result into React Flow nodes /
578
+ * edges. The heavy step the prefetch caches.
579
+ */
580
+ const layoutFor = async (
581
+ openId: string | null,
582
+ inputs: ReturnType<typeof buildInputs>,
583
+ ): Promise<Rendered> => {
584
+ const { rfNodes, rfEdges, groups, partitionByNode, frames } = inputs;
585
+ // Flatten every frame *except* the opened container. The system frame is
586
+ // decorative and flattens (one spacing, no nested edge runs); the opened
587
+ // container has to be a real ELK parent so ELK sizes it to hold its nested
588
+ // components — that fitted box is "layout B". Both passes are precomputed,
589
+ // so the drill-down is a swap between two ELK results, not a reflow at click.
590
+ const result = await computeElkLayout(rfNodes, rfEdges, {
327
591
  direction: 'RIGHT',
328
592
  preserveNodePositions: false,
329
593
  groups,
330
594
  keepSingletonGroups: true,
331
595
  edgeLabels: { enabled: true, placement: 'CENTER', width: C4_LABEL_WIDTH, measure: estimateEdgeLabelWidth },
332
- // The gutter that used to inflate each node box is now real spacing, so
333
- // the ports still sit on the card edge.
334
596
  nodeSpacing: gutter * 2,
335
597
  edgeNodeSpacing: gutter,
336
- // Between-layer/between-partition gap. This is what keeps the external
337
- // band clear of the system frame's padding — ELK gives partitions zero
338
- // default separation. It stacks on top of ELK's reserved label layer, so
339
- // it is kept modest.
340
598
  interLayerSpacing: 40,
341
- // Draw the system frame as a decoration around the boxes rather than an
342
- // ELK parent. Nesting made in-frame edges inherit the frame's padding and
343
- // spacing, so they ran longer than a root-level edge touching the same
344
- // nodes; flattening gives every edge one spacing.
345
- flatGroups: true,
599
+ flatGroups: openId ? new Set(groups.map((g) => g.id).filter((id) => id !== openId)) : true,
346
600
  partitionByNode,
347
- })
348
- .then((result) => {
349
- if (!alive) return;
350
- const positioned = new Map(result.nodes.map((n) => [n.id, n]));
351
- // Draw each edge along ELK's routed polyline and place its label from
352
- // the same points — one geometry for the line and the chip.
353
- const labelPositions = new Map<string, { x: number; y: number }>();
354
- const withPaths = rfEdges.map((e) => {
355
- const pts = result.edgePathPoints?.get(e.id);
356
- if (!pts || pts.length === 0) return e;
357
- labelPositions.set(e.id, pointAlongPath(pts, 0.5));
358
- // Rounded corners on ELK's orthogonal route — gentler than raw right
359
- // angles, and still the exact polyline the label is placed on.
360
- return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
361
- });
362
- setLabelPositions(labelPositions);
363
- const builtGroups = new Set(result.groupBounds.keys());
364
- const groupById = new Map(model.groups.map((g) => [g.id, g]));
365
-
366
- const depthOf = (id: string): number => {
367
- let d = 0;
368
- let g = groupById.get(id);
369
- while (g?.parentId) {
370
- d += 1;
371
- g = groupById.get(g.parentId);
372
- }
373
- return d;
374
- };
601
+ });
602
+
603
+ const positioned = new Map(result.nodes.map((n) => [n.id, n]));
604
+ const labelPositions = new Map<string, { x: number; y: number }>();
605
+ const withPaths = rfEdges.map((e) => {
606
+ const pts = result.edgePathPoints?.get(e.id);
607
+ if (!pts || pts.length === 0) return e;
608
+ labelPositions.set(e.id, pointAlongPath(pts, 0.5));
609
+ return { ...e, data: { ...e.data, path: pointsToSmoothPath(pts, 12) } };
610
+ });
611
+
612
+ const builtGroups = new Set(result.groupBounds.keys());
613
+ // The opened container comes back from ELK as a parent, so it lands in
614
+ // `groupBounds` — but it is drawn as a grown *card*, not a frame. Drop it
615
+ // from the shells; keep it in `groupBounds` so nested children can take
616
+ // their coordinates relative to it.
617
+ const shellBounds = new Map(result.groupBounds);
618
+ if (openId) shellBounds.delete(openId);
375
619
 
376
- // Shell positions from flat synthesis are absolute. React Flow reads a
377
- // child's `position` as relative to its parent, so a shell nested in
378
- // another shell must be made relative to it.
379
- const shellAbs = new Map(result.groupBounds);
380
- const shellOrigin = (id: string | undefined): { x: number; y: number } => {
381
- if (!id) return { x: 0, y: 0 };
382
- const b = shellAbs.get(id);
383
- return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
620
+ const groupById = new Map(frames.map((g) => [g.id, g]));
621
+ const depthOf = (id: string): number => {
622
+ let d = 0;
623
+ let g = groupById.get(id);
624
+ while (g?.parentId) {
625
+ d += 1;
626
+ g = groupById.get(g.parentId);
627
+ }
628
+ return d;
629
+ };
630
+
631
+ const shellAbs = result.groupBounds;
632
+ const shellOrigin = (id: string | undefined): { x: number; y: number } => {
633
+ if (!id) return { x: 0, y: 0 };
634
+ const b = shellAbs.get(id);
635
+ return b ? { x: b.x, y: b.y } : { x: 0, y: 0 };
636
+ };
637
+
638
+ // Only the system frame is emitted today (the drill-down's container frames
639
+ // became grown cards), but the mapping stays keyed off the derived group so a
640
+ // container frame can come back without reshaping this.
641
+ const shells: Node[] = [...shellBounds.entries()]
642
+ .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
643
+ .map(([id, b]) => {
644
+ const def = groupById.get(id);
645
+ const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
646
+ const origin = shellOrigin(parentId);
647
+ const element = model.nodes.find((n) => n.id === id);
648
+ const frameColor =
649
+ def?.kind === 'system'
650
+ ? (theme.colors.text ?? '#888')
651
+ : element
652
+ ? nodeStyle(element, theme).color
653
+ : (theme.colors.accent ?? theme.colors.info);
654
+ return {
655
+ id,
656
+ type: 'c4-group',
657
+ position: { x: b.x - origin.x, y: b.y - origin.y },
658
+ width: Math.max(200, b.width),
659
+ height: Math.max(120, b.height),
660
+ draggable: false,
661
+ selectable: false,
662
+ ...(parentId ? { parentId } : {}),
663
+ data: {
664
+ label: def?.label ?? id,
665
+ kind: def?.kind ?? 'container',
666
+ color: frameColor,
667
+ ...(def?.kind !== 'system' && element
668
+ ? { brand: technologyBrand('technology' in element ? element.technology : undefined) }
669
+ : {}),
670
+ ...(def?.kind === 'container' && openId === id && onCloseContainer
671
+ ? { onCollapse: () => onCloseContainer?.() }
672
+ : {}),
673
+ },
384
674
  };
675
+ });
385
676
 
386
- const shells: Node[] = [...result.groupBounds.entries()]
387
- .sort((a, b) => depthOf(a[0]) - depthOf(b[0]))
388
- .map(([id, b]) => {
389
- const def = groupById.get(id);
390
- const parentId = def?.parentId && builtGroups.has(def.parentId) ? def.parentId : undefined;
391
- const origin = shellOrigin(parentId);
392
- return {
393
- id,
394
- type: 'c4-group',
395
- position: { x: b.x - origin.x, y: b.y - origin.y },
396
- width: Math.max(200, b.width),
397
- height: Math.max(120, b.height),
398
- draggable: false,
399
- selectable: false,
400
- ...(parentId ? { parentId } : {}),
401
- data: {
402
- label: def?.label ?? id,
403
- kind: def?.kind ?? 'container',
404
- color: def?.kind === 'system' ? (theme.colors.text ?? '#888') : (theme.colors.accent ?? theme.colors.info),
405
- },
406
- };
407
- });
408
-
409
- const leaves = rfNodes.map((n) => {
410
- const p = positioned.get(n.id);
411
- // Absolute flow position from the flat layout.
412
- const abs = p ? p.position : n.position;
413
- // Re-parent to the shell this node sits in, if any: a component's
414
- // container, or a container's system frame. React Flow positions a
415
- // child relative to its parent, so subtract the shell origin.
416
- let parentId: string | undefined;
417
- if (n.data && typeof n.data === 'object') {
418
- const modelNode = (n.data as { element?: C4Element }).element;
419
- if (modelNode?.parentId && builtGroups.has(modelNode.parentId)) parentId = modelNode.parentId;
420
- }
421
- const origin = shellOrigin(parentId);
422
- const node = { ...n, position: { x: abs.x - origin.x, y: abs.y - origin.y } };
423
- return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
424
- });
425
-
426
- setNodes([...shells, ...leaves]);
427
- setRfEdges(withPaths);
428
- setReady(true);
429
- requestAnimationFrame(() => fitView({ padding: 0.15 }));
677
+ const leaves = rfNodes.map((n) => {
678
+ const p = positioned.get(n.id);
679
+ const abs = p ? p.position : n.position;
680
+ let parentId: string | undefined;
681
+ if (n.data && typeof n.data === 'object') {
682
+ const modelNode = (n.data as { element?: C4Element }).element;
683
+ // Only the opened container's own components nest under it — the sibling
684
+ // containers and out-of-boundary nodes in the same layout must not.
685
+ const nested =
686
+ modelNode?.kind === 'component' &&
687
+ openId &&
688
+ (modelNode.container ?? modelNode.parentId) === openId
689
+ ? openId
690
+ : undefined;
691
+ const candidate = nested ?? modelNode?.parentId;
692
+ if (candidate && builtGroups.has(candidate)) parentId = candidate;
693
+ }
694
+ const origin = shellOrigin(parentId);
695
+ // The opened container is an ELK parent, so its box is whatever ELK fitted
696
+ // to the nested components — `nodeSize` only knows the closed card.
697
+ const fitted = openId === n.id ? result.groupBounds.get(openId) : undefined;
698
+ const node = {
699
+ ...n,
700
+ position: { x: abs.x - origin.x, y: abs.y - origin.y },
701
+ ...(fitted ? { width: fitted.width, height: fitted.height } : {}),
702
+ };
703
+ return (parentId ? { ...node, parentId } : { ...node, parentId: undefined }) as Node;
704
+ });
705
+
706
+ return { nodes: [...shells, ...leaves], edges: withPaths, labelPositions, groupBounds: result.groupBounds };
707
+ };
708
+
709
+ /**
710
+ * Stamp each growing frame's span onto its shell node, so its view can FLIP
711
+ * from the old rect to its own. `grows` is keyed by frame id, flow-space.
712
+ */
713
+ const injectGrow = (nodes: Node[], grows: Map<string, FrameGrow> | null): Node[] => {
714
+ if (!grows || grows.size === 0) return nodes;
715
+ return nodes.map((n) => {
716
+ const g = grows.get(n.id);
717
+ return n.type === 'c4-group' && g
718
+ ? { ...n, data: { ...n.data, growFrom: g.from, frameRect: g.to } }
719
+ : n;
720
+ });
721
+ };
722
+
723
+ const renderLayout = (r: Rendered, grows: Map<string, FrameGrow> | null) => {
724
+ setNodes(injectGrow(r.nodes, grows));
725
+ setRfEdges(r.edges);
726
+ setLabelPositions(r.labelPositions);
727
+ setReady(true);
728
+ afterLayout();
729
+ };
730
+
731
+ // Render the current open state. Uses the prefetched layout when present
732
+ // (instant swap), else computes it now.
733
+ useEffect(() => {
734
+ let alive = true;
735
+ setReady(false);
736
+ setSelectedId(null);
737
+ const openId = model.openContainerId ?? null;
738
+ const prevOpen = prevOpenRef.current;
739
+ const inputs = buildInputs(openId);
740
+
741
+ // Only a real open-state change animates; the first paint is already at rest.
742
+ if (paintedRef.current && prevOpen !== openId) {
743
+ setSettled(false);
744
+ if (settleTimer.current) clearTimeout(settleTimer.current);
745
+ settleTimer.current = setTimeout(() => setSettled(true), MORPH_MS);
746
+ }
747
+ paintedRef.current = true;
748
+
749
+ /**
750
+ * Every frame whose rect changes between the previous and new layouts, so
751
+ * all boundaries can grow/shrink together (container frames *and* the
752
+ * system frame). Empty when nothing changed (e.g. the initial paint).
753
+ */
754
+ const growsFor = (next: Rendered): Map<string, FrameGrow> | null => {
755
+ if (prevOpen === openId) return null;
756
+ const prevLayout = layoutCache.current.get(prevOpen ?? '');
757
+ if (!prevLayout) return null;
758
+ const grows = new Map<string, FrameGrow>();
759
+ const ids = new Set([...prevLayout.groupBounds.keys(), ...next.groupBounds.keys()]);
760
+ for (const id of ids) {
761
+ const fromRect = containerRect(prevLayout, id);
762
+ const to = containerRect(next, id);
763
+ if (!fromRect || !to) continue;
764
+ if (
765
+ fromRect.x === to.x &&
766
+ fromRect.y === to.y &&
767
+ fromRect.width === to.width &&
768
+ fromRect.height === to.height
769
+ ) {
770
+ continue;
771
+ }
772
+ grows.set(id, { from: fromRect, to });
773
+ }
774
+ return grows.size > 0 ? grows : null;
775
+ };
776
+
777
+ const cached = layoutCache.current.get(openId ?? '');
778
+ if (cached) {
779
+ prevOpenRef.current = openId;
780
+ renderLayout(cached, growsFor(cached));
781
+ return () => {
782
+ alive = false;
783
+ };
784
+ }
785
+ void layoutFor(openId, inputs)
786
+ .then((r) => {
787
+ if (!alive) return;
788
+ layoutCache.current.set(openId ?? '', r);
789
+ const span = growsFor(r);
790
+ prevOpenRef.current = openId;
791
+ renderLayout(r, span);
430
792
  })
431
793
  .catch((err) => {
432
794
  if (!alive) return;
433
795
  console.warn('[c4-graph] ELK layout failed, using grid:', err);
434
- setNodes(rfNodes);
435
- setRfEdges(rfEdges);
796
+ setNodes(inputs.rfNodes.map((n) => ({ ...n, parentId: undefined })));
797
+ setRfEdges(inputs.rfEdges);
436
798
  setReady(true);
437
- requestAnimationFrame(() => fitView({ padding: 0.15 }));
799
+ afterLayout();
438
800
  });
439
-
440
801
  return () => {
441
802
  alive = false;
803
+ if (settleTimer.current) {
804
+ clearTimeout(settleTimer.current);
805
+ settleTimer.current = null;
806
+ }
442
807
  };
443
808
  // eslint-disable-next-line react-hooks/exhaustive-deps
444
809
  }, [layoutKey]);
445
810
 
811
+ // After the first paint, prefetch the opened layout for every container, so a
812
+ // click swaps to a cached result instead of waiting on ELK.
813
+ useEffect(() => {
814
+ if (model.view !== 'container') return;
815
+ let alive = true;
816
+ const containers = model.nodes.filter(
817
+ (n) => n.kind === 'container' && model.nodes.some((c) => c.kind === 'component' && (c.container ?? c.parentId) === n.id),
818
+ );
819
+ void (async () => {
820
+ for (const c of containers) {
821
+ if (!alive) return;
822
+ if (layoutCache.current.has(c.id)) continue;
823
+ try {
824
+ const inputs = buildInputs(c.id);
825
+ const r = await layoutFor(c.id, inputs);
826
+ if (!alive) return;
827
+ layoutCache.current.set(c.id, r);
828
+ } catch {
829
+ // Prefetch is best-effort; a miss just falls back to compute-on-click.
830
+ }
831
+ }
832
+ })();
833
+ return () => {
834
+ alive = false;
835
+ };
836
+ // eslint-disable-next-line react-hooks/exhaustive-deps
837
+ }, [layoutKey, model.view]);
838
+
446
839
  const selected = useMemo(
447
840
  () => model.nodes.find((n) => n.id === selectedId) ?? null,
448
841
  [model, selectedId],
@@ -466,25 +859,30 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
466
859
  [model, selected],
467
860
  );
468
861
 
862
+ /** The container currently opened for drill-down, if any. */
863
+ const openContainerId = model.openContainerId ?? null;
864
+
469
865
  const displayNodes = useMemo(
470
866
  () =>
471
- nodes.map((n) =>
472
- n.type === 'c4-node'
473
- ? { ...n, data: { ...n.data, selected: n.id === selectedId } }
474
- : n,
475
- ),
476
- [nodes, selectedId],
867
+ nodes.map((n) => {
868
+ if (n.type !== 'c4-node') return n;
869
+ const element = (n.data as { element?: C4Element } | undefined)?.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 =
874
+ element?.kind === 'component' &&
875
+ openContainerId != null &&
876
+ (element.container ?? element.parentId) === openContainerId;
877
+ return {
878
+ ...n,
879
+ ...(nested ? { hidden: !settled } : {}),
880
+ data: { ...n.data, selected: n.id === selectedId, settled },
881
+ };
882
+ }),
883
+ [nodes, selectedId, settled, openContainerId],
477
884
  );
478
885
 
479
- // What the current view draws: runtime units at container level, components
480
- // at component level. Mirrors the `inView` filter used for the nodes.
481
- const runtime = model.nodes.filter((n) =>
482
- model.view === 'component' ? n.kind === 'component' : n.kind === 'container',
483
- ).length;
484
- const outside = model.nodes.filter(
485
- (n) => n.kind === 'external-system' || n.kind === 'person',
486
- ).length;
487
-
488
886
  return (
489
887
  <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
490
888
  <GraphLayerStyle />
@@ -533,33 +931,15 @@ function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
533
931
  Laying out C4 view…
534
932
  </div>
535
933
  )}
536
- {title && (
537
- <div
538
- style={{
539
- position: 'absolute',
540
- top: 12,
541
- left: 12,
542
- fontSize: theme.fontSizes[2],
543
- fontWeight: 600,
544
- color: theme.colors.text,
545
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
546
- border: `1px solid ${theme.colors.border ?? '#333'}`,
547
- borderRadius: 6,
548
- padding: '4px 10px',
549
- }}
550
- >
551
- {title}
552
- <span style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, marginLeft: 8 }}>
553
- {model.view} view · {runtime} {model.view === 'container' ? 'containers' : 'components'} · {outside} outside
554
- </span>
555
- </div>
556
- )}
557
934
  {selected && (
558
935
  <div
559
936
  style={{
560
937
  position: 'absolute',
561
938
  top: 12,
562
939
  right: 12,
940
+ // Above the edge-label overlay (zIndex 5), or chips paint over the
941
+ // panel.
942
+ zIndex: 10,
563
943
  width: 280,
564
944
  maxHeight: 'calc(100% - 24px)',
565
945
  overflowY: 'auto',