@principal-ai/subsystems-react 0.53.3 → 0.53.5

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 (40) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +42 -5
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +253 -101
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +55 -2
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +145 -35
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.d.ts +10 -1
  14. package/dist/subsystem/IssueList.d.ts.map +1 -1
  15. package/dist/subsystem/IssueList.js +13 -5
  16. package/dist/subsystem/IssueList.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -1
  18. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.js +2 -2
  20. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  21. package/dist/subsystem/c4.d.ts +39 -9
  22. package/dist/subsystem/c4.d.ts.map +1 -1
  23. package/dist/subsystem/c4.js +28 -6
  24. package/dist/subsystem/c4.js.map +1 -1
  25. package/dist/utils/elkLayout.d.ts +19 -0
  26. package/dist/utils/elkLayout.d.ts.map +1 -1
  27. package/dist/utils/elkLayout.js +37 -10
  28. package/dist/utils/elkLayout.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/index.ts +8 -1
  31. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +1 -1
  32. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +117 -16
  33. package/src/subsystem/C4Graph.groups.test.ts +202 -12
  34. package/src/subsystem/C4Graph.tsx +297 -107
  35. package/src/subsystem/C4NodeCard.tsx +162 -34
  36. package/src/subsystem/IssueList.tsx +30 -5
  37. package/src/subsystem/SubsystemComponentGraph.tsx +11 -1
  38. package/src/subsystem/c4.ts +50 -11
  39. package/src/utils/elkLayout.test.ts +23 -0
  40. package/src/utils/elkLayout.ts +63 -11
@@ -12,7 +12,6 @@
12
12
  * wants it: technology on the top row, description under the label.
13
13
  */
14
14
 
15
- import { useLayoutEffect, useRef } from 'react';
16
15
  import { useTheme } from '@principal-ade/industry-theme';
17
16
  import { AppWindow, Component, Database, Maximize2 } from 'lucide-react';
18
17
  import type { LucideIcon } from 'lucide-react';
@@ -76,16 +75,127 @@ export function nodeSize(node: C4Element): { width: number; height: number } {
76
75
  */
77
76
  const LABEL_SLOT_H = 40;
78
77
 
78
+ /**
79
+ * The technology row's height, pinned for the same reason as the label slot: the
80
+ * row is a line of text plus an optional 16px brand mark, so its natural height
81
+ * depends on which of those are present. Pinning it makes the card's chrome a
82
+ * known quantity, which is what lets `descriptionLineBudget` below say how many
83
+ * description lines fit — and lets the footer pad and the header pad agree about
84
+ * where the card's content actually ends.
85
+ */
86
+ const TECH_SLOT_H = 18;
87
+
88
+ /** The card's own vertical padding (`padding: '10px 12px'`). */
89
+ const CARD_PAD_Y = 10;
90
+
91
+ /** The card's flex `gap`, between each of its rows. */
92
+ const CARD_GAP = 3;
93
+
94
+ /** The description's line height, as a multiple of its font size. */
95
+ const DESC_LINE_HEIGHT = 1.35;
96
+
97
+ /**
98
+ * Breathing room between a grown container's own chrome and the components nested
99
+ * inside it.
100
+ *
101
+ * The description is hidden while grown, so the components land where the
102
+ * description would have been — immediately under the technology row. Without
103
+ * this the gap is the width of one row gap, which reads as the components being
104
+ * crowded against the header while the sides and footer sit `OPEN_SIDE_PAD` away.
105
+ * This is not chrome: nothing is drawn in it.
106
+ */
107
+ const OPEN_HEADER_AIR = 16;
108
+
79
109
  /**
80
110
  * Space a *grown* container reserves above its nested components: the card's own
81
- * top padding + the label slot + the row gap + the technology row, plus a little
111
+ * padding, its label slot, its technology row, the gaps between them, and a little
82
112
  * air. The description is hidden while grown, so it is not part of this.
83
113
  *
114
+ * Derived from the same constants the card is built from, rather than written as
115
+ * a literal, so it cannot drift from `descriptionLineBudget` — which subtracts
116
+ * exactly this chrome (and no air) to decide how much description a closed card
117
+ * can show. Those two disagreeing is how a card ends up taller than the box ELK
118
+ * laid out for it.
119
+ *
84
120
  * The graph hands this to ELK as the compound parent's top padding rather than
85
121
  * nudging the children down itself — so the parent's size and the children's
86
122
  * placement are still one ELK fit, not our arithmetic layered on top of it.
87
123
  */
88
- export const OPEN_HEADER_PAD = 84;
124
+ export const OPEN_HEADER_PAD =
125
+ CARD_PAD_Y * 2 + LABEL_SLOT_H + CARD_GAP + TECH_SLOT_H + CARD_GAP + OPEN_HEADER_AIR;
126
+
127
+ /**
128
+ * How many description lines fit in a card of this height before the drawn box
129
+ * would grow past the box the layout reserved for it.
130
+ *
131
+ * A card's height is a contract with the layout: ELK sized the node, the system
132
+ * frame and every edge against it. The description is the one part of a card whose
133
+ * length the layout cannot know, so it is clamped to what fits rather than allowed
134
+ * to push the border out past the frame drawn around it. Height is a *minimum* on
135
+ * the card, so without this a long description silently grows the drawn box while
136
+ * ELK — and therefore the boundary and the edge endpoints — keeps the old size.
137
+ *
138
+ * `hasTechRow` matters because an empty technology row is zero-height: a person
139
+ * card has one more line of room than a container. The row is pinned to
140
+ * `TECH_SLOT_H` when it has content, so this is exact rather than approximate.
141
+ */
142
+ export function descriptionLineBudget(opts: {
143
+ reservedHeight: number;
144
+ lineHeight: number;
145
+ /** The card's border, which sits above the content and varies by kind. */
146
+ borderWidth: number;
147
+ hasTechRow: boolean;
148
+ /** Whether a collapse affordance is drawn at the bottom-right. */
149
+ reservesFooter: boolean;
150
+ }): number {
151
+ const { reservedHeight, lineHeight, borderWidth, hasTechRow, reservesFooter } = opts;
152
+ if (lineHeight <= 0) return 0;
153
+ const topChrome =
154
+ borderWidth + CARD_PAD_Y + LABEL_SLOT_H + CARD_GAP + (hasTechRow ? TECH_SLOT_H : 0) + CARD_GAP;
155
+ // Reserving the footer is not the same as the card's own bottom padding. The
156
+ // toggle is `position: absolute` inset from the *padding* box, so it reaches
157
+ // `inset + size` up from the content box's bottom edge — and a description that
158
+ // only cleared the padding would still have its last line drawn through the
159
+ // button. Measured: the last line lands in the bottom ~26px band, and the toggle
160
+ // occupies the bottom 28 of it.
161
+ const bottomChrome = reservesFooter
162
+ ? Math.max(CARD_PAD_Y, TOGGLE_INSET + TOGGLE_SIZE)
163
+ : CARD_PAD_Y;
164
+ return Math.max(0, Math.floor((reservedHeight - topChrome - bottomChrome) / lineHeight));
165
+ }
166
+
167
+ /**
168
+ * The drill-down affordance, in the card's own box. Extracted from the button's
169
+ * style so the reserved footer below cannot drift from the thing it is reserving
170
+ * for — the two numbers are the same number.
171
+ */
172
+ const TOGGLE_SIZE = 22;
173
+ const TOGGLE_INSET = 6;
174
+
175
+ /**
176
+ * Space a *grown* container reserves below its nested components, so the last row
177
+ * does not sit under the collapse affordance.
178
+ *
179
+ * The toggle is absolutely positioned, so it takes no space in the card's flow and
180
+ * ELK knows nothing about it: without this the parent is fitted to its children
181
+ * alone and the final row is drawn straight through the button. Reserving the
182
+ * toggle's own reach plus a little air keeps that an ELK fit too, rather than
183
+ * something we nudge afterwards.
184
+ */
185
+ export const OPEN_FOOTER_PAD = TOGGLE_INSET + TOGGLE_SIZE + 8;
186
+
187
+ /**
188
+ * Space a *grown* container reserves to the left and right of its nested
189
+ * components.
190
+ *
191
+ * Deliberately the footer's number rather than its own: the nested cards should
192
+ * sit in one uniform inset, not a wider one at the bottom than at the sides. Both
193
+ * are wider than the card's own 12px text inset — the label and technology row are
194
+ * the container's chrome and sit flush with its padding, while the nested cards
195
+ * are separate boxes it contains, and at the same inset they read as part of that
196
+ * chrome rather than held inside it.
197
+ */
198
+ export const OPEN_SIDE_PAD = OPEN_FOOTER_PAD;
89
199
 
90
200
  type Theme = ReturnType<typeof useTheme>['theme'];
91
201
 
@@ -319,6 +429,22 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
319
429
  const subtitle = nodeSubtitle(node);
320
430
 
321
431
  const size = nodeSize(node);
432
+ // The height the layout reserved for this card — what the system frame and the
433
+ // edge endpoints were placed against. Everything below sizes to this, not to
434
+ // whatever the content would like.
435
+ const reservedHeight = height ?? size.height;
436
+ // A container always has a technology row (at minimum the member count); a
437
+ // person never does.
438
+ const hasTechRow = !!topLabel || node.kind === 'container';
439
+ const descLineHeightPx = theme.fontSizes[0] * DESC_LINE_HEIGHT;
440
+ const descLines = descriptionLineBudget({
441
+ reservedHeight,
442
+ lineHeight: descLineHeightPx,
443
+ borderWidth: style.width,
444
+ hasTechRow,
445
+ // The affordance is drawn exactly when the card can be expanded or collapsed.
446
+ reservesFooter: !!onExpand,
447
+ });
322
448
  const centered = shape.align === 'center';
323
449
  // The pill's inset is a separate concern from alignment, and a centred
324
450
  // component must not inherit it: 30px of each side would leave a 160-wide box
@@ -326,36 +452,21 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
326
452
  // that grow the box past the height the layout reserved.
327
453
  const pillInset = shape.kind === 'pill';
328
454
 
329
- // TEMPORARY diagnostic — remove. The card's height is `minHeight`, so the box
330
- // the browser lays out can exceed the nominal size the layout reserved, and
331
- // nothing in the type system shows that: `nodeSize` and the drawn box are two
332
- // different numbers. `offsetWidth/Height` are the border box, so this reports
333
- // what was actually drawn. Logs on change only, so a reflow that does not
334
- // resize the card stays quiet.
335
- const probeRef = useRef<HTMLDivElement | null>(null);
336
- const probeSeen = useRef('');
337
- useLayoutEffect(() => {
338
- const el = probeRef.current;
339
- if (!el || typeof ResizeObserver === 'undefined') return;
340
- const ro = new ResizeObserver(() => {
341
- const line = `${el.offsetWidth}x${el.offsetHeight} (nominal ${size.width}x${size.height})`;
342
- if (line === probeSeen.current) return;
343
- probeSeen.current = line;
344
- console.log(`[c4-card] ${node.kind} ${node.id} ${line}`);
345
- });
346
- ro.observe(el);
347
- return () => ro.disconnect();
348
- }, [node.kind, node.id, size.width, size.height]);
349
-
350
455
  return (
351
456
  <div
352
- ref={probeRef}
353
457
  onClick={onClick}
354
458
  style={{
355
459
  position: 'relative',
356
460
  width: width ?? size.width,
357
- minHeight: height ?? size.height,
358
- transition: 'width 420ms ease-out, min-height 420ms ease-out',
461
+ // A hard height, not a minimum. The card *is* the box the layout reserved
462
+ // — the system frame is fitted around it and every edge endpoint is placed
463
+ // against it — so it must not be able to grow itself. `overflow: hidden`
464
+ // makes that structural: if the content ever exceeds the box, it is clipped
465
+ // rather than drawn outside the boundary. The description is clamped above
466
+ // so that clipping is never what you actually see.
467
+ height: reservedHeight,
468
+ transition: 'width 420ms ease-out, height 420ms ease-out',
469
+ overflow: 'hidden',
359
470
  boxSizing: 'border-box',
360
471
  display: 'flex',
361
472
  flexDirection: 'column',
@@ -363,7 +474,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
363
474
  // marker is conditional), and centring pushes the tag to a different
364
475
  // height on every card. In a graph grid that makes the row unscannable.
365
476
  justifyContent: 'flex-start',
366
- gap: 3,
477
+ gap: CARD_GAP,
367
478
  padding: pillInset ? '10px 30px' : '10px 12px',
368
479
  borderRadius: shape.radius,
369
480
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
@@ -385,6 +496,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
385
496
  justifyContent: centered ? 'center' : 'space-between',
386
497
  gap: 6,
387
498
  height: LABEL_SLOT_H,
499
+ // Without this the slot shrinks when the content exceeds the card's fixed
500
+ // height, and `descriptionLineBudget`'s chrome stops being the chrome —
501
+ // measured at 39.11px instead of 40 the moment the description ran long.
502
+ flexShrink: 0,
388
503
  minWidth: 0,
389
504
  }}
390
505
  >
@@ -437,6 +552,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
437
552
  letterSpacing: 0.5,
438
553
  textTransform: 'uppercase',
439
554
  minWidth: 0,
555
+ // Pinned, like the label slot above it: an empty row would otherwise be
556
+ // zero-height and the description budget would have to guess which cards
557
+ // have a technology row. Empty stays zero — a person has no technology.
558
+ ...(hasTechRow ? { height: TECH_SLOT_H, flexShrink: 0 } : {}),
440
559
  }}
441
560
  >
442
561
  {topLabel && (
@@ -470,16 +589,25 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
470
589
  </span>
471
590
  )}
472
591
  </span>
473
- {showDescription && subtitle && (
592
+ {showDescription && subtitle && descLines > 0 && (
474
593
  <span
475
594
  style={{
476
595
  fontFamily: theme.fonts.monospace,
477
596
  fontSize: theme.fontSizes[0],
478
597
  color: muted,
479
- lineHeight: 1.35,
598
+ lineHeight: DESC_LINE_HEIGHT,
480
599
  whiteSpace: 'normal',
481
600
  overflowWrap: 'anywhere',
482
601
  textAlign: centered ? 'center' : 'left',
602
+ // Clamped to the room the layout left for it. The card's height is a
603
+ // contract with ELK — the boundary and every edge endpoint are placed
604
+ // against it — so the description cannot be allowed to grow the box.
605
+ // Same mechanism the label uses above; the count is derived from the
606
+ // reserved height rather than tuned by hand.
607
+ display: '-webkit-box',
608
+ WebkitLineClamp: descLines,
609
+ WebkitBoxOrient: 'vertical',
610
+ overflow: 'hidden',
483
611
  }}
484
612
  >
485
613
  {subtitle}
@@ -498,13 +626,13 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
498
626
  aria-label="Show components"
499
627
  style={{
500
628
  position: 'absolute',
501
- right: 6,
502
- bottom: 6,
629
+ right: TOGGLE_INSET,
630
+ bottom: TOGGLE_INSET,
503
631
  display: 'inline-flex',
504
632
  alignItems: 'center',
505
633
  justifyContent: 'center',
506
- width: 22,
507
- height: 22,
634
+ width: TOGGLE_SIZE,
635
+ height: TOGGLE_SIZE,
508
636
  padding: 0,
509
637
  borderRadius: 4,
510
638
  border: `1px solid ${style.color}`,
@@ -30,6 +30,7 @@ import {
30
30
  History,
31
31
  Link2Off,
32
32
  MapPin,
33
+ Minus,
33
34
  Network,
34
35
  Route,
35
36
  Search,
@@ -657,6 +658,17 @@ export interface SubsystemIssueListProps {
657
658
  * honoured without an extra click.
658
659
  */
659
660
  focusCategory?: SubsystemIssueCategory;
661
+ /**
662
+ * Per-layer verification status from the host's lane summary — the same
663
+ * derivation the maintainer's lane icons use. The list only sees findings,
664
+ * so without this a layer with nothing to check (no module claims, say)
665
+ * is indistinguishable from a verified-clean one and renders a green check.
666
+ * A layer whose status here is `'none'` renders muted instead: not
667
+ * applicable, nothing was verified.
668
+ */
669
+ laneStatus?: Partial<
670
+ Record<SubsystemIssueCategory, 'verified' | 'partial' | 'issues' | 'blocked' | 'none'>
671
+ >;
660
672
  /**
661
673
  * Fires whenever the expanded-layer set changes. Publishers use it to focus
662
674
  * the canvas on the layer's findings — dimming everything the expanded
@@ -673,6 +685,7 @@ export function SubsystemIssueList({
673
685
  onHoverIssue,
674
686
  focusCategory,
675
687
  onExpandedCategoriesChange,
688
+ laneStatus,
676
689
  }: SubsystemIssueListProps) {
677
690
  const { theme } = useTheme();
678
691
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -740,11 +753,18 @@ export function SubsystemIssueList({
740
753
  <div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
741
754
  {categories.map((category) => {
742
755
  // A layer with no findings renders its happy state: a green check in
743
- // the header, no count, no cards.
756
+ // the header, no count, no cards. A layer the host reports as 'none'
757
+ // had nothing to verify at all (e.g. no module claims) — it renders
758
+ // muted, same as the maintainer's grey lane icon, because a green
759
+ // check would claim a verification that never ran.
744
760
  const clean = category.count === 0;
745
- const accent = category.severity
746
- ? severityColor(category.severity, theme.colors)
747
- : theme.colors.success;
761
+ const notApplicable = clean && laneStatus?.[category.category] === 'none';
762
+ const accent =
763
+ category.severity
764
+ ? severityColor(category.severity, theme.colors)
765
+ : notApplicable
766
+ ? muted
767
+ : theme.colors.success;
748
768
  const isCollapsed = collapsed.has(category.category);
749
769
  const isHovered = hovered === category.category;
750
770
  return (
@@ -810,7 +830,12 @@ export function SubsystemIssueList({
810
830
  {category.label}
811
831
  </span>
812
832
  {clean ? (
813
- <CircleCheck size={13} color={accent} style={{ flexShrink: 0 }} />
833
+ notApplicable ? (
834
+ // A muted dash: nothing was checked, so nothing passed.
835
+ <Minus size={13} color={accent} style={{ flexShrink: 0 }} />
836
+ ) : (
837
+ <CircleCheck size={13} color={accent} style={{ flexShrink: 0 }} />
838
+ )
814
839
  ) : (
815
840
  <span
816
841
  style={{
@@ -74,6 +74,7 @@ import {
74
74
  issueRung,
75
75
  ISSUE_KIND_ICON,
76
76
  ISSUE_RUNG_ORDER,
77
+ type SubsystemIssueListProps,
77
78
  type SubsystemIssue,
78
79
  type SubsystemIssueCategory,
79
80
  } from './IssueList';
@@ -354,6 +355,13 @@ export interface SubsystemComponentGraphProps {
354
355
  * plain issues view expands everything.
355
356
  */
356
357
  focusIssueCategory?: SubsystemIssueCategory;
358
+ /**
359
+ * Per-layer verification status from the host's lane summary (the same
360
+ * derivation the maintainer's lane icons use). Forwarded to the issues
361
+ * list so a layer with nothing to verify renders muted ("not applicable")
362
+ * instead of a green check for a verification that never ran.
363
+ */
364
+ laneStatus?: SubsystemIssueListProps['laneStatus'];
357
365
  /**
358
366
  * Click an issue. The graph first focuses the target on the canvas itself —
359
367
  * a component target is selected and framed (any edge / trail focus
@@ -528,7 +536,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
528
536
  measured: { w: number; h: number } | null;
529
537
  }
530
538
 
531
- function Inner({ components, trails, graphifyRelations, orderByLine, initialTrailId, onReorderTrails, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
539
+ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrailId, onReorderTrails, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
532
540
  const { theme } = useTheme();
533
541
  const { fitView, fitBounds, screenToFlowPosition } = useReactFlow();
534
542
  const viewport = useViewport();
@@ -2778,6 +2786,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2778
2786
  <SubsystemIssueList
2779
2787
  issues={issues ?? []}
2780
2788
  focusCategory={focusIssueCategory}
2789
+ laneStatus={laneStatus}
2781
2790
  onSelectIssue={focusIssueTarget}
2782
2791
  onDeselectIssue={unfocusIssueTarget}
2783
2792
  onApplyFix={onApplyIssueFix}
@@ -2790,6 +2799,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2790
2799
  <SubsystemIssueList
2791
2800
  issues={issues ?? []}
2792
2801
  focusCategory={focusIssueCategory}
2802
+ laneStatus={laneStatus}
2793
2803
  onSelectIssue={focusIssueTarget}
2794
2804
  onDeselectIssue={unfocusIssueTarget}
2795
2805
  onApplyFix={onApplyIssueFix}
@@ -170,23 +170,23 @@ export interface C4Group {
170
170
  * Derive the frames to draw from the elements and the current scope.
171
171
  *
172
172
  * A container becomes a frame whenever its components are on screen — in the
173
- * component view, or when it is the opened container. The system frame wraps
174
- * whichever containers are drawn. No frame is authored; this is the view of the
175
- * element set, so a container's identity (id, label, technology, colour) lives
176
- * only on the element.
173
+ * component view, or when it is one of the opened containers. The system frame
174
+ * wraps whichever containers are drawn. No frame is authored; this is the view
175
+ * of the element set, so a container's identity (id, label, technology, colour)
176
+ * lives only on the element.
177
177
  */
178
178
  export function deriveC4Groups(
179
- model: Pick<C4Model, 'view' | 'system' | 'nodes' | 'openContainerId'>,
179
+ model: Pick<C4Model, 'view' | 'system' | 'nodes'> & OpenState,
180
180
  ): C4Group[] {
181
181
  const groups: C4Group[] = [];
182
182
  const containers = model.nodes.filter((n): n is C4Container => n.kind === 'container');
183
183
  const components = model.nodes.filter((n): n is C4Component => n.kind === 'component');
184
184
 
185
- const opened = model.openContainerId ?? null;
185
+ const opened = new Set(openContainerSet(model));
186
186
  // Component view shows every container's components; container view shows a
187
187
  // container's components only when it is opened.
188
188
  const framing = (containerId: string): boolean =>
189
- model.view === 'component' || opened === containerId;
189
+ model.view === 'component' || opened.has(containerId);
190
190
 
191
191
  const framedContainers: string[] = [];
192
192
  for (const c of containers) {
@@ -294,7 +294,7 @@ export function protocolColor(protocol: string | undefined): string {
294
294
  * A drawable C4 projection. Authored: the boxes and lines, nothing drawn for
295
295
  * you. Frames are derived by the renderer from the elements' `parentId` chains
296
296
  * — a container is a frame when its components are being shown (component view,
297
- * or opened via `openContainerId`), never a separately-authored thing.
297
+ * or opened via `openContainerIds`), never a separately-authored thing.
298
298
  */
299
299
  export interface C4Model {
300
300
  view: C4View;
@@ -302,13 +302,52 @@ export interface C4Model {
302
302
  nodes: C4Element[];
303
303
  edges: C4Edge[];
304
304
  /**
305
- * Drill-down scope: the id of a container whose components should be shown
306
- * inside it as a frame. Absent/null → the container is drawn as a box and its
307
- * components are hidden. The renderer derives the frames either way.
305
+ * Drill-down scope: the containers whose components are shown nested inside
306
+ * them as a grown card. Absent/empty → every container is drawn as a plain box
307
+ * and all components are hidden. The renderer derives the frames either way.
308
+ *
309
+ * Several may be open at once: each is an independent ELK parent, and opening
310
+ * one never closes another. Order is not significant — {@link openContainerSet}
311
+ * normalizes it, so two permutations of the same set are one layout.
312
+ */
313
+ openContainerIds?: readonly string[];
314
+ /**
315
+ * Single-container shorthand for {@link C4Model.openContainerIds}, folded into
316
+ * it by {@link openContainerSet}. Kept because `C4Model` is a published type
317
+ * and removing the field would break every caller at once; prefer the plural,
318
+ * which is what the drill-down actually means now.
319
+ *
320
+ * @deprecated Use {@link C4Model.openContainerIds}.
308
321
  */
309
322
  openContainerId?: string | null;
310
323
  }
311
324
 
325
+ /** Anything that can say which containers are open, however it says it. */
326
+ type OpenState = Pick<C4Model, 'openContainerId' | 'openContainerIds'>;
327
+
328
+ /**
329
+ * The open containers as a deduplicated, **sorted** list.
330
+ *
331
+ * Sorted on purpose: this doubles as the layout cache key, and two callers that
332
+ * open the same containers in a different order are in the same state — one
333
+ * layout, not two. A plain `Set` iteration order would follow insertion, so
334
+ * `[a, b]` and `[b, a]` would each compute their own ELK run for a diagram that
335
+ * did not change.
336
+ */
337
+ export function openContainerSet(state: OpenState): string[] {
338
+ const ids = new Set<string>(state.openContainerIds ?? []);
339
+ if (state.openContainerId) ids.add(state.openContainerId);
340
+ return [...ids].sort();
341
+ }
342
+
343
+ /**
344
+ * Cache key for one open state. `''` is the closed diagram, which is why it is a
345
+ * join and not a count.
346
+ */
347
+ export function openStateKey(openIds: readonly string[]): string {
348
+ return openIds.join(',');
349
+ }
350
+
312
351
  /**
313
352
  * `owner/name` from a purl, trimmed of scheme and fragment. Used to label the
314
353
  * system frame when a repo key is known.
@@ -171,6 +171,29 @@ describe('planCompoundGroups', () => {
171
171
  expect(plan.skipped).toEqual(['proc']);
172
172
  });
173
173
 
174
+ test('carries the parent chrome padding through, all three pads', () => {
175
+ // `computeElkLayout` turns these into ELK's `elk.padding` on the parent. If
176
+ // they stop here the parent is fitted to its children alone, and chrome drawn
177
+ // around the content — the grown container's label slot, its collapse
178
+ // affordance, the inset that separates nested cards from its edges — is lost.
179
+ const plan = planCompoundGroups(
180
+ [{ id: 'host', memberIds: ['a', 'b'], padTop: 84, padBottom: 36, padX: 36 }],
181
+ ['a', 'b'],
182
+ );
183
+ expect(plan.built[0]!.padTop).toBe(84);
184
+ expect(plan.built[0]!.padBottom).toBe(36);
185
+ expect(plan.built[0]!.padX).toBe(36);
186
+ });
187
+
188
+ test('leaves the padding unset when the group asks for none', () => {
189
+ // Unset, not zero: `computeElkLayout` falls back to the default 12px, so a
190
+ // group that never asked for chrome keeps the padding it always had.
191
+ const plan = planCompoundGroups([{ id: 'proc', memberIds: ['a', 'b'] }], ['a', 'b']);
192
+ expect(plan.built[0]!.padTop).toBeUndefined();
193
+ expect(plan.built[0]!.padBottom).toBeUndefined();
194
+ expect(plan.built[0]!.padX).toBeUndefined();
195
+ });
196
+
174
197
  test('counts leaves through nested groups', () => {
175
198
  // process → module(one leaf) is still a single leaf, so both drop by
176
199
  // default even though the process lists one member id.
@@ -593,6 +593,23 @@ export interface CompoundGroupDef {
593
593
  * partition set on the parent itself is ignored.
594
594
  */
595
595
  padTop?: number;
596
+ /**
597
+ * Space reserved below the last child, in px — the `padTop` of the other end.
598
+ * Set it when the parent draws chrome at the bottom that its children must clear,
599
+ * e.g. the drill-down's absolutely-positioned collapse affordance, which takes
600
+ * no space in flow and so is invisible to ELK unless it is reserved here.
601
+ *
602
+ * Falls back to the same default padding as the other three sides.
603
+ */
604
+ padBottom?: number;
605
+ /**
606
+ * Space reserved to the left *and* right of the children, in px. One number for
607
+ * both sides because a parent that insets its contents does so symmetrically —
608
+ * there is no chrome that lives on one side only.
609
+ *
610
+ * Falls back to the default padding.
611
+ */
612
+ padX?: number;
596
613
  }
597
614
 
598
615
  /** Which groups ELK builds, and which it drops. */
@@ -601,7 +618,14 @@ export interface CompoundGroupPlan {
601
618
  * Built groups in build order — a group's children always appear before it,
602
619
  * so callers can map ids to shells in one forward pass.
603
620
  */
604
- built: Array<{ id: string; childIds: string[]; minWidth?: number; padTop?: number }>;
621
+ built: Array<{
622
+ id: string;
623
+ childIds: string[];
624
+ minWidth?: number;
625
+ padTop?: number;
626
+ padBottom?: number;
627
+ padX?: number;
628
+ }>;
605
629
  /**
606
630
  * Dropped groups. Their members are promoted into the nearest built
607
631
  * ancestor, so callers must clear those members' `parentId`.
@@ -703,6 +727,8 @@ export function planCompoundGroups(
703
727
  // Carried through, not just validated here: the caller turns each of
704
728
  // these into ELK layout options on the parent node.
705
729
  padTop: g.padTop,
730
+ padBottom: g.padBottom,
731
+ padX: g.padX,
706
732
  });
707
733
  }
708
734
  if (!progress) {
@@ -715,6 +741,28 @@ export function planCompoundGroups(
715
741
  return { built, skipped: [...skipped] };
716
742
  }
717
743
 
744
+ /**
745
+ * The four side ports every node in the graph carries, named
746
+ * `<id>_top|_right|_bottom|_left` — the ids `getPortSide` resolves an edge
747
+ * endpoint to.
748
+ *
749
+ * Shared by leaves and compound parents on purpose. A parent is still an ELK
750
+ * *node* that outside edges terminate on, and ELK rejects the whole graph if an
751
+ * edge names a port that was never declared: `JsonImportException: Referenced
752
+ * shape does not exist`. Declaring ports only on leaves silently works right up
753
+ * until the first edge crosses into a node that later becomes a group — which is
754
+ * why the drill-down only broke once a container was opened *and* something
755
+ * outside the boundary pointed at it.
756
+ */
757
+ function cardinalPorts(id: string) {
758
+ return [
759
+ { id: `${id}_top`, properties: { 'port.side': 'NORTH' } },
760
+ { id: `${id}_right`, properties: { 'port.side': 'EAST' } },
761
+ { id: `${id}_bottom`, properties: { 'port.side': 'SOUTH' } },
762
+ { id: `${id}_left`, properties: { 'port.side': 'WEST' } },
763
+ ];
764
+ }
765
+
718
766
  /**
719
767
  * Compute ELK layout for nodes and edges
720
768
  *
@@ -774,12 +822,7 @@ export async function computeElkLayout(
774
822
  x: node.position.x,
775
823
  y,
776
824
  // Add ports on each side for edge connections
777
- ports: [
778
- { id: `${node.id}_top`, properties: { 'port.side': 'NORTH' } },
779
- { id: `${node.id}_right`, properties: { 'port.side': 'EAST' } },
780
- { id: `${node.id}_bottom`, properties: { 'port.side': 'SOUTH' } },
781
- { id: `${node.id}_left`, properties: { 'port.side': 'WEST' } },
782
- ],
825
+ ports: cardinalPorts(node.id),
783
826
  properties: {
784
827
  'portConstraints': 'FIXED_SIDE',
785
828
  ...(layer !== undefined ? { 'layering.layer': String(layer) } : {}),
@@ -991,14 +1034,23 @@ export async function computeElkLayout(
991
1034
  sizeOptions['elk.nodeSize.constraints'] = 'MINIMUM_SIZE';
992
1035
  sizeOptions['elk.nodeSize.minimum'] = `(${g.minWidth},0)`;
993
1036
  }
994
- if (g.padTop != null) {
995
- // Reserving the parent's own chrome. Children then start below it, and
996
- // the parent's height still comes from ELK's fit.
997
- sizeOptions['elk.padding'] = `[top=${g.padTop},left=12,bottom=12,right=12]`;
1037
+ if (g.padTop != null || g.padBottom != null || g.padX != null) {
1038
+ // Reserving the parent's own chrome. Children then sit inside it, and the
1039
+ // parent's size still comes from ELK's fit. Each side falls back to the
1040
+ // default padding, so setting only one side keeps the others as they were
1041
+ // rather than collapsing them to zero.
1042
+ sizeOptions['elk.padding'] =
1043
+ `[top=${g.padTop ?? 12},left=${g.padX ?? 12},bottom=${g.padBottom ?? 12},right=${g.padX ?? 12}]`;
998
1044
  }
999
1045
  builtGroups.set(g.id, {
1000
1046
  id: g.id,
1001
1047
  children,
1048
+ // Same side ports a leaf gets. An edge from outside the group terminates
1049
+ // on the group, and ELK throws on a port reference it cannot resolve.
1050
+ // Deliberately NOT `portConstraints: FIXED_SIDE` the way leaves are: each
1051
+ // port already names its own `port.side`, which is what routes the edge,
1052
+ // and pinning a parent could fight the layout of the children it holds.
1053
+ ports: cardinalPorts(g.id),
1002
1054
  layoutOptions:
1003
1055
  Object.keys(sizeOptions).length > 0
1004
1056
  ? { ...compoundLayoutOptions, ...sizeOptions }