@hyperframes/studio 0.7.99 → 0.7.100

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.
@@ -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 () => {
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from "react";
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import type {
3
3
  RightInspectorPane,
4
4
  RightInspectorPanes,
@@ -7,6 +7,14 @@ import type {
7
7
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
9
  import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
10
+ import {
11
+ defaultPanelWidths,
12
+ fitPanelWidths,
13
+ railsEngaged,
14
+ type PanelWidths,
15
+ } from "../utils/fitPanels";
16
+
17
+ const NO_OVERRIDE = { left: false, right: false } as const;
10
18
 
11
19
  export interface InitialPanelLayoutState {
12
20
  rightCollapsed?: boolean | null;
@@ -20,30 +28,27 @@ function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspect
20
28
  return { layers: false, design: true };
21
29
  }
22
30
 
23
- function getInitialPanelWidths(): { left: number; right: number } {
24
- const viewportWidth = typeof window === "undefined" ? 1496 : window.innerWidth;
31
+ function readViewportWidth(): number {
32
+ return typeof window === "undefined" ? 1496 : window.innerWidth;
33
+ }
34
+
35
+ /**
36
+ * What the user WANTS each panel to be, before the window gets a say. Stored
37
+ * preferences win over the width-derived defaults; neither is clamped here —
38
+ * `fitPanelWidths` owns every clamp so there is one place that decides.
39
+ */
40
+ function getPreferredPanelWidths(): PanelWidths {
25
41
  const preferences = readStudioUiPreferences();
26
- const leftDefault = Math.max(240, Math.min(384, Math.round(viewportWidth * 0.257)));
27
- const rightDefault = Math.max(320, Math.min(424, Math.round(viewportWidth * 0.284)));
42
+ const defaults = defaultPanelWidths(readViewportWidth());
28
43
  return {
29
- left: Math.max(
30
- 160,
31
- Math.min(Math.floor(viewportWidth * 0.5), preferences.leftWidth ?? leftDefault),
32
- ),
33
- right: Math.max(160, Math.min(600, preferences.rightWidth ?? rightDefault)),
44
+ left: preferences.leftWidth ?? defaults.left,
45
+ right: preferences.rightWidth ?? defaults.right,
34
46
  };
35
47
  }
36
48
 
37
- function clampPanelWidth(side: PanelSide, width: number): number {
38
- const max = side === "left" ? Math.floor(window.innerWidth * 0.5) : 600;
39
- return Math.max(160, Math.min(max, width));
40
- }
41
-
42
49
  export function usePanelLayout(initialState?: InitialPanelLayoutState) {
43
- const [initialPanelWidths] = useState(getInitialPanelWidths);
44
- const [leftWidth, setLeftWidth] = useState(initialPanelWidths.left);
45
- const [rightWidth, setRightWidth] = useState(initialPanelWidths.right);
46
- const panelWidthsRef = useRef(initialPanelWidths);
50
+ const [preferredWidths, setPreferredWidths] = useState(getPreferredPanelWidths);
51
+ const [viewportWidth, setViewportWidth] = useState(readViewportWidth);
47
52
  const [leftCollapsed, setLeftCollapsed] = useState(
48
53
  () => readStudioUiPreferences().leftCollapsed ?? false,
49
54
  );
@@ -54,41 +59,109 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
54
59
  const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
55
60
  getInitialRightInspectorPanes(initialState?.rightPanelTab),
56
61
  );
62
+ // Set when the user explicitly reopens a panel the window had auto-collapsed,
63
+ // so the rail cannot immediately swallow it again. Cleared once the window is
64
+ // wide enough that auto-collapse is no longer in play.
65
+ const [autoCollapseOverride, setAutoCollapseOverride] = useState<{
66
+ left: boolean;
67
+ right: boolean;
68
+ }>(NO_OVERRIDE);
69
+
70
+ // Reconciliation is a live window resize away, not a mount-time snapshot: a
71
+ // Studio loaded at 1440 and dragged to a half-screen used to keep its pixel
72
+ // widths and squeeze the preview to nothing.
73
+ useEffect(() => {
74
+ if (typeof window === "undefined") return;
75
+ const handleResize = () => {
76
+ const width = window.innerWidth;
77
+ setViewportWidth(width);
78
+ // Cleared here rather than in an effect watching derived state: the rail
79
+ // flags depend only on width, and width only changes in this handler.
80
+ if (!railsEngaged(width)) {
81
+ setAutoCollapseOverride((prev) => (prev.left || prev.right ? NO_OVERRIDE : prev));
82
+ }
83
+ };
84
+ window.addEventListener("resize", handleResize);
85
+ return () => window.removeEventListener("resize", handleResize);
86
+ }, []);
87
+
88
+ const fitted = fitPanelWidths(viewportWidth, preferredWidths, autoCollapseOverride);
89
+ const leftCollapsedByWidth = fitted.autoCollapseLeft;
90
+ const rightCollapsedByWidth = fitted.autoCollapseRight;
91
+
92
+ // Rendered widths, which the drag handles measure from so the seam does not
93
+ // jump when a panel is currently narrower than its stored preference.
94
+ const fittedRef = useRef(fitted);
95
+ fittedRef.current = fitted;
96
+
57
97
  const panelDragRef = useRef<{
58
98
  side: PanelSide;
59
99
  startX: number;
60
100
  startW: number;
61
101
  } | null>(null);
62
102
 
63
- const updatePanelWidth = useCallback((side: PanelSide, width: number) => {
64
- const next = clampPanelWidth(side, width);
65
- panelWidthsRef.current[side] = next;
66
- if (side === "left") setLeftWidth(next);
67
- else setRightWidth(next);
103
+ // Preferred widths are also held in a ref so a burst of pointer moves or
104
+ // keyboard nudges inside one React batch accumulates, instead of every call
105
+ // in the batch reading the same pre-render value.
106
+ const preferredRef = useRef(preferredWidths);
107
+
108
+ const setPreferred = useCallback((side: PanelSide, width: number) => {
109
+ const next = Math.max(0, Math.round(width));
110
+ preferredRef.current = { ...preferredRef.current, [side]: next };
111
+ setPreferredWidths(preferredRef.current);
68
112
  return next;
69
113
  }, []);
70
114
 
115
+ /** Transient: moves the panel without touching the stored preference. */
116
+ const updatePanelWidth = useCallback(
117
+ (side: PanelSide, width: number) => {
118
+ setPreferred(side, width);
119
+ },
120
+ [setPreferred],
121
+ );
122
+
123
+ /**
124
+ * Durable: only an explicit drag or keyboard nudge writes a preference. A
125
+ * width the window forced on us is never persisted, so the user's real
126
+ * preference survives a temporary squeeze and returns when the window grows.
127
+ */
71
128
  const commitPanelWidth = useCallback(
72
129
  (side: PanelSide, width: number) => {
73
- const next = updatePanelWidth(side, Math.round(width));
74
- writeStudioUiPreferences(side === "left" ? { leftWidth: next } : { rightWidth: next });
130
+ // Persist what the window will actually allow, not a raw pointer delta.
131
+ const candidate = { ...preferredRef.current, [side]: Math.max(0, Math.round(width)) };
132
+ const settled = fitPanelWidths(readViewportWidth(), candidate)[side];
133
+ setPreferred(side, settled);
134
+ writeStudioUiPreferences(side === "left" ? { leftWidth: settled } : { rightWidth: settled });
75
135
  },
76
- [updatePanelWidth],
136
+ [setPreferred],
77
137
  );
78
138
 
79
139
  const adjustPanelWidth = useCallback(
80
140
  (side: PanelSide, delta: number) => {
81
- commitPanelWidth(side, panelWidthsRef.current[side] + delta);
141
+ commitPanelWidth(side, preferredRef.current[side] + delta);
82
142
  },
83
143
  [commitPanelWidth],
84
144
  );
85
145
 
146
+ // The toggle acts on what the user can SEE, not on stored intent. Toggling
147
+ // stored intent instead made the rail's "Show sidebar" button dead in the
148
+ // auto-collapsed state: intent was already false, so the click flipped it to
149
+ // true (persisting a collapse the user never asked for) while the rail stayed
150
+ // railed and nothing visibly happened.
151
+ const effectiveLeftCollapsedRef = useRef(false);
152
+ effectiveLeftCollapsedRef.current = leftCollapsed || leftCollapsedByWidth;
153
+
86
154
  const toggleLeftSidebar = useCallback(() => {
87
- setLeftCollapsed((collapsed) => {
88
- writeStudioUiPreferences({ leftCollapsed: !collapsed });
89
- trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
90
- return !collapsed;
91
- });
155
+ const next = !effectiveLeftCollapsedRef.current;
156
+ setLeftCollapsed(next);
157
+ writeStudioUiPreferences({ leftCollapsed: next });
158
+ trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: next });
159
+ if (!next) setAutoCollapseOverride((prev) => ({ ...prev, left: true }));
160
+ }, []);
161
+
162
+ const setRightCollapsedWithOverride = useCallback((collapsed: boolean) => {
163
+ setRightCollapsed(collapsed);
164
+ if (!collapsed) setAutoCollapseOverride((prev) => ({ ...prev, right: true }));
92
165
  }, []);
93
166
 
94
167
  const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
@@ -97,7 +170,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
97
170
  panelDragRef.current = {
98
171
  side,
99
172
  startX: e.clientX,
100
- startW: panelWidthsRef.current[side],
173
+ startW: fittedRef.current[side],
101
174
  };
102
175
  }, []);
103
176
 
@@ -113,7 +186,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
113
186
 
114
187
  const handlePanelResizeEnd = useCallback(() => {
115
188
  const side = panelDragRef.current?.side;
116
- if (side) commitPanelWidth(side, panelWidthsRef.current[side]);
189
+ if (side) commitPanelWidth(side, preferredRef.current[side]);
117
190
  panelDragRef.current = null;
118
191
  }, [commitPanelWidth]);
119
192
 
@@ -158,13 +231,22 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
158
231
  }, []);
159
232
 
160
233
  return {
161
- leftWidth,
162
- rightWidth,
234
+ leftWidth: fitted.left,
235
+ rightWidth: fitted.right,
163
236
  adjustPanelWidth,
237
+ /**
238
+ * User intent. Persisted to localStorage; never written by auto-collapse.
239
+ * Deliberately read-only outside this hook: `toggleLeftSidebar` is the only
240
+ * writer, so it cannot be flipped without also clearing the rail override
241
+ * (which would open the sidebar and then immediately rail it again).
242
+ */
164
243
  leftCollapsed,
165
- setLeftCollapsed,
244
+ /** User intent. Synced into the shareable URL; never written by auto-collapse. */
166
245
  rightCollapsed,
167
- setRightCollapsed,
246
+ setRightCollapsed: setRightCollapsedWithOverride,
247
+ /** What the shell actually renders: intent OR the window forcing a rail. */
248
+ effectiveLeftCollapsed: leftCollapsed || leftCollapsedByWidth,
249
+ effectiveRightCollapsed: rightCollapsed || rightCollapsedByWidth,
168
250
  rightPanelTab,
169
251
  setRightPanelTab: trackedSetRightPanelTab,
170
252
  rightInspectorPanes,
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ MIN_PREVIEW_H,
4
+ MIN_PREVIEW_W,
5
+ MIN_TIMELINE_H,
6
+ RAIL_W,
7
+ defaultPanelWidths,
8
+ fitPanelWidths,
9
+ fitTimelineHeight,
10
+ } from "./fitPanels";
11
+
12
+ function fitAt(viewportWidth: number) {
13
+ return fitPanelWidths(viewportWidth, defaultPanelWidths(viewportWidth));
14
+ }
15
+
16
+ describe("defaultPanelWidths", () => {
17
+ // Frozen literals, deliberately NOT compared against the live hook: the hook
18
+ // now delegates here, so asserting against it would be circular and would
19
+ // silently stop guarding the "wide windows are untouched" promise.
20
+ it.each([
21
+ [1680, 384, 424],
22
+ [1512, 384, 424],
23
+ [1280, 329, 364],
24
+ ])("keeps %ipx identical to the pre-change defaults", (vw, left, right) => {
25
+ expect(defaultPanelWidths(vw)).toEqual({ left, right });
26
+ });
27
+ });
28
+
29
+ describe("fitPanelWidths", () => {
30
+ it.each([1680, 1512, 1280])("leaves %ipx untouched by reconciliation", (vw) => {
31
+ const preferred = defaultPanelWidths(vw);
32
+ const fitted = fitPanelWidths(vw, preferred);
33
+ expect(fitted.left).toBe(preferred.left);
34
+ expect(fitted.right).toBe(preferred.right);
35
+ expect(fitted.autoCollapseLeft).toBe(false);
36
+ expect(fitted.autoCollapseRight).toBe(false);
37
+ });
38
+
39
+ it.each([1680, 1512, 1280, 1100, 960, 860, 760, 640, 560, 480])(
40
+ "never starves the preview at %ipx",
41
+ (vw) => {
42
+ expect(fitAt(vw).preview).toBeGreaterThanOrEqual(MIN_PREVIEW_W);
43
+ },
44
+ );
45
+
46
+ it("matches the projected preview widths from the plan", () => {
47
+ const projected: Array<[number, number]> = [
48
+ [1680, 866],
49
+ [1512, 698],
50
+ [1280, 581],
51
+ [1100, 499],
52
+ [960, 427],
53
+ [860, 360],
54
+ [760, 432],
55
+ [640, 592],
56
+ [560, 512],
57
+ [480, 432],
58
+ ];
59
+ for (const [vw, preview] of projected) {
60
+ expect({ vw, preview: fitAt(vw).preview }).toEqual({ vw, preview });
61
+ }
62
+ });
63
+
64
+ it("fits everything at its minimum at exactly 860, the derived threshold", () => {
65
+ const fitted = fitAt(860);
66
+ expect(fitted).toMatchObject({
67
+ left: 214,
68
+ right: 280,
69
+ preview: MIN_PREVIEW_W,
70
+ autoCollapseLeft: false,
71
+ });
72
+ });
73
+
74
+ it("rails the sidebar one pixel below the threshold", () => {
75
+ expect(fitAt(860).autoCollapseLeft).toBe(false);
76
+ expect(fitAt(859).autoCollapseLeft).toBe(true);
77
+ expect(fitAt(859).left).toBe(RAIL_W);
78
+ });
79
+
80
+ it("hides the inspector one pixel below its own threshold", () => {
81
+ expect(fitAt(700).autoCollapseRight).toBe(false);
82
+ expect(fitAt(700).right).toBeGreaterThan(0);
83
+ expect(fitAt(699).autoCollapseRight).toBe(true);
84
+ expect(fitAt(699).right).toBe(0);
85
+ });
86
+
87
+ it("never drives an EXPANDED sidebar below its usable minimum", () => {
88
+ // Guards the leftFloor/rightFloor split: the squeeze fallback may only reach
89
+ // the rail width when the panel is actually railed.
90
+ const fitted = fitPanelWidths(900, { left: 600, right: 600 });
91
+ expect(fitted.left).toBeGreaterThanOrEqual(200);
92
+ expect(fitted.right).toBeGreaterThanOrEqual(280);
93
+ });
94
+
95
+ it("caps a single panel at 40% of the window", () => {
96
+ expect(fitPanelWidths(1600, { left: 900, right: 280 }).left).toBe(640);
97
+ });
98
+
99
+ it("lets the preview floor win when the cap alone is not enough", () => {
100
+ // 40% of 1000 is 400, but 400 + 280 + 6 leaves the preview at 314.
101
+ const fitted = fitPanelWidths(1000, { left: 900, right: 280 });
102
+ expect(fitted.left).toBe(354);
103
+ expect(fitted.preview).toBe(MIN_PREVIEW_W);
104
+ });
105
+
106
+ it("survives a viewport narrower than the preview floor alone", () => {
107
+ const fitted = fitAt(300);
108
+ expect(fitted.preview).toBeGreaterThanOrEqual(0);
109
+ expect(Number.isNaN(fitted.preview)).toBe(false);
110
+ expect(fitted.left).toBe(RAIL_W);
111
+ });
112
+
113
+ it("returns preferences untouched before the shell is measured", () => {
114
+ const preferred = { left: 320, right: 400 };
115
+ expect(fitPanelWidths(0, preferred)).toMatchObject({ ...preferred, preview: 0 });
116
+ expect(fitPanelWidths(Number.NaN, preferred)).toMatchObject(preferred);
117
+ });
118
+
119
+ it("is idempotent, so a resize loop cannot oscillate", () => {
120
+ for (const vw of [1512, 1100, 860, 760, 560]) {
121
+ const once = fitPanelWidths(vw, defaultPanelWidths(vw));
122
+ const twice = fitPanelWidths(vw, once);
123
+ expect(twice).toEqual(once);
124
+ }
125
+ });
126
+ });
127
+
128
+ describe("fitTimelineHeight", () => {
129
+ it("keeps a height that fits", () => {
130
+ expect(fitTimelineHeight(717, 429)).toBe(429);
131
+ });
132
+
133
+ it("reclaims height for the preview when the shell shrinks", () => {
134
+ // The measured bug: mounted at 760 tall, dragged to 520. Preview was 47px.
135
+ expect(fitTimelineHeight(477, 429)).toBe(477 - MIN_PREVIEW_H);
136
+ expect(477 - fitTimelineHeight(477, 429)).toBeGreaterThanOrEqual(MIN_PREVIEW_H);
137
+ });
138
+
139
+ it("raises a too-small preference to the timeline minimum", () => {
140
+ expect(fitTimelineHeight(717, 10)).toBe(MIN_TIMELINE_H);
141
+ });
142
+
143
+ it("keeps the timeline usable when the shell is shorter than both minimums", () => {
144
+ expect(fitTimelineHeight(120, 429)).toBe(MIN_TIMELINE_H);
145
+ });
146
+
147
+ it("returns the preference before the shell is measured", () => {
148
+ expect(fitTimelineHeight(0, 429)).toBe(429);
149
+ });
150
+
151
+ it("is idempotent", () => {
152
+ const once = fitTimelineHeight(477, 429);
153
+ expect(fitTimelineHeight(477, once)).toBe(once);
154
+ });
155
+
156
+ it("would return the preferred height if callers kept one", () => {
157
+ // The helper is preference-preserving; the vertical CALLER still stores the
158
+ // clamped value, so a shrink-then-grow does not restore the old height the
159
+ // way panel widths do. Tracked as a known asymmetry, not fixed here.
160
+ const preferred = 429;
161
+ expect(fitTimelineHeight(477, preferred)).toBe(277);
162
+ expect(fitTimelineHeight(717, preferred)).toBe(429);
163
+ });
164
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Panel reconciliation for the Studio shell.
3
+ *
4
+ * The shell is `[sidebar | preview | inspector]` over a full-width timeline.
5
+ * Only the preview is flexible (`flex-1 min-w-0`), so without an explicit floor
6
+ * it absorbs every squeeze: at a 560px window the old 240/320 panel floors alone
7
+ * overflowed the viewport and the preview rendered at 2px, on a *fresh* load.
8
+ * The same held vertically once the window was resized after mount.
9
+ *
10
+ * Every caller (mount, resize, drag, keyboard nudge, preference restore) routes
11
+ * through {@link fitPanelWidths} / {@link fitTimelineHeight} so exactly one place
12
+ * decides who yields. The rule is: the preview gets its floor first, panels
13
+ * shrink toward their minimums, then collapse to rails.
14
+ */
15
+
16
+ /** Smallest preview box worth editing in. Chosen, not derived — see the plan. */
17
+ export const MIN_PREVIEW_W = 360;
18
+ export const MIN_PREVIEW_H = 200;
19
+
20
+ const MIN_LEFT = 200;
21
+ const MIN_RIGHT = 280;
22
+ /**
23
+ * Collapsed sidebar rail, as a layout FOOTPRINT: the `w-10` box (40) plus the
24
+ * `mr-0.5` gap (2) it contributes to the flex row. Measured in the browser at a
25
+ * 760px window: rail box 40, margin-right 2, preview starts at x=43.
26
+ */
27
+ export const RAIL_W = 42;
28
+ export const MIN_TIMELINE_H = 100;
29
+
30
+ /** The two 3px resize seams between the three top-row panels. */
31
+ const PANEL_SEAM_W = 6;
32
+
33
+ /** No single panel may claim more than this share of the window. */
34
+ const PANEL_MAX_RATIO = 0.4;
35
+
36
+ /*
37
+ * Thresholds below are DERIVED from the minimums above, not chosen:
38
+ *
39
+ * MIN_LEFT 200 + MIN_RIGHT 280 + MIN_PREVIEW_W 360 + PANEL_SEAM_W 6 = 846
40
+ * -> below ~860 the sidebar can no longer fit at its minimum.
41
+ * RAIL_W 40 + MIN_RIGHT 280 + MIN_PREVIEW_W 360 + PANEL_SEAM_W 6 = 686
42
+ * -> below ~700 the inspector can no longer fit either.
43
+ *
44
+ * Change a minimum and move the matching threshold with it.
45
+ */
46
+ const RAIL_LEFT_BELOW = 860;
47
+ const RAIL_RIGHT_BELOW = 700;
48
+
49
+ /**
50
+ * True when the window is narrow enough to force at least one panel to a rail.
51
+ * The sidebar threshold is the wider of the two, so it is the whole condition.
52
+ */
53
+ export function railsEngaged(viewportWidth: number): boolean {
54
+ return viewportWidth < RAIL_LEFT_BELOW;
55
+ }
56
+
57
+ export interface PanelWidths {
58
+ left: number;
59
+ right: number;
60
+ }
61
+
62
+ export interface FittedPanelWidths extends PanelWidths {
63
+ preview: number;
64
+ /**
65
+ * Width-driven collapse. Derived per render; NEVER persisted and never written
66
+ * back to the user's own collapse preference — `leftCollapsed` lives in
67
+ * localStorage and `rightCollapsed` is synced into the shareable Studio URL,
68
+ * so a ten-second window drag must not rewrite either.
69
+ */
70
+ autoCollapseLeft: boolean;
71
+ autoCollapseRight: boolean;
72
+ }
73
+
74
+ function clamp(value: number, min: number, max: number): number {
75
+ return Math.max(min, Math.min(max, value));
76
+ }
77
+
78
+ /**
79
+ * The width each panel wants on a given viewport, before reconciliation.
80
+ * Upper bounds (384 / 424) are unchanged from the original implementation, which
81
+ * is what keeps windows at or above 1280px byte-identical to before.
82
+ */
83
+ export function defaultPanelWidths(viewportWidth: number): PanelWidths {
84
+ return {
85
+ left: Math.max(MIN_LEFT, Math.min(384, Math.round(viewportWidth * 0.257))),
86
+ right: Math.max(MIN_RIGHT, Math.min(424, Math.round(viewportWidth * 0.284))),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Resolve preferred panel widths against the viewport, reserving the preview.
92
+ *
93
+ * Order of yielding: window-relative cap, then the inspector shrinks toward its
94
+ * minimum, then the sidebar, then rails. A viewport of 0 (element not measured
95
+ * yet) returns the preferences untouched so a pre-measurement frame cannot
96
+ * clobber a real preference.
97
+ */
98
+ // fallow-ignore-next-line complexity
99
+ export function fitPanelWidths(
100
+ viewportWidth: number,
101
+ preferred: PanelWidths,
102
+ /**
103
+ * Sides the user explicitly reopened while the window was narrow. A side
104
+ * listed here keeps its real width instead of the rail: without this the
105
+ * panel would render expanded at 42px and squash its own content.
106
+ */
107
+ reopened: { left: boolean; right: boolean } = { left: false, right: false },
108
+ ): FittedPanelWidths {
109
+ const vw = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : 0;
110
+ if (vw <= 0) {
111
+ return { ...preferred, preview: 0, autoCollapseLeft: false, autoCollapseRight: false };
112
+ }
113
+
114
+ const autoCollapseLeft = vw < RAIL_LEFT_BELOW && !reopened.left;
115
+ const autoCollapseRight = vw < RAIL_RIGHT_BELOW && !reopened.right;
116
+ const cap = Math.floor(vw * PANEL_MAX_RATIO);
117
+
118
+ // A railed panel's floor is its rail width; an open panel's floor is its
119
+ // minimum usable width. Without this split the squeeze fallback below would
120
+ // drive an *expanded* sidebar down to 40px and render its content unusable.
121
+ const leftFloor = autoCollapseLeft ? RAIL_W : MIN_LEFT;
122
+ const rightFloor = autoCollapseRight ? 0 : MIN_RIGHT;
123
+
124
+ let left = autoCollapseLeft ? RAIL_W : clamp(preferred.left, MIN_LEFT, Math.max(MIN_LEFT, cap));
125
+ let right = autoCollapseRight ? 0 : clamp(preferred.right, MIN_RIGHT, Math.max(MIN_RIGHT, cap));
126
+
127
+ const budget = vw - MIN_PREVIEW_W - PANEL_SEAM_W;
128
+ if (left + right > budget) right = Math.max(rightFloor, budget - left);
129
+ if (left + right > budget) left = Math.max(leftFloor, budget - right);
130
+
131
+ return {
132
+ left,
133
+ right,
134
+ preview: Math.max(0, vw - left - right - PANEL_SEAM_W),
135
+ autoCollapseLeft,
136
+ autoCollapseRight,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Resolve a preferred timeline height against the shell's measured height.
142
+ *
143
+ * Subsumes the clamping that used to be inlined in TimelineResizeDivider's
144
+ * pointer and keyboard handlers and duplicated (mount-only) in NLEContext.
145
+ */
146
+ export function fitTimelineHeight(containerHeight: number, preferred: number): number {
147
+ const h = Number.isFinite(containerHeight) ? containerHeight : 0;
148
+ const wanted = Number.isFinite(preferred) ? preferred : MIN_TIMELINE_H;
149
+ if (h <= 0) return Math.max(MIN_TIMELINE_H, wanted);
150
+ return clamp(wanted, MIN_TIMELINE_H, Math.max(MIN_TIMELINE_H, h - MIN_PREVIEW_H));
151
+ }