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
+2 -2
View File
@@ -399,7 +399,7 @@ export function StudioApp() {
} = useInspectorState(
panelLayout.rightPanelTab,
panelLayout.rightInspectorPanes,
panelLayout.rightCollapsed,
panelLayout.effectiveRightCollapsed,
isPlaying,
domEditSession.domEditSelection,
gestureState === "recording",
@@ -512,7 +512,7 @@ export function StudioApp() {
/>
}
right={
panelLayout.rightCollapsed ? null : (
panelLayout.effectiveRightCollapsed ? null : (
<StudioRightPanel
designPanelActive={designPanelActive}
activeBlockParams={activeBlockParams}
@@ -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;
});
@@ -17,9 +17,10 @@ export function PanelLayoutProvider({
rightWidth,
adjustPanelWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
setRightCollapsed,
effectiveLeftCollapsed,
effectiveRightCollapsed,
rightPanelTab,
setRightPanelTab,
rightInspectorPanes,
@@ -41,9 +42,10 @@ export function PanelLayoutProvider({
rightWidth,
adjustPanelWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
setRightCollapsed,
effectiveLeftCollapsed,
effectiveRightCollapsed,
rightPanelTab,
setRightPanelTab,
rightInspectorPanes,
@@ -59,9 +61,10 @@ export function PanelLayoutProvider({
rightWidth,
adjustPanelWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
setRightCollapsed,
effectiveLeftCollapsed,
effectiveRightCollapsed,
rightPanelTab,
setRightPanelTab,
rightInspectorPanes,
@@ -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,
+164
View File
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import {
MIN_PREVIEW_H,
MIN_PREVIEW_W,
MIN_TIMELINE_H,
RAIL_W,
defaultPanelWidths,
fitPanelWidths,
fitTimelineHeight,
} from "./fitPanels";
function fitAt(viewportWidth: number) {
return fitPanelWidths(viewportWidth, defaultPanelWidths(viewportWidth));
}
describe("defaultPanelWidths", () => {
// Frozen literals, deliberately NOT compared against the live hook: the hook
// now delegates here, so asserting against it would be circular and would
// silently stop guarding the "wide windows are untouched" promise.
it.each([
[1680, 384, 424],
[1512, 384, 424],
[1280, 329, 364],
])("keeps %ipx identical to the pre-change defaults", (vw, left, right) => {
expect(defaultPanelWidths(vw)).toEqual({ left, right });
});
});
describe("fitPanelWidths", () => {
it.each([1680, 1512, 1280])("leaves %ipx untouched by reconciliation", (vw) => {
const preferred = defaultPanelWidths(vw);
const fitted = fitPanelWidths(vw, preferred);
expect(fitted.left).toBe(preferred.left);
expect(fitted.right).toBe(preferred.right);
expect(fitted.autoCollapseLeft).toBe(false);
expect(fitted.autoCollapseRight).toBe(false);
});
it.each([1680, 1512, 1280, 1100, 960, 860, 760, 640, 560, 480])(
"never starves the preview at %ipx",
(vw) => {
expect(fitAt(vw).preview).toBeGreaterThanOrEqual(MIN_PREVIEW_W);
},
);
it("matches the projected preview widths from the plan", () => {
const projected: Array<[number, number]> = [
[1680, 866],
[1512, 698],
[1280, 581],
[1100, 499],
[960, 427],
[860, 360],
[760, 432],
[640, 592],
[560, 512],
[480, 432],
];
for (const [vw, preview] of projected) {
expect({ vw, preview: fitAt(vw).preview }).toEqual({ vw, preview });
}
});
it("fits everything at its minimum at exactly 860, the derived threshold", () => {
const fitted = fitAt(860);
expect(fitted).toMatchObject({
left: 214,
right: 280,
preview: MIN_PREVIEW_W,
autoCollapseLeft: false,
});
});
it("rails the sidebar one pixel below the threshold", () => {
expect(fitAt(860).autoCollapseLeft).toBe(false);
expect(fitAt(859).autoCollapseLeft).toBe(true);
expect(fitAt(859).left).toBe(RAIL_W);
});
it("hides the inspector one pixel below its own threshold", () => {
expect(fitAt(700).autoCollapseRight).toBe(false);
expect(fitAt(700).right).toBeGreaterThan(0);
expect(fitAt(699).autoCollapseRight).toBe(true);
expect(fitAt(699).right).toBe(0);
});
it("never drives an EXPANDED sidebar below its usable minimum", () => {
// Guards the leftFloor/rightFloor split: the squeeze fallback may only reach
// the rail width when the panel is actually railed.
const fitted = fitPanelWidths(900, { left: 600, right: 600 });
expect(fitted.left).toBeGreaterThanOrEqual(200);
expect(fitted.right).toBeGreaterThanOrEqual(280);
});
it("caps a single panel at 40% of the window", () => {
expect(fitPanelWidths(1600, { left: 900, right: 280 }).left).toBe(640);
});
it("lets the preview floor win when the cap alone is not enough", () => {
// 40% of 1000 is 400, but 400 + 280 + 6 leaves the preview at 314.
const fitted = fitPanelWidths(1000, { left: 900, right: 280 });
expect(fitted.left).toBe(354);
expect(fitted.preview).toBe(MIN_PREVIEW_W);
});
it("survives a viewport narrower than the preview floor alone", () => {
const fitted = fitAt(300);
expect(fitted.preview).toBeGreaterThanOrEqual(0);
expect(Number.isNaN(fitted.preview)).toBe(false);
expect(fitted.left).toBe(RAIL_W);
});
it("returns preferences untouched before the shell is measured", () => {
const preferred = { left: 320, right: 400 };
expect(fitPanelWidths(0, preferred)).toMatchObject({ ...preferred, preview: 0 });
expect(fitPanelWidths(Number.NaN, preferred)).toMatchObject(preferred);
});
it("is idempotent, so a resize loop cannot oscillate", () => {
for (const vw of [1512, 1100, 860, 760, 560]) {
const once = fitPanelWidths(vw, defaultPanelWidths(vw));
const twice = fitPanelWidths(vw, once);
expect(twice).toEqual(once);
}
});
});
describe("fitTimelineHeight", () => {
it("keeps a height that fits", () => {
expect(fitTimelineHeight(717, 429)).toBe(429);
});
it("reclaims height for the preview when the shell shrinks", () => {
// The measured bug: mounted at 760 tall, dragged to 520. Preview was 47px.
expect(fitTimelineHeight(477, 429)).toBe(477 - MIN_PREVIEW_H);
expect(477 - fitTimelineHeight(477, 429)).toBeGreaterThanOrEqual(MIN_PREVIEW_H);
});
it("raises a too-small preference to the timeline minimum", () => {
expect(fitTimelineHeight(717, 10)).toBe(MIN_TIMELINE_H);
});
it("keeps the timeline usable when the shell is shorter than both minimums", () => {
expect(fitTimelineHeight(120, 429)).toBe(MIN_TIMELINE_H);
});
it("returns the preference before the shell is measured", () => {
expect(fitTimelineHeight(0, 429)).toBe(429);
});
it("is idempotent", () => {
const once = fitTimelineHeight(477, 429);
expect(fitTimelineHeight(477, once)).toBe(once);
});
it("would return the preferred height if callers kept one", () => {
// The helper is preference-preserving; the vertical CALLER still stores the
// clamped value, so a shrink-then-grow does not restore the old height the
// way panel widths do. Tracked as a known asymmetry, not fixed here.
const preferred = 429;
expect(fitTimelineHeight(477, preferred)).toBe(277);
expect(fitTimelineHeight(717, preferred)).toBe(429);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* Panel reconciliation for the Studio shell.
*
* The shell is `[sidebar | preview | inspector]` over a full-width timeline.
* Only the preview is flexible (`flex-1 min-w-0`), so without an explicit floor
* it absorbs every squeeze: at a 560px window the old 240/320 panel floors alone
* overflowed the viewport and the preview rendered at 2px, on a *fresh* load.
* The same held vertically once the window was resized after mount.
*
* Every caller (mount, resize, drag, keyboard nudge, preference restore) routes
* through {@link fitPanelWidths} / {@link fitTimelineHeight} so exactly one place
* decides who yields. The rule is: the preview gets its floor first, panels
* shrink toward their minimums, then collapse to rails.
*/
/** Smallest preview box worth editing in. Chosen, not derived — see the plan. */
export const MIN_PREVIEW_W = 360;
export const MIN_PREVIEW_H = 200;
const MIN_LEFT = 200;
const MIN_RIGHT = 280;
/**
* Collapsed sidebar rail, as a layout FOOTPRINT: the `w-10` box (40) plus the
* `mr-0.5` gap (2) it contributes to the flex row. Measured in the browser at a
* 760px window: rail box 40, margin-right 2, preview starts at x=43.
*/
export const RAIL_W = 42;
export const MIN_TIMELINE_H = 100;
/** The two 3px resize seams between the three top-row panels. */
const PANEL_SEAM_W = 6;
/** No single panel may claim more than this share of the window. */
const PANEL_MAX_RATIO = 0.4;
/*
* Thresholds below are DERIVED from the minimums above, not chosen:
*
* MIN_LEFT 200 + MIN_RIGHT 280 + MIN_PREVIEW_W 360 + PANEL_SEAM_W 6 = 846
* -> below ~860 the sidebar can no longer fit at its minimum.
* RAIL_W 40 + MIN_RIGHT 280 + MIN_PREVIEW_W 360 + PANEL_SEAM_W 6 = 686
* -> below ~700 the inspector can no longer fit either.
*
* Change a minimum and move the matching threshold with it.
*/
const RAIL_LEFT_BELOW = 860;
const RAIL_RIGHT_BELOW = 700;
/**
* True when the window is narrow enough to force at least one panel to a rail.
* The sidebar threshold is the wider of the two, so it is the whole condition.
*/
export function railsEngaged(viewportWidth: number): boolean {
return viewportWidth < RAIL_LEFT_BELOW;
}
export interface PanelWidths {
left: number;
right: number;
}
export interface FittedPanelWidths extends PanelWidths {
preview: number;
/**
* Width-driven collapse. Derived per render; NEVER persisted and never written
* back to the user's own collapse preference `leftCollapsed` lives in
* localStorage and `rightCollapsed` is synced into the shareable Studio URL,
* so a ten-second window drag must not rewrite either.
*/
autoCollapseLeft: boolean;
autoCollapseRight: boolean;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* The width each panel wants on a given viewport, before reconciliation.
* Upper bounds (384 / 424) are unchanged from the original implementation, which
* is what keeps windows at or above 1280px byte-identical to before.
*/
export function defaultPanelWidths(viewportWidth: number): PanelWidths {
return {
left: Math.max(MIN_LEFT, Math.min(384, Math.round(viewportWidth * 0.257))),
right: Math.max(MIN_RIGHT, Math.min(424, Math.round(viewportWidth * 0.284))),
};
}
/**
* Resolve preferred panel widths against the viewport, reserving the preview.
*
* Order of yielding: window-relative cap, then the inspector shrinks toward its
* minimum, then the sidebar, then rails. A viewport of 0 (element not measured
* yet) returns the preferences untouched so a pre-measurement frame cannot
* clobber a real preference.
*/
// fallow-ignore-next-line complexity
export function fitPanelWidths(
viewportWidth: number,
preferred: PanelWidths,
/**
* Sides the user explicitly reopened while the window was narrow. A side
* listed here keeps its real width instead of the rail: without this the
* panel would render expanded at 42px and squash its own content.
*/
reopened: { left: boolean; right: boolean } = { left: false, right: false },
): FittedPanelWidths {
const vw = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : 0;
if (vw <= 0) {
return { ...preferred, preview: 0, autoCollapseLeft: false, autoCollapseRight: false };
}
const autoCollapseLeft = vw < RAIL_LEFT_BELOW && !reopened.left;
const autoCollapseRight = vw < RAIL_RIGHT_BELOW && !reopened.right;
const cap = Math.floor(vw * PANEL_MAX_RATIO);
// A railed panel's floor is its rail width; an open panel's floor is its
// minimum usable width. Without this split the squeeze fallback below would
// drive an *expanded* sidebar down to 40px and render its content unusable.
const leftFloor = autoCollapseLeft ? RAIL_W : MIN_LEFT;
const rightFloor = autoCollapseRight ? 0 : MIN_RIGHT;
let left = autoCollapseLeft ? RAIL_W : clamp(preferred.left, MIN_LEFT, Math.max(MIN_LEFT, cap));
let right = autoCollapseRight ? 0 : clamp(preferred.right, MIN_RIGHT, Math.max(MIN_RIGHT, cap));
const budget = vw - MIN_PREVIEW_W - PANEL_SEAM_W;
if (left + right > budget) right = Math.max(rightFloor, budget - left);
if (left + right > budget) left = Math.max(leftFloor, budget - right);
return {
left,
right,
preview: Math.max(0, vw - left - right - PANEL_SEAM_W),
autoCollapseLeft,
autoCollapseRight,
};
}
/**
* Resolve a preferred timeline height against the shell's measured height.
*
* Subsumes the clamping that used to be inlined in TimelineResizeDivider's
* pointer and keyboard handlers and duplicated (mount-only) in NLEContext.
*/
export function fitTimelineHeight(containerHeight: number, preferred: number): number {
const h = Number.isFinite(containerHeight) ? containerHeight : 0;
const wanted = Number.isFinite(preferred) ? preferred : MIN_TIMELINE_H;
if (h <= 0) return Math.max(MIN_TIMELINE_H, wanted);
return clamp(wanted, MIN_TIMELINE_H, Math.max(MIN_TIMELINE_H, h - MIN_PREVIEW_H));
}