@principal-ai/subsystems-react 0.53.4 → 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.
@@ -75,16 +75,127 @@ export function nodeSize(node: C4Element): { width: number; height: number } {
75
75
  */
76
76
  const LABEL_SLOT_H = 40;
77
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
+
78
109
  /**
79
110
  * Space a *grown* container reserves above its nested components: the card's own
80
- * 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
81
112
  * air. The description is hidden while grown, so it is not part of this.
82
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
+ *
83
120
  * The graph hands this to ELK as the compound parent's top padding rather than
84
121
  * nudging the children down itself — so the parent's size and the children's
85
122
  * placement are still one ELK fit, not our arithmetic layered on top of it.
86
123
  */
87
- 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;
88
199
 
89
200
  type Theme = ReturnType<typeof useTheme>['theme'];
90
201
 
@@ -318,6 +429,22 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
318
429
  const subtitle = nodeSubtitle(node);
319
430
 
320
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
+ });
321
448
  const centered = shape.align === 'center';
322
449
  // The pill's inset is a separate concern from alignment, and a centred
323
450
  // component must not inherit it: 30px of each side would leave a 160-wide box
@@ -331,8 +458,15 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
331
458
  style={{
332
459
  position: 'relative',
333
460
  width: width ?? size.width,
334
- minHeight: height ?? size.height,
335
- 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',
336
470
  boxSizing: 'border-box',
337
471
  display: 'flex',
338
472
  flexDirection: 'column',
@@ -340,7 +474,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
340
474
  // marker is conditional), and centring pushes the tag to a different
341
475
  // height on every card. In a graph grid that makes the row unscannable.
342
476
  justifyContent: 'flex-start',
343
- gap: 3,
477
+ gap: CARD_GAP,
344
478
  padding: pillInset ? '10px 30px' : '10px 12px',
345
479
  borderRadius: shape.radius,
346
480
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
@@ -362,6 +496,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
362
496
  justifyContent: centered ? 'center' : 'space-between',
363
497
  gap: 6,
364
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,
365
503
  minWidth: 0,
366
504
  }}
367
505
  >
@@ -414,6 +552,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
414
552
  letterSpacing: 0.5,
415
553
  textTransform: 'uppercase',
416
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 } : {}),
417
559
  }}
418
560
  >
419
561
  {topLabel && (
@@ -447,16 +589,25 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
447
589
  </span>
448
590
  )}
449
591
  </span>
450
- {showDescription && subtitle && (
592
+ {showDescription && subtitle && descLines > 0 && (
451
593
  <span
452
594
  style={{
453
595
  fontFamily: theme.fonts.monospace,
454
596
  fontSize: theme.fontSizes[0],
455
597
  color: muted,
456
- lineHeight: 1.35,
598
+ lineHeight: DESC_LINE_HEIGHT,
457
599
  whiteSpace: 'normal',
458
600
  overflowWrap: 'anywhere',
459
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',
460
611
  }}
461
612
  >
462
613
  {subtitle}
@@ -475,13 +626,13 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
475
626
  aria-label="Show components"
476
627
  style={{
477
628
  position: 'absolute',
478
- right: 6,
479
- bottom: 6,
629
+ right: TOGGLE_INSET,
630
+ bottom: TOGGLE_INSET,
480
631
  display: 'inline-flex',
481
632
  alignItems: 'center',
482
633
  justifyContent: 'center',
483
- width: 22,
484
- height: 22,
634
+ width: TOGGLE_SIZE,
635
+ height: TOGGLE_SIZE,
485
636
  padding: 0,
486
637
  borderRadius: 4,
487
638
  border: `1px solid ${style.color}`,
@@ -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) {
@@ -1008,10 +1034,13 @@ export async function computeElkLayout(
1008
1034
  sizeOptions['elk.nodeSize.constraints'] = 'MINIMUM_SIZE';
1009
1035
  sizeOptions['elk.nodeSize.minimum'] = `(${g.minWidth},0)`;
1010
1036
  }
1011
- if (g.padTop != null) {
1012
- // Reserving the parent's own chrome. Children then start below it, and
1013
- // the parent's height still comes from ELK's fit.
1014
- 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}]`;
1015
1044
  }
1016
1045
  builtGroups.set(g.id, {
1017
1046
  id: g.id,