@hyperframes/studio 0.7.59 → 0.7.61

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 (78) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
  3. package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1441 -770
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +4 -9
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +47 -28
  14. package/src/components/editor/PromotableControl.tsx +4 -2
  15. package/src/components/editor/PropertyPanel.tsx +151 -149
  16. package/src/components/editor/PropertyPanelFlat.tsx +44 -42
  17. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  18. package/src/components/editor/manualEditingAvailability.ts +5 -3
  19. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
  20. package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
  23. package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
  24. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  25. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  26. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  27. package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  29. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  30. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  31. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  32. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  33. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  34. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  35. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  36. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  37. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  38. package/src/components/storyboard/useFrameComments.ts +22 -6
  39. package/src/contexts/FileManagerContext.tsx +3 -0
  40. package/src/contexts/PanelLayoutContext.tsx +3 -0
  41. package/src/contexts/ViewModeContext.tsx +60 -7
  42. package/src/hooks/timelineEditingHelpers.ts +2 -2
  43. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  44. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  46. package/src/hooks/useDomEditCommits.ts +2 -2
  47. package/src/hooks/useDomEditSession.ts +1 -1
  48. package/src/hooks/useEditorSave.ts +1 -1
  49. package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
  50. package/src/hooks/useFileManager.ts +76 -12
  51. package/src/hooks/usePanelLayout.test.ts +118 -0
  52. package/src/hooks/usePanelLayout.ts +24 -1
  53. package/src/hooks/usePreviewPersistence.ts +4 -1
  54. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  55. package/src/hooks/useRazorSplit.test.tsx +88 -0
  56. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  57. package/src/hooks/useRazorSplit.ts +50 -16
  58. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  59. package/src/hooks/useSlideshowTabState.ts +61 -0
  60. package/src/hooks/useTimelineEditing.ts +2 -0
  61. package/src/hooks/useTimelineEditingTypes.ts +2 -1
  62. package/src/hooks/useTimelineGroupEditing.ts +1 -1
  63. package/src/player/lib/playbackTypes.ts +7 -0
  64. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  65. package/src/player/lib/timelineDOM.ts +8 -11
  66. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  67. package/src/utils/domEditSaveQueue.test.ts +19 -0
  68. package/src/utils/domEditSaveQueue.ts +2 -1
  69. package/src/utils/sdkCutover.test.ts +21 -5
  70. package/src/utils/sdkEditTransaction.ts +5 -4
  71. package/src/utils/studioFileHistory.ts +6 -4
  72. package/src/utils/studioFileVersion.test.ts +36 -0
  73. package/src/utils/studioFileVersion.ts +23 -0
  74. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  75. package/src/utils/studioSaveDiagnostics.ts +21 -0
  76. package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
  77. package/dist/assets/index-Dj5p8U_A.css +0 -1
  78. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -3,11 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
4
4
  import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
5
5
  import { BorderRadiusEditor } from "./BorderRadiusEditor";
6
- import {
7
- formatStrokeSummary,
8
- parseStrokeSummary,
9
- STROKE_STYLE_OPTIONS,
10
- } from "./propertyPanelFlatStyleHelpers";
6
+ import { STROKE_STYLE_OPTIONS } from "./propertyPanelFlatStyleHelpers";
11
7
  import {
12
8
  buildBoxShadowPresetValue,
13
9
  buildClipPathValue,
@@ -147,10 +143,9 @@ function FlatFillFields({
147
143
  }
148
144
 
149
145
  /* ------------------------------------------------------------------ */
150
- /* Flat Stroke row — combined width+style+color */
146
+ /* Flat Stroke row — width (numeric), style (select), color */
151
147
  /* ------------------------------------------------------------------ */
152
148
 
153
- // fallow-ignore-next-line complexity
154
149
  function FlatStrokeRow({
155
150
  styles,
156
151
  disabled,
@@ -167,52 +162,40 @@ function FlatStrokeRow({
167
162
  const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
168
163
  const borderColorValue =
169
164
  styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
170
- const summary = formatStrokeSummary(borderWidthValue, borderStyleValue);
171
- const tier = resolveValueTier(
172
- styles["border-width"] != null || styles["border-style"] != null ? summary : undefined,
173
- formatStrokeSummary(0, "none"),
174
- );
165
+ const widthDisplay = formatPxMetricValue(borderWidthValue);
175
166
 
176
167
  return (
177
168
  <>
178
169
  <FlatRow
179
- label="Stroke"
180
- value={summary}
181
- tier={tier}
170
+ label="Stroke width"
171
+ value={widthDisplay}
172
+ tier={resolveValueTier(styles["border-width"], "0px")}
182
173
  disabled={disabled}
183
174
  onCommit={async (next) => {
184
- const parsed = parseStrokeSummary(next);
185
- if (!parsed) return;
186
- if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return;
187
- const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, {
175
+ const normalizedWidth = normalizePanelPxValue(next, {
188
176
  min: 0,
189
177
  max: 200,
190
178
  fallback: borderWidthValue,
191
179
  });
192
180
  if (!normalizedWidth) return;
181
+ // buildStrokeWidthStyleUpdates already covers "a width typed in
182
+ // from 0 needs a style to actually render a visible border" —
183
+ // it only defaults to solid when the current style is none/hidden,
184
+ // never clobbering an already-chosen style (dashed, dotted, …).
193
185
  for (const [property, value] of buildStrokeWidthStyleUpdates(
194
186
  normalizedWidth,
195
- parsed.style,
187
+ borderStyleValue,
196
188
  )) {
197
189
  await onSetStyle(property, value);
198
190
  }
199
- for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) {
200
- await onSetStyle(property, value);
201
- }
202
191
  }}
203
- suffix={
204
- <>
205
- <span
206
- className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
207
- style={{ backgroundColor: borderColorValue }}
208
- />
209
- <span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
210
- </>
211
- }
212
192
  />
213
193
  <FlatSelectRow
214
194
  label="Stroke style"
215
195
  value={borderStyleValue}
196
+ // Valid border-style keywords — the ONLY way to set this, since a
197
+ // free-text field here would require typing an exact CSS keyword
198
+ // (e.g. "dashed") with no indication of which ones are valid.
216
199
  options={STROKE_STYLE_OPTIONS}
217
200
  tier={resolveValueTier(styles["border-style"], "none")}
218
201
  disabled={disabled}
@@ -111,7 +111,7 @@ function FlatTextFieldEditor({
111
111
  >
112
112
  Weight
113
113
  </span>
114
- <label className="flex items-center gap-1.5">
114
+ <label className="flex items-center gap-1.5 border-b border-panel-border-input/50 pb-px hover:border-panel-border-input">
115
115
  <select
116
116
  value={weight}
117
117
  onChange={(e) => {
@@ -267,7 +267,7 @@ export function FlatTextSection({
267
267
 
268
268
  if (textFields.length > 1) {
269
269
  return (
270
- <div className="space-y-1.5">
270
+ <div className="space-y-2.5">
271
271
  <FlatTextLayerList
272
272
  fields={textFields}
273
273
  activeFieldKey={activeField.key}
@@ -295,7 +295,7 @@ export function FlatTextSection({
295
295
  }
296
296
 
297
297
  return (
298
- <div className="space-y-1.5">
298
+ <div className="space-y-2.5">
299
299
  <FlatTextFieldEditor
300
300
  field={activeField}
301
301
  styles={styles}
@@ -134,6 +134,13 @@ describe("classic property-panel primitive telemetry", () => {
134
134
  const input = host.querySelector("input");
135
135
  if (!input) throw new Error("expected metric input");
136
136
 
137
+ // Regression guard for CommitField's shared `align` default: the classic
138
+ // panel lays out label-then-value inline, where left-aligned reads
139
+ // naturally — this must stay left even though the flat inspector's
140
+ // FlatRow now opts into `align="right"` for its own justify-between rows.
141
+ expect(input.className).toContain("text-left");
142
+ expect(input.className).not.toContain("text-right");
143
+
137
144
  act(() => blurInput(input));
138
145
  expect(trackStudioEvent).not.toHaveBeenCalled();
139
146
 
@@ -448,6 +455,13 @@ function representativeElement() {
448
455
 
449
456
  describe("classic PropertyPanel input coverage", () => {
450
457
  it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
458
+ vi.resetModules();
459
+ vi.doMock("./manualEditingAvailability", async () => {
460
+ const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
461
+ "./manualEditingAvailability",
462
+ );
463
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
464
+ });
451
465
  const { PropertyPanel } = await import("./PropertyPanel");
452
466
  const host = render(
453
467
  <PropertyPanel
@@ -9,11 +9,17 @@ export function CommitField({
9
9
  value,
10
10
  disabled,
11
11
  liveCommit,
12
+ align = "left",
12
13
  onCommit,
13
14
  }: {
14
15
  value: string;
15
16
  disabled?: boolean;
16
17
  liveCommit?: boolean;
18
+ /** The legacy panel lays out label-then-value inline (left reads naturally);
19
+ * the flat inspector lays out label…value across a `justify-between` row,
20
+ * where a left-aligned value looks stranded at the edge of its own
21
+ * right-hand box instead of lining up with every other row's value. */
22
+ align?: "left" | "right";
17
23
  onCommit: (nextValue: string) => void;
18
24
  }) {
19
25
  const [draft, setDraft] = useState(value);
@@ -92,7 +98,9 @@ export function CommitField({
92
98
  scheduleCommit(nextDraft);
93
99
  }}
94
100
  title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
95
- className="min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
101
+ className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
102
+ align === "right" ? "text-right" : "text-left"
103
+ }`}
96
104
  />
97
105
  );
98
106
  }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { AgentChatMessageButton } from "./AgentChatMessageButton";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.useRealTimers();
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ describe("AgentChatMessageButton", () => {
17
+ it("reports a successful copy and resets to an explicit re-copy action", async () => {
18
+ vi.useFakeTimers();
19
+ const writeText = vi.fn().mockResolvedValue(undefined);
20
+ Object.defineProperty(navigator, "clipboard", {
21
+ configurable: true,
22
+ value: { writeText },
23
+ });
24
+ const onCopied = vi.fn();
25
+ const host = document.createElement("div");
26
+ document.body.append(host);
27
+ const root = createRoot(host);
28
+ act(() => {
29
+ root.render(<AgentChatMessageButton message="handoff" onCopied={onCopied} />);
30
+ });
31
+
32
+ const button = host.querySelector("button");
33
+ if (!button) throw new Error("copy button not rendered");
34
+ await act(async () => {
35
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
36
+ await Promise.resolve();
37
+ });
38
+
39
+ expect(writeText).toHaveBeenCalledWith("handoff");
40
+ expect(onCopied).toHaveBeenCalledOnce();
41
+ expect(button.textContent).toBe("Copied — paste in your agent chat");
42
+
43
+ act(() => vi.advanceTimersByTime(3000));
44
+ expect(button.textContent).toBe("Copy again");
45
+ act(() => root.unmount());
46
+ });
47
+ });
@@ -0,0 +1,45 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Button } from "../ui/Button";
3
+
4
+ export const APPLY_STORYBOARD_FEEDBACK_MESSAGE =
5
+ "Read the storyboard feedback I saved in .hyperframes/frame-comments.json and revise the frames.";
6
+
7
+ export function AgentChatMessageButton({
8
+ message,
9
+ label = "Copy prompt for agent",
10
+ onCopied,
11
+ }: {
12
+ message: string;
13
+ label?: string;
14
+ onCopied?: () => void;
15
+ }) {
16
+ const [copyState, setCopyState] = useState<"idle" | "copied" | "again" | "failed">("idle");
17
+
18
+ useEffect(() => {
19
+ if (copyState !== "copied") return;
20
+ const timeout = window.setTimeout(() => setCopyState("again"), 3000);
21
+ return () => window.clearTimeout(timeout);
22
+ }, [copyState]);
23
+
24
+ const copyMessage = async () => {
25
+ try {
26
+ await navigator.clipboard.writeText(message);
27
+ setCopyState("copied");
28
+ onCopied?.();
29
+ } catch {
30
+ setCopyState("failed");
31
+ }
32
+ };
33
+
34
+ return (
35
+ <Button size="sm" variant="secondary" onClick={() => void copyMessage()}>
36
+ {copyState === "copied"
37
+ ? "Copied — paste in your agent chat"
38
+ : copyState === "again"
39
+ ? "Copy again"
40
+ : copyState === "failed"
41
+ ? "Copy failed"
42
+ : label}
43
+ </Button>
44
+ );
45
+ }
@@ -1,11 +1,16 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
- import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
2
+ import { setFrameVoiceover } from "@hyperframes/core/storyboard";
3
3
  import type { StoryboardFrameView } from "../../hooks/useStoryboard";
4
4
  import { useFileManagerContext } from "../../contexts/FileManagerContext";
5
5
  import { useViewMode } from "../../contexts/ViewModeContext";
6
6
  import { Button } from "../ui/Button";
7
7
  import { FramePoster, posterTime } from "./FramePoster";
8
- import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
8
+ import {
9
+ AgentChatMessageButton,
10
+ APPLY_STORYBOARD_FEEDBACK_MESSAGE,
11
+ } from "./AgentChatMessageButton";
12
+ import { FRAME_STATUS_META } from "./frameStatus";
13
+ import type { CommentsSubmitState } from "./useFrameComments";
9
14
 
10
15
  export interface StoryboardFrameFocusProps {
11
16
  projectId: string;
@@ -19,13 +24,26 @@ export interface StoryboardFrameFocusProps {
19
24
  onSaved: () => void;
20
25
  /** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
21
26
  onSelectComposition: (path: string) => void;
27
+ /** Whether SCRIPT.md exists and owns final narration/TTS. */
28
+ scriptExists: boolean;
29
+ /** Shared board draft for this frame, preserved when entering/leaving focus. */
30
+ commentDraft: string;
31
+ onCommentDraftChange: (text: string) => void;
32
+ pendingComment: string | null;
33
+ pendingCommentCount: number;
34
+ commentDraftCount: number;
35
+ commentsSubmitState: CommentsSubmitState;
36
+ commentsSubmitError: string | null;
37
+ feedbackMessageCopied: boolean;
38
+ onFeedbackMessageCopied: () => void;
39
+ onSaveFeedback: () => void;
22
40
  /** Project signature the board was loaded with (busts the poster cache). */
23
41
  posterVersion?: string;
24
42
  }
25
43
 
26
44
  /**
27
45
  * Full-area focus on a single frame: large poster, editable voiceover guide,
28
- * status advancement, full narrative, and a jump into the live preview. Edits
46
+ * review feedback, full narrative, and a jump into the live preview. Edits
29
47
  * are written back to STORYBOARD.md in place (markdown stays canonical).
30
48
  *
31
49
  * Mounted with a `key` per frame, so `draft` initializes from the frame and a
@@ -41,25 +59,39 @@ export function StoryboardFrameFocus({
41
59
  onNavigate,
42
60
  onSaved,
43
61
  onSelectComposition,
62
+ scriptExists,
63
+ commentDraft,
64
+ onCommentDraftChange,
65
+ pendingComment,
66
+ pendingCommentCount,
67
+ commentDraftCount,
68
+ commentsSubmitState,
69
+ commentsSubmitError,
70
+ feedbackMessageCopied,
71
+ onFeedbackMessageCopied,
72
+ onSaveFeedback,
44
73
  posterVersion,
45
74
  }: StoryboardFrameFocusProps) {
46
75
  const { readProjectFile, writeProjectFile } = useFileManagerContext();
47
- const { setViewMode } = useViewMode();
76
+ const { setViewMode, registerViewModeGuard } = useViewMode();
48
77
  const [draft, setDraft] = useState(frame.voiceover ?? "");
78
+ const [savedVoiceover, setSavedVoiceover] = useState(frame.voiceover ?? "");
49
79
  const [busy, setBusy] = useState(false);
50
80
  const [error, setError] = useState<string | null>(null);
51
81
 
52
82
  const applyEdit = useCallback(
53
83
  async (edit: (source: string) => string) => {
54
- if (busy) return; // one read-modify-write at a time; avoids a lost update
84
+ if (busy) return false; // one read-modify-write at a time; avoids a lost update
55
85
  setBusy(true);
56
86
  setError(null);
57
87
  try {
58
88
  const source = await readProjectFile(storyboardPath);
59
89
  await writeProjectFile(storyboardPath, edit(source));
60
90
  onSaved();
91
+ return true;
61
92
  } catch (err: unknown) {
62
93
  setError(err instanceof Error ? err.message : "failed to save");
94
+ return false;
63
95
  } finally {
64
96
  setBusy(false);
65
97
  }
@@ -68,11 +100,12 @@ export function StoryboardFrameFocus({
68
100
  );
69
101
 
70
102
  const title = frame.title ?? `Frame ${frame.index}`;
71
- const dirty = draft !== (frame.voiceover ?? "");
72
- const canOpenPreview = frame.srcExists && Boolean(frame.src);
103
+ const dirty = draft !== savedVoiceover;
104
+ const canOpenPreview = frame.status !== "outline" && frame.srcExists && Boolean(frame.src);
73
105
 
74
- const saveVoiceover = useCallback(() => {
75
- return applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
106
+ const saveVoiceover = useCallback(async () => {
107
+ const saved = await applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
108
+ if (saved) setSavedVoiceover(draft);
76
109
  }, [applyEdit, frame.index, draft]);
77
110
 
78
111
  // Closing the tab with a dirty voiceover would lose it silently — same
@@ -90,7 +123,12 @@ export function StoryboardFrameFocus({
90
123
  // dirty. An in-flight save does NOT count as safe: if it fails after unmount
91
124
  // the error lands on an unmounted component and the draft is silently lost,
92
125
  // so keep confirming until the save actually lands (dirty clears on success).
93
- const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
126
+ const confirmLeave = useCallback(
127
+ () => !dirty || window.confirm("Discard unsaved voiceover changes?"),
128
+ [dirty],
129
+ );
130
+ useEffect(() => registerViewModeGuard(confirmLeave), [confirmLeave, registerViewModeGuard]);
131
+
94
132
  const handleBack = () => {
95
133
  if (confirmLeave()) onBack();
96
134
  };
@@ -113,12 +151,12 @@ export function StoryboardFrameFocus({
113
151
  });
114
152
 
115
153
  const openInPreview = () => {
154
+ if (!setViewMode("timeline")) return;
116
155
  if (frame.src) onSelectComposition(frame.src);
117
- setViewMode("timeline");
118
156
  };
119
157
 
120
158
  return (
121
- <div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
159
+ <div className="flex w-full max-w-[100vw] flex-1 min-h-0 min-w-0 flex-col overflow-hidden bg-neutral-950 text-neutral-200">
122
160
  <div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
123
161
  <button
124
162
  type="button"
@@ -127,10 +165,10 @@ export function StoryboardFrameFocus({
127
165
  >
128
166
  ← Board
129
167
  </button>
130
- <span className="text-sm font-medium text-neutral-200">
168
+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">
131
169
  Frame {frame.number ?? frame.index} — {title}
132
170
  </span>
133
- <div className="ml-auto flex items-center gap-1">
171
+ <div className="flex shrink-0 items-center gap-1">
134
172
  <NavButton
135
173
  label="‹ Prev"
136
174
  disabled={frame.index <= 1}
@@ -144,8 +182,41 @@ export function StoryboardFrameFocus({
144
182
  </div>
145
183
  </div>
146
184
 
147
- <div className="flex flex-1 min-h-0">
148
- <div className="flex w-3/5 min-w-0 items-center justify-center bg-neutral-900/40 p-8">
185
+ {(commentDraftCount > 0 || pendingCommentCount > 0) && (
186
+ <div className="flex flex-wrap items-center justify-between gap-2 border-b border-neutral-800 bg-neutral-900/80 px-4 py-2">
187
+ <div>
188
+ <div className="text-xs font-medium text-neutral-200">
189
+ {commentDraftCount > 0 ? "Feedback ready to save" : "Feedback saved"}
190
+ </div>
191
+ <div className="text-[11px] text-neutral-500">
192
+ {commentDraftCount > 0
193
+ ? "Save this batch and copy the message for your agent."
194
+ : feedbackMessageCopied
195
+ ? "Message copied — paste it in your terminal or IDE agent chat."
196
+ : "The agent has not been notified yet."}
197
+ </div>
198
+ </div>
199
+ {commentDraftCount > 0 ? (
200
+ <Button
201
+ size="sm"
202
+ variant="primary"
203
+ onClick={onSaveFeedback}
204
+ loading={commentsSubmitState === "saving"}
205
+ >
206
+ Save &amp; copy message ({commentDraftCount})
207
+ </Button>
208
+ ) : (
209
+ <AgentChatMessageButton
210
+ message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
211
+ label={feedbackMessageCopied ? "Copy again" : "Copy prompt for agent"}
212
+ onCopied={onFeedbackMessageCopied}
213
+ />
214
+ )}
215
+ </div>
216
+ )}
217
+
218
+ <div className="flex flex-1 min-h-0 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
219
+ <div className="flex w-full shrink-0 items-center justify-center bg-neutral-900/40 p-4 sm:p-8 lg:h-full lg:w-3/5">
149
220
  <div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
150
221
  {canOpenPreview && frame.src ? (
151
222
  <FramePoster
@@ -157,19 +228,13 @@ export function StoryboardFrameFocus({
157
228
  posterVersion={posterVersion}
158
229
  />
159
230
  ) : (
160
- <div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
161
- {frame.status === "outline" ? "Not built yet" : "No preview"}
162
- </div>
231
+ <FramePlan frame={frame} />
163
232
  )}
164
233
  </div>
165
234
  </div>
166
235
 
167
- <div className="w-2/5 min-w-0 space-y-6 overflow-auto border-l border-neutral-800 px-6 py-5">
168
- <StatusRow
169
- status={frame.status}
170
- busy={busy}
171
- onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
172
- />
236
+ <div className="w-full shrink-0 space-y-6 border-t border-neutral-800 px-4 py-5 sm:px-6 lg:h-full lg:w-2/5 lg:overflow-auto lg:border-t-0 lg:border-l">
237
+ <ReadOnlyStatus status={frame.status} />
173
238
 
174
239
  <div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
175
240
  {frame.duration && <span>Duration {frame.duration}</span>}
@@ -178,7 +243,10 @@ export function StoryboardFrameFocus({
178
243
 
179
244
  <section>
180
245
  <div className="mb-1 flex items-center justify-between">
181
- <h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
246
+ <h3
247
+ className="text-xs font-semibold uppercase tracking-wider text-neutral-400"
248
+ title="Storyboard voiceover is a guide; SCRIPT.md is the final TTS source."
249
+ >
182
250
  🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
183
251
  </h3>
184
252
  <Button
@@ -187,31 +255,70 @@ export function StoryboardFrameFocus({
187
255
  onClick={saveVoiceover}
188
256
  disabled={!dirty}
189
257
  loading={busy}
190
- className="bg-emerald-600 text-white enabled:hover:bg-emerald-500 shadow-none"
191
258
  >
192
- {busy ? "Saving…" : "Save"}
259
+ {busy ? "Saving…" : "Save voiceover"}
193
260
  </Button>
194
261
  </div>
195
262
  <textarea
196
263
  value={draft}
197
264
  onChange={(e) => setDraft(e.target.value)}
198
- onBlur={() => {
199
- // Same autosave paradigm as the status row above — mixed save
200
- // models inside one panel taught users the panel autosaves,
201
- // then lost their voiceover. Explicit Save stays as the
202
- // affordance; blur is the safety net.
203
- if (dirty && !busy) void saveVoiceover();
204
- }}
205
265
  rows={3}
206
266
  placeholder="What the narrator says over this frame…"
207
267
  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"
208
268
  />
209
269
  <p className="mt-1 text-[11px] text-neutral-600">
210
- A draft guide. SCRIPT.md locks the final narration that drives TTS.
270
+ {dirty
271
+ ? "Unsaved changes"
272
+ : scriptExists
273
+ ? "Saved. SCRIPT.md drives final TTS."
274
+ : "Saved to STORYBOARD.md. This voiceover guides narration for the frame."}
211
275
  </p>
212
276
  {error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
213
277
  </section>
214
278
 
279
+ <section>
280
+ <div className="mb-1">
281
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
282
+ Frame feedback
283
+ </h3>
284
+ </div>
285
+ <textarea
286
+ value={commentDraft}
287
+ onChange={(e) => onCommentDraftChange(e.target.value)}
288
+ rows={3}
289
+ placeholder="Tell your agent what to change in this frame…"
290
+ aria-label={`Comment on ${title}`}
291
+ className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 placeholder:text-neutral-600 outline-none focus:border-sky-700"
292
+ />
293
+ {pendingCommentCount > 0 ? (
294
+ <div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-md border border-sky-900/70 bg-sky-950/20 px-2.5 py-2">
295
+ <p className="text-[11px] text-sky-200">
296
+ Paste the agent prompt in your terminal or IDE chat.
297
+ </p>
298
+ <AgentChatMessageButton
299
+ message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
300
+ onCopied={onFeedbackMessageCopied}
301
+ />
302
+ </div>
303
+ ) : (
304
+ <p className="mt-1 text-[11px] text-neutral-600">
305
+ {commentDraftCount > 0
306
+ ? "Your change is ready. Save it using the review bar above."
307
+ : "Add a change to prepare feedback for the agent."}
308
+ </p>
309
+ )}
310
+ {pendingComment && (
311
+ <p className="mt-1 text-[11px] text-sky-400/90">
312
+ <span className="font-medium">Pending:</span> “{pendingComment}”
313
+ </p>
314
+ )}
315
+ {commentsSubmitError && (
316
+ <p className="mt-1 text-[11px] text-red-400">
317
+ Couldn’t submit: {commentsSubmitError}
318
+ </p>
319
+ )}
320
+ </section>
321
+
215
322
  {frame.narrative && (
216
323
  <section>
217
324
  <h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
@@ -221,9 +328,15 @@ export function StoryboardFrameFocus({
221
328
  </section>
222
329
  )}
223
330
 
224
- <Button size="sm" variant="secondary" onClick={openInPreview} disabled={!canOpenPreview}>
225
- Open in Preview
226
- </Button>
331
+ {canOpenPreview ? (
332
+ <Button size="sm" variant="secondary" onClick={openInPreview}>
333
+ Open in Preview →
334
+ </Button>
335
+ ) : (
336
+ <div className="rounded-md border border-neutral-800 bg-neutral-900/60 px-3 py-2 text-xs text-neutral-500">
337
+ Preview becomes available after your agent builds this frame.
338
+ </div>
339
+ )}
227
340
  </div>
228
341
  </div>
229
342
  </div>
@@ -251,38 +364,48 @@ function NavButton({
251
364
  );
252
365
  }
253
366
 
254
- function StatusRow({
255
- status,
256
- busy,
257
- onSet,
258
- }: {
259
- status: FrameStatus;
260
- busy: boolean;
261
- onSet: (next: FrameStatus) => void;
262
- }) {
367
+ function ReadOnlyStatus({ status }: { status: StoryboardFrameView["status"] }) {
368
+ const meta = FRAME_STATUS_META[status];
263
369
  return (
264
370
  <div className="flex items-center gap-2">
265
371
  <span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
266
372
  Status
267
373
  </span>
268
- <div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5">
269
- {FRAME_STATUS_ORDER.map((option) => (
270
- <button
271
- key={option}
272
- type="button"
273
- disabled={busy}
274
- aria-pressed={status === option}
275
- title={FRAME_STATUS_META[option].tooltip}
276
- onClick={() => onSet(option)}
277
- className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
278
- status === option
279
- ? "bg-neutral-700 text-neutral-100"
280
- : "text-neutral-400 hover:text-neutral-200"
281
- }`}
282
- >
283
- {FRAME_STATUS_META[option].label}
284
- </button>
285
- ))}
374
+ <span className={`rounded px-2 py-1 text-xs font-medium ${meta.chipClass}`}>
375
+ {meta.label}
376
+ </span>
377
+ <span className="text-[11px] text-neutral-600">Updated by your agent</span>
378
+ </div>
379
+ );
380
+ }
381
+
382
+ // The empty state selects copy from frame status and whichever storyboard fields are available.
383
+ // fallow-ignore-next-line complexity
384
+ function FramePlan({ frame }: { frame: StoryboardFrameView }) {
385
+ const title = frame.title ?? `Frame ${frame.index}`;
386
+ const isOutline = frame.status === "outline";
387
+ return (
388
+ <div className="flex h-full w-full items-center justify-center bg-[radial-gradient(circle_at_center,_rgba(38,38,38,0.8),_rgba(10,10,10,1))] p-10">
389
+ <div className="max-w-xl text-center">
390
+ <span className="rounded-full border border-neutral-700 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
391
+ {isOutline ? "Planned frame" : "Preview unavailable"}
392
+ </span>
393
+ <h2 className="mt-4 text-2xl font-semibold text-neutral-100">{title}</h2>
394
+ {frame.scene && (
395
+ <p className="mt-3 text-base leading-relaxed text-neutral-300">{frame.scene}</p>
396
+ )}
397
+ {!frame.scene && frame.narrative && (
398
+ <p className="mt-3 line-clamp-4 text-sm leading-relaxed text-neutral-400">
399
+ {frame.narrative}
400
+ </p>
401
+ )}
402
+ <p className="mt-5 text-xs text-neutral-600">
403
+ {isOutline
404
+ ? "A visual preview will appear when your agent builds the sketch."
405
+ : frame.src
406
+ ? `Frame file not found: ${frame.src}`
407
+ : "This frame does not link to a source file."}
408
+ </p>
286
409
  </div>
287
410
  </div>
288
411
  );