@hyperframes/studio 0.7.60 → 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 (47) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-cH6NfVV_.js → index-2mxh_HSy.js} +177 -177
  3. package/dist/assets/{index-D6etaey-.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-Dh_WhagG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1202 -673
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +3 -8
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +34 -26
  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/propertyPanelFlatPrimitives.test.tsx +10 -0
  20. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -0
  21. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  22. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  23. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  24. package/src/components/editor/propertyPanelFlatTextSection.tsx +2 -2
  25. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  26. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  27. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  28. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  29. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  30. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  31. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  32. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  33. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  34. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  35. package/src/components/storyboard/useFrameComments.ts +22 -6
  36. package/src/contexts/ViewModeContext.tsx +60 -7
  37. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  38. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  39. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  40. package/src/hooks/useSlideshowTabState.ts +61 -0
  41. package/src/player/lib/playbackTypes.ts +7 -0
  42. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  43. package/src/player/lib/timelineDOM.ts +8 -11
  44. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  45. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  46. package/dist/assets/index-DXbu6IPT.css +0 -1
  47. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -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
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveStoryboardHandoffStep, deriveStoryboardReviewStage } from "./storyboardReviewStage";
3
+
4
+ describe("deriveStoryboardReviewStage", () => {
5
+ it.each([
6
+ [[], "empty"],
7
+ [["outline", "outline"], "plan-review"],
8
+ [["outline", "built"], "sketch-in-progress"],
9
+ [["outline", "animated"], "sketch-in-progress"],
10
+ [["built", "built"], "sketch-review"],
11
+ [["built", "animated"], "animation-in-progress"],
12
+ [["animated", "animated"], "final-review"],
13
+ ] as const)("maps %j to %s", (statuses, expected) => {
14
+ expect(deriveStoryboardReviewStage(statuses.map((status) => ({ status }))).stage).toBe(
15
+ expected,
16
+ );
17
+ });
18
+
19
+ it("returns counts for the visible progress summary", () => {
20
+ expect(
21
+ deriveStoryboardReviewStage([
22
+ { status: "outline" },
23
+ { status: "built" },
24
+ { status: "animated" },
25
+ { status: "animated" },
26
+ ]).counts,
27
+ ).toEqual({ outline: 1, built: 1, animated: 2 });
28
+ });
29
+ });
30
+
31
+ describe("storyboard review handoff", () => {
32
+ it.each([
33
+ [0, 0, 1],
34
+ [2, 0, 2],
35
+ [0, 3, 3],
36
+ [1, 3, 2],
37
+ ] as const)("maps %i drafts and %i pending comments to step %i", (drafts, pending, step) => {
38
+ expect(deriveStoryboardHandoffStep(drafts, pending)).toBe(step);
39
+ });
40
+ });
@@ -0,0 +1,45 @@
1
+ import type { FrameStatus } from "@hyperframes/core/storyboard";
2
+
3
+ export type StoryboardReviewStage =
4
+ | "empty"
5
+ | "plan-review"
6
+ | "sketch-in-progress"
7
+ | "sketch-review"
8
+ | "animation-in-progress"
9
+ | "final-review";
10
+
11
+ export interface StoryboardReviewSummary {
12
+ stage: StoryboardReviewStage;
13
+ counts: Record<FrameStatus, number>;
14
+ frameCount: number;
15
+ }
16
+
17
+ export type StoryboardHandoffStep = 1 | 2 | 3;
18
+
19
+ /** Current user action in the Studio → agent feedback handoff. */
20
+ export function deriveStoryboardHandoffStep(
21
+ draftCount: number,
22
+ pendingCount: number,
23
+ ): StoryboardHandoffStep {
24
+ if (draftCount > 0) return 2;
25
+ if (pendingCount > 0) return 3;
26
+ return 1;
27
+ }
28
+
29
+ /** Derive the board-level review moment from agent-owned frame statuses. */
30
+ export function deriveStoryboardReviewStage(
31
+ frames: ReadonlyArray<{ status: FrameStatus }>,
32
+ ): StoryboardReviewSummary {
33
+ const counts: Record<FrameStatus, number> = { outline: 0, built: 0, animated: 0 };
34
+ for (const frame of frames) counts[frame.status] += 1;
35
+
36
+ let stage: StoryboardReviewStage;
37
+ if (frames.length === 0) stage = "empty";
38
+ else if (counts.outline === frames.length) stage = "plan-review";
39
+ else if (counts.outline > 0) stage = "sketch-in-progress";
40
+ else if (counts.built === frames.length) stage = "sketch-review";
41
+ else if (counts.built > 0) stage = "animation-in-progress";
42
+ else stage = "final-review";
43
+
44
+ return { stage, counts, frameCount: frames.length };
45
+ }
@@ -17,8 +17,10 @@ export interface FrameCommentsValue {
17
17
  /** How many frames currently carry a non-empty draft. */
18
18
  draftCount: number;
19
19
  submitState: CommentsSubmitState;
20
+ /** Most recent submit failure, shown next to the submit action. */
21
+ submitError: string | null;
20
22
  /** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
21
- submit: () => Promise<void>;
23
+ submit: () => Promise<boolean>;
22
24
  /**
23
25
  * Comments already submitted but not yet consumed by the agent (the file
24
26
  * still exists on disk). Refreshed on mount, after submit, and on window
@@ -34,6 +36,7 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
34
36
  const { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
35
37
  const [drafts, setDrafts] = useState<Record<number, string>>({});
36
38
  const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
39
+ const [submitError, setSubmitError] = useState<string | null>(null);
37
40
  const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
38
41
 
39
42
  const refreshPending = useCallback(async () => {
@@ -61,22 +64,35 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
61
64
  [drafts],
62
65
  );
63
66
 
67
+ // One guarded async transaction owns loading, success, failure, and cleanup state.
68
+ // fallow-ignore-next-line complexity
64
69
  const submit = useCallback(async () => {
65
- if (draftCount === 0 || submitState === "saving") return;
70
+ if (draftCount === 0 || submitState === "saving") return false;
66
71
  setSubmitState("saving");
72
+ setSubmitError(null);
67
73
  try {
68
74
  const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
69
75
  const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
70
76
  await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
71
77
  setDrafts({});
72
78
  setPending(file.comments);
73
- } catch {
74
- // writeProjectFile surfaces save failures through the studio save banner;
75
- // just re-arm the button so the user can retry.
79
+ return true;
80
+ } catch (err: unknown) {
81
+ setSubmitError(err instanceof Error ? err.message : "Failed to submit comments");
82
+ return false;
76
83
  } finally {
77
84
  setSubmitState("idle");
78
85
  }
79
86
  }, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
80
87
 
81
- return { drafts, setDraft, draftCount, submitState, submit, pending, refreshPending };
88
+ return {
89
+ drafts,
90
+ setDraft,
91
+ draftCount,
92
+ submitState,
93
+ submitError,
94
+ submit,
95
+ pending,
96
+ refreshPending,
97
+ };
82
98
  }