fix(studio): hide the Slideshow tab and panel for non-slideshow compositions

The Slideshow tab rendered unconditionally, showing the branching editor
for any composition regardless of whether it was actually a slideshow —
a plain video comp offered a tab with nothing meaningful to edit.

Gate it on the composition carrying the slideshow JSON island
(<script type="application/hyperframes-slideshow+json">), the same
definitive marker the CLI's `present` command already requires (it
refuses to run without one). Presence-only, not full manifest
validation, so a malformed island still surfaces the tab rather than
disappearing entirely. Also bounce rightPanelTab off "slideshow" to
"renders" if the active composition stops being a slideshow while that
tab is open (e.g. switching files), since its button would otherwise
vanish with no way back to it.

Extracted the gating + scene-list derivation into useSlideshowTabState
to keep StudioRightPanel.tsx under the 600-LOC gate.
This commit is contained in:
Vance Ingalls
2026-07-17 00:37:39 -07:00
parent 8eccc6e9a3
commit 67696cd8de
3 changed files with 167 additions and 26 deletions
@@ -1,17 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react"; import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
import { PropertyPanel } from "./editor/PropertyPanel"; import { PropertyPanel } from "./editor/PropertyPanel";
import { LayersPanel } from "./editor/LayersPanel"; import { LayersPanel } from "./editor/LayersPanel";
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
import { BlockParamsPanel } from "./editor/BlockParamsPanel"; import { BlockParamsPanel } from "./editor/BlockParamsPanel";
import { RenderQueue } from "./renders/RenderQueue"; import { RenderQueue } from "./renders/RenderQueue";
import { SlideshowPanel } from "./panels/SlideshowPanel"; import { SlideshowPanel } from "./panels/SlideshowPanel";
import type { SceneInfo } from "./panels/SlideshowPanel";
import { VariablesPanel } from "./panels/VariablesPanel"; import { VariablesPanel } from "./panels/VariablesPanel";
import { PanelTabButton } from "./PanelTabButton"; import { PanelTabButton } from "./PanelTabButton";
import { usePreviewVariablesStore } from "../hooks/previewVariablesStore"; import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
import type { RenderJob } from "./renders/useRenderQueue"; import type { RenderJob } from "./renders/useRenderQueue";
import type { BlockParam } from "@hyperframes/core/registry"; import type { BlockParam } from "@hyperframes/core/registry";
import type { IframeWindow } from "../player/lib/playbackTypes";
import { import {
STUDIO_FLAT_INSPECTOR_ENABLED, STUDIO_FLAT_INSPECTOR_ENABLED,
STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_INSPECTOR_PANELS_ENABLED,
@@ -19,6 +17,7 @@ import {
import type { Composition } from "@hyperframes/sdk"; import type { Composition } from "@hyperframes/sdk";
import type { EditHistoryKind } from "../utils/editHistory"; import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist"; import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider"; import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
@@ -168,6 +167,7 @@ export function StudioRightPanel({
readProjectFile, readProjectFile,
writeProjectFile, writeProjectFile,
fileTree, fileTree,
editingFile,
} = useFileManagerContext(); } = useFileManagerContext();
// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately, // Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
@@ -216,22 +216,13 @@ export function StudioRightPanel({
const renderJobs = renderQueue.jobs as RenderJob[]; const renderJobs = renderQueue.jobs as RenderJob[];
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers"; const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
// Derive scene list from the live clip manifest in the preview iframe. const { isSlideshowComposition, slideshowScenes } = useSlideshowTabState({
// fallow-ignore-next-line complexity editingFileContent: editingFile?.content,
const slideshowScenes = useMemo<SceneInfo[]>(() => { previewIframeRef,
try { refreshKey,
const win = previewIframeRef.current?.contentWindow as IframeWindow | null; rightPanelTab,
return (win?.__clipManifest?.scenes ?? []).map((s) => ({ setRightPanelTab,
id: s.id, });
label: s.label,
start: s.start,
duration: s.duration,
}));
} catch {
return [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIframeRef, rightPanelTab, refreshKey]);
const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive; const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive;
const layersPaneOpen = const layersPaneOpen =
inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED; inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED;
@@ -506,12 +497,14 @@ export function StudioRightPanel({
active={rightPanelTab === "renders"} active={rightPanelTab === "renders"}
onClick={() => setRightPanelTab("renders")} onClick={() => setRightPanelTab("renders")}
/> />
<PanelTabButton {isSlideshowComposition && (
label="Slideshow" <PanelTabButton
tooltip="Slideshow branching editor" label="Slideshow"
active={rightPanelTab === "slideshow"} tooltip="Slideshow branching editor"
onClick={() => setRightPanelTab("slideshow")} active={rightPanelTab === "slideshow"}
/> onClick={() => setRightPanelTab("slideshow")}
/>
)}
<PanelTabButton <PanelTabButton
label="Variables" label="Variables"
tooltip="Template variables — declare, preview with values" tooltip="Template variables — declare, preview with values"
@@ -528,7 +521,7 @@ export function StudioRightPanel({
compositionPath={activeBlockParams.compositionPath} compositionPath={activeBlockParams.compositionPath}
onClose={onCloseBlockParams ?? (() => {})} onClose={onCloseBlockParams ?? (() => {})}
/> />
) : rightPanelTab === "slideshow" ? ( ) : rightPanelTab === "slideshow" && isSlideshowComposition ? (
<SlideshowPanel <SlideshowPanel
scenes={slideshowScenes} scenes={slideshowScenes}
onPersist={onPersistSlideshow} onPersist={onPersistSlideshow}
@@ -0,0 +1,89 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { useSlideshowTabState } from "./useSlideshowTabState";
import type { RightPanelTab } from "../utils/studioHelpers";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const SLIDESHOW_HTML = `<html><body><script type="application/hyperframes-slideshow+json">{"slides":[]}</script></body></html>`;
const PLAIN_HTML = `<html><body><div id="title">hi</div></body></html>`;
afterEach(() => {
document.body.innerHTML = "";
});
function renderHook(params: {
editingFileContent: string | null | undefined;
rightPanelTab: RightPanelTab;
}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const setRightPanelTabCalls: RightPanelTab[] = [];
let current: ReturnType<typeof useSlideshowTabState> | null = null;
function Harness() {
current = useSlideshowTabState({
editingFileContent: params.editingFileContent,
previewIframeRef: { current: null },
refreshKey: 0,
rightPanelTab: params.rightPanelTab,
setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
});
return null;
}
act(() => {
root.render(React.createElement(Harness));
});
return {
getState: (): ReturnType<typeof useSlideshowTabState> => {
if (!current) throw new Error("useSlideshowTabState did not render");
return current;
},
setRightPanelTabCalls,
unmount: () => act(() => root.unmount()),
};
}
describe("useSlideshowTabState", () => {
it("detects a slideshow composition via the JSON island", () => {
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "design" });
expect(harness.getState().isSlideshowComposition).toBe(true);
harness.unmount();
});
it("reports false for a plain (non-slideshow) composition", () => {
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "design" });
expect(harness.getState().isSlideshowComposition).toBe(false);
harness.unmount();
});
it("reports false when there is no editing file yet", () => {
const harness = renderHook({ editingFileContent: undefined, rightPanelTab: "design" });
expect(harness.getState().isSlideshowComposition).toBe(false);
harness.unmount();
});
it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
harness.unmount();
});
it("does not bounce when the composition is a slideshow", () => {
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
expect(harness.setRightPanelTabCalls).toEqual([]);
harness.unmount();
});
it("does not bounce a tab other than 'slideshow'", () => {
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
expect(harness.setRightPanelTabCalls).toEqual([]);
harness.unmount();
});
});
@@ -0,0 +1,59 @@
import { useEffect, useMemo, type MutableRefObject } from "react";
import { slideshowIslandRegex } from "@hyperframes/core/slideshow";
import type { SceneInfo } from "../components/panels/SlideshowPanel";
import type { IframeWindow } from "../player/lib/playbackTypes";
import type { RightPanelTab } from "../utils/studioHelpers";
/**
* Derives whether the currently-edited composition is a slideshow (carries
* the slideshow JSON island — the same definitive marker the CLI's `present`
* command requires; it refuses to run without one) and the live scene list
* for the Slideshow panel, and bounces `rightPanelTab` off "slideshow" the
* moment it stops applying (e.g. the user switches to a non-slideshow file
* while that tab was open) so the panel never shows a dangling active tab
* whose button is no longer even rendered.
*
* Extracted from StudioRightPanel to keep that file under the 600-LOC gate.
*/
export function useSlideshowTabState(params: {
editingFileContent: string | null | undefined;
previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
refreshKey: number;
rightPanelTab: RightPanelTab;
setRightPanelTab: (tab: RightPanelTab) => void;
}): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } {
const { editingFileContent, previewIframeRef, refreshKey, rightPanelTab, setRightPanelTab } =
params;
// Presence-only (not full manifest validation): a malformed island should
// still surface the Slideshow tab so the user can see/fix it, rather than
// making the whole panel disappear.
const isSlideshowComposition = useMemo(
() => Boolean(editingFileContent && slideshowIslandRegex("i").test(editingFileContent)),
[editingFileContent],
);
// Derive scene list from the live clip manifest in the preview iframe.
const slideshowScenes = useMemo<SceneInfo[]>(() => {
try {
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
return (win?.__clipManifest?.scenes ?? []).map((s) => ({
id: s.id,
label: s.label,
start: s.start,
duration: s.duration,
}));
} catch {
return [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIframeRef, rightPanelTab, refreshKey]);
useEffect(() => {
if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
setRightPanelTab("renders");
}
}, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);
return { isSlideshowComposition, slideshowScenes };
}