feat(studio): storyboard view-mode toggle and shell (#1529)

Second PR in the Studio storyboarding stack. Adds the top-level toggle
between the storyboard and the timeline/preview stage, behind the flag.

- STUDIO_STORYBOARD_ENABLED flag (VITE_STUDIO_ENABLE_STORYBOARD, default
  off) now gates the UI.
- ViewModeContext: timeline|storyboard state mirrored to the ?view= query
  param, so it survives reloads and an agent can deep-link ?view=storyboard.
- Segmented Storyboard|Preview control in StudioHeader (flag-gated).
- StudioApp swaps the whole center stage for a full-width StoryboardView
  when storyboard mode is active.
- useStoryboard hook + StoryboardView shell: global-direction header,
  loading/error/empty states. The frame contact-sheet grid lands in PR3.
- Extract StudioOverlays from App.tsx to stay within the 600-line studio
  decomposition budget.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-17 13:06:49 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8a13a07074
commit 195d7aa7bd
8 changed files with 526 additions and 123 deletions
@@ -0,0 +1,93 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
/**
* Top-level Studio view mode.
*
* `timeline` is the existing NLE/preview stage. `storyboard` replaces that stage
* with the storyboard contact sheet. The mode is mirrored to the `?view=` query
* param so it survives reloads and — importantly — so an agent can deep-link the
* user straight into the storyboard by navigating the tab to `?view=storyboard`.
*/
export type StudioViewMode = "timeline" | "storyboard";
const VIEW_QUERY_PARAM = "view";
function readViewModeFromUrl(): StudioViewMode {
if (typeof window === "undefined") return "timeline";
return new URLSearchParams(window.location.search).get(VIEW_QUERY_PARAM) === "storyboard"
? "storyboard"
: "timeline";
}
function writeViewModeToUrl(mode: StudioViewMode): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (mode === "storyboard") {
url.searchParams.set(VIEW_QUERY_PARAM, "storyboard");
} else {
url.searchParams.delete(VIEW_QUERY_PARAM);
}
window.history.replaceState(window.history.state, "", url);
}
export interface ViewModeValue {
viewMode: StudioViewMode;
setViewMode: (mode: StudioViewMode) => void;
}
/**
* Owns the view-mode state. When `enabled` is false (storyboard flag off) the
* mode is pinned to `timeline` and the URL is left untouched, so the feature is
* fully inert until the flag is on.
*/
export function useViewModeState(enabled: boolean): ViewModeValue {
const [viewMode, setMode] = useState<StudioViewMode>(() =>
enabled ? readViewModeFromUrl() : "timeline",
);
// Reflect back/forward navigation and agent-driven URL changes.
useEffect(() => {
if (!enabled) return;
const onPopState = () => setMode(readViewModeFromUrl());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [enabled]);
const setViewMode = useCallback(
(mode: StudioViewMode) => {
if (!enabled) return;
setMode(mode);
writeViewModeToUrl(mode);
},
[enabled],
);
const effectiveMode = enabled ? viewMode : "timeline";
return useMemo(() => ({ viewMode: effectiveMode, setViewMode }), [effectiveMode, setViewMode]);
}
const ViewModeContext = createContext<ViewModeValue | null>(null);
export function useViewMode(): ViewModeValue {
const ctx = useContext(ViewModeContext);
if (!ctx) throw new Error("useViewMode must be used within ViewModeProvider");
return ctx;
}
export function ViewModeProvider({
value,
children,
}: {
value: ViewModeValue;
children: ReactNode;
}) {
return <ViewModeContext value={value}>{children}</ViewModeContext>;
}