@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,9 @@
12
12
  * pure and needs no ELK — the layout half it feeds is covered by the node e2e.
13
13
  */
14
14
  import { describe, expect, test } from 'bun:test';
15
- import { c4GroupDefs, rebaseGroupBoundsAbsolute, showDescriptionFor } from './C4Graph';
15
+ import { c4GroupDefs, hidesComponentDuringMorph, rebaseGroupBoundsAbsolute, showDescriptionFor } from './C4Graph';
16
+ import { OPEN_FOOTER_PAD, OPEN_SIDE_PAD } from './C4NodeCard';
17
+ import { openContainerSet, openStateKey } from './c4';
16
18
  import type { C4Element, C4Model } from './c4';
17
19
 
18
20
  const SYSTEM = 'system:studio';
@@ -54,7 +56,7 @@ const groupById = (defs: ReturnType<typeof c4GroupDefs>['groups']) =>
54
56
 
55
57
  describe('c4GroupDefs — the system boundary', () => {
56
58
  test('is a group of its own, even with nothing open', () => {
57
- const { groups } = c4GroupDefs(base, null);
59
+ const { groups } = c4GroupDefs(base, []);
58
60
  expect(groupById(groups).has(SYSTEM)).toBe(true);
59
61
  });
60
62
 
@@ -64,7 +66,7 @@ describe('c4GroupDefs — the system boundary', () => {
64
66
  { id: 'external:github', kind: 'external-system', label: 'GitHub' },
65
67
  { id: 'person:dev', kind: 'person', label: 'Dev' },
66
68
  ]);
67
- const system = groupById(c4GroupDefs(withOutsiders, null).groups).get(SYSTEM)!;
69
+ const system = groupById(c4GroupDefs(withOutsiders, []).groups).get(SYSTEM)!;
68
70
  // People and external systems sit outside the boundary — that is what the
69
71
  // boundary means — so they must not be laid out as its contents.
70
72
  expect(system.memberIds).toEqual([HOST, NOTES]);
@@ -73,7 +75,7 @@ describe('c4GroupDefs — the system boundary', () => {
73
75
  test('is not dropped when a container opens', () => {
74
76
  // The frame must not shrink to the opened container: it is the *system*
75
77
  // boundary, and the open state adds a container inside it, not replaces it.
76
- const { groups } = c4GroupDefs(base, HOST);
78
+ const { groups } = c4GroupDefs(base, [HOST]);
77
79
  const system = groupById(groups).get(SYSTEM)!;
78
80
  expect(system.memberIds).toContain(HOST);
79
81
  expect(system.memberIds).toContain(NOTES);
@@ -85,7 +87,7 @@ describe('c4GroupDefs — the opened container', () => {
85
87
  // The load-bearing assertion. As a sibling it is laid out from the root, the
86
88
  // frame is no longer the origin of its contents, and the boundary's corner
87
89
  // stops being pinned across the two layouts.
88
- const { groups } = c4GroupDefs(base, HOST);
90
+ const { groups } = c4GroupDefs(base, [HOST]);
89
91
  const open = groupById(groups).get(HOST)!;
90
92
  expect(open.parentId).toBe(SYSTEM);
91
93
  });
@@ -93,12 +95,12 @@ describe('c4GroupDefs — the opened container', () => {
93
95
  test('is drawn as a card as well as laid out as a group', () => {
94
96
  // It has a node (the card we click) *and* a group (the parent ELK sizes to
95
97
  // hold its components). Without the flag, ELK takes the bare leaf box.
96
- const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
98
+ const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
97
99
  expect(open.drawnAsCard).toBe(true);
98
100
  });
99
101
 
100
102
  test('lists its components, and reserves room for its own chrome', () => {
101
- const open = groupById(c4GroupDefs(base, HOST).groups).get(HOST)!;
103
+ const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
102
104
  expect(open.memberIds).toEqual(['cmp:a', 'cmp:b']);
103
105
  // Its label slot and technology row, so children land below them.
104
106
  expect(open.padTop).toBeGreaterThan(0);
@@ -106,14 +108,42 @@ describe('c4GroupDefs — the opened container', () => {
106
108
  expect(open.minWidth).toBeGreaterThan(0);
107
109
  });
108
110
 
111
+ test('reserves the footer too, so components clear the collapse toggle', () => {
112
+ // The toggle is absolutely positioned, so it takes no space in the card's flow
113
+ // and ELK cannot see it. Without the footer the parent's fit stops at the last
114
+ // component row and that row is drawn straight through the button — measured at
115
+ // 12px of clearance against the 28px the button occupies.
116
+ const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
117
+ expect(open.padBottom).toBe(OPEN_FOOTER_PAD);
118
+ // And it is enough: the toggle's top edge is inset + size above the card bottom.
119
+ expect(open.padBottom!).toBeGreaterThanOrEqual(6 + 22);
120
+ });
121
+
122
+ test('reserves the same inset at the sides as at the footer', () => {
123
+ // One uniform inset around the nested cards, rather than a wider one at the
124
+ // bottom than at the sides.
125
+ const open = groupById(c4GroupDefs(base, [HOST]).groups).get(HOST)!;
126
+ expect(open.padX).toBe(OPEN_SIDE_PAD);
127
+ expect(open.padX).toBe(open.padBottom);
128
+ // And it is a real inset, wider than the card's own 12px text padding — the
129
+ // reason it exists is that matching that padding read as too tight.
130
+ expect(open.padX!).toBeGreaterThan(12);
131
+ });
132
+
133
+ test('a container that is not open reserves no footer', () => {
134
+ // The padding is a property of being *grown*. A closed container is a plain
135
+ // leaf and ELK never applies it, so asking for it would be misleading.
136
+ expect(groupById(c4GroupDefs(base, []).groups).has(HOST)).toBe(false);
137
+ });
138
+
109
139
  test('is absent when nothing is open, so the closed card stays a leaf', () => {
110
140
  // The closed container is a plain node — no group. The group's whole purpose is
111
141
  // to hold components, and there are none in view.
112
- expect(groupById(c4GroupDefs(base, null).groups).has(HOST)).toBe(false);
142
+ expect(groupById(c4GroupDefs(base, []).groups).has(HOST)).toBe(false);
113
143
  });
114
144
 
115
145
  test('adds no group for a container with no components in view', () => {
116
- const { groups } = c4GroupDefs(model([container(NOTES)]), NOTES);
146
+ const { groups } = c4GroupDefs(model([container(NOTES)]), [NOTES]);
117
147
  expect(groupById(groups).has(NOTES)).toBe(false);
118
148
  });
119
149
  });
@@ -122,26 +152,186 @@ describe('c4GroupDefs — frames', () => {
122
152
  test('the opened container is not also drawn as a frame', () => {
123
153
  // It is drawn as a grown card. Leaving it in `frames` would draw a frame
124
154
  // around the card.
125
- const { frames } = c4GroupDefs(base, HOST);
155
+ const { frames } = c4GroupDefs(base, [HOST]);
126
156
  expect(frames.map((g) => g.id)).not.toContain(HOST);
127
157
  });
128
158
 
129
159
  test('frames feed the bands, so they exclude the opened container too', () => {
130
160
  // Bands come from frames only. Counting the opened container as a frame would
131
161
  // give it its own layer, away from the containers it belongs beside.
132
- const { frames } = c4GroupDefs(base, HOST);
162
+ const { frames } = c4GroupDefs(base, [HOST]);
133
163
  expect(frames).toEqual([expect.objectContaining({ id: SYSTEM, kind: 'system' })]);
134
164
  });
135
165
 
136
166
  test('the component view keeps a frame per container with components', () => {
137
167
  const componentView: C4Model = { ...base, view: 'component' };
138
- const { frames } = c4GroupDefs(componentView, null);
168
+ const { frames } = c4GroupDefs(componentView, []);
139
169
  expect(frames.map((g) => g.id)).toContain(HOST);
140
170
  // Those frames nest inside the system boundary.
141
171
  expect(frames.find((g) => g.id === HOST)?.parentId).toBe(SYSTEM);
142
172
  });
143
173
  });
144
174
 
175
+ describe('c4GroupDefs — several containers open at once', () => {
176
+ // The second open container needs components of its own to be a parent at all,
177
+ // so this fixture has two containers that both nest.
178
+ const pair = model([
179
+ container(HOST),
180
+ container(NOTES),
181
+ component('cmp:a', HOST),
182
+ component('cmp:b', HOST),
183
+ component('cmp:x', NOTES),
184
+ component('cmp:y', NOTES),
185
+ ]);
186
+
187
+ test('each open container becomes its own parent, holding only its own components', () => {
188
+ const groups = groupById(c4GroupDefs(pair, [HOST, NOTES]).groups);
189
+ // The bug this replaces: nesting both under one parent, or letting the second
190
+ // overwrite the first, puts one container's components inside the other.
191
+ expect(groups.get(HOST)!.memberIds).toEqual(['cmp:a', 'cmp:b']);
192
+ expect(groups.get(NOTES)!.memberIds).toEqual(['cmp:x', 'cmp:y']);
193
+ });
194
+
195
+ test('both nest inside the system frame, and neither displaces it', () => {
196
+ const { groups } = c4GroupDefs(pair, [HOST, NOTES]);
197
+ const byId = groupById(groups);
198
+ expect(byId.get(HOST)!.parentId).toBe(SYSTEM);
199
+ expect(byId.get(NOTES)!.parentId).toBe(SYSTEM);
200
+ // The boundary is the system. Growing a second container inside it must not
201
+ // shrink the frame to whichever one is open.
202
+ expect(byId.get(SYSTEM)!.memberIds).toEqual([HOST, NOTES]);
203
+ });
204
+
205
+ test('neither is also drawn as a frame', () => {
206
+ const { frames } = c4GroupDefs(pair, [HOST, NOTES]);
207
+ const ids = frames.map((g) => g.id);
208
+ expect(ids).not.toContain(HOST);
209
+ expect(ids).not.toContain(NOTES);
210
+ });
211
+
212
+ test('an unopened third container stays a plain leaf', () => {
213
+ const trio = model([
214
+ container(HOST),
215
+ container(NOTES),
216
+ container('container:cache'),
217
+ component('cmp:a', HOST),
218
+ component('cmp:x', NOTES),
219
+ component('cmp:c', 'container:cache'),
220
+ ]);
221
+ const groups = groupById(c4GroupDefs(trio, [HOST, NOTES]).groups);
222
+ expect(groups.has('container:cache')).toBe(false);
223
+ });
224
+
225
+ test('the group list is identical whatever order the open set is given in', () => {
226
+ // The cache is keyed on the set, so `[a,b]` and `[b,a]` must produce one
227
+ // layout — which means the defs have to match exactly, order included.
228
+ const forwards = c4GroupDefs(pair, [HOST, NOTES]).groups;
229
+ const backwards = c4GroupDefs(pair, [NOTES, HOST]).groups;
230
+ expect(backwards).toEqual(forwards);
231
+ });
232
+
233
+ test('a duplicate id in the open set is not emitted twice', () => {
234
+ // `openContainerIds` is caller-supplied; a repeated id would otherwise become
235
+ // two groups with the same id, which ELK rejects as a duplicate.
236
+ const groups = c4GroupDefs(pair, [HOST, HOST]).groups;
237
+ expect(groups.filter((g) => g.id === HOST)).toHaveLength(1);
238
+ });
239
+ });
240
+
241
+ describe('openContainerSet', () => {
242
+ test('is empty when nothing is open, in either spelling', () => {
243
+ expect(openContainerSet({})).toEqual([]);
244
+ expect(openContainerSet({ openContainerId: null })).toEqual([]);
245
+ expect(openContainerSet({ openContainerIds: [] })).toEqual([]);
246
+ });
247
+
248
+ test('sorts, so the same set in any order is one cache key', () => {
249
+ // Insertion order would give `[b,a]` and `[a,b]` separate keys, and a second
250
+ // ELK run for a diagram that did not change.
251
+ expect(openContainerSet({ openContainerIds: ['b', 'a'] })).toEqual(['a', 'b']);
252
+ expect(openStateKey(openContainerSet({ openContainerIds: ['b', 'a'] }))).toBe(
253
+ openStateKey(openContainerSet({ openContainerIds: ['a', 'b'] })),
254
+ );
255
+ });
256
+
257
+ test('folds the deprecated single id in, and unions it with the set', () => {
258
+ // Both fields set must not lose either: a caller migrating across would
259
+ // silently drop a container from the drill-down.
260
+ expect(openContainerSet({ openContainerId: 'b', openContainerIds: ['a'] })).toEqual(['a', 'b']);
261
+ expect(openContainerSet({ openContainerId: 'a' })).toEqual(['a']);
262
+ });
263
+
264
+ test('collapses a duplicate rather than emitting a repeated group', () => {
265
+ expect(openContainerSet({ openContainerIds: ['a', 'a'] })).toEqual(['a']);
266
+ expect(openContainerSet({ openContainerId: 'a', openContainerIds: ['a'] })).toEqual(['a']);
267
+ });
268
+
269
+ test('the closed diagram keys as the empty string', () => {
270
+ // `''` is the cache key the closed state has always used; a different
271
+ // sentinel would silently orphan every cached closed layout.
272
+ expect(openStateKey(openContainerSet({}))).toBe('');
273
+ });
274
+ });
275
+
276
+ describe('hidesComponentDuringMorph', () => {
277
+ // The reason the drill-down keeps several containers open: a container that was
278
+ // already open must not blink because a *different* one is changing.
279
+ const HOST = 'container:host';
280
+ const CLI = 'container:notes';
281
+ /** `open` is the full open set; `targets` is only what is changing this tick. */
282
+ const ids = (...v: string[]) => new Set(v);
283
+ const none = new Set<string>();
284
+
285
+ test('withholds the components of a container that is opening', () => {
286
+ expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST), false)).toBe(true);
287
+ });
288
+
289
+ test('withholds them again while it is closing', () => {
290
+ // Closing is the same transition seen from the other side: the card shrinks
291
+ // and the components must not be visible being squeezed out of it.
292
+ expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST), false)).toBe(true);
293
+ });
294
+
295
+ test('keeps them on screen while a *different* container opens', () => {
296
+ // The load-bearing case. HOST is open and unaffected; CLI is arriving.
297
+ expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(CLI), false)).toBe(false);
298
+ });
299
+
300
+ test('keeps them on screen while a different container closes', () => {
301
+ // Targets are the symmetric difference, so a closing CLI names only CLI.
302
+ expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(CLI), false)).toBe(false);
303
+ });
304
+
305
+ test('withholds them when the container itself is the one closing', () => {
306
+ // HOST stays in the open set for this render — it is still drawn, shrinking —
307
+ // so the open set alone cannot be the test. The target set can.
308
+ expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST, CLI), false)).toBe(true);
309
+ });
310
+
311
+ test('shows everything once the morph settles', () => {
312
+ expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST), true)).toBe(false);
313
+ });
314
+
315
+ test('shows nothing once the morph settles, even with targets left over', () => {
316
+ // Belt and braces: a stale target set must not outlive the settle, and if it
317
+ // did, settled still wins.
318
+ expect(hidesComponentDuringMorph(HOST, ids(HOST), ids(HOST, CLI), true)).toBe(false);
319
+ });
320
+
321
+ test('never withholds a component of a closed container', () => {
322
+ expect(hidesComponentDuringMorph(HOST, ids(CLI), ids(HOST), false)).toBe(false);
323
+ });
324
+
325
+ test('never withholds a node with no container', () => {
326
+ // Externals and people have no owner; they are root cards, not nested ones.
327
+ expect(hidesComponentDuringMorph(undefined, ids(HOST), ids(HOST), false)).toBe(false);
328
+ });
329
+
330
+ test('settled with nothing open is not hidden either', () => {
331
+ expect(hidesComponentDuringMorph(HOST, none, none, true)).toBe(false);
332
+ });
333
+ });
334
+
145
335
  describe('rebaseGroupBoundsAbsolute', () => {
146
336
  // Real numbers from the drill-down: the system frame sits at (12,12), and the
147
337
  // opened container nests inside it, so ELK reports it 24px/64px in — offset by