@principal-ai/subsystems-react 0.53.1 → 0.53.3

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 (42) 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 +83 -0
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +314 -105
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +45 -10
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +75 -21
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.js +2 -2
  14. package/dist/subsystem/IssueList.js.map +1 -1
  15. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  16. package/dist/subsystem/SubsystemComponentGraph.js +24 -7
  17. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  18. package/dist/subsystem/model.d.ts +13 -2
  19. package/dist/subsystem/model.d.ts.map +1 -1
  20. package/dist/subsystem/model.js.map +1 -1
  21. package/dist/subsystem/nodes.d.ts.map +1 -1
  22. package/dist/subsystem/nodes.js +14 -6
  23. package/dist/subsystem/nodes.js.map +1 -1
  24. package/dist/utils/elkLayout.d.ts +49 -13
  25. package/dist/utils/elkLayout.d.ts.map +1 -1
  26. package/dist/utils/elkLayout.js +57 -8
  27. package/dist/utils/elkLayout.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/index.ts +1 -1
  30. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +1 -1
  31. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +2 -1
  32. package/src/subsystem/C4Graph.groups.test.ts +217 -0
  33. package/src/subsystem/C4Graph.morph.test.tsx +208 -0
  34. package/src/subsystem/C4Graph.tsx +344 -111
  35. package/src/subsystem/C4NodeCard.test.ts +47 -8
  36. package/src/subsystem/C4NodeCard.tsx +84 -20
  37. package/src/subsystem/IssueList.tsx +2 -2
  38. package/src/subsystem/SubsystemComponentGraph.tsx +25 -7
  39. package/src/subsystem/model.ts +13 -2
  40. package/src/subsystem/nodes.tsx +14 -6
  41. package/src/utils/elkLayout.test.ts +131 -0
  42. package/src/utils/elkLayout.ts +81 -14
@@ -11,6 +11,8 @@ import {
11
11
  NODE_H,
12
12
  nodeSize,
13
13
  COMPONENT_SIZE,
14
+ COMPONENT_W,
15
+ COMPONENT_ASPECT,
14
16
  } from './C4NodeCard';
15
17
  import type { C4Element, C4Container } from './c4';
16
18
 
@@ -190,6 +192,19 @@ describe('nodeShape — kind without relying on colour or dash', () => {
190
192
  expect(s.radius).toBeGreaterThanOrEqual(NODE_H / 2);
191
193
  });
192
194
 
195
+ test('a component centres its text, a container does not', () => {
196
+ // Alignment is the cheapest level cue there is: it needs no comparison
197
+ // against anything else, so it survives a crop or a narrow viewport where
198
+ // the aspect ratio stops being readable.
199
+ expect(nodeShape(node({ kind: 'component' })).align).toBe('center');
200
+ expect(nodeShape(node()).align).toBe('left');
201
+ });
202
+
203
+ test('a person centres its text too, but for its own reason', () => {
204
+ // Not a level cue — a pill has no straight edge for text to sit against.
205
+ expect(nodeShape(node({ kind: 'person' })).align).toBe('center');
206
+ });
207
+
193
208
  test('shape does not depend on confirmation state', () => {
194
209
  expect(nodeShape(node({ state: 'proposed' })).kind).toBe(
195
210
  nodeShape(node({ state: 'accepted' })).kind,
@@ -250,10 +265,19 @@ describe('nodeMissing', () => {
250
265
  });
251
266
  });
252
267
 
253
- describe('nodeSize — a component is a proper square', () => {
254
- test('a component has equal width and height', () => {
268
+ describe('nodeSize — a component reads as a square', () => {
269
+ test('a component is wider than it is tall, by the optical correction', () => {
270
+ // Equal sides get perceived as tall, so the box is built 8:7 to land on the
271
+ // square we actually want. Pinned both ways: the ratio the layout gets, and
272
+ // a ceiling on how far the correction may go — the upper bound is there to
273
+ // stop the box drifting toward the container's 5:3, which would cost the
274
+ // level signal this aspect exists to carry.
255
275
  const s = nodeSize(node({ kind: 'component' }));
256
- expect(s.width).toBe(s.height);
276
+ expect(s.width).toBeGreaterThan(s.height);
277
+ expect(s.width / s.height).toBeCloseTo(COMPONENT_ASPECT, 2);
278
+ expect(COMPONENT_ASPECT).toBeGreaterThan(1);
279
+ expect(COMPONENT_ASPECT).toBeLessThanOrEqual(1.25);
280
+ expect(COMPONENT_ASPECT).toBeLessThan(NODE_W / NODE_H);
257
281
  });
258
282
 
259
283
  test('a container is wider than it is tall', () => {
@@ -268,9 +292,19 @@ describe('nodeSize — a component is a proper square', () => {
268
292
  expect(c.height).toBeLessThan(k.height);
269
293
  });
270
294
 
271
- test('the square still fits its three lines of text', () => {
295
+ test('the two levels are not the same shape', () => {
296
+ // The reason a separate constant exists at all: a component is the
297
+ // square-ish member of the set, not a miniature container.
298
+ const c = nodeSize(node({ kind: 'component' }));
299
+ const k = nodeSize(node());
300
+ expect(c.width / c.height).toBeLessThan(1.2);
301
+ expect(k.width / k.height).toBeGreaterThan(1.5);
302
+ });
303
+
304
+ test('the box still fits its three lines of text', () => {
272
305
  // tag + 2-line label slot + subtitle + 10px padding x2. Without this the
273
- // square clips its own subtitle.
306
+ // component clips its own subtitle. The optical correction is applied to
307
+ // the width only, so this still holds at 140 tall.
274
308
  const s = nodeSize(node({ kind: 'component' }));
275
309
  const content = 16 + 40 + 16 + 20;
276
310
  expect(s.height).toBeGreaterThanOrEqual(content);
@@ -289,9 +323,14 @@ describe('card metrics', () => {
289
323
  expect(NODE_H).toBe(150);
290
324
  });
291
325
 
292
- test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
293
- expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).width);
294
- expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).height);
326
+ test('COMPONENT_SIZE is the short side, not the width', () => {
327
+ // Kept for callers that size off a single number. It is the height; the
328
+ // width the graph lays out against is COMPONENT_W, and collapsing the two
329
+ // back into one number would delete the optical correction.
330
+ const s = nodeSize(node({ kind: 'component' }));
331
+ expect(COMPONENT_SIZE).toBe(s.height);
332
+ expect(COMPONENT_SIZE).not.toBe(s.width);
333
+ expect(COMPONENT_W).toBe(s.width);
295
334
  });
296
335
 
297
336
  test('is near-square, not a wide strip', () => {
@@ -12,6 +12,7 @@
12
12
  * wants it: technology on the top row, description under the label.
13
13
  */
14
14
 
15
+ import { useLayoutEffect, useRef } from 'react';
15
16
  import { useTheme } from '@principal-ade/industry-theme';
16
17
  import { AppWindow, Component, Database, Maximize2 } from 'lucide-react';
17
18
  import type { LucideIcon } from 'lucide-react';
@@ -27,20 +28,45 @@ export const NODE_W = 250;
27
28
  export const NODE_H = 150;
28
29
 
29
30
  /**
30
- * A component is a proper square — equal sides, and visibly smaller than the
31
- * container it sits inside. Square-vs-rectangle is the clearest signal that
32
- * these are different C4 levels, and it survives grayscale and colour-blind
33
- * reading where a border-weight difference alone does not.
31
+ * A component has to *read* as a square, and equal sides do not manage that.
32
+ *
33
+ * A literally square box is perceived as tall: the eye measures it against the
34
+ * upright frame and the surrounding boxes, not against an abstract 1:1, so it
35
+ * lands looking narrower than it is. The correction is to make the real thing
36
+ * wider than it is tall, which is what this is for.
37
+ *
38
+ * 8:7 is further than the textbook 5%, and deliberately: the card's height is a
39
+ * `minHeight`, so a long description grows the drawn box past the height
40
+ * reserved here (see the probe below). 160 buys the square reading against a
41
+ * *rendered* height that runs past 140, not just against the nominal one.
42
+ *
43
+ * The level signal survives it: 8:7 is still unmistakably the square member of
44
+ * the set against the container's 5:3, and still beats a border-weight
45
+ * difference alone under grayscale and colour-blind reading.
46
+ */
47
+ export const COMPONENT_ASPECT = 8 / 7;
48
+
49
+ /** Short side of a component card — the height, and a hard ceiling on the drawn box. */
50
+ export const COMPONENT_H = 140;
51
+
52
+ /** Long side — the optically corrected one. 160, derived so the ratio is the constant. */
53
+ export const COMPONENT_W = Math.round(COMPONENT_H * COMPONENT_ASPECT);
54
+
55
+ /**
56
+ * Nominal edge of a component card, for callers that sized a box off a single
57
+ * number. It is the *short* side; the width the graph lays out against is
58
+ * `COMPONENT_W`, not this.
34
59
  */
35
- export const COMPONENT_SIZE = 140;
60
+ export const COMPONENT_SIZE = COMPONENT_H;
36
61
 
37
62
  /**
38
- * Per-level box size. The graph lays out against these, so a square component
39
- * and a rectangular container do not collide at a shared nominal width.
63
+ * Per-level box size. The graph lays out against these, so a near-square
64
+ * component and a rectangular container do not collide at a shared nominal
65
+ * width.
40
66
  */
41
67
  export function nodeSize(node: C4Element): { width: number; height: number } {
42
68
  return node.kind === 'component'
43
- ? { width: COMPONENT_SIZE, height: COMPONENT_SIZE }
69
+ ? { width: COMPONENT_W, height: COMPONENT_H }
44
70
  : { width: NODE_W, height: NODE_H };
45
71
  }
46
72
 
@@ -186,6 +212,13 @@ export interface NodeShape {
186
212
  kind: C4ShapeKind;
187
213
  /** Corner radius, for the shapes that use one. */
188
214
  radius: number;
215
+ /**
216
+ * Horizontal text alignment. This is a level cue, not a typographic
217
+ * preference: a column of left-aligned containers beside a set of centred
218
+ * ones reads as two kinds without comparing box sizes, and unlike the aspect
219
+ * ratio it survives a crop or a narrow viewport.
220
+ */
221
+ align: 'left' | 'center';
189
222
  }
190
223
 
191
224
  /**
@@ -195,24 +228,30 @@ export interface NodeShape {
195
228
  * The convention:
196
229
  *
197
230
  * container wide rectangle, slight radius — a runtime boundary
198
- * component square — a part inside one (see `nodeSize`)
199
- * external system wide rectangle, no radius — someone else's system
200
- * person pill — not a box at all
231
+ * component square, text centred — a part inside one
232
+ * external system wide rectangle, no radius — someone else's system
233
+ * person pill, text centred — not a box at all
234
+ *
235
+ * `square` here is a corner treatment, not a claim about the dimensions: a
236
+ * component's box is optically 8:7 (see `COMPONENT_ASPECT`) and still reads as
237
+ * this shape.
201
238
  *
202
239
  * A container keeps a slight radius; an external system is drawn with none, so
203
240
  * the two are distinguishable by corner treatment as well as by the kind icon
204
- * and frame position. The border dash stays spent on proposed-vs-accepted.
241
+ * and frame position. Alignment is the third, cheapest cue — it needs no
242
+ * comparison against anything. The border dash stays spent on
243
+ * proposed-vs-accepted.
205
244
  */
206
245
  export function nodeShape(node: C4Element): NodeShape {
207
246
  switch (node.kind) {
208
247
  case 'person':
209
- return { kind: 'pill', radius: NODE_H / 2 };
248
+ return { kind: 'pill', radius: NODE_H / 2, align: 'center' };
210
249
  case 'external-system':
211
- return { kind: 'rect', radius: 0 };
250
+ return { kind: 'rect', radius: 0, align: 'left' };
212
251
  case 'component':
213
- return { kind: 'square', radius: 6 };
252
+ return { kind: 'square', radius: 6, align: 'center' };
214
253
  case 'container':
215
- return { kind: 'rect', radius: 4 };
254
+ return { kind: 'rect', radius: 4, align: 'left' };
216
255
  }
217
256
  }
218
257
 
@@ -280,12 +319,37 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
280
319
  const subtitle = nodeSubtitle(node);
281
320
 
282
321
  const size = nodeSize(node);
283
- // A pill (a person) has no straight left edge, so left-aligned text reads as
284
- // spilling out of the oval. Centre the text and inset it from the curve.
285
- const centered = shape.kind === 'pill';
322
+ const centered = shape.align === 'center';
323
+ // The pill's inset is a separate concern from alignment, and a centred
324
+ // component must not inherit it: 30px of each side would leave a 160-wide box
325
+ // 100px of text, which wraps the description into exactly the extra lines
326
+ // that grow the box past the height the layout reserved.
327
+ const pillInset = shape.kind === 'pill';
328
+
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]);
286
349
 
287
350
  return (
288
351
  <div
352
+ ref={probeRef}
289
353
  onClick={onClick}
290
354
  style={{
291
355
  position: 'relative',
@@ -300,7 +364,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
300
364
  // height on every card. In a graph grid that makes the row unscannable.
301
365
  justifyContent: 'flex-start',
302
366
  gap: 3,
303
- padding: centered ? '10px 30px' : '10px 12px',
367
+ padding: pillInset ? '10px 30px' : '10px 12px',
304
368
  borderRadius: shape.radius,
305
369
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
306
370
  border: `${style.width}px ${style.dash} ${style.color}`,
@@ -14,6 +14,7 @@
14
14
  import { useEffect, useRef, useState } from 'react';
15
15
  import type { LucideIcon } from 'lucide-react';
16
16
  import {
17
+ AppWindow,
17
18
  ChevronDown,
18
19
  ChevronRight,
19
20
  CircleCheck,
@@ -32,7 +33,6 @@ import {
32
33
  Network,
33
34
  Route,
34
35
  Search,
35
- Server,
36
36
  Shapes,
37
37
  Sigma,
38
38
  Split,
@@ -82,7 +82,7 @@ export const SUBSYSTEM_ISSUE_CATEGORY_ICON: Record<
82
82
  repo: FolderGit2,
83
83
  construct: Component,
84
84
  'static-topology': Network,
85
- 'dynamic-topology': Server,
85
+ 'dynamic-topology': AppWindow,
86
86
  trail: Route,
87
87
  };
88
88
 
@@ -1140,15 +1140,18 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1140
1140
  [trails],
1141
1141
  );
1142
1142
 
1143
- // Per-node diagnostics badge: fold each component-targeted finding that maps
1144
- // to a verification rung into one badge per node — worst severity wins, the
1145
- // chip shows the earliest failing rung, and the count tallies the findings.
1143
+ // Per-node diagnostics badge: fold each component-targeted finding into one
1144
+ // badge per node — worst severity wins, the chip shows the earliest failing
1145
+ // rung, and the count tallies the findings. A finding with no construct rung
1146
+ // still badges the node when it carries a dedicated kind icon (e.g. a C4
1147
+ // process gap): the chip wears the kind's icon instead of a rung's, so the
1148
+ // node says "diagnostics here" for every finding the sidebar lists.
1146
1149
  const issueBadgeByAlias = useMemo(() => {
1147
1150
  const map = new Map<string, SubsystemNodeIssue>();
1148
1151
  if (!issues?.length) return map;
1149
1152
  for (const issue of issues) {
1150
1153
  const rung = issueRung(issue.kind);
1151
- if (!rung) continue;
1154
+ if (!rung && !ISSUE_KIND_ICON[issue.kind]) continue;
1152
1155
  const comp = issueComponent(issue);
1153
1156
  if (!comp) continue;
1154
1157
  const prev = map.get(comp.alias);
@@ -1158,12 +1161,27 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1158
1161
  ? 'error'
1159
1162
  : 'info',
1160
1163
  rung:
1161
- prev != null && ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
1162
- ? prev.rung
1163
- : rung,
1164
+ rung == null
1165
+ ? prev?.rung
1166
+ : prev?.rung != null &&
1167
+ ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
1168
+ ? prev.rung
1169
+ : rung,
1164
1170
  count: (prev?.count ?? 0) + 1,
1165
1171
  });
1166
1172
  }
1173
+ // A rung finding wins the chip; only rung-less nodes wear a kind icon —
1174
+ // the first kind-iconed finding the sidebar lists for that node.
1175
+ for (const [alias, badge] of map) {
1176
+ if (badge.rung != null) continue;
1177
+ const kinded = issues.find(
1178
+ (i) =>
1179
+ !issueRung(i.kind) &&
1180
+ ISSUE_KIND_ICON[i.kind] &&
1181
+ issueComponent(i)?.alias === alias,
1182
+ );
1183
+ if (kinded) map.set(alias, { ...badge, kind: kinded.kind });
1184
+ }
1167
1185
  return map;
1168
1186
  }, [issues, issueComponent]);
1169
1187
 
@@ -1135,8 +1135,19 @@ export type SubsystemIssueRung =
1135
1135
  export interface SubsystemNodeIssue {
1136
1136
  /** Worst severity on the node — `error` (contradicts source) beats `info`. */
1137
1137
  severity: 'error' | 'info';
1138
- /** Earliest failing rung across the node's findings. */
1139
- rung: SubsystemIssueRung;
1138
+ /**
1139
+ * Earliest failing rung across the node's findings. Absent when none of the
1140
+ * node's findings sit on the construct ladder — a rung-less finding (e.g. a
1141
+ * process gap) carries its audit `kind` instead.
1142
+ */
1143
+ rung?: SubsystemIssueRung;
1144
+ /**
1145
+ * The audit kind of the node's finding when that finding has no construct
1146
+ * rung: the chip wears the kind's icon (`ISSUE_KIND_ICON`) rather than a
1147
+ * rung's. Set only when `rung` is absent — a rung finding is more specific
1148
+ * about where the construct fails, so it wins the chip.
1149
+ */
1150
+ kind?: string;
1140
1151
  /** Total findings on this node. */
1141
1152
  count: number;
1142
1153
  }
@@ -46,6 +46,7 @@ import {
46
46
  import { componentColor } from '../pierre/constructColors';
47
47
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
48
48
  import { ISSUE_KIND_ICON, ISSUE_RUNG_ICON } from './IssueList';
49
+ import { Search } from 'lucide-react';
49
50
  import type { LucideIcon } from 'lucide-react';
50
51
 
51
52
  export const CONSTRUCT_LABEL: Record<string, string> = {
@@ -122,11 +123,13 @@ function useSubsystemCallbacks(): SubsystemGraphCallbacks {
122
123
 
123
124
  /**
124
125
  * The diagnostics overlay for a component node: the node's own border turns
125
- * dotted (see the node's `borderStyle`) and an earliest-failing-rung corner
126
- * chip (file → symbol → declaration → type → signature) carries the severity
127
- * color, with a count when the node has more than one finding. The chip is
128
- * purely additive and non-interactive; the border stays construct-colored, so
129
- * severity never has to compete with the construct palette.
126
+ * dotted (see the node's `borderStyle`) and a severity-colored corner chip
127
+ * carries the count. The chip's icon is the earliest-failing construct rung
128
+ * (file → symbol → declaration → type → signature); a finding with no rung
129
+ * (e.g. a C4 process gap) wears its kind icon instead — see
130
+ * `SubsystemNodeIssue.kind`. The chip is purely additive and non-interactive;
131
+ * the border stays construct-colored, so severity never has to compete with
132
+ * the construct palette.
130
133
  */
131
134
  function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
132
135
  const { theme } = useTheme();
@@ -134,10 +137,15 @@ function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
134
137
  const color = isError
135
138
  ? (theme.colors.error ?? '#e5534b')
136
139
  : (theme.colors.warning ?? '#d4a017');
140
+ const Icon = issue.rung
141
+ ? ISSUE_RUNG_ICON[issue.rung]
142
+ : issue.kind
143
+ ? (ISSUE_KIND_ICON[issue.kind] ?? Search)
144
+ : Search;
137
145
  return (
138
146
  <IssueChip
139
147
  color={color}
140
- Icon={ISSUE_RUNG_ICON[issue.rung]}
148
+ Icon={Icon}
141
149
  count={issue.count}
142
150
  anchor={{ right: -9, bottom: -8 }}
143
151
  />
@@ -16,10 +16,126 @@ import {
16
16
  fractionAlongPath,
17
17
  planCompoundGroups,
18
18
  getElkOptions,
19
+ absoluteBoundsOf,
20
+ flowPositionOf,
19
21
  type Point,
20
22
  } from './elkLayout';
21
23
  import { EDGE_LABEL_WIDTH, EDGE_LABEL_SIDE_PADDING } from './edgeLabel';
22
24
 
25
+ /**
26
+ * A synthetic laid-out result standing in for the drill-down's open layout: a
27
+ * compound parent at (12,12) with two children, a sibling container, and a
28
+ * flattened frame that never enters the ELK tree.
29
+ */
30
+ function syntheticResult() {
31
+ const rect = (x: number, y: number, width: number, height: number) => ({
32
+ x, y, width, height,
33
+ });
34
+ return {
35
+ absoluteRects: new Map([
36
+ ['container:host', rect(12, 12, 508, 416)],
37
+ ['cmp:a', rect(24, 96, 140, 140)],
38
+ ['cmp:b', rect(24, 276, 140, 140)],
39
+ ['container:api', rect(576, 218, 250, 150)],
40
+ ]),
41
+ // A nested group's entry is parent-relative — the trap `flowPositionOf` avoids.
42
+ groupBounds: new Map([
43
+ ['container:host', rect(12, 12, 508, 416)],
44
+ ['container:api', rect(576, 218, 250, 150)],
45
+ ['system:acme', rect(-12, -52, 1152, 504)],
46
+ ]),
47
+ };
48
+ }
49
+
50
+ describe('absolute coordinate space', () => {
51
+ test('a root node keeps its absolute position', () => {
52
+ const r = syntheticResult();
53
+ expect(flowPositionOf(r, 'container:host')).toEqual({ x: 12, y: 12 });
54
+ expect(flowPositionOf(r, 'container:api')).toEqual({ x: 576, y: 218 });
55
+ });
56
+
57
+ // The bug this guards: a child's own position is already parent-relative, so
58
+ // subtracting the parent's absolute origin again pushed it out of its card.
59
+ test('a child is relative to its parent, offset exactly once', () => {
60
+ const r = syntheticResult();
61
+ expect(flowPositionOf(r, 'cmp:a', 'container:host')).toEqual({ x: 12, y: 84 });
62
+ expect(flowPositionOf(r, 'cmp:b', 'container:host')).toEqual({ x: 12, y: 264 });
63
+ });
64
+
65
+ test('children land inside the parent box they are nested in', () => {
66
+ const r = syntheticResult();
67
+ const box = absoluteBoundsOf(r, 'container:host')!;
68
+ for (const id of ['cmp:a', 'cmp:b']) {
69
+ const p = flowPositionOf(r, id, 'container:host')!;
70
+ const own = r.absoluteRects.get(id)!;
71
+ expect(p.x).toBeGreaterThanOrEqual(0);
72
+ expect(p.y).toBeGreaterThanOrEqual(0);
73
+ expect(p.x + own.width).toBeLessThanOrEqual(box.width);
74
+ expect(p.y + own.height).toBeLessThanOrEqual(box.height);
75
+ }
76
+ });
77
+
78
+ // A compound parent has no `result.nodes` entry at all, so anything reading
79
+ // positions from there leaves it on its authored slot.
80
+ test('a compound parent gets a real position, not a fallback', () => {
81
+ const r = syntheticResult();
82
+ expect(flowPositionOf(r, 'container:host')).not.toBeUndefined();
83
+ expect(flowPositionOf(r, 'container:host')).toEqual(
84
+ r.absoluteRects.get('container:host') && { x: 12, y: 12 },
85
+ );
86
+ });
87
+
88
+ test('a flattened frame resolves from the synthesized bounds', () => {
89
+ // Not in absoluteRects — it never entered the ELK tree.
90
+ expect(syntheticResult().absoluteRects.has('system:acme')).toBe(false);
91
+ expect(absoluteBoundsOf(syntheticResult(), 'system:acme')).toEqual({
92
+ x: -12, y: -52, width: 1152, height: 504,
93
+ });
94
+ });
95
+
96
+ test('an unknown id has no position rather than a silent zero', () => {
97
+ expect(flowPositionOf(syntheticResult(), 'nope')).toBeUndefined();
98
+ expect(absoluteBoundsOf(syntheticResult(), 'nope')).toBeUndefined();
99
+ });
100
+
101
+ test('an unknown parent falls back to absolute, not to a wrong origin', () => {
102
+ expect(flowPositionOf(syntheticResult(), 'cmp:a', 'nope')).toEqual({ x: 24, y: 96 });
103
+ });
104
+ });
105
+
106
+ /**
107
+ * A card's FLIP offset is the difference between where it was and where the new
108
+ * layout puts it. If this is computed in the wrong space the card slides to a
109
+ * plausible-looking wrong place, which is what "the anchors shift" looks like.
110
+ */
111
+ describe('morph offsets', () => {
112
+ interface Rect { x: number; y: number; width: number; height: number }
113
+ const offsetTo = (from: Rect, to: Rect) => ({
114
+ x: from.x - to.x,
115
+ y: from.y - to.y,
116
+ });
117
+
118
+ test('a card that moves is offset by exactly the move', () => {
119
+ // Closed: (330,182) 250x150. Open: the compound parent ELK fitted at (12,12).
120
+ const closed = { x: 330, y: 182, width: 250, height: 150 };
121
+ const open = { x: 12, y: 12, width: 508, height: 416 };
122
+ expect(offsetTo(closed, open)).toEqual({ x: 318, y: 170 });
123
+ });
124
+
125
+ test('a card that does not move gets no offset', () => {
126
+ const same = { x: 576, y: 218, width: 250, height: 150 };
127
+ expect(offsetTo(same, same)).toEqual({ x: 0, y: 0 });
128
+ });
129
+
130
+ test('an offset ignores size, because the card transitions its own size', () => {
131
+ // Growing in place must not shift the anchor: the card's width/height
132
+ // transition handles the size, a translate must stay 0.
133
+ const before = { x: 12, y: 12, width: 250, height: 150 };
134
+ const after = { x: 12, y: 12, width: 508, height: 416 };
135
+ expect(offsetTo(before, after)).toEqual({ x: 0, y: 0 });
136
+ });
137
+ });
138
+
23
139
  describe('planCompoundGroups', () => {
24
140
  test('drops a single-leaf group by default and promotes its member', () => {
25
141
  const plan = planCompoundGroups(
@@ -130,6 +246,21 @@ describe('planCompoundGroups', () => {
130
246
  );
131
247
  expect(plan.built[0]!.minWidth).toBe(220);
132
248
  });
249
+
250
+ // `padTop` only reaches ELK if the plan carries it; when it was dropped here
251
+ // the parent's own header was silently overlaid by its first child.
252
+ test('carries padTop through to the plan', () => {
253
+ const plan = planCompoundGroups(
254
+ [{ id: 'mod', memberIds: ['a', 'b'], padTop: 84 }],
255
+ ['a', 'b'],
256
+ );
257
+ expect(plan.built[0]!.padTop).toBe(84);
258
+ });
259
+
260
+ test('leaves padTop unset when the group does not reserve chrome', () => {
261
+ const plan = planCompoundGroups([{ id: 'mod', memberIds: ['a', 'b'] }], ['a', 'b']);
262
+ expect(plan.built[0]!.padTop).toBeUndefined();
263
+ });
133
264
  });
134
265
 
135
266
  describe('elkLayout helper functions', () => {