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
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { shouldOpenInspector } from "./StudioHeader";
describe("shouldOpenInspector", () => {
it("opens when the panel is hidden", () => {
expect(shouldOpenInspector(true, false)).toBe(true);
});
it("opens when a non-inspector tab is showing", () => {
expect(shouldOpenInspector(false, false)).toBe(true);
});
it("closes when the inspector is genuinely on screen", () => {
expect(shouldOpenInspector(false, true)).toBe(false);
});
it("opens when the window railed the panel away", () => {
// The regression this guards: the button used to branch on the raw
// rightCollapsed intent, which is still `false` while the window has the
// panel railed. That took the close branch, wrote rightCollapsed=true, and
// since that value is synced into the shareable Studio URL, a click that
// did nothing visible rewrote the link.
const userIntentIsOpen = false;
const windowRailedItAway = true;
expect(shouldOpenInspector(windowRailedItAway, true)).toBe(true);
expect(shouldOpenInspector(userIntentIsOpen, true)).toBe(false);
});
});
@@ -196,6 +196,21 @@ export function ViewModeToggle() {
);
}
/**
* Does the header's Inspector button open the panel, or close it?
*
* Takes the EFFECTIVE collapse state, so a panel the window has railed away
* counts as closed even though the user's stored intent still says open. The
* argument name is the guard: passing raw intent here is the bug this exists
* to keep out.
*/
export function shouldOpenInspector(
effectiveRightCollapsed: boolean,
inspectorPanelActive: boolean,
): boolean {
return effectiveRightCollapsed || !inspectorPanelActive;
}
// fallow-ignore-next-line complexity
export function StudioHeader({
captureFrameHref,
@@ -208,7 +223,11 @@ export function StudioHeader({
onExport,
}: StudioHeaderProps) {
const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
// effectiveRightCollapsed, not the raw intent: in the auto-railed state the
// intent is still "open" while the panel is hidden, so branching on intent
// made this button write rightCollapsed=true — and that value is synced into
// the shareable Studio URL, so a dead click would rewrite a link.
const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
const isRendering = renderQueue.isRendering;
return (
@@ -328,7 +347,7 @@ export function StudioHeader({
<button
type="button"
onClick={() => {
if (rightCollapsed || !inspectorPanelActive) {
if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
setRightPanelTab("design");
setRightCollapsed(false);
@@ -36,7 +36,7 @@ export function StudioLeftSidebar({
onAddCompositionToTimeline,
}: StudioLeftSidebarProps) {
const {
leftCollapsed,
effectiveLeftCollapsed,
leftWidth,
adjustPanelWidth,
toggleLeftSidebar,
@@ -71,7 +71,7 @@ export function StudioLeftSidebar({
[renderQueue, waitForPendingDomEditSaves],
);
if (leftCollapsed) {
if (effectiveLeftCollapsed) {
return (
<div className="mr-0.5 flex w-10 flex-shrink-0 flex-col items-center rounded-lg border border-neutral-800/50 bg-neutral-950 pt-1">
<button
@@ -11,7 +11,7 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import type { CompositionLevel } from "./CompositionBreadcrumb";
import { useCompositionStack } from "./useCompositionStack";
import { MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
import { setCompositionSourceMap } from "../editor/domEditingDom";
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
@@ -273,13 +273,22 @@ export function NLEProvider({
}, []);
const containerRef = useRef<HTMLDivElement>(null);
// A height persisted on a tall window can exceed this window's container and
// collapse the flex-1 preview to 0px — clamp once the container is measurable
// (the drag/keyboard paths already clamp; the restore path must too).
// collapse the flex-1 preview to 0px. Observing the container rather than
// clamping once at mount is what makes a window RESIZED after load behave the
// same as one loaded at that size: dragging 760 -> 520 tall used to leave the
// timeline at its stored 429px and the preview at 47px.
useEffect(() => {
const containerH = containerRef.current?.getBoundingClientRect().height;
if (!containerH) return;
const max = containerH - MIN_PREVIEW_H;
setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
const element = containerRef.current;
if (!element || typeof ResizeObserver === "undefined") return;
const reconcile = () => {
const containerH = element.getBoundingClientRect().height;
if (!containerH) return;
setTimelineH((prev) => fitTimelineHeight(containerH, prev));
};
reconcile();
const observer = new ResizeObserver(reconcile);
observer.observe(element);
return () => observer.disconnect();
}, []);
const hasLoadedOnceRef = useRef(false);
@@ -1,7 +1,5 @@
import { useCallback, useRef } from "react";
export const MIN_TIMELINE_H = 100;
export const MIN_PREVIEW_H = 120;
import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
/**
* Horizontal drag/keyboard-resizable divider between the preview and the
@@ -41,12 +39,7 @@ export function TimelineResizeDivider({
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const containerH = rect.height;
const newTimelineH = Math.max(
MIN_TIMELINE_H,
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
);
setTimelineH(newTimelineH);
setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
},
[disabled, containerRef, setTimelineH],
);
@@ -61,10 +54,10 @@ export function TimelineResizeDivider({
if (disabled) return;
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
e.preventDefault();
const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
const delta = e.key === "ArrowUp" ? 16 : -16;
setTimelineH((prev) => {
const next = Math.max(MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, prev + delta));
const next = fitTimelineHeight(containerH, prev + delta);
persistTimelineH(next);
return next;
});