@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
@@ -3,11 +3,13 @@ import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
3
3
  import {
4
4
  STUDIO_INSPECTOR_PANELS_ENABLED,
5
5
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
6
+ STUDIO_STORYBOARD_ENABLED,
6
7
  } from "./editor/manualEditingAvailability";
7
8
  import { getHistoryShortcutLabel } from "../utils/studioHelpers";
8
9
  import { useStudioShellContext } from "../contexts/StudioContext";
9
10
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
10
11
  import { useDomEditActionsContext } from "../contexts/DomEditContext";
12
+ import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
11
13
  import { trackStudioEvent } from "../utils/studioTelemetry";
12
14
 
13
15
  export interface StudioHeaderProps {
@@ -141,6 +143,46 @@ function HyperframesLogo() {
141
143
  );
142
144
  }
143
145
 
146
+ const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [
147
+ { mode: "storyboard", label: "Storyboard" },
148
+ { mode: "timeline", label: "Preview" },
149
+ ];
150
+
151
+ /** Segmented control switching the main stage between storyboard and preview. */
152
+ function ViewModeToggle() {
153
+ const { viewMode, setViewMode } = useViewMode();
154
+ return (
155
+ <div
156
+ className="flex items-center gap-0.5 rounded-md bg-neutral-800 p-0.5"
157
+ role="tablist"
158
+ aria-label="Studio view"
159
+ >
160
+ {VIEW_MODE_OPTIONS.map(({ mode, label }) => {
161
+ const active = viewMode === mode;
162
+ return (
163
+ <button
164
+ key={mode}
165
+ type="button"
166
+ role="tab"
167
+ aria-selected={active}
168
+ onClick={() => {
169
+ if (active) return;
170
+ trackStudioEvent("view_mode_toggle", { mode });
171
+ setViewMode(mode);
172
+ }}
173
+ className={`rounded px-3 py-1 text-[11px] font-medium transition-colors ${
174
+ active ? "bg-neutral-200 text-neutral-900" : "text-neutral-400 hover:text-neutral-200"
175
+ }`}
176
+ >
177
+ {label}
178
+ </button>
179
+ );
180
+ })}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ // fallow-ignore-next-line complexity
144
186
  export function StudioHeader({
145
187
  captureFrameHref,
146
188
  captureFrameFilename,
@@ -164,6 +206,8 @@ export function StudioHeader({
164
206
  </span>
165
207
  <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
166
208
  </div>
209
+ {/* Center: storyboard / preview toggle (flag-gated) */}
210
+ {STUDIO_STORYBOARD_ENABLED && <ViewModeToggle />}
167
211
  {/* Right: toolbar buttons */}
168
212
  <div className="flex items-center gap-1.5">
169
213
  <button
@@ -0,0 +1,70 @@
1
+ import type { ComponentProps } from "react";
2
+ import { LintModal } from "./LintModal";
3
+ import { AskAgentModal } from "./AskAgentModal";
4
+ import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
5
+ import { StudioToast } from "./StudioToast";
6
+ import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
7
+ import type { useDomEditSession } from "../hooks/useDomEditSession";
8
+ import type { useToast } from "../hooks/useToast";
9
+
10
+ type LintFindings = ComponentProps<typeof LintModal>["findings"];
11
+
12
+ export interface StudioOverlaysProps {
13
+ projectId: string;
14
+ lintModal: LintFindings | null;
15
+ closeLintModal: () => void;
16
+ consoleErrors: LintFindings | null;
17
+ clearConsoleErrors: () => void;
18
+ domEditSession: ReturnType<typeof useDomEditSession>;
19
+ activeCompPath: string | null;
20
+ dragOverlayActive: boolean;
21
+ appToast: ReturnType<typeof useToast>["appToast"];
22
+ dismissToast: () => void;
23
+ }
24
+
25
+ /**
26
+ * Floating overlays for the studio shell: lint / console-error modals, the
27
+ * ask-agent modal, the global drag overlay, and the toast. Extracted from
28
+ * `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
29
+ */
30
+ // fallow-ignore-next-line complexity
31
+ export function StudioOverlays({
32
+ projectId,
33
+ lintModal,
34
+ closeLintModal,
35
+ consoleErrors,
36
+ clearConsoleErrors,
37
+ domEditSession,
38
+ activeCompPath,
39
+ dragOverlayActive,
40
+ appToast,
41
+ dismissToast,
42
+ }: StudioOverlaysProps) {
43
+ return (
44
+ <>
45
+ {lintModal !== null && (
46
+ <LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
47
+ )}
48
+ {consoleErrors !== null && consoleErrors.length > 0 && (
49
+ <LintModal findings={consoleErrors} projectId={projectId} onClose={clearConsoleErrors} />
50
+ )}
51
+ {domEditSession.agentModalOpen && domEditSession.domEditSelection && (
52
+ <AskAgentModal
53
+ selectionLabel={domEditSession.domEditSelection.label}
54
+ contextPreview={buildAgentContextPreview(domEditSession.domEditSelection, activeCompPath)}
55
+ anchorPoint={domEditSession.agentModalAnchorPoint}
56
+ onSubmit={domEditSession.handleAgentModalSubmit}
57
+ onClose={() => {
58
+ domEditSession.setAgentModalOpen(false);
59
+ domEditSession.setAgentPromptSelectionContext(undefined);
60
+ domEditSession.setAgentModalAnchorPoint(null);
61
+ }}
62
+ />
63
+ )}
64
+ {dragOverlayActive && <StudioGlobalDragOverlay />}
65
+ {appToast && (
66
+ <StudioToast message={appToast.message} tone={appToast.tone} onDismiss={dismissToast} />
67
+ )}
68
+ </>
69
+ );
70
+ }
@@ -12,26 +12,26 @@ import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language
12
12
  import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
13
13
  import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
14
14
  import { oneDark } from "@codemirror/theme-one-dark";
15
+ import type { Extension } from "@codemirror/state";
15
16
  import { html } from "@codemirror/lang-html";
16
17
  import { css } from "@codemirror/lang-css";
17
18
  import { javascript } from "@codemirror/lang-javascript";
19
+ import { markdown } from "@codemirror/lang-markdown";
18
20
 
19
- function getLanguageExtension(language: string) {
20
- switch (language) {
21
- case "html":
22
- return html();
23
- case "css":
24
- return css();
25
- case "javascript":
26
- case "js":
27
- case "typescript":
28
- case "ts":
29
- return javascript({
30
- typescript: language === "typescript" || language === "ts",
31
- });
32
- default:
33
- return html();
34
- }
21
+ const LANGUAGE_EXTENSIONS: Record<string, () => Extension> = {
22
+ html: () => html(),
23
+ css: () => css(),
24
+ markdown: () => markdown(),
25
+ md: () => markdown(),
26
+ javascript: () => javascript(),
27
+ js: () => javascript(),
28
+ typescript: () => javascript({ typescript: true }),
29
+ ts: () => javascript({ typescript: true }),
30
+ };
31
+
32
+ function getLanguageExtension(language: string): Extension {
33
+ const factory = LANGUAGE_EXTENSIONS[language] ?? html;
34
+ return factory();
35
35
  }
36
36
 
37
37
  function detectLanguage(filePath: string): string {
@@ -45,6 +45,8 @@ function detectLanguage(filePath: string): string {
45
45
  jsx: "javascript",
46
46
  tsx: "typescript",
47
47
  json: "javascript",
48
+ md: "markdown",
49
+ markdown: "markdown",
48
50
  };
49
51
  return map[ext] ?? "html";
50
52
  }
@@ -74,6 +76,7 @@ export const SourceEditor = memo(function SourceEditor({
74
76
  const contentRef = useRef(content);
75
77
  contentRef.current = content;
76
78
 
79
+ // fallow-ignore-next-line complexity
77
80
  const mountEditor = useCallback(
78
81
  (node: HTMLDivElement | null) => {
79
82
  if (editorRef.current) {
@@ -82,6 +82,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
82
82
  true,
83
83
  );
84
84
 
85
+ // Storyboard view: a top-level, toggleable view that renders STORYBOARD.md as a
86
+ // contact sheet of live HTML frame tiles, replacing the timeline/preview stage.
87
+ // Opt-in / off by default until the experience is ready for broad exposure.
88
+ // VITE_STUDIO_ENABLE_STORYBOARD=1 npx hyperframes preview
89
+ export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
90
+ env,
91
+ ["VITE_STUDIO_ENABLE_STORYBOARD", "VITE_STUDIO_STORYBOARD_ENABLED"],
92
+ false,
93
+ );
94
+
85
95
  // When disabled (the default), drag/resize/rotate commits always take the CSS
86
96
  // persist path instead of being intercepted into GSAP script keyframe
87
97
  // mutations. The keyframe intercept rewrites timeline tweens from drag
@@ -94,14 +104,22 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
94
104
 
95
105
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
96
106
 
97
- // Stage 7 Step 3b: shadow dispatch parity mode dispatches ops to the SDK
98
- // session alongside the server patch path and logs mismatches via telemetry.
99
- // Default on: server stays authoritative (no user-visible change), so we want
100
- // the sdk_shadow_dispatch parity signal from all traffic. Disable via
101
- // VITE_STUDIO_SDK_SHADOW_ENABLED=false.
102
- export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
107
+ // Stage 7 Step 3c: SDK cutover routes inline-style ops through SDK dispatch
108
+ // instead of the server patch-element API. Default false; enable via
109
+ // VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
110
+ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
111
+ env,
112
+ ["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
113
+ false,
114
+ );
115
+
116
+ // Resolver-parity tripwire (telemetry-only, decoupled from cutover).
117
+ // Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
118
+ // divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
119
+ // Soak gate: retire once zero element_not_found divergences over a clean window.
120
+ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
103
121
  env,
104
- ["VITE_STUDIO_SDK_SHADOW_ENABLED"],
122
+ ["VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED"],
105
123
  true,
106
124
  );
107
125
 
@@ -0,0 +1,56 @@
1
+ import { useEffect, useState } from "react";
2
+ import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3
+
4
+ export interface FramePosterProps {
5
+ projectId: string;
6
+ /** Project-relative path to the frame's HTML sub-composition. */
7
+ src: string;
8
+ /** Time (seconds) to seek to for the poster. */
9
+ seconds: number;
10
+ title: string;
11
+ /** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
12
+ fit?: "cover" | "contain";
13
+ }
14
+
15
+ /**
16
+ * Server-rendered poster for a frame. The thumbnail route seeks the composition
17
+ * by time (at its real fps) and caches the result, so there's no live iframe,
18
+ * no postMessage seek, and no client-side fps assumption. Shared by the
19
+ * contact-sheet tile and the frame-focus view.
20
+ */
21
+ export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
22
+ const [failed, setFailed] = useState(false);
23
+ // The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
24
+ // prior load error would stick. Reset when the poster target changes.
25
+ useEffect(() => setFailed(false), [src, seconds]);
26
+ if (failed) {
27
+ return (
28
+ <div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
29
+ Preview unavailable
30
+ </div>
31
+ );
32
+ }
33
+ const url = buildCompositionThumbnailUrl({
34
+ previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
35
+ seekTime: seconds,
36
+ duration: 0,
37
+ origin: window.location.origin,
38
+ });
39
+ return (
40
+ <img
41
+ src={url}
42
+ alt={title}
43
+ draggable={false}
44
+ loading="lazy"
45
+ onError={() => setFailed(true)}
46
+ className={`h-full w-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
47
+ />
48
+ );
49
+ }
50
+
51
+ /** Time (seconds) to show a frame at — past the intro so the key moment is visible. */
52
+ export function posterTime(frame: { poster?: number; durationSeconds?: number }): number {
53
+ if (frame.poster != null) return frame.poster;
54
+ if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
55
+ return 1.5;
56
+ }
@@ -0,0 +1,45 @@
1
+ import type { StoryboardGlobals } from "@hyperframes/core/storyboard";
2
+
3
+ export interface StoryboardDirectionProps {
4
+ globals: StoryboardGlobals;
5
+ frameCount: number;
6
+ }
7
+
8
+ /**
9
+ * Global direction header: the message/thesis plus arc, audience, and format.
10
+ * This is the storyboard's "north star" pulled from the manifest frontmatter.
11
+ */
12
+ export function StoryboardDirection({ globals, frameCount }: StoryboardDirectionProps) {
13
+ const meta = [
14
+ { label: "Arc", value: globals.arc },
15
+ { label: "Audience", value: globals.audience },
16
+ { label: "Voice", value: globals.extra.voice },
17
+ { label: "Format", value: globals.format },
18
+ { label: "Frames", value: String(frameCount) },
19
+ ].filter((item): item is { label: string; value: string } => Boolean(item.value));
20
+
21
+ return (
22
+ <header className="border-b border-neutral-800 pb-5">
23
+ <div className="text-[11px] font-medium uppercase tracking-wider text-neutral-500">
24
+ Storyboard
25
+ </div>
26
+ {globals.message ? (
27
+ <h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-100">
28
+ {globals.message}
29
+ </h1>
30
+ ) : (
31
+ <h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-400">
32
+ Untitled storyboard
33
+ </h1>
34
+ )}
35
+ <dl className="mt-4 flex flex-wrap gap-x-8 gap-y-2">
36
+ {meta.map((item) => (
37
+ <div key={item.label} className="flex items-baseline gap-2">
38
+ <dt className="text-[11px] uppercase tracking-wider text-neutral-500">{item.label}</dt>
39
+ <dd className="text-sm text-neutral-300">{item.value}</dd>
40
+ </div>
41
+ ))}
42
+ </dl>
43
+ </header>
44
+ );
45
+ }
@@ -0,0 +1,262 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
3
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
4
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
5
+ import { useViewMode } from "../../contexts/ViewModeContext";
6
+ import { FramePoster, posterTime } from "./FramePoster";
7
+ import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
8
+
9
+ export interface StoryboardFrameFocusProps {
10
+ projectId: string;
11
+ /** Path to STORYBOARD.md (edits are written here). */
12
+ storyboardPath: string;
13
+ frame: StoryboardFrameView;
14
+ frameCount: number;
15
+ onBack: () => void;
16
+ onNavigate: (delta: number) => void;
17
+ /** Re-parse the manifest after an edit is saved. */
18
+ onSaved: () => void;
19
+ /** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
20
+ onSelectComposition: (path: string) => void;
21
+ }
22
+
23
+ /**
24
+ * Full-area focus on a single frame: large poster, editable voiceover guide,
25
+ * status advancement, full narrative, and a jump into the live preview. Edits
26
+ * are written back to STORYBOARD.md in place (markdown stays canonical).
27
+ *
28
+ * Mounted with a `key` per frame, so `draft` initializes from the frame and a
29
+ * save-triggered reload never clobbers in-progress typing.
30
+ */
31
+ // fallow-ignore-next-line complexity
32
+ export function StoryboardFrameFocus({
33
+ projectId,
34
+ storyboardPath,
35
+ frame,
36
+ frameCount,
37
+ onBack,
38
+ onNavigate,
39
+ onSaved,
40
+ onSelectComposition,
41
+ }: StoryboardFrameFocusProps) {
42
+ const { readProjectFile, writeProjectFile } = useFileManagerContext();
43
+ const { setViewMode } = useViewMode();
44
+ const [draft, setDraft] = useState(frame.voiceover ?? "");
45
+ const [busy, setBusy] = useState(false);
46
+ const [error, setError] = useState<string | null>(null);
47
+
48
+ const applyEdit = useCallback(
49
+ async (edit: (source: string) => string) => {
50
+ if (busy) return; // one read-modify-write at a time; avoids a lost update
51
+ setBusy(true);
52
+ setError(null);
53
+ try {
54
+ const source = await readProjectFile(storyboardPath);
55
+ await writeProjectFile(storyboardPath, edit(source));
56
+ onSaved();
57
+ } catch (err: unknown) {
58
+ setError(err instanceof Error ? err.message : "failed to save");
59
+ } finally {
60
+ setBusy(false);
61
+ }
62
+ },
63
+ [readProjectFile, writeProjectFile, storyboardPath, onSaved, busy],
64
+ );
65
+
66
+ const title = frame.title ?? `Frame ${frame.index}`;
67
+ const dirty = draft !== (frame.voiceover ?? "");
68
+ const canOpenPreview = frame.srcExists && Boolean(frame.src);
69
+
70
+ // Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
71
+ const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
72
+ const handleBack = () => {
73
+ if (confirmLeave()) onBack();
74
+ };
75
+ const handleNavigate = (delta: number) => {
76
+ if (confirmLeave()) onNavigate(delta);
77
+ };
78
+
79
+ // ←/→ navigate frames, Esc returns to the Board — but never while typing in a field.
80
+ useEffect(() => {
81
+ // fallow-ignore-next-line complexity
82
+ const onKey = (e: KeyboardEvent) => {
83
+ const el = document.activeElement;
84
+ if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return;
85
+ if (e.key === "Escape") handleBack();
86
+ else if (e.key === "ArrowLeft" && frame.index > 1) handleNavigate(-1);
87
+ else if (e.key === "ArrowRight" && frame.index < frameCount) handleNavigate(1);
88
+ };
89
+ window.addEventListener("keydown", onKey);
90
+ return () => window.removeEventListener("keydown", onKey);
91
+ });
92
+
93
+ const openInPreview = () => {
94
+ if (frame.src) onSelectComposition(frame.src);
95
+ setViewMode("timeline");
96
+ };
97
+
98
+ return (
99
+ <div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
100
+ <div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
101
+ <button
102
+ type="button"
103
+ onClick={handleBack}
104
+ className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800"
105
+ >
106
+ ← Board
107
+ </button>
108
+ <span className="text-sm font-medium text-neutral-200">
109
+ Frame {frame.number ?? frame.index} — {title}
110
+ </span>
111
+ <div className="ml-auto flex items-center gap-1">
112
+ <NavButton
113
+ label="‹ Prev"
114
+ disabled={frame.index <= 1}
115
+ onClick={() => handleNavigate(-1)}
116
+ />
117
+ <NavButton
118
+ label="Next ›"
119
+ disabled={frame.index >= frameCount}
120
+ onClick={() => handleNavigate(1)}
121
+ />
122
+ </div>
123
+ </div>
124
+
125
+ <div className="flex flex-1 min-h-0">
126
+ <div className="flex w-3/5 min-w-0 items-center justify-center bg-neutral-900/40 p-8">
127
+ <div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
128
+ {canOpenPreview && frame.src ? (
129
+ <FramePoster
130
+ projectId={projectId}
131
+ src={frame.src}
132
+ seconds={posterTime(frame)}
133
+ title={title}
134
+ fit="contain"
135
+ />
136
+ ) : (
137
+ <div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
138
+ {frame.status === "outline" ? "Not built yet" : "No preview"}
139
+ </div>
140
+ )}
141
+ </div>
142
+ </div>
143
+
144
+ <div className="w-2/5 min-w-0 space-y-6 overflow-auto border-l border-neutral-800 px-6 py-5">
145
+ <StatusRow
146
+ status={frame.status}
147
+ busy={busy}
148
+ onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
149
+ />
150
+
151
+ <div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
152
+ {frame.duration && <span>Duration {frame.duration}</span>}
153
+ {frame.transitionIn && <span>Transition {frame.transitionIn}</span>}
154
+ </div>
155
+
156
+ <section>
157
+ <div className="mb-1 flex items-center justify-between">
158
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
159
+ 🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
160
+ </h3>
161
+ <button
162
+ type="button"
163
+ onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
164
+ disabled={!dirty || busy}
165
+ className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
166
+ >
167
+ Save
168
+ </button>
169
+ </div>
170
+ <textarea
171
+ value={draft}
172
+ onChange={(e) => setDraft(e.target.value)}
173
+ rows={3}
174
+ placeholder="What the narrator says over this frame…"
175
+ className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
176
+ />
177
+ <p className="mt-1 text-[11px] text-neutral-600">
178
+ A draft guide. SCRIPT.md locks the final narration that drives TTS.
179
+ </p>
180
+ {error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
181
+ </section>
182
+
183
+ {frame.narrative && (
184
+ <section>
185
+ <h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
186
+ Narrative
187
+ </h3>
188
+ <p className="whitespace-pre-wrap text-sm text-neutral-300">{frame.narrative}</p>
189
+ </section>
190
+ )}
191
+
192
+ <button
193
+ type="button"
194
+ onClick={openInPreview}
195
+ disabled={!canOpenPreview}
196
+ className="rounded border border-neutral-700 px-3 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:opacity-40"
197
+ >
198
+ Open in Preview →
199
+ </button>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ );
204
+ }
205
+
206
+ function NavButton({
207
+ label,
208
+ disabled,
209
+ onClick,
210
+ }: {
211
+ label: string;
212
+ disabled: boolean;
213
+ onClick: () => void;
214
+ }) {
215
+ return (
216
+ <button
217
+ type="button"
218
+ onClick={onClick}
219
+ disabled={disabled}
220
+ className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
221
+ >
222
+ {label}
223
+ </button>
224
+ );
225
+ }
226
+
227
+ function StatusRow({
228
+ status,
229
+ busy,
230
+ onSet,
231
+ }: {
232
+ status: FrameStatus;
233
+ busy: boolean;
234
+ onSet: (next: FrameStatus) => void;
235
+ }) {
236
+ return (
237
+ <div className="flex items-center gap-2">
238
+ <span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
239
+ Status
240
+ </span>
241
+ <div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5">
242
+ {FRAME_STATUS_ORDER.map((option) => (
243
+ <button
244
+ key={option}
245
+ type="button"
246
+ disabled={busy}
247
+ aria-pressed={status === option}
248
+ title={FRAME_STATUS_META[option].tooltip}
249
+ onClick={() => onSet(option)}
250
+ className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
251
+ status === option
252
+ ? "bg-neutral-700 text-neutral-100"
253
+ : "text-neutral-400 hover:text-neutral-200"
254
+ }`}
255
+ >
256
+ {FRAME_STATUS_META[option].label}
257
+ </button>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ );
262
+ }