@hyperframes/studio 0.6.110 → 0.6.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/index-B7S86vK1.js +370 -0
  2. package/dist/assets/index-DP8pPIk2.css +1 -0
  3. package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
  4. package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +8 -5
  7. package/src/App.tsx +133 -141
  8. package/src/components/StudioHeader.tsx +44 -0
  9. package/src/components/StudioOverlays.tsx +70 -0
  10. package/src/components/editor/SourceEditor.tsx +19 -16
  11. package/src/components/editor/manualEditingAvailability.ts +25 -7
  12. package/src/components/storyboard/FramePoster.tsx +56 -0
  13. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  14. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  15. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  16. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  17. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  18. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  19. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  20. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  21. package/src/components/storyboard/StoryboardView.tsx +78 -0
  22. package/src/components/storyboard/frameStatus.ts +36 -0
  23. package/src/contexts/ViewModeContext.tsx +98 -0
  24. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  25. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  26. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  27. package/src/hooks/timelineEditingHelpers.ts +2 -0
  28. package/src/hooks/useAppHotkeys.ts +20 -0
  29. package/src/hooks/useDomEditCommits.ts +43 -14
  30. package/src/hooks/useDomEditSession.test.ts +41 -0
  31. package/src/hooks/useDomEditSession.ts +38 -6
  32. package/src/hooks/useDomGeometryCommits.ts +5 -9
  33. package/src/hooks/useElementLifecycleOps.ts +30 -0
  34. package/src/hooks/useGsapAnimationOps.ts +78 -51
  35. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  36. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  37. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  38. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  39. package/src/hooks/useGsapScriptCommits.ts +95 -40
  40. package/src/hooks/useSdkSession.test.ts +12 -0
  41. package/src/hooks/useSdkSession.ts +91 -25
  42. package/src/hooks/useStoryboard.ts +80 -0
  43. package/src/hooks/useTimelineEditing.ts +163 -68
  44. package/src/utils/gsapSoftReload.ts +14 -0
  45. package/src/utils/sdkCutover.gate.test.ts +61 -0
  46. package/src/utils/sdkCutover.test.ts +839 -0
  47. package/src/utils/sdkCutover.ts +465 -0
  48. package/src/utils/sdkOpMapping.ts +43 -0
  49. package/src/utils/sdkResolverShadow.test.ts +366 -0
  50. package/src/utils/sdkResolverShadow.ts +313 -0
  51. package/dist/assets/index-1C8oFiPi.css +0 -1
  52. package/dist/assets/index-D-3sGz65.js +0 -296
  53. package/src/utils/sdkShadow.test.ts +0 -606
  54. package/src/utils/sdkShadow.ts +0 -517
  55. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  56. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  57. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  58. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -0,0 +1,88 @@
1
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
2
+ import { FramePoster, posterTime } from "./FramePoster";
3
+ import { FRAME_STATUS_META } from "./frameStatus";
4
+
5
+ export interface StoryboardFrameTileProps {
6
+ projectId: string;
7
+ frame: StoryboardFrameView;
8
+ /** Open this frame in the full-area focus view. */
9
+ onOpen: (index: number) => void;
10
+ }
11
+
12
+ function firstLine(text: string): string {
13
+ return (
14
+ text
15
+ .split("\n")
16
+ .find((line) => line.trim().length > 0)
17
+ ?.trim() ?? ""
18
+ );
19
+ }
20
+
21
+ function placeholderMessage(frame: StoryboardFrameView): string {
22
+ if (frame.status === "outline") return "Not built yet";
23
+ if (frame.src && !frame.srcExists) return "Frame file not found";
24
+ return "No preview";
25
+ }
26
+
27
+ /** A single contact-sheet tile: poster preview + its metadata. Click to focus. */
28
+ // fallow-ignore-next-line complexity
29
+ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFrameTileProps) {
30
+ const meta = FRAME_STATUS_META[frame.status];
31
+ const renderable = frame.srcExists && frame.status !== "outline";
32
+ const title = frame.title ?? `Frame ${frame.index}`;
33
+ const sceneLine = frame.scene ?? firstLine(frame.narrative);
34
+
35
+ return (
36
+ <article className="min-w-0">
37
+ <button
38
+ type="button"
39
+ onClick={() => onOpen(frame.index)}
40
+ className="group relative block aspect-video w-full overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900 text-left transition-colors hover:border-neutral-600"
41
+ >
42
+ <div className="absolute left-2 top-2 z-10 flex h-6 min-w-6 items-center justify-center rounded-full bg-black/70 px-1.5 text-xs font-semibold text-neutral-100">
43
+ {frame.number ?? frame.index}
44
+ </div>
45
+ {renderable && frame.src ? (
46
+ <FramePoster
47
+ projectId={projectId}
48
+ src={frame.src}
49
+ seconds={posterTime(frame)}
50
+ title={title}
51
+ />
52
+ ) : (
53
+ <FrameTilePlaceholder frame={frame} />
54
+ )}
55
+ </button>
56
+
57
+ <div className="mt-2 flex items-start justify-between gap-2">
58
+ <h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
59
+ <span
60
+ title={meta.tooltip}
61
+ aria-label={`Status: ${meta.label} — ${meta.tooltip}`}
62
+ className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${meta.chipClass}`}
63
+ >
64
+ {meta.label}
65
+ </span>
66
+ </div>
67
+ {sceneLine && <p className="mt-0.5 line-clamp-2 text-xs text-neutral-400">{sceneLine}</p>}
68
+ {frame.voiceover && (
69
+ <p className="mt-1 line-clamp-2 text-xs italic text-neutral-500">
70
+ <span aria-hidden="true">🎙 </span>“{frame.voiceover}”
71
+ </p>
72
+ )}
73
+ <div className="mt-1 flex gap-3 text-[11px] text-neutral-600">
74
+ {frame.duration && <span>{frame.duration}</span>}
75
+ {frame.transitionIn && <span>↘ {frame.transitionIn}</span>}
76
+ </div>
77
+ </article>
78
+ );
79
+ }
80
+
81
+ function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
82
+ return (
83
+ <div className="flex h-full w-full flex-col items-center justify-center gap-1 border border-dashed border-neutral-700 bg-neutral-950 text-center">
84
+ <span className="text-xs font-medium text-neutral-400">{frame.title ?? "Outline"}</span>
85
+ <span className="text-[11px] text-neutral-600">{placeholderMessage(frame)}</span>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,33 @@
1
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
2
+ import { StoryboardFrameTile } from "./StoryboardFrameTile";
3
+
4
+ export interface StoryboardGridProps {
5
+ projectId: string;
6
+ frames: StoryboardFrameView[];
7
+ /** Open a frame in the full-area focus view. */
8
+ onOpenFrame: (index: number) => void;
9
+ }
10
+
11
+ /** The contact sheet: ordered frame tiles in a responsive grid. */
12
+ export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGridProps) {
13
+ if (frames.length === 0) {
14
+ return (
15
+ <div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
16
+ This storyboard has no frames yet.
17
+ </div>
18
+ );
19
+ }
20
+
21
+ return (
22
+ <div className="mt-8 grid gap-x-6 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
23
+ {frames.map((frame) => (
24
+ <StoryboardFrameTile
25
+ key={frame.index}
26
+ projectId={projectId}
27
+ frame={frame}
28
+ onOpen={onOpenFrame}
29
+ />
30
+ ))}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,136 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { StoryboardResponse } from "../../hooks/useStoryboard";
3
+ import { StoryboardDirection } from "./StoryboardDirection";
4
+ import { StoryboardGrid } from "./StoryboardGrid";
5
+ import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
6
+ import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
7
+ import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
8
+ import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
9
+
10
+ type SubView = "board" | "source";
11
+
12
+ export interface StoryboardLoadedProps {
13
+ projectId: string;
14
+ data: StoryboardResponse;
15
+ /** Re-fetch the manifest after a source edit is saved. */
16
+ reload: () => void;
17
+ /** Select a composition in the timeline (used by "Open in Preview"). */
18
+ onSelectComposition: (path: string) => void;
19
+ }
20
+
21
+ function clampIndex(index: number, count: number): number {
22
+ return Math.max(1, Math.min(count, index));
23
+ }
24
+
25
+ /** A storyboard that exists on disk: Board (contact sheet) ↔ Source ↔ frame focus. */
26
+ // fallow-ignore-next-line complexity
27
+ export function StoryboardLoaded({
28
+ projectId,
29
+ data,
30
+ reload,
31
+ onSelectComposition,
32
+ }: StoryboardLoadedProps) {
33
+ const [subView, setSubView] = useState<SubView>("board");
34
+ const [sourceDirty, setSourceDirty] = useState(false);
35
+ const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
36
+ const sourceFiles = useMemo<SourceFile[]>(() => {
37
+ const files: SourceFile[] = [{ path: data.path, label: data.path }];
38
+ if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
39
+ return files;
40
+ // Depend on the stable fields, not the `data.script` object — every reload()
41
+ // produces a fresh object and would needlessly re-create this array.
42
+ }, [data.path, data.script?.path, data.script?.exists]);
43
+
44
+ // Leaving the source editor drops its in-memory buffer; confirm when it's dirty.
45
+ // fallow-ignore-next-line complexity
46
+ const changeSubView = (next: SubView) => {
47
+ if (next === subView) return;
48
+ if (
49
+ subView === "source" &&
50
+ sourceDirty &&
51
+ !window.confirm("Discard unsaved markdown changes?")
52
+ ) {
53
+ return;
54
+ }
55
+ setSubView(next);
56
+ };
57
+
58
+ const focusedFrame =
59
+ focusedIndex != null ? (data.frames.find((f) => f.index === focusedIndex) ?? null) : null;
60
+
61
+ if (focusedFrame) {
62
+ return (
63
+ <StoryboardFrameFocus
64
+ key={focusedFrame.index}
65
+ projectId={projectId}
66
+ storyboardPath={data.path}
67
+ frame={focusedFrame}
68
+ frameCount={data.frames.length}
69
+ onBack={() => setFocusedIndex(null)}
70
+ onNavigate={(delta) =>
71
+ setFocusedIndex(clampIndex(focusedFrame.index + delta, data.frames.length))
72
+ }
73
+ onSaved={reload}
74
+ onSelectComposition={onSelectComposition}
75
+ />
76
+ );
77
+ }
78
+
79
+ return (
80
+ <div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
81
+ <div className="flex items-center border-b border-neutral-800 px-4 py-2">
82
+ <SubViewToggle value={subView} onChange={changeSubView} />
83
+ </div>
84
+ {subView === "board" ? (
85
+ <div className="flex-1 min-h-0 overflow-auto">
86
+ <div className="mx-auto max-w-[1400px] px-8 py-8">
87
+ <StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
88
+ <div className="mt-5">
89
+ <StoryboardStatusLegend />
90
+ </div>
91
+ <StoryboardGrid
92
+ projectId={projectId}
93
+ frames={data.frames}
94
+ onOpenFrame={setFocusedIndex}
95
+ />
96
+ {data.script && <StoryboardScriptPanel script={data.script} />}
97
+ </div>
98
+ </div>
99
+ ) : (
100
+ <StoryboardSourceEditor
101
+ files={sourceFiles}
102
+ onSaved={reload}
103
+ onDirtyChange={setSourceDirty}
104
+ />
105
+ )}
106
+ </div>
107
+ );
108
+ }
109
+
110
+ const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
111
+ { value: "board", label: "Board" },
112
+ { value: "source", label: "Source" },
113
+ ];
114
+
115
+ function SubViewToggle({ value, onChange }: { value: SubView; onChange: (next: SubView) => void }) {
116
+ return (
117
+ <div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5" role="tablist">
118
+ {SUB_VIEWS.map((option) => (
119
+ <button
120
+ key={option.value}
121
+ type="button"
122
+ role="tab"
123
+ aria-selected={value === option.value}
124
+ onClick={() => onChange(option.value)}
125
+ className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
126
+ value === option.value
127
+ ? "bg-neutral-700 text-neutral-100"
128
+ : "text-neutral-400 hover:text-neutral-200"
129
+ }`}
130
+ >
131
+ {option.label}
132
+ </button>
133
+ ))}
134
+ </div>
135
+ );
136
+ }
@@ -0,0 +1,26 @@
1
+ import type { StoryboardScript } from "../../hooks/useStoryboard";
2
+
3
+ export interface StoryboardScriptPanelProps {
4
+ script: StoryboardScript;
5
+ }
6
+
7
+ /**
8
+ * Collapsible view of the companion narration script (SCRIPT.md). The mature
9
+ * pipeline keeps the full voiceover script — voice settings, per-line delivery
10
+ * and timing — in this file; here it's surfaced read-only alongside the frames.
11
+ * (Per-frame VO iteration will live in the frame focus view.)
12
+ */
13
+ export function StoryboardScriptPanel({ script }: StoryboardScriptPanelProps) {
14
+ if (!script.exists) return null;
15
+ return (
16
+ <details className="mt-10 rounded-lg border border-neutral-800 bg-neutral-900/50">
17
+ <summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-neutral-300">
18
+ Narration script
19
+ <span className="ml-2 font-normal text-neutral-500">{script.path}</span>
20
+ </summary>
21
+ <pre className="max-h-[420px] overflow-auto whitespace-pre-wrap border-t border-neutral-800 px-4 py-3 text-xs leading-relaxed text-neutral-400">
22
+ {script.content}
23
+ </pre>
24
+ </details>
25
+ );
26
+ }
@@ -0,0 +1,231 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { marked } from "marked";
3
+ import DOMPurify from "dompurify";
4
+ import { SourceEditor } from "../editor/SourceEditor";
5
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
6
+
7
+ export interface SourceFile {
8
+ path: string;
9
+ label: string;
10
+ }
11
+
12
+ export interface StoryboardSourceEditorProps {
13
+ files: SourceFile[];
14
+ /** Called after a successful save so the Board can re-parse the updated file. */
15
+ onSaved: () => void;
16
+ /** Surfaces unsaved-edit state so the parent can guard the Board↔Source toggle. */
17
+ onDirtyChange?: (dirty: boolean) => void;
18
+ }
19
+
20
+ const DISCARD_PROMPT = "Discard unsaved markdown changes?";
21
+
22
+ interface EditableFile {
23
+ content: string;
24
+ setContent: (next: string) => void;
25
+ dirty: boolean;
26
+ loading: boolean;
27
+ saving: boolean;
28
+ error: string | null;
29
+ save: () => void;
30
+ }
31
+
32
+ /** Load a project file's raw text and track edits + save state via the shared file manager. */
33
+ function useEditableFile(path: string, onSaved: () => void): EditableFile {
34
+ const { readProjectFile, writeProjectFile } = useFileManagerContext();
35
+ const [content, setContent] = useState("");
36
+ const [saved, setSaved] = useState("");
37
+ const [loading, setLoading] = useState(true);
38
+ const [saving, setSaving] = useState(false);
39
+ const [error, setError] = useState<string | null>(null);
40
+
41
+ useEffect(() => {
42
+ if (!path) return;
43
+ let cancelled = false;
44
+ setLoading(true);
45
+ setError(null);
46
+ readProjectFile(path)
47
+ .then((text) => {
48
+ if (cancelled) return;
49
+ setContent(text);
50
+ setSaved(text);
51
+ })
52
+ .catch((err: unknown) => {
53
+ if (!cancelled) setError(err instanceof Error ? err.message : "failed to load file");
54
+ })
55
+ .finally(() => {
56
+ if (!cancelled) setLoading(false);
57
+ });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [path, readProjectFile]);
62
+
63
+ const save = useCallback(() => {
64
+ if (saving) return; // coalesce a fast double Cmd+S into one PUT
65
+ setSaving(true);
66
+ setError(null);
67
+ writeProjectFile(path, content)
68
+ .then(() => {
69
+ setSaved(content);
70
+ onSaved();
71
+ })
72
+ .catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
73
+ .finally(() => setSaving(false));
74
+ }, [writeProjectFile, path, content, onSaved, saving]);
75
+
76
+ return { content, setContent, dirty: content !== saved, loading, saving, error, save };
77
+ }
78
+
79
+ /** Preview links open in a new tab with the `window.opener` back-channel severed. */
80
+ function hardenLinks(node: Element): void {
81
+ if (node.tagName === "A" && node.hasAttribute("href")) {
82
+ node.setAttribute("target", "_blank");
83
+ node.setAttribute("rel", "noopener noreferrer");
84
+ }
85
+ }
86
+
87
+ /** Render markdown to sanitized HTML, debounced so we don't re-parse on every keystroke. */
88
+ function useMarkdownPreview(source: string): string {
89
+ const [debounced, setDebounced] = useState(source);
90
+ const primed = useRef(false);
91
+ useEffect(() => {
92
+ // Paint the first non-empty content immediately (no 200ms blank window after a file
93
+ // loads), exactly once, then debounce all subsequent keystrokes.
94
+ if (!primed.current && source !== "") {
95
+ primed.current = true;
96
+ setDebounced(source);
97
+ return;
98
+ }
99
+ const id = window.setTimeout(() => setDebounced(source), 200);
100
+ return () => window.clearTimeout(id);
101
+ }, [source]);
102
+ return useMemo(() => {
103
+ // `{ async: false }` pins the synchronous string return (no Promise union to narrow).
104
+ const html = marked.parse(debounced, { async: false });
105
+ // Scope the link-hardening hook to this call; `finally` guarantees removal even if
106
+ // `sanitize` throws, so the hook can never leak into other DOMPurify consumers.
107
+ DOMPurify.addHook("afterSanitizeAttributes", hardenLinks);
108
+ try {
109
+ return DOMPurify.sanitize(html);
110
+ } finally {
111
+ DOMPurify.removeHook("afterSanitizeAttributes");
112
+ }
113
+ }, [debounced]);
114
+ }
115
+
116
+ function isSaveShortcut(event: React.KeyboardEvent): boolean {
117
+ return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
118
+ }
119
+
120
+ // Minimal prose styling for the rendered preview (Tailwind doesn't style raw HTML).
121
+ const PREVIEW_PROSE =
122
+ "text-sm leading-relaxed text-neutral-300 " +
123
+ "[&_h1]:mb-2 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-neutral-100 " +
124
+ "[&_h2]:mb-1.5 [&_h2]:mt-5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-neutral-100 " +
125
+ "[&_h3]:mt-4 [&_h3]:font-semibold [&_h3]:text-neutral-200 " +
126
+ "[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 " +
127
+ "[&_code]:rounded [&_code]:bg-neutral-800 [&_code]:px-1 [&_code]:text-[0.85em] [&_code]:text-neutral-200 " +
128
+ "[&_pre]:my-3 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-3 " +
129
+ "[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
130
+ "[&_hr]:my-4 [&_hr]:border-neutral-800 [&_a]:text-sky-400 [&_strong]:text-neutral-100 " +
131
+ "[&_img]:my-2 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded " +
132
+ "[&_table]:my-3 [&_th]:border [&_th]:border-neutral-800 [&_th]:px-2 [&_th]:py-1 " +
133
+ "[&_td]:border [&_td]:border-neutral-800 [&_td]:px-2 [&_td]:py-1";
134
+
135
+ /**
136
+ * Raw markdown editor + live preview for the storyboard's canonical files
137
+ * (STORYBOARD.md / SCRIPT.md). Markdown stays the source of truth: saving writes
138
+ * the file and re-parses the Board. Deliberately raw (not WYSIWYG) so the
139
+ * structured frame fields can't be mangled; the preview is sanitized before render.
140
+ */
141
+ // fallow-ignore-next-line complexity
142
+ export function StoryboardSourceEditor({
143
+ files,
144
+ onSaved,
145
+ onDirtyChange,
146
+ }: StoryboardSourceEditorProps) {
147
+ const [selected, setSelected] = useState(files[0]?.path ?? "");
148
+ // Reconcile against the current file list so a removed/renamed file can't strand the tab.
149
+ const activePath = files.some((f) => f.path === selected) ? selected : (files[0]?.path ?? "");
150
+ const file = useEditableFile(activePath, onSaved);
151
+ const previewHtml = useMarkdownPreview(file.content);
152
+
153
+ // Surface dirty state to the parent (guards the Board↔Source toggle) and warn
154
+ // on browser-level navigation while there are unsaved edits.
155
+ useEffect(() => onDirtyChange?.(file.dirty), [onDirtyChange, file.dirty]);
156
+ useEffect(() => {
157
+ if (!file.dirty) return;
158
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
159
+ event.preventDefault();
160
+ event.returnValue = "";
161
+ };
162
+ window.addEventListener("beforeunload", onBeforeUnload);
163
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
164
+ }, [file.dirty]);
165
+
166
+ // Switching files discards the in-memory buffer; confirm when there are unsaved edits.
167
+ const selectFile = (path: string) => {
168
+ if (path === activePath) return;
169
+ if (file.dirty && !window.confirm(DISCARD_PROMPT)) return;
170
+ setSelected(path);
171
+ };
172
+
173
+ return (
174
+ <div
175
+ className="flex flex-1 min-h-0 flex-col"
176
+ onKeyDown={(e) => {
177
+ if (!isSaveShortcut(e)) return;
178
+ e.preventDefault();
179
+ if (file.dirty && !file.saving) file.save();
180
+ }}
181
+ >
182
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-4 py-2">
183
+ {files.map((f) => (
184
+ <button
185
+ key={f.path}
186
+ type="button"
187
+ onClick={() => selectFile(f.path)}
188
+ className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
189
+ activePath === f.path
190
+ ? "bg-neutral-800 text-neutral-100"
191
+ : "text-neutral-400 hover:text-neutral-200"
192
+ }`}
193
+ >
194
+ {f.label}
195
+ </button>
196
+ ))}
197
+ <div className="ml-auto flex items-center gap-3">
198
+ {file.error && <span className="text-xs text-red-400">{file.error}</span>}
199
+ <span className="text-xs text-neutral-500">
200
+ {file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"}
201
+ </span>
202
+ <button
203
+ type="button"
204
+ onClick={file.save}
205
+ disabled={!file.dirty || file.saving}
206
+ className="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-40"
207
+ >
208
+ Save
209
+ </button>
210
+ </div>
211
+ </div>
212
+ <div className="flex flex-1 min-h-0">
213
+ <div className="w-1/2 min-w-0 border-r border-neutral-800">
214
+ {file.loading ? (
215
+ <div className="p-4 text-sm text-neutral-500">Loading {activePath}…</div>
216
+ ) : (
217
+ <SourceEditor
218
+ content={file.content}
219
+ language="markdown"
220
+ filePath={activePath}
221
+ onChange={file.setContent}
222
+ />
223
+ )}
224
+ </div>
225
+ <div className="w-1/2 min-w-0 overflow-auto bg-neutral-950 px-6 py-4">
226
+ <div className={PREVIEW_PROSE} dangerouslySetInnerHTML={{ __html: previewHtml }} />
227
+ </div>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,21 @@
1
+ import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
2
+
3
+ /**
4
+ * Explains the frame lifecycle: a frame advances outline → built → animated.
5
+ * Mirrors the status chips on each tile (shares FRAME_STATUS_META).
6
+ */
7
+ export function StoryboardStatusLegend() {
8
+ return (
9
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
10
+ <span className="uppercase tracking-wider text-neutral-600">Status</span>
11
+ {FRAME_STATUS_ORDER.map((status, i) => (
12
+ <span key={status} className="flex items-center gap-1.5">
13
+ {i > 0 && <span className="text-neutral-700">→</span>}
14
+ <span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
15
+ <span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
16
+ <span className="text-neutral-500">— {FRAME_STATUS_META[status].description}</span>
17
+ </span>
18
+ ))}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from "react";
2
+ import { useStoryboard } from "../../hooks/useStoryboard";
3
+ import { StoryboardLoaded } from "./StoryboardLoaded";
4
+
5
+ export interface StoryboardViewProps {
6
+ projectId: string;
7
+ /** Select a composition in the timeline (used by the frame focus "Open in Preview"). */
8
+ onSelectComposition: (path: string) => void;
9
+ }
10
+
11
+ /**
12
+ * Top-level storyboard stage. Replaces the timeline/preview when the view mode
13
+ * is `storyboard`. Handles the load states here; once a storyboard exists,
14
+ * {@link StoryboardLoaded} owns the Board ↔ Source experience.
15
+ */
16
+ // fallow-ignore-next-line complexity
17
+ export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
18
+ const { data, loading, error, reload } = useStoryboard(projectId);
19
+
20
+ if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
21
+ if (error) {
22
+ return (
23
+ <StoryboardFrame>
24
+ <Message tone="error">Couldn’t load the storyboard: {error}</Message>
25
+ </StoryboardFrame>
26
+ );
27
+ }
28
+ if (!data) return <StoryboardFrame>{null}</StoryboardFrame>;
29
+ if (!data.exists) {
30
+ return (
31
+ <StoryboardFrame>
32
+ <EmptyState path={data.path} />
33
+ </StoryboardFrame>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <StoryboardLoaded
39
+ projectId={projectId}
40
+ data={data}
41
+ reload={reload}
42
+ onSelectComposition={onSelectComposition}
43
+ />
44
+ );
45
+ }
46
+
47
+ function StoryboardFrame({ children }: { children: ReactNode }) {
48
+ return (
49
+ <div className="flex-1 min-h-0 overflow-auto bg-neutral-950 text-neutral-200">
50
+ <div className="mx-auto max-w-[1400px] px-8 py-8">{children}</div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "muted" | "error" }) {
56
+ return (
57
+ <div
58
+ className={`px-6 py-12 text-center text-sm ${
59
+ tone === "error" ? "text-red-400" : "text-neutral-500"
60
+ }`}
61
+ >
62
+ {children}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ function EmptyState({ path }: { path: string }) {
68
+ return (
69
+ <div className="rounded-lg border border-dashed border-neutral-800 px-6 py-16 text-center">
70
+ <h2 className="text-base font-semibold text-neutral-300">No storyboard yet</h2>
71
+ <p className="mx-auto mt-2 max-w-md text-sm text-neutral-500">
72
+ Add a <code className="rounded bg-neutral-900 px-1 py-0.5 text-neutral-400">{path}</code> at
73
+ the project root to plan this video frame by frame. Your agent can create and iterate on it
74
+ for you.
75
+ </p>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,36 @@
1
+ import type { FrameStatus } from "@hyperframes/core/storyboard";
2
+
3
+ /**
4
+ * Single source of truth for how each frame lifecycle status is presented —
5
+ * label, tooltip, description, and the chip/dot color classes — so the tile
6
+ * chip and the legend dot can't drift apart.
7
+ */
8
+ export const FRAME_STATUS_META: Record<
9
+ FrameStatus,
10
+ { label: string; tooltip: string; description: string; chipClass: string; dotClass: string }
11
+ > = {
12
+ outline: {
13
+ label: "Outline",
14
+ tooltip: "Planned in text — no HTML frame built yet.",
15
+ description: "Planned in text. Story and intent exist; the visual isn’t built.",
16
+ chipClass: "bg-neutral-800 text-neutral-300",
17
+ dotClass: "bg-neutral-500",
18
+ },
19
+ built: {
20
+ label: "Built",
21
+ tooltip: "Static HTML frame built — not animated yet.",
22
+ description: "The HTML frame exists as a static key moment. Look is locked; motion isn’t.",
23
+ chipClass: "bg-sky-500/20 text-sky-300",
24
+ dotClass: "bg-sky-400",
25
+ },
26
+ animated: {
27
+ label: "Animated",
28
+ tooltip: "Keyframed and animated — plays in the final cut.",
29
+ description: "Keyframed into motion. This is the file stitched into the final video.",
30
+ chipClass: "bg-emerald-500/20 text-emerald-300",
31
+ dotClass: "bg-emerald-400",
32
+ },
33
+ };
34
+
35
+ /** The lifecycle order an agent advances each frame through. */
36
+ export const FRAME_STATUS_ORDER: FrameStatus[] = ["outline", "built", "animated"];