@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.
- package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
- package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
- package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
- package/dist/assets/index-BhWig0mx.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1441 -770
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -9
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +47 -28
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +44 -42
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
- package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +2 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
- package/src/hooks/useFileManager.ts +76 -12
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/player/lib/playbackTypes.ts +7 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.ts +8 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.test.ts +21 -5
- package/src/utils/sdkEditTransaction.ts +5 -4
- package/src/utils/studioFileHistory.ts +6 -4
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +21 -0
- package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
- package/dist/assets/index-Dj5p8U_A.css +0 -1
- 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
|
-
|
|
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
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
180
|
+
submitError,
|
|
181
|
+
messageCopied,
|
|
182
|
+
onSave,
|
|
183
|
+
onMessageCopied,
|
|
139
184
|
}: {
|
|
140
185
|
draftCount: number;
|
|
141
186
|
pendingCount: number;
|
|
142
187
|
submitState: CommentsSubmitState;
|
|
143
|
-
|
|
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-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 & 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 &
|
|
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
|
+
});
|