fix(studio): keep the preview alive when the window is tight (#3091)

Panel sizes are reconciled against the window on every resize, with the preview holding a 360x200 floor that panels yield to before it gives.

Measured preview pane: 760px window 192 -> 433, 560px window 2 -> 516. Windows at or above 1280px are unchanged.

- fitPanels owns the who-yields decision for both axes
- panel caps are window-relative, replacing a flat 600px inspector cap
- below 860 the sidebar rails, below 700 the inspector collapses too
- auto-collapse is derived render state and never writes leftCollapsed
  (localStorage) or rightCollapsed (synced into the shareable URL)
- the rail and header toggles act on the effective state, so neither is a
  dead click that silently persists a collapse the user never asked for
This commit is contained in:
Miguel Ángel
2026-08-07 13:35:39 -07:00
committed by GitHub
parent 13f9af1cec
commit d5cc1c9c62
11 changed files with 632 additions and 66 deletions
@@ -60,6 +60,11 @@ function renderPanelLayout() {
return renderPanelLayoutWith(usePanelLayout);
}
function resizeWindowTo(width: number) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
window.dispatchEvent(new Event("resize"));
}
describe("usePanelLayout — right inspector panes", () => {
it("opens Design with the intended viewport-scaled panel widths", () => {
const harness = renderPanelLayout();
@@ -160,6 +165,118 @@ describe("usePanelLayout — right inspector panes", () => {
harness.unmount();
});
it("caps a panel relative to the window instead of at a flat 600px", () => {
resizeWindowTo(700);
const harness = renderPanelLayout();
// The old flat cap let the inspector claim 600 of a 700px window.
expect(harness.getState().rightWidth).toBeLessThanOrEqual(280);
harness.unmount();
});
it("rails both panels once the window cannot fit them", () => {
resizeWindowTo(560);
const harness = renderPanelLayout();
expect(harness.getState()).toMatchObject({
effectiveLeftCollapsed: true,
effectiveRightCollapsed: true,
leftCollapsed: false,
rightCollapsed: false,
});
harness.unmount();
});
it("auto-collapse never writes the user's persisted or URL-synced intent", () => {
const harness = renderPanelLayout();
act(() => resizeWindowTo(560));
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
// localStorage carries leftCollapsed; the shareable URL carries rightCollapsed.
// A ten-second window drag must rewrite neither.
expect(readStudioUiPreferences().leftCollapsed).toBeUndefined();
expect(harness.getState().leftCollapsed).toBe(false);
expect(harness.getState().rightCollapsed).toBe(false);
harness.unmount();
});
it("returns the user's own width when the window grows back", () => {
const harness = renderPanelLayout();
const wide = harness.getState().leftWidth;
act(() => resizeWindowTo(560));
expect(harness.getState().leftWidth).toBeLessThan(wide);
act(() => resizeWindowTo(1496));
expect(harness.getState().leftWidth).toBe(wide);
harness.unmount();
});
it("keeps an explicitly collapsed sidebar collapsed after a narrow trip", () => {
const harness = renderPanelLayout();
act(() => harness.getState().toggleLeftSidebar());
expect(readStudioUiPreferences().leftCollapsed).toBe(true);
act(() => resizeWindowTo(560));
act(() => resizeWindowTo(1496));
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
harness.unmount();
});
it("lets the user reopen a panel the window auto-collapsed", () => {
const harness = renderPanelLayout();
act(() => resizeWindowTo(560));
expect(harness.getState().effectiveRightCollapsed).toBe(true);
// Without this the header Inspector button would be dead below 700px.
act(() => harness.getState().setRightCollapsed(false));
expect(harness.getState().effectiveRightCollapsed).toBe(false);
harness.unmount();
});
it("opens the sidebar when the rail's own button is clicked", () => {
const harness = renderPanelLayout();
act(() => resizeWindowTo(560));
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
// Regression: the toggle used to flip stored INTENT, which was already
// false here, so the click persisted leftCollapsed=true and the rail stayed
// railed — a dead button that silently saved a collapse nobody asked for.
act(() => harness.getState().toggleLeftSidebar());
expect(harness.getState().effectiveLeftCollapsed).toBe(false);
expect(harness.getState().leftCollapsed).toBe(false);
expect(readStudioUiPreferences().leftCollapsed).toBe(false);
// And it gets a real width: rendering an expanded sidebar at the 42px rail
// width would squash its own content. Only a real-UI click caught this.
expect(harness.getState().leftWidth).toBeGreaterThanOrEqual(200);
harness.unmount();
});
it("closes the sidebar again on the next click", () => {
const harness = renderPanelLayout();
act(() => resizeWindowTo(560));
act(() => harness.getState().toggleLeftSidebar());
act(() => harness.getState().toggleLeftSidebar());
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
expect(readStudioUiPreferences().leftCollapsed).toBe(true);
harness.unmount();
});
it("forgets that reopen once the window is wide again", () => {
const harness = renderPanelLayout();
act(() => resizeWindowTo(560));
act(() => harness.getState().setRightCollapsed(false));
expect(harness.getState().effectiveRightCollapsed).toBe(false);
// Widening past the threshold clears the override, so a later narrow trip
// rails again rather than staying open forever off one old click.
act(() => resizeWindowTo(1496));
act(() => resizeWindowTo(560));
expect(harness.getState().effectiveRightCollapsed).toBe(true);
harness.unmount();
});
it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
vi.resetModules();
vi.doMock("../components/editor/manualEditingAvailability", async () => {
+121 -39
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import type {
RightInspectorPane,
RightInspectorPanes,
@@ -7,6 +7,14 @@ import type {
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
import {
defaultPanelWidths,
fitPanelWidths,
railsEngaged,
type PanelWidths,
} from "../utils/fitPanels";
const NO_OVERRIDE = { left: false, right: false } as const;
export interface InitialPanelLayoutState {
rightCollapsed?: boolean | null;
@@ -20,30 +28,27 @@ function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspect
return { layers: false, design: true };
}
function getInitialPanelWidths(): { left: number; right: number } {
const viewportWidth = typeof window === "undefined" ? 1496 : window.innerWidth;
function readViewportWidth(): number {
return typeof window === "undefined" ? 1496 : window.innerWidth;
}
/**
* What the user WANTS each panel to be, before the window gets a say. Stored
* preferences win over the width-derived defaults; neither is clamped here —
* `fitPanelWidths` owns every clamp so there is one place that decides.
*/
function getPreferredPanelWidths(): PanelWidths {
const preferences = readStudioUiPreferences();
const leftDefault = Math.max(240, Math.min(384, Math.round(viewportWidth * 0.257)));
const rightDefault = Math.max(320, Math.min(424, Math.round(viewportWidth * 0.284)));
const defaults = defaultPanelWidths(readViewportWidth());
return {
left: Math.max(
160,
Math.min(Math.floor(viewportWidth * 0.5), preferences.leftWidth ?? leftDefault),
),
right: Math.max(160, Math.min(600, preferences.rightWidth ?? rightDefault)),
left: preferences.leftWidth ?? defaults.left,
right: preferences.rightWidth ?? defaults.right,
};
}
function clampPanelWidth(side: PanelSide, width: number): number {
const max = side === "left" ? Math.floor(window.innerWidth * 0.5) : 600;
return Math.max(160, Math.min(max, width));
}
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
const [initialPanelWidths] = useState(getInitialPanelWidths);
const [leftWidth, setLeftWidth] = useState(initialPanelWidths.left);
const [rightWidth, setRightWidth] = useState(initialPanelWidths.right);
const panelWidthsRef = useRef(initialPanelWidths);
const [preferredWidths, setPreferredWidths] = useState(getPreferredPanelWidths);
const [viewportWidth, setViewportWidth] = useState(readViewportWidth);
const [leftCollapsed, setLeftCollapsed] = useState(
() => readStudioUiPreferences().leftCollapsed ?? false,
);
@@ -54,41 +59,109 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
getInitialRightInspectorPanes(initialState?.rightPanelTab),
);
// Set when the user explicitly reopens a panel the window had auto-collapsed,
// so the rail cannot immediately swallow it again. Cleared once the window is
// wide enough that auto-collapse is no longer in play.
const [autoCollapseOverride, setAutoCollapseOverride] = useState<{
left: boolean;
right: boolean;
}>(NO_OVERRIDE);
// Reconciliation is a live window resize away, not a mount-time snapshot: a
// Studio loaded at 1440 and dragged to a half-screen used to keep its pixel
// widths and squeeze the preview to nothing.
useEffect(() => {
if (typeof window === "undefined") return;
const handleResize = () => {
const width = window.innerWidth;
setViewportWidth(width);
// Cleared here rather than in an effect watching derived state: the rail
// flags depend only on width, and width only changes in this handler.
if (!railsEngaged(width)) {
setAutoCollapseOverride((prev) => (prev.left || prev.right ? NO_OVERRIDE : prev));
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const fitted = fitPanelWidths(viewportWidth, preferredWidths, autoCollapseOverride);
const leftCollapsedByWidth = fitted.autoCollapseLeft;
const rightCollapsedByWidth = fitted.autoCollapseRight;
// Rendered widths, which the drag handles measure from so the seam does not
// jump when a panel is currently narrower than its stored preference.
const fittedRef = useRef(fitted);
fittedRef.current = fitted;
const panelDragRef = useRef<{
side: PanelSide;
startX: number;
startW: number;
} | null>(null);
const updatePanelWidth = useCallback((side: PanelSide, width: number) => {
const next = clampPanelWidth(side, width);
panelWidthsRef.current[side] = next;
if (side === "left") setLeftWidth(next);
else setRightWidth(next);
// Preferred widths are also held in a ref so a burst of pointer moves or
// keyboard nudges inside one React batch accumulates, instead of every call
// in the batch reading the same pre-render value.
const preferredRef = useRef(preferredWidths);
const setPreferred = useCallback((side: PanelSide, width: number) => {
const next = Math.max(0, Math.round(width));
preferredRef.current = { ...preferredRef.current, [side]: next };
setPreferredWidths(preferredRef.current);
return next;
}, []);
/** Transient: moves the panel without touching the stored preference. */
const updatePanelWidth = useCallback(
(side: PanelSide, width: number) => {
setPreferred(side, width);
},
[setPreferred],
);
/**
* Durable: only an explicit drag or keyboard nudge writes a preference. A
* width the window forced on us is never persisted, so the user's real
* preference survives a temporary squeeze and returns when the window grows.
*/
const commitPanelWidth = useCallback(
(side: PanelSide, width: number) => {
const next = updatePanelWidth(side, Math.round(width));
writeStudioUiPreferences(side === "left" ? { leftWidth: next } : { rightWidth: next });
// Persist what the window will actually allow, not a raw pointer delta.
const candidate = { ...preferredRef.current, [side]: Math.max(0, Math.round(width)) };
const settled = fitPanelWidths(readViewportWidth(), candidate)[side];
setPreferred(side, settled);
writeStudioUiPreferences(side === "left" ? { leftWidth: settled } : { rightWidth: settled });
},
[updatePanelWidth],
[setPreferred],
);
const adjustPanelWidth = useCallback(
(side: PanelSide, delta: number) => {
commitPanelWidth(side, panelWidthsRef.current[side] + delta);
commitPanelWidth(side, preferredRef.current[side] + delta);
},
[commitPanelWidth],
);
// The toggle acts on what the user can SEE, not on stored intent. Toggling
// stored intent instead made the rail's "Show sidebar" button dead in the
// auto-collapsed state: intent was already false, so the click flipped it to
// true (persisting a collapse the user never asked for) while the rail stayed
// railed and nothing visibly happened.
const effectiveLeftCollapsedRef = useRef(false);
effectiveLeftCollapsedRef.current = leftCollapsed || leftCollapsedByWidth;
const toggleLeftSidebar = useCallback(() => {
setLeftCollapsed((collapsed) => {
writeStudioUiPreferences({ leftCollapsed: !collapsed });
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
return !collapsed;
});
const next = !effectiveLeftCollapsedRef.current;
setLeftCollapsed(next);
writeStudioUiPreferences({ leftCollapsed: next });
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: next });
if (!next) setAutoCollapseOverride((prev) => ({ ...prev, left: true }));
}, []);
const setRightCollapsedWithOverride = useCallback((collapsed: boolean) => {
setRightCollapsed(collapsed);
if (!collapsed) setAutoCollapseOverride((prev) => ({ ...prev, right: true }));
}, []);
const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
@@ -97,7 +170,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
panelDragRef.current = {
side,
startX: e.clientX,
startW: panelWidthsRef.current[side],
startW: fittedRef.current[side],
};
}, []);
@@ -113,7 +186,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
const handlePanelResizeEnd = useCallback(() => {
const side = panelDragRef.current?.side;
if (side) commitPanelWidth(side, panelWidthsRef.current[side]);
if (side) commitPanelWidth(side, preferredRef.current[side]);
panelDragRef.current = null;
}, [commitPanelWidth]);
@@ -158,13 +231,22 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
}, []);
return {
leftWidth,
rightWidth,
leftWidth: fitted.left,
rightWidth: fitted.right,
adjustPanelWidth,
/**
* User intent. Persisted to localStorage; never written by auto-collapse.
* Deliberately read-only outside this hook: `toggleLeftSidebar` is the only
* writer, so it cannot be flipped without also clearing the rail override
* (which would open the sidebar and then immediately rail it again).
*/
leftCollapsed,
setLeftCollapsed,
/** User intent. Synced into the shareable URL; never written by auto-collapse. */
rightCollapsed,
setRightCollapsed,
setRightCollapsed: setRightCollapsedWithOverride,
/** What the shell actually renders: intent OR the window forcing a rail. */
effectiveLeftCollapsed: leftCollapsed || leftCollapsedByWidth,
effectiveRightCollapsed: rightCollapsed || rightCollapsedByWidth,
rightPanelTab,
setRightPanelTab: trackedSetRightPanelTab,
rightInspectorPanes,