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 = ""; + vi.doUnmock("../components/editor/manualEditingAvailability"); + vi.resetModules(); +}); + +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 = hook(); + 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()), + }; +} + +function renderPanelLayout() { + return renderPanelLayoutWith(usePanelLayout); +} + +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(); + }); + + 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 384e983f6..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 }); @@ -96,6 +110,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 +131,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { setRightPanelTab: trackedSetRightPanelTab, rightInspectorPanes, toggleRightInspectorPane, + setExclusiveRightInspectorPane, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove,