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,72 @@
import { useCallback, useEffect, useState } from "react";
import type {
StoryboardFrame,
StoryboardGlobals,
StoryboardWarning,
} from "@hyperframes/core/storyboard";
import { buildProjectApiPath } from "../utils/projectRouting";
/** A frame as returned by the API: parsed frame + disk-resolution info. */
export interface StoryboardFrameView extends StoryboardFrame {
/** Whether `src` resolves to an existing file inside the project. */
srcExists: boolean;
}
/** Shape of `GET /api/projects/:id/storyboard`. */
export interface StoryboardResponse {
exists: boolean;
path: string;
globals: StoryboardGlobals;
frames: StoryboardFrameView[];
warnings: StoryboardWarning[];
}
export interface UseStoryboardResult {
data: StoryboardResponse | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/**
* Load the parsed storyboard manifest for a project. Markdown stays canonical on
* disk; this fetches the server-derived JSON the storyboard view renders.
*/
export function useStoryboard(projectId: string | null): UseStoryboardResult {
const [data, setData] = useState<StoryboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
useEffect(() => {
if (!projectId) return;
let cancelled = false;
setLoading(true);
setError(null);
// Route through buildProjectApiPath so the (URL-derived) projectId is encoded
// into the path rather than interpolated raw (CodeQL js/client-side-request-forgery).
fetch(buildProjectApiPath(projectId, "/storyboard"))
.then((res) => {
if (!res.ok) throw new Error(`storyboard request failed: ${res.status}`);
return res.json() as Promise<StoryboardResponse>;
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : "failed to load storyboard");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId, reloadKey]);
return { data, loading, error, reload };
}