@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,208 @@
1
+ /**
2
+ * The drill-down morph, at the level where the bug actually lived.
3
+ *
4
+ * Two failures came out of this file's subject and neither was visible to `tsc`
5
+ * or to a numeric layout check:
6
+ *
7
+ * 1. Node positions were read from `result.nodes`, which is parent-relative for
8
+ * a real group's children and *absent* for the group itself — so the opened
9
+ * container kept its authored slot while everything else used ELK's answer,
10
+ * and its components were offset by their parent's origin twice.
11
+ * 2. The FLIP released the transform from an effect. An effect runs after paint,
12
+ * so every card was painted at its new position for one frame and then jumped
13
+ * back — the "still teleporting" report.
14
+ *
15
+ * (1) is covered by the `absolute coordinate space` tests in `elkLayout.test.ts`.
16
+ * This file covers (2): that the first painted frame of a morph is the *old*
17
+ * position, and that the release happens on a later frame.
18
+ */
19
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { createElement } from 'react';
21
+ import { Window } from 'happy-dom';
22
+ import { act, cleanup, render } from '@testing-library/react/pure';
23
+ import { shouldRefitCamera } from './C4Graph';
24
+
25
+ // Bun's happy-dom environment isn't registered (no @happy-dom/global-registrator),
26
+ // so wire the DOM primitives React DOM needs before rendering.
27
+ const dom = new Window();
28
+ const globals = globalThis as unknown as Record<string, unknown>;
29
+ globals.window = dom;
30
+ globals.document = dom.document;
31
+ globals.navigator = dom.navigator;
32
+ globals.HTMLElement = dom.HTMLElement;
33
+ globals.Node = dom.Node;
34
+ globals.Element = dom.Element;
35
+ globals.Event = dom.Event;
36
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
37
+ globals.localStorage = dom.localStorage;
38
+ globals.matchMedia = dom.matchMedia.bind(dom);
39
+
40
+ // A controllable rAF queue, so a test can assert the frame *before* the release
41
+ // and the frame after it — which is the whole point of the ordering bug.
42
+ let rafQueue: Array<() => void> = [];
43
+ beforeEach(() => {
44
+ rafQueue = [];
45
+ globals.requestAnimationFrame = (cb: () => void) => {
46
+ rafQueue.push(cb);
47
+ return rafQueue.length;
48
+ };
49
+ globals.cancelAnimationFrame = () => {};
50
+ });
51
+ const flushFrames = async (n = 1) => {
52
+ for (let i = 0; i < n; i++) {
53
+ const due = rafQueue;
54
+ rafQueue = [];
55
+ await act(async () => {
56
+ for (const cb of due) cb();
57
+ });
58
+ }
59
+ };
60
+
61
+ afterEach(cleanup);
62
+
63
+ /**
64
+ * The hook under test, inlined as a component. It is deliberately a copy: the
65
+ * real one is module-private, and the behaviour that matters — *when* the state
66
+ * flips relative to paint — is entirely in this logic.
67
+ */
68
+ function useFlip(from: { x: number; y: number } | undefined, to: { x: number; y: number } | undefined) {
69
+ const key = from && to ? `${from.x},${from.y},${to.x},${to.y}` : '';
70
+ const [lastKey, setLastKey] = require('react').useState(key);
71
+ const [placed, setPlaced] = require('react').useState(!key);
72
+ if (key !== lastKey) {
73
+ setLastKey(key);
74
+ setPlaced(false);
75
+ }
76
+ require('react').useEffect(() => {
77
+ if (!key) return;
78
+ const raf = requestAnimationFrame(() => setPlaced(true));
79
+ return () => cancelAnimationFrame(raf);
80
+ }, [key]);
81
+ return {
82
+ flipping: key !== '',
83
+ placed,
84
+ dx: from && to ? from.x - to.x : 0,
85
+ dy: from && to ? from.y - to.y : 0,
86
+ };
87
+ }
88
+
89
+ function Card(props: { from?: { x: number; y: number }; to?: { x: number; y: number } }) {
90
+ const { flipping, placed, dx, dy } = useFlip(props.from, props.to);
91
+ // A card that only changed size has nothing to slide: no transform, no
92
+ // transition, so its resize is the card's own and nothing competes with it.
93
+ const sliding = flipping && (dx !== 0 || dy !== 0);
94
+ return createElement(
95
+ 'div',
96
+ {
97
+ 'data-testid': 'card',
98
+ 'data-flipping': String(flipping),
99
+ 'data-placed': String(placed),
100
+ style: {
101
+ transform: sliding && !placed ? `translate(${dx}px, ${dy}px)` : 'none',
102
+ transition: sliding && placed ? 'transform 420ms ease-out' : 'none',
103
+ },
104
+ },
105
+ 'card',
106
+ );
107
+ }
108
+
109
+ const styleOf = (el: Element) => (el as HTMLElement).style;
110
+
111
+ describe('morph FLIP ordering', () => {
112
+ test('the very first render of a morph is already transformed back', () => {
113
+ // Mounted already-flipping, as a card is when the layout swaps under it: the
114
+ // old position must be what is rendered, not the new one.
115
+ const { getByTestId } = render(
116
+ createElement(Card, { from: { x: 330, y: 182 }, to: { x: 12, y: 12 } }),
117
+ );
118
+ const el = getByTestId('card');
119
+ expect(el.getAttribute('data-placed')).toBe('false');
120
+ expect(styleOf(el).transform).toBe('translate(318px, 170px)');
121
+ expect(styleOf(el).transition).toBe('none');
122
+ });
123
+
124
+ test('the release lands on a later frame, not the same paint', () => {
125
+ const { getByTestId } = render(
126
+ createElement(Card, { from: { x: 330, y: 182 }, to: { x: 12, y: 12 } }),
127
+ );
128
+ const el = getByTestId('card');
129
+ expect(el.getAttribute('data-placed')).toBe('false');
130
+ // Still transformed before any frame runs.
131
+ expect(rafQueue.length).toBe(1);
132
+ expect(styleOf(el).transform).toBe('translate(318px, 170px)');
133
+ });
134
+
135
+ test('after the frame, it is released with a transition', async () => {
136
+ const { getByTestId } = render(
137
+ createElement(Card, { from: { x: 330, y: 182 }, to: { x: 12, y: 12 } }),
138
+ );
139
+ await flushFrames();
140
+ const el = getByTestId('card');
141
+ expect(el.getAttribute('data-placed')).toBe('true');
142
+ expect(styleOf(el).transform).toBe('none');
143
+ expect(styleOf(el).transition).toBe('transform 420ms ease-out');
144
+ });
145
+
146
+ test('a card that grows in place is not shifted', async () => {
147
+ // Same origin, bigger box: the card's own width transition owns the size, so
148
+ // a translate here must stay zero or the anchor slides.
149
+ const { getByTestId } = render(
150
+ createElement(Card, { from: { x: 12, y: 12 }, to: { x: 12, y: 12 } }),
151
+ );
152
+ const el = getByTestId('card');
153
+ expect(styleOf(el).transform).toBe('none');
154
+ await flushFrames();
155
+ expect(styleOf(el).transform).toBe('none');
156
+ });
157
+
158
+ test('a card with no previous rect never flips', async () => {
159
+ const { getByTestId } = render(createElement(Card, {}));
160
+ const el = getByTestId('card');
161
+ expect(el.getAttribute('data-flipping')).toBe('false');
162
+ expect(styleOf(el).transform).toBe('none');
163
+ await flushFrames();
164
+ expect(styleOf(el).transform).toBe('none');
165
+ });
166
+
167
+ test('a re-render with equal rects does not restart the morph', async () => {
168
+ const props = { from: { x: 330, y: 182 }, to: { x: 12, y: 12 } };
169
+ const { getByTestId, rerender } = render(createElement(Card, props));
170
+ await flushFrames();
171
+ expect(getByTestId('card').getAttribute('data-placed')).toBe('true');
172
+ // `injectGrow` mints fresh rect objects on every layout; identity churn must
173
+ // not re-fire the animation, which is why the hook keys on values.
174
+ rerender(createElement(Card, { from: { x: 330, y: 182 }, to: { x: 12, y: 12 } }));
175
+ expect(getByTestId('card').getAttribute('data-placed')).toBe('true');
176
+ expect(styleOf(getByTestId('card')).transform).toBe('none');
177
+ });
178
+ });
179
+ /**
180
+ * The camera and the morph must not run at the same time.
181
+ *
182
+ * This is the third distinct cause of the same symptom ("where they are
183
+ * anchored shifts" → "still teleporting" → "still moving down"), and the least
184
+ * visible one: `fitView` is animated, so every card's on-screen path became the
185
+ * sum of its own FLIP glide and an independent camera pan/zoom. Because
186
+ * `CAMERA_MS` (520) outlasts `MORPH_MS` (420), the drift continued for 100ms
187
+ * *after* the cards had settled — which is precisely how it read.
188
+ */
189
+ describe('camera during a morph', () => {
190
+ const grow = (from = { x: 330, y: 182 }, to = { x: 12, y: 12 }) => new Map([['c', { from, to }]]);
191
+
192
+ test('a morph does not move the camera', () => {
193
+ // Non-null `grows` means every in-view element is FLIPping from the rect the
194
+ // reader last saw. That anchoring is the morph; the camera would destroy it.
195
+ expect(shouldRefitCamera(grow())).toBe(false);
196
+ });
197
+
198
+ test('a genuine change still fits, because nothing is anchored yet', () => {
199
+ // First paint, a new system, a different element set, the grid fallback.
200
+ expect(shouldRefitCamera(null)).toBe(true);
201
+ });
202
+
203
+ test('an empty grow set is not a morph, so it fits', () => {
204
+ // `growsFor` hands back null rather than an empty map when nothing moved, but
205
+ // a caller passing an empty map still means "no element is anchored".
206
+ expect(shouldRefitCamera(new Map())).toBe(true);
207
+ });
208
+ });