@principal-ai/subsystems-react 0.53.0 → 0.53.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/subsystem/C4Graph.d.ts +91 -1
  2. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  3. package/dist/subsystem/C4Graph.js +672 -180
  4. package/dist/subsystem/C4Graph.js.map +1 -1
  5. package/dist/subsystem/C4NodeCard.d.ts +36 -1
  6. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  7. package/dist/subsystem/C4NodeCard.js +45 -10
  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 +14 -5
  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/utils/elkLayout.d.ts +68 -8
  17. package/dist/utils/elkLayout.d.ts.map +1 -1
  18. package/dist/utils/elkLayout.js +95 -14
  19. package/dist/utils/elkLayout.js.map +1 -1
  20. package/package.json +2 -1
  21. package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
  22. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
  23. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
  24. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +132 -0
  25. package/src/subsystem/C4Graph.groups.test.ts +217 -0
  26. package/src/subsystem/C4Graph.morph.test.tsx +208 -0
  27. package/src/subsystem/C4Graph.tsx +803 -226
  28. package/src/subsystem/C4NodeCard.test.ts +6 -3
  29. package/src/subsystem/C4NodeCard.tsx +92 -20
  30. package/src/subsystem/IssueList.category.test.ts +39 -0
  31. package/src/subsystem/IssueList.tsx +16 -5
  32. package/src/subsystem/c4.ts +74 -3
  33. package/src/utils/elkLayout.test.ts +131 -0
  34. package/src/utils/elkLayout.ts +133 -16
@@ -0,0 +1,125 @@
1
+ import React, { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
4
+ import { C4NodeCard, NODE_W, NODE_H } from '../../../subsystem/C4NodeCard';
5
+ import type { C4Container, C4Component } from '../../../subsystem/c4';
6
+
7
+ const meta = {
8
+ title: 'Subsystem/C4Graph/C4CardGrow',
9
+ parameters: { layout: 'fullscreen' },
10
+ tags: ['autodocs'],
11
+ decorators: [
12
+ (Story) => (
13
+ <ThemeProvider theme={defaultEditorTheme}>
14
+ <Story />
15
+ </ThemeProvider>
16
+ ),
17
+ ],
18
+ } satisfies Meta;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Isolated grow/shrink of one container card — no graph, no frames, no
25
+ // components. Toggling the expand affordance animates the card's own size
26
+ // between the box dimensions and an expanded rectangle, so the FLIP motion can
27
+ // be judged on its own.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const GROWN_W = 420;
31
+ const GROWN_H = 300;
32
+
33
+ const card: C4Container = {
34
+ kind: 'container',
35
+ id: 'container:studio-host',
36
+ label: 'Subsystem Studio — host',
37
+ containerKind: 'application',
38
+ technology: 'Bun + Electrobun',
39
+ description: 'Owns the model store on disk and serves the renderer over RPC.',
40
+ state: 'accepted',
41
+ parentId: 'system:acme',
42
+ };
43
+
44
+ const COMPONENTS: C4Component[] = [
45
+ {
46
+ kind: 'component',
47
+ id: 'component:audit-verification',
48
+ label: 'Audit & verification',
49
+ container: card.id,
50
+ technology: 'Bun',
51
+ description: 'Checks a subsystem model and reports what is wrong.',
52
+ state: 'accepted',
53
+ },
54
+ {
55
+ kind: 'component',
56
+ id: 'component:model-store',
57
+ label: 'Model store I/O',
58
+ container: card.id,
59
+ technology: 'Bun fs',
60
+ description: 'Reads and writes model JSON under ~/.principal.',
61
+ state: 'accepted',
62
+ },
63
+ ];
64
+
65
+ function Demo() {
66
+ const [open, setOpen] = useState(false);
67
+ const [grown, setGrown] = useState(false);
68
+ // True when the card has finished shrinking back to its box — only then is the
69
+ // description shown again (it stays hidden through open *and* the shrink).
70
+ const [settled, setSettled] = useState(true);
71
+ return (
72
+ <div
73
+ style={{
74
+ width: '100%',
75
+ height: '100vh',
76
+ display: 'flex',
77
+ alignItems: 'center',
78
+ justifyContent: 'center',
79
+ background: '#12141a',
80
+ }}
81
+ >
82
+ <div
83
+ onTransitionEnd={(e) => {
84
+ // The card's own size transition bubbles here.
85
+ if (e.propertyName !== 'width') return;
86
+ if (open) setGrown(true);
87
+ else setSettled(true);
88
+ }}
89
+ >
90
+ <C4NodeCard
91
+ node={card}
92
+ width={open ? GROWN_W : NODE_W}
93
+ height={open ? GROWN_H : NODE_H}
94
+ showDescription={settled}
95
+ onExpand={() => {
96
+ // Leaving the resting state: hide the description immediately.
97
+ setSettled(false);
98
+ if (open) setGrown(false);
99
+ setOpen((v) => !v);
100
+ }}
101
+ >
102
+ {grown && (
103
+ <div
104
+ style={{
105
+ display: 'grid',
106
+ gridTemplateColumns: '1fr 1fr',
107
+ gap: 10,
108
+ marginTop: 8,
109
+ minHeight: 0,
110
+ }}
111
+ >
112
+ {COMPONENTS.map((c) => (
113
+ <C4NodeCard key={c.id} node={c} />
114
+ ))}
115
+ </div>
116
+ )}
117
+ </C4NodeCard>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ export const Grow: Story = {
124
+ render: () => <Demo />,
125
+ };
@@ -3,7 +3,7 @@ import '@xyflow/react/dist/style.css';
3
3
  import type { Meta, StoryObj } from '@storybook/react';
4
4
  import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
5
  import { C4Graph } from '../../../subsystem/C4Graph';
6
- import type { C4Edge, C4Group, C4Model, C4Element, C4Component } from '../../../subsystem/c4';
6
+ import type { C4Edge, C4Model, C4Element, C4Component, C4Container } from '../../../subsystem/c4';
7
7
 
8
8
  const meta = {
9
9
  title: 'Subsystem/C4Graph/C4Component',
@@ -53,7 +53,25 @@ function component(
53
53
  };
54
54
  }
55
55
 
56
+ /** A container element. Its components make it frame in the renderer. */
57
+ function container(over: Partial<C4Container> & Pick<C4Container, 'id' | 'label'>): C4Element {
58
+ return {
59
+ kind: 'container',
60
+ containerKind: 'application',
61
+ technology: '',
62
+ state: 'accepted',
63
+ members: [],
64
+ constructs: [],
65
+ parentId: SYSTEM.id,
66
+ ...over,
67
+ };
68
+ }
69
+
56
70
  const NODES: C4Element[] = [
71
+ // --- the containers these components sit in ------------------------------
72
+ container({ id: HOST, label: 'Subsystem Studio — host', technology: 'Bun + Electrobun' }),
73
+ container({ id: RENDERER, label: 'Subsystem Studio — renderer', technology: 'React 19' }),
74
+
57
75
  // --- inside the host ------------------------------------------------------
58
76
  component({
59
77
  id: 'component:audit-verification',
@@ -131,13 +149,6 @@ const NODES: C4Element[] = [
131
149
  }),
132
150
  ];
133
151
 
134
- /** The two containers at this level, drawn as frames. */
135
- const GROUPS: C4Group[] = [
136
- { id: HOST, kind: 'container', label: 'Subsystem Studio — host', parentId: SYSTEM.id, memberIds: NODES.filter((n) => n.parentId === HOST).map((n) => n.id) },
137
- { id: RENDERER, kind: 'container', label: 'Subsystem Studio — renderer', parentId: SYSTEM.id, memberIds: NODES.filter((n) => n.parentId === RENDERER).map((n) => n.id) },
138
- { id: SYSTEM.id, kind: 'system', label: SYSTEM.label, memberIds: [HOST, RENDERER] },
139
- ];
140
-
141
152
  /** A line between components — protocol-labelled, like the container view. */
142
153
  function edge(id: string, source: string, target: string, protocol: string): C4Edge {
143
154
  return { id, source, target, label: protocol, protocol, mechanisms: [], count: 1 };
@@ -158,7 +169,6 @@ const componentModel: C4Model = {
158
169
  view: 'component',
159
170
  system: SYSTEM,
160
171
  nodes: NODES,
161
- groups: GROUPS,
162
172
  edges: EDGES,
163
173
  };
164
174
 
@@ -170,7 +180,7 @@ function Demo() {
170
180
  return (
171
181
  <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
172
182
  <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
173
- <C4Graph model={model} title="Subsystem Modeling" onSelectNode={setSelected} />
183
+ <C4Graph model={model} onSelectNode={setSelected} />
174
184
  </div>
175
185
  <div
176
186
  style={{
@@ -184,7 +194,7 @@ function Demo() {
184
194
  borderTop: '1px solid #333',
185
195
  }}
186
196
  >
187
- <span>{components} components in {model.groups.filter((g) => g.kind === 'container').length} containers</span>
197
+ <span>{components} components in {model.nodes.filter((n) => n.kind === 'container').length} containers</span>
188
198
  <span>{model.edges.length} lines</span>
189
199
  <span>selected: {selected ?? '(none)'}</span>
190
200
  <span style={{ color: '#777' }}>each component is a grouping stitched across many models</span>
@@ -195,14 +195,6 @@ const containerModel: C4Model = {
195
195
  view: 'container',
196
196
  system: SYSTEM,
197
197
  nodes: NODES,
198
- groups: [
199
- {
200
- id: SYSTEM.id,
201
- kind: 'system',
202
- label: SYSTEM.label,
203
- memberIds: NODES.filter((n) => n.parentId === SYSTEM.id).map((n) => n.id),
204
- },
205
- ],
206
198
  edges: EDGES,
207
199
  };
208
200
 
@@ -218,7 +210,7 @@ function Demo() {
218
210
  return (
219
211
  <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
220
212
  <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
221
- <C4Graph model={model} title="Subsystem Modeling" onSelectNode={setSelected} />
213
+ <C4Graph model={model} onSelectNode={setSelected} />
222
214
  </div>
223
215
  <div
224
216
  style={{
@@ -0,0 +1,132 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import '@xyflow/react/dist/style.css';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { C4Graph } from '../../../subsystem/C4Graph';
6
+ import type { C4Edge, C4Model, C4Element, C4Component, C4Container } from '../../../subsystem/c4';
7
+
8
+ const meta = {
9
+ title: 'Subsystem/C4Graph/C4Transition',
10
+ component: C4Graph,
11
+ parameters: { layout: 'fullscreen' },
12
+ tags: ['autodocs'],
13
+ decorators: [
14
+ (Story) => (
15
+ <ThemeProvider theme={defaultEditorTheme}>
16
+ <Story />
17
+ </ThemeProvider>
18
+ ),
19
+ ],
20
+ } satisfies Meta<typeof C4Graph>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Drill-down: two layouts, one transition.
27
+ //
28
+ // Closed, the container is a box. Its expand affordance (bottom-right) opens it,
29
+ // and the graph swaps to a *second, already-computed* layout in which the same
30
+ // element is an ELK parent — so ELK sizes it to hold its own components, and they
31
+ // arrive as nested cards. The same affordance closes it again.
32
+ //
33
+ // Nothing is a frame here: the container stays a card and grows (its own
34
+ // width/height transition), and the system frame around it grows with it — also
35
+ // by size, since it is a real ELK parent and ELK owns both its origin and its fit.
36
+ // Components stay hidden until the grow settles, and the card's description
37
+ // returns only after the shrink — hence the two settled states below.
38
+ //
39
+ // The story also carries component→component edges, which the nested layout
40
+ // routes inside the parent.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const SYSTEM = { id: 'system:acme', label: 'Subsystem Modeling', repoKey: 'pkg:github/acme/subsystem-modeling' };
44
+ const HOST = 'container:studio-host';
45
+
46
+ function container(over: Partial<C4Container> & Pick<C4Container, 'id' | 'label'>): C4Element {
47
+ return {
48
+ kind: 'container',
49
+ containerKind: 'application',
50
+ technology: '',
51
+ state: 'accepted',
52
+ members: [],
53
+ constructs: [],
54
+ parentId: SYSTEM.id,
55
+ ...over,
56
+ };
57
+ }
58
+
59
+ function component(
60
+ over: Partial<C4Component> & Pick<C4Component, 'id' | 'label' | 'container'>,
61
+ ): C4Element {
62
+ return {
63
+ kind: 'component',
64
+ members: [],
65
+ constructs: [],
66
+ state: 'accepted',
67
+ parentId: over.container,
68
+ technology: 'Bun',
69
+ ...over,
70
+ };
71
+ }
72
+
73
+ const NODES: C4Element[] = [
74
+ container({ id: HOST, label: 'Subsystem Studio — host', technology: 'Bun + Electrobun', description: 'The Electron main process.' }),
75
+ component({ id: 'component:audit-verification', label: 'Audit & verification', container: HOST, description: 'Checks a model, reports problems.' }),
76
+ component({ id: 'component:maintain-proposals', label: 'Maintain & proposals', container: HOST, description: 'Runs the maintain agent, records proposals.' }),
77
+ component({ id: 'component:model-store', label: 'Model store I/O', container: HOST, technology: 'Bun fs', description: 'Reads and writes model JSON.' }),
78
+ ];
79
+
80
+ function edge(id: string, source: string, target: string, protocol: string): C4Edge {
81
+ return { id, source, target, label: protocol, protocol, mechanisms: [], count: 1 };
82
+ }
83
+
84
+ const EDGES: C4Edge[] = [
85
+ edge('e:audit-store', 'component:audit-verification', 'component:model-store', 'fs · JSON'),
86
+ edge('e:proposals-store', 'component:maintain-proposals', 'component:model-store', 'fs · JSON'),
87
+ ];
88
+
89
+ function Demo() {
90
+ const [openId, setOpenId] = useState<string | null>(null);
91
+ const [selected, setSelected] = useState<string | null>(null);
92
+
93
+ const model = useMemo<C4Model>(
94
+ () => ({ view: 'container', system: SYSTEM, nodes: NODES, edges: EDGES, openContainerId: openId }),
95
+ [openId],
96
+ );
97
+
98
+ return (
99
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
100
+ <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
101
+ <C4Graph
102
+ model={model}
103
+ onSelectNode={setSelected}
104
+ onOpenContainer={setOpenId}
105
+ onCloseContainer={() => setOpenId(null)}
106
+ />
107
+ </div>
108
+ <div
109
+ style={{
110
+ padding: '6px 12px',
111
+ display: 'flex',
112
+ alignItems: 'center',
113
+ gap: 18,
114
+ fontFamily: 'monospace',
115
+ fontSize: 12,
116
+ color: '#aaa',
117
+ borderTop: '1px solid #333',
118
+ }}
119
+ >
120
+ <span>{openId ? `opened: ${openId}` : 'closed'}</span>
121
+ <span>selected: {selected ?? '(none)'}</span>
122
+ <span style={{ color: '#777' }}>
123
+ the expand icon (bottom-right of the container) opens it, and closes it again
124
+ </span>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ export const DrillDown: Story = {
131
+ render: () => <Demo />,
132
+ };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The nesting the drill-down's stability rests on.
3
+ *
4
+ * The 206px "moves down" report was not a FLIP bug or a camera bug — it was the
5
+ * software-system boundary having no position of its own. Flattened, the frame was
6
+ * synthesized as a union of its members' bounds, so it inherited their top edge and
7
+ * slid whenever ELK re-placed one of them. Making it a real ELK parent fixed it,
8
+ * and only because the opened container nests *inside* it.
9
+ *
10
+ * None of this is visible in the rendered output: get the nesting wrong and the
11
+ * graph still draws, just wrong. So it is asserted here instead. `c4GroupDefs` is
12
+ * pure and needs no ELK — the layout half it feeds is covered by the node e2e.
13
+ */
14
+ import { describe, expect, test } from 'bun:test';
15
+ import { c4GroupDefs, rebaseGroupBoundsAbsolute, showDescriptionFor } from './C4Graph';
16
+ import type { C4Element, C4Model } from './c4';
17
+
18
+ const SYSTEM = 'system:studio';
19
+
20
+ const container = (id: string): C4Element => ({
21
+ id,
22
+ kind: 'container',
23
+ label: id,
24
+ parentId: SYSTEM,
25
+ });
26
+ const component = (id: string, of: string): C4Element => ({
27
+ id,
28
+ kind: 'component',
29
+ label: id,
30
+ container: of,
31
+ });
32
+
33
+ /** A container view: two containers, one of them with components. */
34
+ function model(nodes: C4Element[]): C4Model {
35
+ return {
36
+ view: 'container',
37
+ system: { id: SYSTEM, label: 'Studio' },
38
+ nodes,
39
+ edges: [],
40
+ };
41
+ }
42
+
43
+ const HOST = 'container:host';
44
+ const NOTES = 'container:notes';
45
+ const base = model([
46
+ container(HOST),
47
+ container(NOTES),
48
+ component('cmp:a', HOST),
49
+ component('cmp:b', HOST),
50
+ ]);
51
+
52
+ const groupById = (defs: ReturnType<typeof c4GroupDefs>['groups']) =>
53
+ new Map(defs.map((g) => [g.id, g]));
54
+
55
+ describe('c4GroupDefs — the system boundary', () => {
56
+ test('is a group of its own, even with nothing open', () => {
57
+ const { groups } = c4GroupDefs(base, null);
58
+ expect(groupById(groups).has(SYSTEM)).toBe(true);
59
+ });
60
+
61
+ test('holds the containers, and not the things outside the boundary', () => {
62
+ const withOutsiders = model([
63
+ ...base.nodes,
64
+ { id: 'external:github', kind: 'external-system', label: 'GitHub' },
65
+ { id: 'person:dev', kind: 'person', label: 'Dev' },
66
+ ]);
67
+ const system = groupById(c4GroupDefs(withOutsiders, null).groups).get(SYSTEM)!;
68
+ // People and external systems sit outside the boundary — that is what the
69
+ // boundary means — so they must not be laid out as its contents.
70
+ expect(system.memberIds).toEqual([HOST, NOTES]);
71
+ });
72
+
73
+ test('is not dropped when a container opens', () => {
74
+ // The frame must not shrink to the opened container: it is the *system*
75
+ // boundary, and the open state adds a container inside it, not replaces it.
76
+ const { groups } = c4GroupDefs(base, HOST);
77
+ const system = groupById(groups).get(SYSTEM)!;
78
+ expect(system.memberIds).toContain(HOST);
79
+ expect(system.memberIds).toContain(NOTES);
80
+ });
81
+ });
82
+
83
+ describe('c4GroupDefs — the opened container', () => {
84
+ test('nests inside the system frame, not beside it', () => {
85
+ // The load-bearing assertion. As a sibling it is laid out from the root, the
86
+ // frame is no longer the origin of its contents, and the boundary's corner
87
+ // stops being pinned across the two layouts.
88
+ const { groups } = c4GroupDefs(base, HOST);
89
+ const open = groupById(groups).get(HOST)!;
90
+ expect(open.parentId).toBe(SYSTEM);
91
+ });
92
+
93
+ test('is drawn as a card as well as laid out as a group', () => {
94
+ // It has a node (the card we click) *and* a group (the parent ELK sizes to
95
+ // hold its components). Without the flag, ELK takes the bare leaf box.
96
+ const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
97
+ expect(open.drawnAsCard).toBe(true);
98
+ });
99
+
100
+ test('lists its components, and reserves room for its own chrome', () => {
101
+ const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
102
+ expect(open.memberIds).toEqual(['cmp:a', 'cmp:b']);
103
+ // Its label slot and technology row, so children land below them.
104
+ expect(open.padTop).toBeGreaterThan(0);
105
+ // Never narrower than the closed card: opening only grows the box.
106
+ expect(open.minWidth).toBeGreaterThan(0);
107
+ });
108
+
109
+ test('is absent when nothing is open, so the closed card stays a leaf', () => {
110
+ // The closed container is a plain node — no group. The group's whole purpose is
111
+ // to hold components, and there are none in view.
112
+ expect(groupById(c4GroupDefs(base, null).groups).has(HOST)).toBe(false);
113
+ });
114
+
115
+ test('adds no group for a container with no components in view', () => {
116
+ const { groups } = c4GroupDefs(model([container(NOTES)]), NOTES);
117
+ expect(groupById(groups).has(NOTES)).toBe(false);
118
+ });
119
+ });
120
+
121
+ describe('c4GroupDefs — frames', () => {
122
+ test('the opened container is not also drawn as a frame', () => {
123
+ // It is drawn as a grown card. Leaving it in `frames` would draw a frame
124
+ // around the card.
125
+ const { frames } = c4GroupDefs(base, HOST);
126
+ expect(frames.map((g) => g.id)).not.toContain(HOST);
127
+ });
128
+
129
+ test('frames feed the bands, so they exclude the opened container too', () => {
130
+ // Bands come from frames only. Counting the opened container as a frame would
131
+ // give it its own layer, away from the containers it belongs beside.
132
+ const { frames } = c4GroupDefs(base, HOST);
133
+ expect(frames).toEqual([expect.objectContaining({ id: SYSTEM, kind: 'system' })]);
134
+ });
135
+
136
+ test('the component view keeps a frame per container with components', () => {
137
+ const componentView: C4Model = { ...base, view: 'component' };
138
+ const { frames } = c4GroupDefs(componentView, null);
139
+ expect(frames.map((g) => g.id)).toContain(HOST);
140
+ // Those frames nest inside the system boundary.
141
+ expect(frames.find((g) => g.id === HOST)?.parentId).toBe(SYSTEM);
142
+ });
143
+ });
144
+
145
+ describe('rebaseGroupBoundsAbsolute', () => {
146
+ // Real numbers from the drill-down: the system frame sits at (12,12), and the
147
+ // opened container nests inside it, so ELK reports it 24px/64px in — offset by
148
+ // the frame's own 12,12 plus its 12px padding.
149
+ const result = () => ({
150
+ groupBounds: new Map([
151
+ [SYSTEM, { x: 12, y: 12, width: 298, height: 874 }],
152
+ [HOST, { x: 24, y: 64, width: 250, height: 596 }],
153
+ ]),
154
+ absoluteRects: new Map([
155
+ [SYSTEM, { x: 12, y: 12, width: 298, height: 874 }],
156
+ [HOST, { x: 36, y: 76, width: 250, height: 596 }],
157
+ ]),
158
+ });
159
+
160
+ test('lifts a nested group out of its parent origin', () => {
161
+ const rebased = rebaseGroupBoundsAbsolute(result());
162
+ // Unrebased, `containerRect` would read (24,64) and the morph would glide the
163
+ // container to a place it was never drawn.
164
+ expect(rebased.get(HOST)).toEqual({ x: 36, y: 76, width: 250, height: 596 });
165
+ });
166
+
167
+ test('leaves a root-level group alone', () => {
168
+ const rebased = rebaseGroupBoundsAbsolute(result());
169
+ expect(rebased.get(SYSTEM)).toEqual({ x: 12, y: 12, width: 298, height: 874 });
170
+ });
171
+
172
+ test('keeps a flat frame, which has no node and is already absolute', () => {
173
+ // Flat frames are synthesized from absolute member bounds and have no entry in
174
+ // `absoluteRects` — they must survive rather than drop out.
175
+ const r = result();
176
+ r.groupBounds.set('flat', { x: 12, y: 402, width: 274, height: 150 });
177
+ const rebased = rebaseGroupBoundsAbsolute(r);
178
+ expect(rebased.get('flat')).toEqual({ x: 12, y: 402, width: 274, height: 150 });
179
+ });
180
+
181
+ test('does not mutate the layout result', () => {
182
+ // `computeElkLayout`'s own result is read elsewhere in its native mixed space.
183
+ const r = result();
184
+ rebaseGroupBoundsAbsolute(r);
185
+ expect(r.groupBounds.get(HOST)).toEqual({ x: 24, y: 64, width: 250, height: 596 });
186
+ });
187
+
188
+ test('keeps every entry — no group is lost in the translation', () => {
189
+ const rebased = rebaseGroupBoundsAbsolute(result());
190
+ expect([...rebased.keys()].sort()).toEqual([SYSTEM, HOST].sort());
191
+ });
192
+ });
193
+
194
+ describe('showDescriptionFor', () => {
195
+ test('a plain card shows its description', () => {
196
+ expect(showDescriptionFor(false, true)).toBe(true);
197
+ });
198
+
199
+ test('no description while a morph is in flight', () => {
200
+ // The card is resizing underneath it; the line would slide across a moving box.
201
+ expect(showDescriptionFor(false, false)).toBe(false);
202
+ });
203
+
204
+ test('an expanded container never shows it, however long it has settled', () => {
205
+ // The regression: keying only on `settled` brought the line back the moment the
206
+ // grow finished, into the space the components now occupy. ELK reserves header
207
+ // chrome only, so it landed on top of the first component rather than above it.
208
+ expect(showDescriptionFor(true, true)).toBe(false);
209
+ expect(showDescriptionFor(true, false)).toBe(false);
210
+ });
211
+
212
+ test('an unset open flag is not "open"', () => {
213
+ // `open` is undefined until the layout stamps it, so `undefined` must not be
214
+ // read as truthy — that would hide descriptions on every card, always.
215
+ expect(showDescriptionFor(undefined, true)).toBe(true);
216
+ });
217
+ });