@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,10 +3,14 @@ import type { StoryboardResponse } from "../../hooks/useStoryboard";
3
3
  import { Button } from "../ui/Button";
4
4
  import { StoryboardDirection } from "./StoryboardDirection";
5
5
  import { StoryboardGrid } from "./StoryboardGrid";
6
- import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
7
6
  import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
8
7
  import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
9
8
  import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
9
+ import { StoryboardReviewGuide } from "./StoryboardReviewGuide";
10
+ import {
11
+ AgentChatMessageButton,
12
+ APPLY_STORYBOARD_FEEDBACK_MESSAGE,
13
+ } from "./AgentChatMessageButton";
10
14
  import { useFrameComments, type CommentsSubmitState } from "./useFrameComments";
11
15
 
12
16
  type SubView = "board" | "source";
@@ -35,6 +39,7 @@ export function StoryboardLoaded({
35
39
  const [subView, setSubView] = useState<SubView>("board");
36
40
  const [sourceDirty, setSourceDirty] = useState(false);
37
41
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
42
+ const [feedbackMessageCopied, setFeedbackMessageCopied] = useState(false);
38
43
  const comments = useFrameComments(data.frames);
39
44
  // When the board refreshes off a project change (agent revised frames), the
40
45
  // agent has likely consumed the comments file too — re-check so the pending
@@ -43,6 +48,20 @@ export function StoryboardLoaded({
43
48
  useEffect(() => {
44
49
  void refreshPending();
45
50
  }, [data.signature, refreshPending]);
51
+ useEffect(() => {
52
+ if (comments.draftCount > 0) setFeedbackMessageCopied(false);
53
+ }, [comments.draftCount]);
54
+
55
+ const saveFeedbackAndCopyMessage = async () => {
56
+ const saved = await comments.submit();
57
+ if (!saved) return;
58
+ try {
59
+ await navigator.clipboard.writeText(APPLY_STORYBOARD_FEEDBACK_MESSAGE);
60
+ setFeedbackMessageCopied(true);
61
+ } catch {
62
+ setFeedbackMessageCopied(false);
63
+ }
64
+ };
46
65
  const sourceFiles = useMemo<SourceFile[]>(() => {
47
66
  const files: SourceFile[] = [{ path: data.path, label: data.path }];
48
67
  if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
@@ -82,31 +101,54 @@ export function StoryboardLoaded({
82
101
  }
83
102
  onSaved={reload}
84
103
  onSelectComposition={onSelectComposition}
104
+ scriptExists={Boolean(data.script?.exists)}
105
+ commentDraft={comments.drafts[focusedFrame.index] ?? ""}
106
+ onCommentDraftChange={(text) => comments.setDraft(focusedFrame.index, text)}
107
+ pendingComment={
108
+ comments.pending?.find((entry) => entry.frame === focusedFrame.index)?.text ?? null
109
+ }
110
+ pendingCommentCount={comments.pending?.length ?? 0}
111
+ commentDraftCount={comments.draftCount}
112
+ commentsSubmitState={comments.submitState}
113
+ commentsSubmitError={comments.submitError}
114
+ feedbackMessageCopied={feedbackMessageCopied}
115
+ onFeedbackMessageCopied={() => setFeedbackMessageCopied(true)}
116
+ onSaveFeedback={() => void saveFeedbackAndCopyMessage()}
85
117
  posterVersion={data.signature}
86
118
  />
87
119
  );
88
120
  }
89
121
 
90
122
  return (
91
- <div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
92
- <div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
123
+ <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">
124
+ <div className="flex flex-wrap items-center gap-3 border-b border-neutral-800 px-4 py-2">
93
125
  <SubViewToggle value={subView} onChange={changeSubView} />
94
126
  {subView === "board" && (
95
127
  <CommentsSubmitBar
96
128
  draftCount={comments.draftCount}
97
129
  pendingCount={comments.pending?.length ?? 0}
98
130
  submitState={comments.submitState}
99
- onSubmit={() => void comments.submit()}
131
+ submitError={comments.submitError}
132
+ messageCopied={feedbackMessageCopied}
133
+ onSave={() => void saveFeedbackAndCopyMessage()}
134
+ onMessageCopied={() => setFeedbackMessageCopied(true)}
100
135
  />
101
136
  )}
102
137
  </div>
103
138
  {subView === "board" ? (
104
139
  <div className="flex-1 min-h-0 overflow-auto">
105
- <div className="mx-auto max-w-[1400px] px-8 py-8">
140
+ <div className="mx-auto max-w-[1400px] px-4 py-5 sm:px-8 sm:py-8">
106
141
  <StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
107
- <div className="mt-5">
108
- <StoryboardStatusLegend />
109
- </div>
142
+ <StoryboardReviewGuide
143
+ frames={data.frames}
144
+ draftCount={comments.draftCount}
145
+ pendingCount={comments.pending?.length ?? 0}
146
+ onFeedbackMessageCopied={() => setFeedbackMessageCopied(true)}
147
+ />
148
+ <StoryboardWarnings
149
+ warnings={data.warnings}
150
+ onOpenSource={() => changeSubView("source")}
151
+ />
110
152
  <StoryboardGrid
111
153
  projectId={projectId}
112
154
  frames={data.frames}
@@ -135,34 +177,90 @@ function CommentsSubmitBar({
135
177
  draftCount,
136
178
  pendingCount,
137
179
  submitState,
138
- onSubmit,
180
+ submitError,
181
+ messageCopied,
182
+ onSave,
183
+ onMessageCopied,
139
184
  }: {
140
185
  draftCount: number;
141
186
  pendingCount: number;
142
187
  submitState: CommentsSubmitState;
143
- onSubmit: () => void;
188
+ submitError: string | null;
189
+ messageCopied: boolean;
190
+ onSave: () => void;
191
+ onMessageCopied: () => void;
144
192
  }) {
145
193
  return (
146
- <div className="ml-auto flex items-center gap-3">
194
+ <div className="ml-auto flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
147
195
  {pendingCount > 0 && (
148
- <span className="text-xs text-sky-300">
149
- {pendingCount} comment{pendingCount > 1 ? "s" : ""} pending — reply anything in your agent
150
- chat and it will apply them.
196
+ <>
197
+ <span className="text-xs text-sky-300">
198
+ {messageCopied
199
+ ? "Feedback saved · Message copied — paste it in your terminal or IDE agent chat."
200
+ : "Feedback saved · Agent not notified."}
201
+ </span>
202
+ <AgentChatMessageButton
203
+ message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
204
+ label={messageCopied ? "Copy again" : "Copy prompt for agent"}
205
+ onCopied={onMessageCopied}
206
+ />
207
+ </>
208
+ )}
209
+ {pendingCount === 0 && draftCount === 0 && (
210
+ <span className="text-xs text-neutral-500">Add frame comments to request changes.</span>
211
+ )}
212
+ {submitError && (
213
+ <span className="max-w-64 truncate text-xs text-red-400" title={submitError}>
214
+ Couldn’t submit: {submitError}
151
215
  </span>
152
216
  )}
153
- <Button
154
- variant="primary"
155
- size="sm"
156
- loading={submitState === "saving"}
157
- disabled={draftCount === 0 || submitState === "saving"}
158
- onClick={onSubmit}
159
- >
160
- {draftCount > 0 ? `Submit comments (${draftCount})` : "Submit comments"}
161
- </Button>
217
+ {draftCount > 0 && (
218
+ <Button
219
+ variant="primary"
220
+ size="sm"
221
+ loading={submitState === "saving"}
222
+ disabled={submitState === "saving"}
223
+ onClick={onSave}
224
+ >
225
+ Save &amp; copy message ({draftCount})
226
+ </Button>
227
+ )}
162
228
  </div>
163
229
  );
164
230
  }
165
231
 
232
+ function StoryboardWarnings({
233
+ warnings,
234
+ onOpenSource,
235
+ }: {
236
+ warnings: StoryboardResponse["warnings"];
237
+ onOpenSource: () => void;
238
+ }) {
239
+ if (warnings.length === 0) return null;
240
+ return (
241
+ <details className="mt-3 rounded-lg border border-amber-900/60 bg-amber-950/20 px-4 py-2 text-xs text-amber-200">
242
+ <summary className="cursor-pointer font-medium">
243
+ {warnings.length} storyboard warning{warnings.length === 1 ? "" : "s"}
244
+ </summary>
245
+ <ul className="mt-2 space-y-1 text-amber-200/80">
246
+ {warnings.map((warning, index) => (
247
+ <li key={`${warning.line ?? "unknown"}-${index}`}>
248
+ {warning.line ? `Line ${warning.line}: ` : ""}
249
+ {warning.message}
250
+ </li>
251
+ ))}
252
+ </ul>
253
+ <button
254
+ type="button"
255
+ onClick={onOpenSource}
256
+ className="mt-2 rounded text-amber-100 underline underline-offset-2 hover:text-white"
257
+ >
258
+ Open source to fix
259
+ </button>
260
+ </details>
261
+ );
262
+ }
263
+
166
264
  const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
167
265
  { value: "board", label: "Board" },
168
266
  { value: "source", label: "Source" },
@@ -0,0 +1,300 @@
1
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
2
+ import {
3
+ AgentChatMessageButton,
4
+ APPLY_STORYBOARD_FEEDBACK_MESSAGE,
5
+ } from "./AgentChatMessageButton";
6
+ import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
7
+ import {
8
+ deriveStoryboardHandoffStep,
9
+ deriveStoryboardReviewStage,
10
+ type StoryboardHandoffStep,
11
+ type StoryboardReviewStage,
12
+ } from "./storyboardReviewStage";
13
+
14
+ const GUIDE_COPY: Record<StoryboardReviewStage, { eyebrow: string; title: string; body: string }> =
15
+ {
16
+ empty: {
17
+ eyebrow: "Waiting for a plan",
18
+ title: "The storyboard has no frames yet",
19
+ body: "Ask your agent to draft the story plan. Frames will appear here automatically.",
20
+ },
21
+ "plan-review": {
22
+ eyebrow: "Ready for review",
23
+ title: "Review the story plan",
24
+ body: "Check the sequence, scene direction, and voiceover before visual work begins. Leave frame comments, save them, then reply in your terminal or IDE agent chat.",
25
+ },
26
+ "sketch-in-progress": {
27
+ eyebrow: "Build in progress",
28
+ title: "Visual sketches are in progress",
29
+ body: "New posters appear automatically as your agent builds them. You can comment now; wait until no frames remain in Outline before approving the layouts.",
30
+ },
31
+ "sketch-review": {
32
+ eyebrow: "Ready for review",
33
+ title: "Review the visual direction",
34
+ body: "Check composition, hierarchy, and copy. Save frame comments, then reply in your terminal or IDE agent chat.",
35
+ },
36
+ "animation-in-progress": {
37
+ eyebrow: "Build in progress",
38
+ title: "Animation is in progress",
39
+ body: "The board refreshes as frames advance. Review completed frames now; the final review is ready when every frame is Animated.",
40
+ },
41
+ "final-review": {
42
+ eyebrow: "Ready for review",
43
+ title: "Review motion and timing",
44
+ body: "Every frame is animated. Open a frame in Preview to review it in the timeline, or leave frame comments for another revision.",
45
+ },
46
+ };
47
+
48
+ const REVIEW_ACTION_COPY: Record<
49
+ StoryboardReviewStage,
50
+ { body: string; approvalMessage?: string }
51
+ > = {
52
+ empty: { body: "Add comments as previews arrive." },
53
+ "plan-review": {
54
+ body: "Add comments where you want changes. If everything looks right, approve this pass in agent chat.",
55
+ approvalMessage: "Approve this storyboard plan and continue to visual sketches.",
56
+ },
57
+ "sketch-in-progress": {
58
+ body: "Add comments as previews arrive. You’ll be prompted to approve when this pass is ready.",
59
+ },
60
+ "sketch-review": {
61
+ body: "Add comments where you want changes. If everything looks right, approve this pass in agent chat.",
62
+ approvalMessage: "Approve these storyboard sketches and continue to animation.",
63
+ },
64
+ "animation-in-progress": {
65
+ body: "Add comments as previews arrive. You’ll be prompted to approve when this pass is ready.",
66
+ },
67
+ "final-review": {
68
+ body: "Add comments where you want changes. If everything looks right, approve this pass in agent chat.",
69
+ approvalMessage: "Approve this final storyboard review and continue to rendering.",
70
+ },
71
+ };
72
+
73
+ type ReviewStepOffset = -1 | 0 | 1;
74
+ const REVIEW_STEP_STATES: Record<
75
+ ReviewStepOffset,
76
+ {
77
+ textClass: string;
78
+ numberClass: string;
79
+ ariaCurrent: "step" | undefined;
80
+ marker: (number: StoryboardHandoffStep) => StoryboardHandoffStep | string;
81
+ }
82
+ > = {
83
+ [-1]: {
84
+ textClass: "text-emerald-400",
85
+ numberClass: "border-emerald-700 bg-emerald-500/10",
86
+ ariaCurrent: undefined,
87
+ marker: () => "✓",
88
+ },
89
+ [0]: {
90
+ textClass: "text-sky-300",
91
+ numberClass: "border-sky-500 bg-sky-500/15",
92
+ ariaCurrent: "step",
93
+ marker: (number) => number,
94
+ },
95
+ [1]: {
96
+ textClass: "text-neutral-600",
97
+ numberClass: "border-neutral-700",
98
+ ariaCurrent: undefined,
99
+ marker: (number) => number,
100
+ },
101
+ };
102
+
103
+ const REVIEW_STEP_SEPARATOR_CLASS: Record<StoryboardHandoffStep, string> = {
104
+ 1: "invisible",
105
+ 2: "",
106
+ 3: "",
107
+ };
108
+
109
+ export interface StoryboardReviewGuideProps {
110
+ frames: StoryboardFrameView[];
111
+ draftCount: number;
112
+ pendingCount: number;
113
+ onFeedbackMessageCopied: () => void;
114
+ }
115
+
116
+ /** Stage-aware instructions plus the explicit Studio → agent handoff. */
117
+ export function StoryboardReviewGuide({
118
+ frames,
119
+ draftCount,
120
+ pendingCount,
121
+ onFeedbackMessageCopied,
122
+ }: StoryboardReviewGuideProps) {
123
+ const summary = deriveStoryboardReviewStage(frames);
124
+ const copy = GUIDE_COPY[summary.stage];
125
+ const handoffStep = deriveStoryboardHandoffStep(draftCount, pendingCount);
126
+ const progress = progressLabel(summary);
127
+
128
+ return (
129
+ <section className="mt-5 rounded-lg border border-neutral-800 bg-neutral-900/60 px-4 py-3">
130
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
131
+ <div className="max-w-3xl">
132
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-sky-400">
133
+ {copy.eyebrow}
134
+ </div>
135
+ <h2 className="mt-0.5 text-sm font-semibold text-neutral-100">{copy.title}</h2>
136
+ <p className="mt-1 text-xs leading-relaxed text-neutral-400">{copy.body}</p>
137
+ </div>
138
+ {summary.frameCount > 0 && (
139
+ <div className="shrink-0">
140
+ <div className="mb-1.5 text-right text-[11px] font-medium text-neutral-300">
141
+ {progress}
142
+ </div>
143
+ <div className="flex flex-wrap justify-end gap-1.5" aria-label="Frame status summary">
144
+ {FRAME_STATUS_ORDER.map((status) => (
145
+ <span
146
+ key={status}
147
+ className={`rounded px-2 py-1 text-[10px] font-medium ${FRAME_STATUS_META[status].chipClass}`}
148
+ >
149
+ {summary.counts[status]} {FRAME_STATUS_META[status].label}
150
+ </span>
151
+ ))}
152
+ </div>
153
+ </div>
154
+ )}
155
+ </div>
156
+
157
+ {summary.frameCount > 0 && (
158
+ <div className="mt-3 border-t border-neutral-800 pt-3">
159
+ <ReviewSteps current={handoffStep} />
160
+ <NextAction
161
+ stage={summary.stage}
162
+ step={handoffStep}
163
+ draftCount={draftCount}
164
+ onFeedbackMessageCopied={onFeedbackMessageCopied}
165
+ />
166
+ </div>
167
+ )}
168
+ </section>
169
+ );
170
+ }
171
+
172
+ function ReviewSteps({ current }: { current: StoryboardHandoffStep }) {
173
+ const steps = ["Review frames", "Save feedback", "Reply in agent chat"];
174
+ return (
175
+ <ol className="hidden items-center gap-2 sm:flex" aria-label="Storyboard review workflow">
176
+ {steps.map((label, index) => (
177
+ <ReviewStep
178
+ key={label}
179
+ label={label}
180
+ number={(index + 1) as StoryboardHandoffStep}
181
+ current={current}
182
+ />
183
+ ))}
184
+ </ol>
185
+ );
186
+ }
187
+
188
+ function ReviewStep({
189
+ label,
190
+ number,
191
+ current,
192
+ }: {
193
+ label: string;
194
+ number: StoryboardHandoffStep;
195
+ current: StoryboardHandoffStep;
196
+ }) {
197
+ const offset = Math.sign(number - current) as ReviewStepOffset;
198
+ const state = REVIEW_STEP_STATES[offset];
199
+
200
+ return (
201
+ <li className="flex items-center gap-2">
202
+ <span
203
+ className={`text-neutral-700 ${REVIEW_STEP_SEPARATOR_CLASS[number]}`}
204
+ aria-hidden="true"
205
+ >
206
+
207
+ </span>
208
+ <span
209
+ aria-current={state.ariaCurrent}
210
+ className={`flex items-center gap-1.5 text-[11px] font-medium ${state.textClass}`}
211
+ >
212
+ <span
213
+ className={`flex h-5 w-5 items-center justify-center rounded-full border text-[10px] ${state.numberClass}`}
214
+ >
215
+ {state.marker(number)}
216
+ </span>
217
+ {label}
218
+ </span>
219
+ </li>
220
+ );
221
+ }
222
+
223
+ function NextAction({
224
+ stage,
225
+ step,
226
+ draftCount,
227
+ onFeedbackMessageCopied,
228
+ }: {
229
+ stage: StoryboardReviewStage;
230
+ step: StoryboardHandoffStep;
231
+ draftCount: number;
232
+ onFeedbackMessageCopied: () => void;
233
+ }) {
234
+ if (step === 3) {
235
+ return <AgentHandoffAction onFeedbackMessageCopied={onFeedbackMessageCopied} />;
236
+ }
237
+ if (step === 2) return <SaveFeedbackAction draftCount={draftCount} />;
238
+ return <ReviewFramesAction stage={stage} />;
239
+ }
240
+
241
+ function AgentHandoffAction({ onFeedbackMessageCopied }: { onFeedbackMessageCopied: () => void }) {
242
+ return (
243
+ <div className="mt-3 flex flex-col gap-3 rounded-md border border-sky-900/70 bg-sky-950/20 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
244
+ <div className="max-w-3xl">
245
+ <div className="text-xs font-semibold text-sky-200">Next: return to your agent chat</div>
246
+ <p className="mt-0.5 text-[11px] text-neutral-400">
247
+ Feedback is saved, but the agent has not been notified. Paste this prompt in your terminal
248
+ or IDE agent chat.
249
+ </p>
250
+ </div>
251
+ <AgentChatMessageButton
252
+ message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
253
+ onCopied={onFeedbackMessageCopied}
254
+ />
255
+ </div>
256
+ );
257
+ }
258
+
259
+ function SaveFeedbackAction({ draftCount }: { draftCount: number }) {
260
+ return (
261
+ <div className="mt-3 rounded-md border border-neutral-800 bg-neutral-950/40 px-3 py-2.5">
262
+ <div>
263
+ <div className="text-xs font-semibold text-neutral-200">Next: save your feedback</div>
264
+ <p className="mt-0.5 text-[11px] text-neutral-500">
265
+ {draftCount} frame{draftCount === 1 ? " has" : "s have"} feedback ready. Use Save &amp;
266
+ copy message above to prepare this batch for your agent.
267
+ </p>
268
+ </div>
269
+ </div>
270
+ );
271
+ }
272
+
273
+ function ReviewFramesAction({ stage }: { stage: StoryboardReviewStage }) {
274
+ const copy = REVIEW_ACTION_COPY[stage];
275
+
276
+ return (
277
+ <div className="mt-3 flex flex-col gap-3 rounded-md border border-neutral-800 bg-neutral-950/40 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
278
+ <div>
279
+ <div className="text-xs font-semibold text-neutral-200">Next: review the frames</div>
280
+ <p className="mt-0.5 text-[11px] text-neutral-500">{copy.body}</p>
281
+ </div>
282
+ {copy.approvalMessage && (
283
+ <AgentChatMessageButton message={copy.approvalMessage} label="Copy approval message" />
284
+ )}
285
+ </div>
286
+ );
287
+ }
288
+
289
+ // The label intentionally folds six workflow states into three user-facing progress formats.
290
+ // fallow-ignore-next-line complexity
291
+ function progressLabel(summary: ReturnType<typeof deriveStoryboardReviewStage>): string {
292
+ if (summary.stage === "sketch-in-progress" || summary.stage === "sketch-review") {
293
+ const ready = summary.frameCount - summary.counts.outline;
294
+ return `${ready} of ${summary.frameCount} visual sketches ready`;
295
+ }
296
+ if (summary.stage === "animation-in-progress" || summary.stage === "final-review") {
297
+ return `${summary.counts.animated} of ${summary.frameCount} animations ready`;
298
+ }
299
+ return `${summary.frameCount} plan frame${summary.frameCount === 1 ? "" : "s"} ready`;
300
+ }
@@ -0,0 +1,170 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { ViewModeProvider, useViewModeState } from "../../contexts/ViewModeContext";
7
+ import { ViewModeToggle } from "../StudioHeader";
8
+ import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ vi.mock("../../contexts/FileManagerContext", () => ({
13
+ useFileManagerContext: () => ({
14
+ readProjectFile: vi.fn(),
15
+ writeProjectFile: vi.fn(),
16
+ }),
17
+ }));
18
+
19
+ vi.mock("../../utils/studioTelemetry", () => ({
20
+ trackStudioEvent: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("./FramePoster", () => ({
24
+ FramePoster: () => <div>poster</div>,
25
+ posterTime: () => 0,
26
+ }));
27
+
28
+ const onSelectComposition = vi.fn();
29
+
30
+ function TestApp() {
31
+ const viewMode = useViewModeState();
32
+ return (
33
+ <ViewModeProvider value={viewMode}>
34
+ <span data-view-mode={viewMode.viewMode}>{viewMode.viewMode}</span>
35
+ <ViewModeToggle />
36
+ {viewMode.viewMode === "storyboard" && (
37
+ <StoryboardFrameFocus
38
+ projectId="project"
39
+ storyboardPath="STORYBOARD.md"
40
+ frame={{
41
+ index: 1,
42
+ number: 1,
43
+ title: "Opening",
44
+ status: "built",
45
+ src: "frames/01-opening.html",
46
+ srcExists: true,
47
+ voiceover: "Original voiceover",
48
+ narrative: "",
49
+ extra: {},
50
+ }}
51
+ frameCount={1}
52
+ onBack={vi.fn()}
53
+ onNavigate={vi.fn()}
54
+ onSaved={vi.fn()}
55
+ onSelectComposition={onSelectComposition}
56
+ scriptExists={false}
57
+ commentDraft=""
58
+ onCommentDraftChange={vi.fn()}
59
+ pendingComment={null}
60
+ pendingCommentCount={0}
61
+ commentDraftCount={0}
62
+ commentsSubmitState="idle"
63
+ commentsSubmitError={null}
64
+ feedbackMessageCopied={false}
65
+ onFeedbackMessageCopied={vi.fn()}
66
+ onSaveFeedback={vi.fn()}
67
+ />
68
+ )}
69
+ </ViewModeProvider>
70
+ );
71
+ }
72
+
73
+ function renderApp(): { host: HTMLDivElement; root: Root } {
74
+ const host = document.createElement("div");
75
+ document.body.append(host);
76
+ const root = createRoot(host);
77
+ act(() => root.render(<TestApp />));
78
+ return { host, root };
79
+ }
80
+
81
+ function makeVoiceoverDirty(host: HTMLElement): void {
82
+ const textarea = host.querySelector<HTMLTextAreaElement>(
83
+ 'textarea[placeholder^="What the narrator says"]',
84
+ );
85
+ if (!textarea) throw new Error("voiceover textarea not rendered");
86
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
87
+ act(() => {
88
+ valueSetter?.call(textarea, "Changed voiceover");
89
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
90
+ });
91
+ }
92
+
93
+ function clickButton(host: HTMLElement, label: string): void {
94
+ const button = [...host.querySelectorAll("button")].find(
95
+ (candidate) => candidate.textContent?.trim() === label,
96
+ );
97
+ if (!button) throw new Error(`button not found: ${label}`);
98
+ act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
99
+ }
100
+
101
+ async function goBack(): Promise<void> {
102
+ await act(async () => {
103
+ window.history.back();
104
+ await new Promise((resolve) => setTimeout(resolve, 0));
105
+ });
106
+ }
107
+
108
+ beforeEach(() => {
109
+ window.history.replaceState({ entry: "timeline" }, "", "/");
110
+ window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
111
+ onSelectComposition.mockReset();
112
+ });
113
+
114
+ afterEach(() => {
115
+ document.body.innerHTML = "";
116
+ vi.restoreAllMocks();
117
+ });
118
+
119
+ describe("dirty storyboard voiceover view-mode guard", () => {
120
+ it("guards the header Preview transition on decline and allows it on accept", () => {
121
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
122
+ const { host, root } = renderApp();
123
+ makeVoiceoverDirty(host);
124
+
125
+ clickButton(host, "Preview");
126
+ expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
127
+ expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
128
+
129
+ confirm.mockReturnValue(true);
130
+ clickButton(host, "Preview");
131
+ expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
132
+ expect(onSelectComposition).not.toHaveBeenCalled();
133
+ act(() => root.unmount());
134
+ });
135
+
136
+ it("guards Open in Preview on decline and selects the frame only after accept", () => {
137
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
138
+ const { host, root } = renderApp();
139
+ makeVoiceoverDirty(host);
140
+
141
+ clickButton(host, "Open in Preview →");
142
+ expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
143
+ expect(onSelectComposition).not.toHaveBeenCalled();
144
+
145
+ confirm.mockReturnValue(true);
146
+ clickButton(host, "Open in Preview →");
147
+ expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
148
+ expect(onSelectComposition).toHaveBeenCalledWith("frames/01-opening.html");
149
+ act(() => root.unmount());
150
+ });
151
+
152
+ it("guards browser history transitions on decline and allows them on accept", async () => {
153
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
154
+ const { host, root } = renderApp();
155
+ makeVoiceoverDirty(host);
156
+
157
+ await goBack();
158
+ expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
159
+ expect(window.location.search).toBe("?view=storyboard");
160
+ expect(window.history.state).toEqual({ entry: "storyboard" });
161
+ expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
162
+
163
+ confirm.mockReturnValue(true);
164
+ await goBack();
165
+ expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
166
+ expect(window.location.search).toBe("");
167
+ expect(window.history.state).toEqual({ entry: "timeline" });
168
+ act(() => root.unmount());
169
+ });
170
+ });