From 79b688f204d00e85d4dd111e1e35c8d4dc5703ba Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 15 Jul 2026 14:33:48 -0700 Subject: [PATCH 1/2] fix(studio): show Layers full-height in the flat inspector, not split with Design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat inspector split Layers and Design into a vertically-resizable stacked pair whenever both panes were toggled on, mirroring the legacy panel's layout. For the flat redesign this reads as two competing panels crammed into one column; Layers should always render full-height by itself there instead. Gate the split-view branch behind !STUDIO_FLAT_INSPECTOR_ENABLED so it still applies to the legacy panel, and fall through to Layers rendering alone (the existing `layersPaneOpen` branch already does this — it just never got reached previously because the split check ran first). Also added setExclusiveRightInspectorPane (radio-style: selecting one pane turns the other off) and use it for the Design/Layers tab clicks under the flat flag, since leaving both panes independently toggleable would highlight both tabs as "active" while only one actually renders. New usePanelLayout.test.ts covers both the existing toggle behavior and the new exclusive variant. Full studio suite (2634 tests) green; typecheck/ oxlint/oxfmt clean. --- .../src/components/StudioRightPanel.tsx | 15 +++- .../src/contexts/PanelLayoutContext.tsx | 3 + .../studio/src/hooks/usePanelLayout.test.ts | 70 +++++++++++++++++++ packages/studio/src/hooks/usePanelLayout.ts | 9 +++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 packages/studio/src/hooks/usePanelLayout.test.ts diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index cac09bc94..a63ee3a74 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -12,7 +12,10 @@ import { usePreviewVariablesStore } from "../hooks/previewVariablesStore"; import type { RenderJob } from "./renders/useRenderQueue"; import type { BlockParam } from "@hyperframes/core/registry"; import type { IframeWindow } from "../player/lib/playbackTypes"; -import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability"; +import { + STUDIO_FLAT_INSPECTOR_ENABLED, + STUDIO_INSPECTOR_PANELS_ENABLED, +} from "./editor/manualEditingAvailability"; import type { Composition } from "@hyperframes/sdk"; import type { EditHistoryKind } from "../utils/editHistory"; import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist"; @@ -80,6 +83,7 @@ export function StudioRightPanel({ setRightPanelTab, rightInspectorPanes, toggleRightInspectorPane, + setExclusiveRightInspectorPane, handlePanelResizeStart, handlePanelResizeMove, handlePanelResizeEnd, @@ -223,6 +227,13 @@ export function StudioRightPanel({ setRightPanelTab(pane); return; } + // Flat inspector: Layers always renders full-height by itself (see the + // render branch below), so the two panes are mutually exclusive here — + // otherwise both tabs could show "active" while only one actually shows. + if (STUDIO_FLAT_INSPECTOR_ENABLED) { + setExclusiveRightInspectorPane(pane); + return; + } toggleRightInspectorPane(pane); }; @@ -516,7 +527,7 @@ export function StudioRightPanel({ domEditSaveTimestampRef={domEditSaveTimestampRef} recordEdit={recordEdit} /> - ) : layersPaneOpen && designPaneOpen ? ( + ) : layersPaneOpen && designPaneOpen && !STUDIO_FLAT_INSPECTOR_ENABLED ? (
{ + document.body.innerHTML = ""; +}); + +function renderPanelLayout() { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let current: ReturnType | null = null; + + function Harness() { + current = usePanelLayout(); + return null; + } + + act(() => { + root.render(React.createElement(Harness)); + }); + + return { + getState: (): ReturnType => { + if (!current) throw new Error("usePanelLayout did not render"); + return current; + }, + unmount: () => act(() => root.unmount()), + }; +} + +describe("usePanelLayout — right inspector panes", () => { + it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => { + const harness = renderPanelLayout(); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true }); + + act(() => harness.getState().toggleRightInspectorPane("layers")); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true }); + + harness.unmount(); + }); + + it("toggleRightInspectorPane refuses to turn off the last remaining pane", () => { + const harness = renderPanelLayout(); + act(() => harness.getState().toggleRightInspectorPane("design")); + // Only "design" was on; toggling it off would leave both false — guarded. + expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true }); + harness.unmount(); + }); + + it("setExclusiveRightInspectorPane is radio-style — selecting one turns the other off", () => { + const harness = renderPanelLayout(); + act(() => harness.getState().toggleRightInspectorPane("layers")); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true }); + + act(() => harness.getState().setExclusiveRightInspectorPane("layers")); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false }); + + act(() => harness.getState().setExclusiveRightInspectorPane("design")); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true }); + + harness.unmount(); + }); +}); diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index 384e983f6..2e0420e72 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -96,6 +96,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { }); }, []); + // Radio-style variant for the flat inspector: Layers always renders full- + // height by itself there (never split-shared with Design), so leaving both + // panes independently toggleable would highlight both tabs as "active" + // while only one actually shows. Selecting one turns the other off. + const setExclusiveRightInspectorPane = useCallback((pane: RightInspectorPane) => { + setRightInspectorPanes({ design: pane === "design", layers: pane === "layers" }); + }, []); + return { leftWidth, setLeftWidth, @@ -109,6 +117,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { setRightPanelTab: trackedSetRightPanelTab, rightInspectorPanes, toggleRightInspectorPane, + setExclusiveRightInspectorPane, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove, From fb24ecac22a766f8a405923551ca51b850487cfc Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 15 Jul 2026 20:53:47 -0700 Subject: [PATCH 2/2] fix(studio): make setRightPanelTab itself flat-aware, not just the direct tab click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #2497 (Rames D Jusso) found a real gap: the exclusivity this PR introduced only applied to the direct in-panel tab click, which calls setExclusiveRightInspectorPane. Every OTHER caller that reaches setRightPanelTab("design"|"layers") — element select (useDomSelection.ts), closing block-params (App.tsx), the header Inspector button (StudioHeader.tsx), and even this PR's own "!inspectorTabActive" entry branch in handleInspectorPaneButtonClick — went through trackedSetRightPanelTab's old unconditional additive `{...panes, [tab]: true}`, reproducing the exact "both tabs highlight, only one renders" bug this PR claims to fix. Confirmed via the reviewer's traced repro: fresh boot, click Layers tab while no inspector tab is yet active → rightInspectorPanes ends up {design:true, layers:true}. Fixed at the reviewer's preferred choke point: trackedSetRightPanelTab itself is now flat-aware, applying the same exclusive-radio update setExclusiveRightInspectorPane does whenever STUDIO_FLAT_INSPECTOR_ENABLED is on, falling back to the legacy additive update otherwise. This closes the gap for every current and future caller of setRightPanelTab, not just the one call site this PR touched. New usePanelLayout.test.ts cases pin both directions: setRightPanelTab stays additive under flat=off (legacy split-view behavior unchanged), and enforces exclusivity under flat=on even when called directly (not through the tab-click handler) — using the vi.doMock(manualEditingAvailability) pattern already established in PropertyPanel.test.tsx for flag-dependent module state. Full studio suite (2643 tests) green; typecheck/oxlint/oxfmt clean. --- .../studio/src/hooks/usePanelLayout.test.ts | 54 +++++++++++++++++-- packages/studio/src/hooks/usePanelLayout.ts | 16 +++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/hooks/usePanelLayout.test.ts b/packages/studio/src/hooks/usePanelLayout.test.ts index 2106b02b6..b1ddf2fa1 100644 --- a/packages/studio/src/hooks/usePanelLayout.test.ts +++ b/packages/studio/src/hooks/usePanelLayout.test.ts @@ -2,23 +2,25 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { usePanelLayout } from "./usePanelLayout"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; + vi.doUnmock("../components/editor/manualEditingAvailability"); + vi.resetModules(); }); -function renderPanelLayout() { +function renderPanelLayoutWith(hook: typeof usePanelLayout) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); let current: ReturnType | null = null; function Harness() { - current = usePanelLayout(); + current = hook(); return null; } @@ -35,6 +37,10 @@ function renderPanelLayout() { }; } +function renderPanelLayout() { + return renderPanelLayoutWith(usePanelLayout); +} + describe("usePanelLayout — right inspector panes", () => { it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => { const harness = renderPanelLayout(); @@ -67,4 +73,46 @@ describe("usePanelLayout — right inspector panes", () => { harness.unmount(); }); + + it("setRightPanelTab additively opens a pane when the flat inspector is off (legacy behavior)", async () => { + vi.resetModules(); + vi.doMock("../components/editor/manualEditingAvailability", async () => { + const actual = await vi.importActual< + typeof import("../components/editor/manualEditingAvailability") + >("../components/editor/manualEditingAvailability"); + return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false }; + }); + const { usePanelLayout: usePanelLayoutFlatOff } = await import("./usePanelLayout"); + const harness = renderPanelLayoutWith(usePanelLayoutFlatOff); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true }); + + act(() => harness.getState().setRightPanelTab("layers")); + // Legacy (split-view) behavior: additive, both panes end up open. + expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: 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 () => { + const actual = await vi.importActual< + typeof import("../components/editor/manualEditingAvailability") + >("../components/editor/manualEditingAvailability"); + return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true }; + }); + const { usePanelLayout: usePanelLayoutFlatOn } = await import("./usePanelLayout"); + const harness = renderPanelLayoutWith(usePanelLayoutFlatOn); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true }); + + // Element-select / block-params-close / header Inspector-button callers + // all reach setRightPanelTab directly, not through the in-panel tab + // click's own setExclusiveRightInspectorPane call — this must still + // enforce exclusivity under the flat flag, or both tabs end up + // highlighted while only one pane actually renders. + act(() => harness.getState().setRightPanelTab("layers")); + expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false }); + + harness.unmount(); + }); }); diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index 2e0420e72..df94b244e 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -6,6 +6,7 @@ import type { } from "../utils/studioHelpers"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences"; import { trackStudioEvent } from "../utils/studioTelemetry"; +import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability"; export interface InitialPanelLayoutState { rightCollapsed?: boolean | null; @@ -80,7 +81,20 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { const trackedSetRightPanelTab = useCallback( (tab: RightPanelTab) => { if (tab === "design" || tab === "layers") { - setRightInspectorPanes((panes) => ({ ...panes, [tab]: true })); + // Flat inspector: Layers always renders full-height by itself (see + // StudioRightPanel's render gate), so this MUST land on the same + // radio-style exclusivity setExclusiveRightInspectorPane enforces for + // the direct in-panel tab click — every OTHER path that reaches here + // (element select, closing block-params, the header Inspector + // button, and this function's own callers outside an active + // inspector tab) would otherwise additively leave both panes `true` + // and reproduce the "both tabs highlight, only one renders" bug this + // still-additive branch used to cause under the flat flag. + setRightInspectorPanes( + STUDIO_FLAT_INSPECTOR_ENABLED + ? { design: tab === "design", layers: tab === "layers" } + : (panes) => ({ ...panes, [tab]: true }), + ); } setRightPanelTab(tab); trackStudioEvent("tab_switch", { panel: "right_panel", tab });