@hyperframes/studio 0.7.99 → 0.7.101

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 (35) hide show
  1. package/dist/assets/{hyperframes-player-BY5RLMBA.js → hyperframes-player-BxSY8bs4.js} +1 -1
  2. package/dist/assets/{index-CQ07_DLG.js → index-BlRPDw0A.js} +1 -1
  3. package/dist/assets/{index-DMSqmZM6.js → index-DF06yGPO.js} +200 -200
  4. package/dist/assets/{index-DeXLAktv.js → index-Dkz5RbFX.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +473 -359
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +2 -2
  10. package/src/components/StudioHeader.test.ts +28 -0
  11. package/src/components/StudioHeader.tsx +21 -2
  12. package/src/components/StudioLeftSidebar.tsx +2 -2
  13. package/src/components/nle/NLEContext.tsx +16 -7
  14. package/src/components/nle/TimelineResizeDivider.tsx +4 -11
  15. package/src/components/sidebar/LeftSidebar.storage.test.ts +28 -0
  16. package/src/components/sidebar/LeftSidebar.tsx +18 -3
  17. package/src/contexts/PanelLayoutContext.tsx +6 -3
  18. package/src/hooks/gsapEditOutcome.ts +19 -1
  19. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +47 -1
  20. package/src/hooks/gsapKeyframeCacheHelpers.ts +31 -3
  21. package/src/hooks/gsapResizeGeometrySweep.test.ts +289 -0
  22. package/src/hooks/gsapResizeIntercept.test.ts +2 -2
  23. package/src/hooks/gsapResizeIntercept.ts +37 -14
  24. package/src/hooks/gsapResizeMixedTween.test.ts +168 -0
  25. package/src/hooks/gsapResizeSweep.test.ts +274 -0
  26. package/src/hooks/useGsapAwareEditing.test.tsx +19 -2
  27. package/src/hooks/useGsapAwareEditing.ts +13 -5
  28. package/src/hooks/useGsapTweenCache.ts +16 -19
  29. package/src/hooks/usePanelLayout.test.ts +117 -0
  30. package/src/hooks/usePanelLayout.ts +121 -39
  31. package/src/player/components/Player.test.ts +13 -0
  32. package/src/player/components/Player.tsx +7 -1
  33. package/src/utils/clipboard.ts +1 -1
  34. package/src/utils/fitPanels.test.ts +164 -0
  35. package/src/utils/fitPanels.ts +151 -0
@@ -0,0 +1,274 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * Every shape of animated element a composition can hand the resize, swept.
4
+ *
5
+ * Both faults this branch fixes were found one composition at a time, which is
6
+ * a bad way to find the third. So this drives the real intercept across the
7
+ * cross-product of what an element's tweens can look like and holds every run
8
+ * to the two rules that were broken:
9
+ *
10
+ * 1. Never address an animation the source does not have. Sending a stale id
11
+ * is what "animation not found" is, and it leaves the element unsavable.
12
+ * 2. Never leave a tween spanning two property groups. The parser classifies
13
+ * such a tween as neither, so it loses its group suffix and its id along
14
+ * with it, and every later edit has nothing to address.
15
+ *
16
+ * The server stand-in answers the way the real one does — an id it cannot find
17
+ * is a rejection — and applies what it is told, so a run that corrupts the
18
+ * animation list is caught by the next mutation in the same run rather than by
19
+ * a person noticing weeks later.
20
+ */
21
+ import { afterEach, expect, it, vi } from "vitest";
22
+ import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
23
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
24
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
25
+ import { usePlayerStore } from "../player/store/playerStore";
26
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
31
+ document.body.innerHTML = "";
32
+ });
33
+
34
+ type Props = Record<string, number>;
35
+
36
+ function tween(id: string, properties: Props, duration: number): GsapAnimation {
37
+ return {
38
+ id,
39
+ targetSelector: "#el",
40
+ propertyGroup: classifyTweenPropertyGroup(properties),
41
+ method: "to",
42
+ properties,
43
+ position: 0,
44
+ resolvedStart: 0,
45
+ duration,
46
+ ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
47
+ } as unknown as GsapAnimation;
48
+ }
49
+
50
+ /** The dimensions an element's animations actually vary across. */
51
+ const SCALE = {
52
+ none: null,
53
+ "instant hold": () => tween("#el-scale", { scale: 1.2 }, 0),
54
+ tween: () => tween("#el-scale", { scale: 1.2 }, 2),
55
+ longhands: () => tween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
56
+ } as const;
57
+ const SIZE = {
58
+ none: null,
59
+ "instant hold": () => tween("#el-size", { width: 300, height: 200 }, 0),
60
+ tween: () => tween("#el-size", { width: 300, height: 200 }, 2),
61
+ } as const;
62
+ const POSITION = {
63
+ none: null,
64
+ "static hold": () => tween("#el-position", { x: 40, y: 60 }, 0),
65
+ tween: () => tween("#el-position", { x: 40, y: 60 }, 2),
66
+ } as const;
67
+ const EXTRA = {
68
+ none: null,
69
+ // What a 3D card carries, and the shape that produced two same-group ids.
70
+ "3d and rotation": () => [
71
+ tween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
72
+ tween("#el-rotation", { rotation: 720 }, 0),
73
+ tween("#el-other-2", { z: 50 }, 0),
74
+ ],
75
+ // A tween that already spans two groups, which the resize has to split.
76
+ "a mixed tween": () => [tween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
77
+ } as const;
78
+
79
+ interface Recorded {
80
+ rejected: string[];
81
+ corrupted: string[];
82
+ }
83
+
84
+ /**
85
+ * The source, as the server sees it: a list of animations, a rejection for an
86
+ * id that is not in it, and the effect of each mutation applied.
87
+ */
88
+ function fakeSource(initial: GsapAnimation[]) {
89
+ let current = initial;
90
+ const recorded: Recorded = { rejected: [], corrupted: [] };
91
+
92
+ const mergeInto = (id: string, properties: Props) => {
93
+ current = current.map((animation) => {
94
+ if (animation.id !== id) return animation;
95
+ const before = classifyTweenPropertyGroup(animation.properties ?? {});
96
+ const merged = { ...(animation.properties ?? {}), ...properties };
97
+ const after = classifyTweenPropertyGroup(merged);
98
+ // Mixing is only a fault when the resize CAUSED it. A tween that already
99
+ // spanned two groups is an input, and splitting it is the point.
100
+ if (before !== undefined && after === undefined) recorded.corrupted.push(id);
101
+ return { ...animation, properties: merged, propertyGroup: after } as GsapAnimation;
102
+ });
103
+ };
104
+
105
+ const split = (id: string) => {
106
+ const target = current.find((animation) => animation.id === id);
107
+ if (!target) return;
108
+ const byGroup = new Map<string, Props>();
109
+ for (const [key, value] of Object.entries(target.properties ?? {})) {
110
+ const group = classifyTweenPropertyGroup({ [key]: value as number }) ?? "other";
111
+ byGroup.set(group, { ...(byGroup.get(group) ?? {}), [key]: value as number });
112
+ }
113
+ current = [
114
+ ...current.filter((animation) => animation.id !== id),
115
+ ...[...byGroup].map(([group, properties]) =>
116
+ tween(`#el-split-${group}`, properties, target.duration ?? 0),
117
+ ),
118
+ ];
119
+ };
120
+
121
+ const apply = (mutation: Record<string, unknown>, id: string | undefined) => {
122
+ const properties = (mutation.properties ?? {}) as Props;
123
+ if (mutation.type === "split-into-property-groups") return id && split(id);
124
+ if (!id) {
125
+ if (mutation.type === "add")
126
+ current = [...current, tween(`#el-added-${current.length}`, properties, 0)];
127
+ return;
128
+ }
129
+ mergeInto(id, properties);
130
+ const framed = (mutation.keyframes as Array<{ properties: Props }> | undefined) ?? [];
131
+ for (const frame of framed) mergeInto(id, frame.properties);
132
+ };
133
+
134
+ const commitMutation = vi.fn(async (_selection: unknown, mutation: Record<string, unknown>) => {
135
+ const id = mutation.animationId as string | undefined;
136
+ if (id && !current.some((animation) => animation.id === id)) {
137
+ recorded.rejected.push(`${String(mutation.type)}:${id}`);
138
+ throw new Error("animation not found");
139
+ }
140
+ apply(mutation, id);
141
+ });
142
+
143
+ return { commitMutation, recorded, animations: () => current };
144
+ }
145
+
146
+ type Dimension = Array<[string, () => GsapAnimation[]]>;
147
+
148
+ function dimension(
149
+ entries: Record<string, null | (() => GsapAnimation | GsapAnimation[])>,
150
+ ): Dimension {
151
+ return Object.entries(entries).map(([name, make]) => [
152
+ name,
153
+ () => {
154
+ if (!make) return [];
155
+ const made = make();
156
+ return Array.isArray(made) ? made : [made];
157
+ },
158
+ ]);
159
+ }
160
+
161
+ function buildCases() {
162
+ const dimensions = [dimension(SCALE), dimension(SIZE), dimension(POSITION), dimension(EXTRA)];
163
+ let combos: Array<Array<[string, () => GsapAnimation[]]>> = [[]];
164
+ for (const next of dimensions) {
165
+ combos = combos.flatMap((combo) => next.map((entry) => [...combo, entry]));
166
+ }
167
+ const labels = ["scale", "size", "position", "extra"];
168
+ return combos.map((combo) => ({
169
+ name: combo.map(([name], index) => `${labels[index]} ${name}`).join(" / "),
170
+ animations: combo.flatMap(([, make]) => make()),
171
+ }));
172
+ }
173
+
174
+ const CASES = buildCases();
175
+
176
+ it(`sweeps ${CASES.length} animated shapes without a stale id or a mixed tween`, async () => {
177
+ const failures: string[] = [];
178
+
179
+ for (const testCase of CASES) {
180
+ document.body.innerHTML = "";
181
+ const el = document.createElement("div");
182
+ el.id = "el";
183
+ el.setAttribute("data-hf-studio-original-box-width", "630");
184
+ el.setAttribute("data-hf-studio-original-box-height", "408");
185
+ document.body.append(el);
186
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
187
+
188
+ const source = fakeSource(testCase.animations);
189
+ try {
190
+ await tryGsapResizeIntercept(
191
+ { id: "el", selector: "#el", element: el } as DomEditSelection,
192
+ { width: 326, height: 213 },
193
+ testCase.animations,
194
+ null,
195
+ source.commitMutation as never,
196
+ async () => source.animations(),
197
+ );
198
+ } catch (error) {
199
+ // A rejection is recorded below; anything else is worth reporting as-is.
200
+ if (!(error instanceof Error) || error.message !== "animation not found") {
201
+ failures.push(`${testCase.name} — threw ${String(error)}`);
202
+ }
203
+ }
204
+
205
+ if (source.recorded.rejected.length > 0) {
206
+ failures.push(`${testCase.name} — stale id: ${source.recorded.rejected.join(", ")}`);
207
+ }
208
+ if (source.recorded.corrupted.length > 0) {
209
+ failures.push(`${testCase.name} — mixed tween: ${source.recorded.corrupted.join(", ")}`);
210
+ }
211
+ }
212
+
213
+ expect(failures).toEqual([]);
214
+ });
215
+
216
+ /**
217
+ * Which tween a resize edits when the element has several in the same group.
218
+ *
219
+ * A composition animates the same property more than once — a scale-in early
220
+ * and a scale-out late — and the one the user means is the one under the
221
+ * playhead. Editing the wrong one changes a moment they are not looking at and
222
+ * leaves the moment they ARE looking at unchanged, which reads as "the resize
223
+ * did nothing".
224
+ */
225
+ function scaleAt(id: string, position: number): GsapAnimation {
226
+ return {
227
+ ...tween(id, { scale: 1 }, 2),
228
+ position,
229
+ resolvedStart: position,
230
+ keyframes: {
231
+ keyframes: [
232
+ { percentage: 0, properties: { scale: 1 } },
233
+ { percentage: 100, properties: { scale: 1.4 } },
234
+ ],
235
+ },
236
+ } as unknown as GsapAnimation;
237
+ }
238
+
239
+ const PLAYHEADS: Array<[number, string]> = [
240
+ [0.5, "#el-early"],
241
+ [1.9, "#el-early"],
242
+ [3, "#el-early"],
243
+ [3.1, "#el-late"],
244
+ [4.5, "#el-late"],
245
+ [9, "#el-late"],
246
+ ];
247
+
248
+ it.each(PLAYHEADS)("at t=%s edits the tween under the playhead (%s)", async (time, expected) => {
249
+ document.body.innerHTML = "";
250
+ const el = document.createElement("div");
251
+ el.id = "el";
252
+ el.setAttribute("data-hf-studio-original-box-width", "630");
253
+ el.setAttribute("data-hf-studio-original-box-height", "408");
254
+ document.body.append(el);
255
+ usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
256
+
257
+ const animations = [scaleAt("#el-early", 0), scaleAt("#el-late", 4)];
258
+ const commitMutation = vi.fn();
259
+ await tryGsapResizeIntercept(
260
+ { id: "el", selector: "#el", element: el } as DomEditSelection,
261
+ { width: 326, height: 213 },
262
+ animations,
263
+ null,
264
+ commitMutation as never,
265
+ async () => animations,
266
+ );
267
+
268
+ const touched = new Set(
269
+ commitMutation.mock.calls
270
+ .map((call) => (call[1] as { animationId?: string }).animationId)
271
+ .filter((id): id is string => id != null),
272
+ );
273
+ expect([...touched]).toEqual([expected]);
274
+ });
@@ -313,8 +313,8 @@ describe("useGsapAwareEditing anchored resize", () => {
313
313
  act(() => h.root.unmount());
314
314
  });
315
315
 
316
- it("does not apply the anchor twice when scale route already settles the drop point", async () => {
317
- mocks.resize.mockResolvedValue({ status: "persisted" });
316
+ it("does not apply the anchor twice when the resize already settled the drop point", async () => {
317
+ mocks.resize.mockResolvedValue({ status: "persisted", ownsDragOffset: true });
318
318
  const scale = { propertyGroup: "scale" } as GsapAnimation;
319
319
  const h = mountResizeHandler([scale]);
320
320
  await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
@@ -322,4 +322,21 @@ describe("useGsapAwareEditing anchored resize", () => {
322
322
  expect(h.fallback).not.toHaveBeenCalled();
323
323
  act(() => h.root.unmount());
324
324
  });
325
+
326
+ /**
327
+ * The same element, and the resize says it did NOT settle the drop point.
328
+ *
329
+ * This is the shape that broke: an element whose scale is an instant hold has
330
+ * a scale-group tween and still commits width/height. Reading the tweens said
331
+ * "scale route, it settles its own position", so the offset was withheld,
332
+ * nobody wrote it, and the element snapped back on every drag.
333
+ */
334
+ it("applies the anchor when the resize leaves the drop point to the caller", async () => {
335
+ mocks.resize.mockResolvedValue({ status: "persisted" });
336
+ const scale = { propertyGroup: "scale" } as GsapAnimation;
337
+ const h = mountResizeHandler([scale]);
338
+ await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
339
+ expect(mocks.drag).toHaveBeenCalledTimes(1);
340
+ act(() => h.root.unmount());
341
+ });
325
342
  });
@@ -258,13 +258,21 @@ export function useGsapAwareEditing({
258
258
  makeFetchFallback(selection),
259
259
  );
260
260
  assertGsapEditPersisted(outcome);
261
+ // What the resize actually did, not what its animations suggest
262
+ // it would do. An element whose scale is an instant hold has a
263
+ // scale-group tween and still commits width/height, so guessing
264
+ // from the tweens withheld an offset nobody had written and the
265
+ // element snapped back to its authored position on every drag.
266
+ const ownsDragOffset =
267
+ outcome.status === "persisted" && outcome.ownsDragOffset === true;
261
268
  logResize("intercept-handled", {
262
269
  scaleRoute,
263
- willForwardOffset: !!(offset && !scaleRoute),
270
+ ownsDragOffset,
271
+ willForwardOffset: !!(offset && !ownsDragOffset),
264
272
  });
265
- // Scale-route resize persists its residual position internally.
266
- // Width/height persists the already-settled anchor through drag.
267
- if (offset && !scaleRoute) {
273
+ // A resize that moved the element itself has already written
274
+ // where it landed. Everything else leaves the anchor to the drag.
275
+ if (offset && !ownsDragOffset) {
268
276
  const dragOutcome = await tryGsapDragIntercept(
269
277
  selection,
270
278
  offset,
@@ -275,7 +283,7 @@ export function useGsapAwareEditing({
275
283
  );
276
284
  assertGsapEditPersisted(dragOutcome);
277
285
  }
278
- logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
286
+ logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
279
287
  return;
280
288
  } catch (error) {
281
289
  trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
@@ -4,6 +4,7 @@ import { usePlayerStore } from "../player/store/playerStore";
4
4
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
5
5
  import {
6
6
  clearKeyframeCacheForElement,
7
+ elementCacheKeys,
7
8
  pruneKeyframeCacheToFiles,
8
9
  publishKeyframeCache,
9
10
  writeGsapAnimationsForElement,
@@ -302,8 +303,9 @@ export function useGsapAnimationsForElement(
302
303
  // scan already cached. Only clear when no source cached this element —
303
304
  // otherwise selecting it would wipe its diamonds.
304
305
  const { keyframeCache } = usePlayerStore.getState();
305
- const hasCached =
306
- keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
306
+ const hasCached = elementCacheKeys(sourceFile, elementId).some((key) =>
307
+ keyframeCache.has(key),
308
+ );
307
309
  if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
308
310
  return;
309
311
  }
@@ -314,14 +316,16 @@ export function useGsapAnimationsForElement(
314
316
  ...(ease ? { ease } : {}),
315
317
  ...(easeEach ? { easeEach } : {}),
316
318
  };
317
- // PropertyPanel reads the cache by bare elementId (without sourceFile
318
- // prefix), so the same entry is written under the bare key for
319
- // cross-component lookups. Both keys land in one publish: a reader that woke
320
- // between two separate writes saw the prefixed key updated and the bare one
321
- // still stale.
319
+ // elementCacheKeys owns the key-variant list every writer sets (prefixed,
320
+ // index.html fallback, bare id). Building it by hand here is what let this
321
+ // site drift: it omitted the fallback key, and it wrote the bare id without
322
+ // the string coercion that keeps prune from throwing on a non-string. All
323
+ // keys land in one publish: a reader that woke between two separate writes
324
+ // saw the prefixed key updated and the bare one still stale.
322
325
  publishKeyframeCache((draft) => {
323
- draft.keyframeCache.set(`${sourceFile}#${elementId}`, merged);
324
- draft.keyframeCache.set(elementId, merged);
326
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
327
+ draft.keyframeCache.set(key, merged);
328
+ }
325
329
  });
326
330
  // eslint-disable-next-line react-hooks/exhaustive-deps
327
331
  }, [elementId, sourceFile, animations, domClipChildrenKey]);
@@ -426,13 +430,8 @@ export function usePopulateKeyframeCacheForFile(
426
430
  // in between re-rendered against a cache only partly filled in.
427
431
  publishKeyframeCache((draft) => {
428
432
  for (const [id, data] of scanned) {
429
- const cacheKey = `${sf}#${id}`;
430
- const fallbackKey = `index.html#${id}`;
431
- const alreadyCached =
432
- draft.keyframeCache.has(cacheKey) ||
433
- draft.keyframeCache.has(fallbackKey) ||
434
- draft.keyframeCache.has(id);
435
- if (alreadyCached) continue;
433
+ const keys = elementCacheKeys(sf, id);
434
+ if (keys.some((key) => draft.keyframeCache.has(key))) continue;
436
435
  // Skip position-only set tweens from runtime too, same filter as AST path
437
436
  const isPosOnly =
438
437
  data.keyframes.length === 1 &&
@@ -445,9 +444,7 @@ export function usePopulateKeyframeCacheForFile(
445
444
  keyframes: data.keyframes,
446
445
  ...(data.easeEach ? { easeEach: data.easeEach } : {}),
447
446
  };
448
- draft.keyframeCache.set(cacheKey, entry);
449
- if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
450
- draft.keyframeCache.set(id, entry);
447
+ for (const key of keys) draft.keyframeCache.set(key, entry);
451
448
  }
452
449
  });
453
450
  runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
@@ -60,6 +60,11 @@ function renderPanelLayout() {
60
60
  return renderPanelLayoutWith(usePanelLayout);
61
61
  }
62
62
 
63
+ function resizeWindowTo(width: number) {
64
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
65
+ window.dispatchEvent(new Event("resize"));
66
+ }
67
+
63
68
  describe("usePanelLayout — right inspector panes", () => {
64
69
  it("opens Design with the intended viewport-scaled panel widths", () => {
65
70
  const harness = renderPanelLayout();
@@ -160,6 +165,118 @@ describe("usePanelLayout — right inspector panes", () => {
160
165
  harness.unmount();
161
166
  });
162
167
 
168
+ it("caps a panel relative to the window instead of at a flat 600px", () => {
169
+ resizeWindowTo(700);
170
+ const harness = renderPanelLayout();
171
+ // The old flat cap let the inspector claim 600 of a 700px window.
172
+ expect(harness.getState().rightWidth).toBeLessThanOrEqual(280);
173
+ harness.unmount();
174
+ });
175
+
176
+ it("rails both panels once the window cannot fit them", () => {
177
+ resizeWindowTo(560);
178
+ const harness = renderPanelLayout();
179
+ expect(harness.getState()).toMatchObject({
180
+ effectiveLeftCollapsed: true,
181
+ effectiveRightCollapsed: true,
182
+ leftCollapsed: false,
183
+ rightCollapsed: false,
184
+ });
185
+ harness.unmount();
186
+ });
187
+
188
+ it("auto-collapse never writes the user's persisted or URL-synced intent", () => {
189
+ const harness = renderPanelLayout();
190
+ act(() => resizeWindowTo(560));
191
+
192
+ expect(harness.getState().effectiveLeftCollapsed).toBe(true);
193
+ // localStorage carries leftCollapsed; the shareable URL carries rightCollapsed.
194
+ // A ten-second window drag must rewrite neither.
195
+ expect(readStudioUiPreferences().leftCollapsed).toBeUndefined();
196
+ expect(harness.getState().leftCollapsed).toBe(false);
197
+ expect(harness.getState().rightCollapsed).toBe(false);
198
+ harness.unmount();
199
+ });
200
+
201
+ it("returns the user's own width when the window grows back", () => {
202
+ const harness = renderPanelLayout();
203
+ const wide = harness.getState().leftWidth;
204
+
205
+ act(() => resizeWindowTo(560));
206
+ expect(harness.getState().leftWidth).toBeLessThan(wide);
207
+
208
+ act(() => resizeWindowTo(1496));
209
+ expect(harness.getState().leftWidth).toBe(wide);
210
+ harness.unmount();
211
+ });
212
+
213
+ it("keeps an explicitly collapsed sidebar collapsed after a narrow trip", () => {
214
+ const harness = renderPanelLayout();
215
+ act(() => harness.getState().toggleLeftSidebar());
216
+ expect(readStudioUiPreferences().leftCollapsed).toBe(true);
217
+
218
+ act(() => resizeWindowTo(560));
219
+ act(() => resizeWindowTo(1496));
220
+
221
+ expect(harness.getState().effectiveLeftCollapsed).toBe(true);
222
+ harness.unmount();
223
+ });
224
+
225
+ it("lets the user reopen a panel the window auto-collapsed", () => {
226
+ const harness = renderPanelLayout();
227
+ act(() => resizeWindowTo(560));
228
+ expect(harness.getState().effectiveRightCollapsed).toBe(true);
229
+
230
+ // Without this the header Inspector button would be dead below 700px.
231
+ act(() => harness.getState().setRightCollapsed(false));
232
+ expect(harness.getState().effectiveRightCollapsed).toBe(false);
233
+ harness.unmount();
234
+ });
235
+
236
+ it("opens the sidebar when the rail's own button is clicked", () => {
237
+ const harness = renderPanelLayout();
238
+ act(() => resizeWindowTo(560));
239
+ expect(harness.getState().effectiveLeftCollapsed).toBe(true);
240
+
241
+ // Regression: the toggle used to flip stored INTENT, which was already
242
+ // false here, so the click persisted leftCollapsed=true and the rail stayed
243
+ // railed — a dead button that silently saved a collapse nobody asked for.
244
+ act(() => harness.getState().toggleLeftSidebar());
245
+
246
+ expect(harness.getState().effectiveLeftCollapsed).toBe(false);
247
+ expect(harness.getState().leftCollapsed).toBe(false);
248
+ expect(readStudioUiPreferences().leftCollapsed).toBe(false);
249
+ // And it gets a real width: rendering an expanded sidebar at the 42px rail
250
+ // width would squash its own content. Only a real-UI click caught this.
251
+ expect(harness.getState().leftWidth).toBeGreaterThanOrEqual(200);
252
+ harness.unmount();
253
+ });
254
+
255
+ it("closes the sidebar again on the next click", () => {
256
+ const harness = renderPanelLayout();
257
+ act(() => resizeWindowTo(560));
258
+ act(() => harness.getState().toggleLeftSidebar());
259
+ act(() => harness.getState().toggleLeftSidebar());
260
+
261
+ expect(harness.getState().effectiveLeftCollapsed).toBe(true);
262
+ expect(readStudioUiPreferences().leftCollapsed).toBe(true);
263
+ harness.unmount();
264
+ });
265
+
266
+ it("forgets that reopen once the window is wide again", () => {
267
+ const harness = renderPanelLayout();
268
+ act(() => resizeWindowTo(560));
269
+ act(() => harness.getState().setRightCollapsed(false));
270
+ expect(harness.getState().effectiveRightCollapsed).toBe(false);
271
+
272
+ // Widening past the threshold clears the override, so a later narrow trip
273
+ // rails again rather than staying open forever off one old click.
274
+ act(() => resizeWindowTo(1496));
275
+ act(() => resizeWindowTo(560));
276
+ expect(harness.getState().effectiveRightCollapsed).toBe(true);
277
+ harness.unmount();
278
+ });
279
+
163
280
  it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
164
281
  vi.resetModules();
165
282
  vi.doMock("../components/editor/manualEditingAvailability", async () => {