@hyperframes/studio 0.7.60 → 0.7.62

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 (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -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
  );
@@ -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" },