diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx
index 9db71a2d23..40842ecc58 100644
--- a/packages/studio/src/components/StudioLeftSidebar.tsx
+++ b/packages/studio/src/components/StudioLeftSidebar.tsx
@@ -38,7 +38,7 @@ export function StudioLeftSidebar({
const {
leftCollapsed,
leftWidth,
- setLeftWidth,
+ adjustPanelWidth,
toggleLeftSidebar,
handlePanelResizeStart,
handlePanelResizeMove,
@@ -173,8 +173,7 @@ export function StudioLeftSidebar({
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const delta = e.key === "ArrowLeft" ? -16 : 16;
- const maxLeft = Math.floor(window.innerWidth * 0.5);
- setLeftWidth(Math.max(160, Math.min(maxLeft, leftWidth + delta)));
+ adjustPanelWidth("left", delta);
}}
>
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx
index 178407f679..fc96e3605c 100644
--- a/packages/studio/src/components/StudioRightPanel.tsx
+++ b/packages/studio/src/components/StudioRightPanel.tsx
@@ -96,7 +96,7 @@ export function StudioRightPanel({
}: StudioRightPanelProps) {
const {
rightWidth,
- setRightWidth,
+ adjustPanelWidth,
rightPanelTab,
setRightPanelTab,
rightInspectorPanes,
@@ -464,7 +464,7 @@ export function StudioRightPanel({
e.preventDefault();
// Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
const delta = e.key === "ArrowLeft" ? 16 : -16;
- setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
+ adjustPanelWidth("right", delta);
}}
>
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
@@ -473,7 +473,7 @@ export function StudioRightPanel({
{captionEditMode ? (
diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx
index 968fcafb75..5bdece6459 100644
--- a/packages/studio/src/components/nle/NLEContext.tsx
+++ b/packages/studio/src/components/nle/NLEContext.tsx
@@ -19,7 +19,7 @@ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
// Timeline gets a generous default height so the preview isn't oversized and the
// tracks have room to breathe (CapCut-style). Users can still drag the divider.
-const DEFAULT_TIMELINE_H = 340;
+const DEFAULT_TIMELINE_H = 360;
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
return compositionLoading;
diff --git a/packages/studio/src/contexts/PanelLayoutContext.tsx b/packages/studio/src/contexts/PanelLayoutContext.tsx
index 67ed23da4f..b696da0a8b 100644
--- a/packages/studio/src/contexts/PanelLayoutContext.tsx
+++ b/packages/studio/src/contexts/PanelLayoutContext.tsx
@@ -14,9 +14,8 @@ export function usePanelLayoutContext(): PanelLayoutValue {
export function PanelLayoutProvider({
value: {
leftWidth,
- setLeftWidth,
rightWidth,
- setRightWidth,
+ adjustPanelWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
@@ -39,9 +38,8 @@ export function PanelLayoutProvider({
const stable = useMemo
(
() => ({
leftWidth,
- setLeftWidth,
rightWidth,
- setRightWidth,
+ adjustPanelWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
@@ -58,9 +56,8 @@ export function PanelLayoutProvider({
}),
[
leftWidth,
- setLeftWidth,
rightWidth,
- setRightWidth,
+ adjustPanelWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
diff --git a/packages/studio/src/hooks/usePanelLayout.test.ts b/packages/studio/src/hooks/usePanelLayout.test.ts
index b1ddf2fa17..0a3d18a7c3 100644
--- a/packages/studio/src/hooks/usePanelLayout.test.ts
+++ b/packages/studio/src/hooks/usePanelLayout.test.ts
@@ -2,11 +2,30 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import { usePanelLayout } from "./usePanelLayout";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+beforeEach(() => {
+ const entries = new Map();
+ Object.defineProperty(window, "localStorage", {
+ configurable: true,
+ value: {
+ get length() {
+ return entries.size;
+ },
+ clear: () => entries.clear(),
+ getItem: (key: string) => entries.get(key) ?? null,
+ key: (index: number) => Array.from(entries.keys())[index] ?? null,
+ removeItem: (key: string) => entries.delete(key),
+ setItem: (key: string, value: string) => entries.set(key, value),
+ } satisfies Storage,
+ });
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1496 });
+});
+
afterEach(() => {
document.body.innerHTML = "";
vi.doUnmock("../components/editor/manualEditingAvailability");
@@ -42,6 +61,54 @@ function renderPanelLayout() {
}
describe("usePanelLayout — right inspector panes", () => {
+ it("opens Design with the intended viewport-scaled panel widths", () => {
+ const harness = renderPanelLayout();
+
+ expect(harness.getState()).toMatchObject({
+ leftWidth: 384,
+ rightWidth: 424,
+ rightCollapsed: false,
+ rightPanelTab: "design",
+ });
+
+ harness.unmount();
+ });
+
+ it("persists the latest pointer width even before React rerenders", () => {
+ const harness = renderPanelLayout();
+ const state = harness.getState();
+ const target = { setPointerCapture: vi.fn() };
+
+ act(() => {
+ state.handlePanelResizeStart("left", {
+ preventDefault: vi.fn(),
+ target,
+ pointerId: 1,
+ clientX: 100,
+ } as unknown as React.PointerEvent);
+ state.handlePanelResizeMove({ clientX: 140 } as React.PointerEvent);
+ state.handlePanelResizeEnd();
+ });
+
+ expect(harness.getState().leftWidth).toBe(424);
+ expect(readStudioUiPreferences().leftWidth).toBe(424);
+ harness.unmount();
+ });
+
+ it("accumulates and persists rapid keyboard resize steps", () => {
+ const harness = renderPanelLayout();
+ const state = harness.getState();
+
+ act(() => {
+ state.adjustPanelWidth("right", 16);
+ state.adjustPanelWidth("right", 16);
+ });
+
+ expect(harness.getState().rightWidth).toBe(456);
+ expect(readStudioUiPreferences().rightWidth).toBe(456);
+ harness.unmount();
+ });
+
it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
const harness = renderPanelLayout();
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts
index df94b244e2..cc358b6e65 100644
--- a/packages/studio/src/hooks/usePanelLayout.ts
+++ b/packages/studio/src/hooks/usePanelLayout.ts
@@ -13,30 +13,76 @@ export interface InitialPanelLayoutState {
rightPanelTab?: RightPanelTab | null;
}
+type PanelSide = "left" | "right";
+
function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes {
if (tab === "layers") return { layers: true, design: false };
return { layers: false, design: true };
}
+function getInitialPanelWidths(): { left: number; right: number } {
+ const viewportWidth = typeof window === "undefined" ? 1496 : window.innerWidth;
+ 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)));
+ 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)),
+ };
+}
+
+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 [leftWidth, setLeftWidth] = useState(240);
- const [rightWidth, setRightWidth] = useState(400);
+ const [initialPanelWidths] = useState(getInitialPanelWidths);
+ const [leftWidth, setLeftWidth] = useState(initialPanelWidths.left);
+ const [rightWidth, setRightWidth] = useState(initialPanelWidths.right);
+ const panelWidthsRef = useRef(initialPanelWidths);
const [leftCollapsed, setLeftCollapsed] = useState(
() => readStudioUiPreferences().leftCollapsed ?? false,
);
- const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? true);
+ const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? false);
const [rightPanelTab, setRightPanelTab] = useState(
- initialState?.rightPanelTab ?? "renders",
+ initialState?.rightPanelTab ?? "design",
);
const [rightInspectorPanes, setRightInspectorPanes] = useState(() =>
getInitialRightInspectorPanes(initialState?.rightPanelTab),
);
const panelDragRef = useRef<{
- side: "left" | "right";
+ 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);
+ return next;
+ }, []);
+
+ const commitPanelWidth = useCallback(
+ (side: PanelSide, width: number) => {
+ const next = updatePanelWidth(side, Math.round(width));
+ writeStudioUiPreferences(side === "left" ? { leftWidth: next } : { rightWidth: next });
+ },
+ [updatePanelWidth],
+ );
+
+ const adjustPanelWidth = useCallback(
+ (side: PanelSide, delta: number) => {
+ commitPanelWidth(side, panelWidthsRef.current[side] + delta);
+ },
+ [commitPanelWidth],
+ );
+
const toggleLeftSidebar = useCallback(() => {
setLeftCollapsed((collapsed) => {
writeStudioUiPreferences({ leftCollapsed: !collapsed });
@@ -45,38 +91,31 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
});
}, []);
- const handlePanelResizeStart = useCallback(
- (side: "left" | "right", e: React.PointerEvent) => {
- e.preventDefault();
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
- panelDragRef.current = {
- side,
- startX: e.clientX,
- startW: side === "left" ? leftWidth : rightWidth,
- };
+ const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
+ e.preventDefault();
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+ panelDragRef.current = {
+ side,
+ startX: e.clientX,
+ startW: panelWidthsRef.current[side],
+ };
+ }, []);
+
+ const handlePanelResizeMove = useCallback(
+ (e: React.PointerEvent) => {
+ const drag = panelDragRef.current;
+ if (!drag) return;
+ const delta = e.clientX - drag.startX;
+ updatePanelWidth(drag.side, drag.startW + (drag.side === "left" ? delta : -delta));
},
- [leftWidth, rightWidth],
+ [updatePanelWidth],
);
- const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
- const drag = panelDragRef.current;
- if (!drag) return;
- const delta = e.clientX - drag.startX;
- const maxLeft = Math.floor(window.innerWidth * 0.5);
- const newW = Math.max(
- 160,
- Math.min(
- drag.side === "left" ? maxLeft : 600,
- drag.startW + (drag.side === "left" ? delta : -delta),
- ),
- );
- if (drag.side === "left") setLeftWidth(newW);
- else setRightWidth(newW);
- }, []);
-
const handlePanelResizeEnd = useCallback(() => {
+ const side = panelDragRef.current?.side;
+ if (side) commitPanelWidth(side, panelWidthsRef.current[side]);
panelDragRef.current = null;
- }, []);
+ }, [commitPanelWidth]);
const trackedSetRightPanelTab = useCallback(
(tab: RightPanelTab) => {
@@ -120,9 +159,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
return {
leftWidth,
- setLeftWidth,
rightWidth,
- setRightWidth,
+ adjustPanelWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
diff --git a/packages/studio/src/player/components/PlayerControls.test.ts b/packages/studio/src/player/components/PlayerControls.test.ts
deleted file mode 100644
index 84f23ac599..0000000000
--- a/packages/studio/src/player/components/PlayerControls.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { resolveSeekPercent } from "./PlayerControls";
-
-describe("resolveSeekPercent", () => {
- it("returns 0 when the track width is invalid", () => {
- expect(resolveSeekPercent(100, 0, 0)).toBe(0);
- });
-
- it("snaps to the start within the edge threshold", () => {
- expect(resolveSeekPercent(105, 100, 200)).toBe(0);
- });
-
- it("snaps to the end within the edge threshold", () => {
- expect(resolveSeekPercent(298, 100, 200)).toBe(1);
- });
-
- it("preserves the true percent away from the edges", () => {
- expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
- });
-});
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx
index 0ebe6ec2d2..1ac690617b 100644
--- a/packages/studio/src/player/components/PlayerControls.tsx
+++ b/packages/studio/src/player/components/PlayerControls.tsx
@@ -1,15 +1,13 @@
-import { useRef, useCallback, useEffect, memo } from "react";
+import { useRef, useEffect, memo } from "react";
import gsap from "gsap";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
-import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time";
-import { usePlayerStore } from "../store/playerStore";
+import { formatFrameTime, formatTime } from "../lib/time";
+import { liveTime, usePlayerStore } from "../store/playerStore";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { Tooltip } from "../../components/ui";
+import { useMountEffect } from "../../hooks/useMountEffect";
import { ShortcutsPanel } from "./ShortcutsPanel";
import { SpeedMenu } from "./SpeedMenu";
-import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
-
-export { resolveSeekPercent };
/* ── Icon sub-components ─────────────────────────────────────────── */
@@ -78,10 +76,8 @@ const MuteButton = memo(function MuteButton({
disabled={controlsDisabled}
aria-label={label}
aria-pressed={audioMuted}
- className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
- audioMuted
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
- : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
+ className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
+ audioMuted ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
}`}
>