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
@@ -3,11 +3,13 @@ import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
STUDIO_STORYBOARD_ENABLED,
} from "./editor/manualEditingAvailability";
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
import { useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useDomEditActionsContext } from "../contexts/DomEditContext";
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
import { trackStudioEvent } from "../utils/studioTelemetry";
export interface StudioHeaderProps {
@@ -141,6 +143,46 @@ function HyperframesLogo() {
);
}
const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [
{ mode: "storyboard", label: "Storyboard" },
{ mode: "timeline", label: "Preview" },
];
/** Segmented control switching the main stage between storyboard and preview. */
function ViewModeToggle() {
const { viewMode, setViewMode } = useViewMode();
return (
<div
className="flex items-center gap-0.5 rounded-md bg-neutral-800 p-0.5"
role="tablist"
aria-label="Studio view"
>
{VIEW_MODE_OPTIONS.map(({ mode, label }) => {
const active = viewMode === mode;
return (
<button
key={mode}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
if (active) return;
trackStudioEvent("view_mode_toggle", { mode });
setViewMode(mode);
}}
className={`rounded px-3 py-1 text-[11px] font-medium transition-colors ${
active ? "bg-neutral-200 text-neutral-900" : "text-neutral-400 hover:text-neutral-200"
}`}
>
{label}
</button>
);
})}
</div>
);
}
// fallow-ignore-next-line complexity
export function StudioHeader({
captureFrameHref,
captureFrameFilename,
@@ -164,6 +206,8 @@ export function StudioHeader({
</span>
<span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
</div>
{/* Center: storyboard / preview toggle (flag-gated) */}
{STUDIO_STORYBOARD_ENABLED && <ViewModeToggle />}
{/* Right: toolbar buttons */}
<div className="flex items-center gap-1.5">
<button
@@ -0,0 +1,70 @@
import type { ComponentProps } from "react";
import { LintModal } from "./LintModal";
import { AskAgentModal } from "./AskAgentModal";
import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
import { StudioToast } from "./StudioToast";
import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
import type { useDomEditSession } from "../hooks/useDomEditSession";
import type { useToast } from "../hooks/useToast";
type LintFindings = ComponentProps<typeof LintModal>["findings"];
export interface StudioOverlaysProps {
projectId: string;
lintModal: LintFindings | null;
closeLintModal: () => void;
consoleErrors: LintFindings | null;
clearConsoleErrors: () => void;
domEditSession: ReturnType<typeof useDomEditSession>;
activeCompPath: string | null;
dragOverlayActive: boolean;
appToast: ReturnType<typeof useToast>["appToast"];
dismissToast: () => void;
}
/**
* Floating overlays for the studio shell: lint / console-error modals, the
* ask-agent modal, the global drag overlay, and the toast. Extracted from
* `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
*/
// fallow-ignore-next-line complexity
export function StudioOverlays({
projectId,
lintModal,
closeLintModal,
consoleErrors,
clearConsoleErrors,
domEditSession,
activeCompPath,
dragOverlayActive,
appToast,
dismissToast,
}: StudioOverlaysProps) {
return (
<>
{lintModal !== null && (
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
)}
{consoleErrors !== null && consoleErrors.length > 0 && (
<LintModal findings={consoleErrors} projectId={projectId} onClose={clearConsoleErrors} />
)}
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
<AskAgentModal
selectionLabel={domEditSession.domEditSelection.label}
contextPreview={buildAgentContextPreview(domEditSession.domEditSelection, activeCompPath)}
anchorPoint={domEditSession.agentModalAnchorPoint}
onSubmit={domEditSession.handleAgentModalSubmit}
onClose={() => {
domEditSession.setAgentModalOpen(false);
domEditSession.setAgentPromptSelectionContext(undefined);
domEditSession.setAgentModalAnchorPoint(null);
}}
/>
)}
{dragOverlayActive && <StudioGlobalDragOverlay />}
{appToast && (
<StudioToast message={appToast.message} tone={appToast.tone} onDismiss={dismissToast} />
)}
</>
);
}
@@ -82,6 +82,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
true,
);
// Storyboard view: a top-level, toggleable view that renders STORYBOARD.md as a
// contact sheet of live HTML frame tiles, replacing the timeline/preview stage.
// Opt-in / off by default until the experience is ready for broad exposure.
// VITE_STUDIO_ENABLE_STORYBOARD=1 npx hyperframes preview
export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_STORYBOARD", "VITE_STUDIO_STORYBOARD_ENABLED"],
false,
);
// When disabled (the default), drag/resize/rotate commits always take the CSS
// persist path instead of being intercepted into GSAP script keyframe
// mutations. The keyframe intercept rewrites timeline tweens from drag
@@ -0,0 +1,44 @@
import type { StoryboardGlobals } from "@hyperframes/core/storyboard";
export interface StoryboardDirectionProps {
globals: StoryboardGlobals;
frameCount: number;
}
/**
* Global direction header: the message/thesis plus arc, audience, and format.
* This is the storyboard's "north star" pulled from the manifest frontmatter.
*/
export function StoryboardDirection({ globals, frameCount }: StoryboardDirectionProps) {
const meta = [
{ label: "Arc", value: globals.arc },
{ label: "Audience", value: globals.audience },
{ label: "Format", value: globals.format },
{ label: "Frames", value: String(frameCount) },
].filter((item): item is { label: string; value: string } => Boolean(item.value));
return (
<header className="border-b border-neutral-800 pb-5">
<div className="text-[11px] font-medium uppercase tracking-wider text-neutral-500">
Storyboard
</div>
{globals.message ? (
<h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-100">
{globals.message}
</h1>
) : (
<h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-400">
Untitled storyboard
</h1>
)}
<dl className="mt-4 flex flex-wrap gap-x-8 gap-y-2">
{meta.map((item) => (
<div key={item.label} className="flex items-baseline gap-2">
<dt className="text-[11px] uppercase tracking-wider text-neutral-500">{item.label}</dt>
<dd className="text-sm text-neutral-300">{item.value}</dd>
</div>
))}
</dl>
</header>
);
}
@@ -0,0 +1,78 @@
import type { ReactNode } from "react";
import { useStoryboard } from "../../hooks/useStoryboard";
import { StoryboardDirection } from "./StoryboardDirection";
export interface StoryboardViewProps {
projectId: string;
}
/**
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
* is `storyboard`. PR2 lands the shell (global direction + states); the frame
* contact-sheet grid arrives in PR3.
*/
// fallow-ignore-next-line complexity
export function StoryboardView({ projectId }: StoryboardViewProps) {
const { data, loading, error } = useStoryboard(projectId);
if (loading) return <StoryboardFrame>{<Message>Loading storyboard</Message>}</StoryboardFrame>;
if (error) {
return (
<StoryboardFrame>
<Message tone="error">Couldnt load the storyboard: {error}</Message>
</StoryboardFrame>
);
}
if (!data) return <StoryboardFrame>{null}</StoryboardFrame>;
if (!data.exists) {
return (
<StoryboardFrame>
<EmptyState path={data.path} />
</StoryboardFrame>
);
}
return (
<StoryboardFrame>
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
{/* PR3: frame contact-sheet grid renders here. */}
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
{data.frames.length} frame{data.frames.length === 1 ? "" : "s"} parsed the contact sheet
renders here next.
</div>
</StoryboardFrame>
);
}
function StoryboardFrame({ children }: { children: ReactNode }) {
return (
<div className="flex-1 min-h-0 overflow-auto bg-neutral-950 text-neutral-200">
<div className="mx-auto max-w-[1400px] px-8 py-8">{children}</div>
</div>
);
}
function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "muted" | "error" }) {
return (
<div
className={`px-6 py-12 text-center text-sm ${
tone === "error" ? "text-red-400" : "text-neutral-500"
}`}
>
{children}
</div>
);
}
function EmptyState({ path }: { path: string }) {
return (
<div className="rounded-lg border border-dashed border-neutral-800 px-6 py-16 text-center">
<h2 className="text-base font-semibold text-neutral-300">No storyboard yet</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-neutral-500">
Add a <code className="rounded bg-neutral-900 px-1 py-0.5 text-neutral-400">{path}</code> at
the project root to plan this video frame by frame. Your agent can create and iterate on it
for you.
</p>
</div>
);
}