feat(studio): storyboard empty state with agent copy-prompt and skeleton preview (#2091)

The storyboard view's empty state was a single "no storyboard yet" line. Add a
copy-to-clipboard prompt box (a ready-to-paste handoff prompt carrying the
canonical STORYBOARD.md frontmatter + per-frame format) so users can hand it to
their coding agent, plus a faded skeleton of the contact-sheet grid so landing
on an empty board previews what a filled one looks like instead of a dead end.
This commit is contained in:
Miguel Ángel
2026-07-08 22:32:40 -04:00
committed by GitHub
parent 623f91d6a1
commit 0ac000181e
@@ -1,5 +1,7 @@
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { Copy, Check } from "@phosphor-icons/react";
import { useStoryboard } from "../../hooks/useStoryboard";
import { copyTextToClipboard } from "../../utils/clipboard";
import { Button } from "../ui/Button";
import { StoryboardLoaded } from "./StoryboardLoaded";
@@ -70,15 +72,91 @@ function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "mu
);
}
function handoffPrompt(path: string): string {
return `Create a \`${path}\` at the project root to plan this video frame by frame.
Use this format:
---
format: 1920x1080
message: <the one-line takeaway of the video>
arc: <the narrative shape, e.g. Problem → Solution>
audience: <who it's for>
---
## Frame 1 — <title>
- duration: 5s
- transition_in: crossfade
- status: planned
- src: compositions/frames/01-<slug>.html
<A sentence or two: what's on screen and what the narration says.>
Add one \`## Frame N\` section per beat. Keep the arc tight.`;
}
function EmptyState({ path }: { path: string }) {
const [copied, setCopied] = useState(false);
const prompt = handoffPrompt(path);
const onCopy = async () => {
if (await copyTextToClipboard(prompt)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}
};
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>
<div className="rounded-lg border border-dashed border-neutral-800 px-6 py-10 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. Hand this prompt to your coding
agent to scaffold it.
</p>
<div className="mx-auto mt-6 max-w-2xl overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900 text-left">
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
<span className="font-mono text-xs text-neutral-500">Prompt for your agent</span>
<Button
size="sm"
variant="secondary"
onClick={onCopy}
icon={copied ? <Check size={14} /> : <Copy size={14} />}
>
{copied ? "Copied" : "Copy prompt"}
</Button>
</div>
<pre className="max-h-64 overflow-auto px-4 py-3 font-mono text-xs leading-relaxed text-neutral-400 whitespace-pre-wrap">
{prompt}
</pre>
</div>
</div>
<SkeletonPreview />
</div>
);
}
/** Faded placeholder of a filled board so landing here isn't a dead end —
* it previews the contact-sheet layout {@link StoryboardGrid} renders. */
function SkeletonPreview() {
return (
<div aria-hidden="true" className="mt-10 select-none opacity-40">
<div className="mb-4 text-center text-xs uppercase tracking-wide text-neutral-600">
Preview
</div>
<div className="grid gap-x-6 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
{[0, 1, 2].map((i) => (
<div key={i} className="rounded-lg border border-neutral-800 bg-neutral-900/40 p-3">
<div className="aspect-video w-full rounded bg-neutral-800/60" />
<div className="mt-3 h-3 w-2/3 rounded bg-neutral-800/60" />
<div className="mt-2 h-2.5 w-full rounded bg-neutral-800/40" />
<div className="mt-1.5 h-2.5 w-4/5 rounded bg-neutral-800/40" />
</div>
))}
</div>
</div>
);
}