@hyperframes/studio 0.8.49 → 0.8.51

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 (281) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +29 -16
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7283 -8606
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +132 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  20. package/src/components/EditorShell.tsx +28 -7
  21. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  22. package/src/components/LintModal.tsx +6 -16
  23. package/src/components/MediaPreview.tsx +2 -2
  24. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  25. package/src/components/StudioHeader.tsx +2 -62
  26. package/src/components/StudioLeftSidebar.tsx +7 -4
  27. package/src/components/StudioOverlays.tsx +2 -6
  28. package/src/components/StudioRightPanel.tsx +4 -4
  29. package/src/components/StudioToast.tsx +2 -2
  30. package/src/components/TimelineToolbar.tsx +7 -7
  31. package/src/components/dock/Dock.test.tsx +192 -0
  32. package/src/components/dock/Dock.tsx +264 -0
  33. package/src/components/dock/dock.css +23 -0
  34. package/src/components/dock/dockLayout.ts +58 -0
  35. package/src/components/dock/dockLayoutSchema.ts +66 -0
  36. package/src/components/dock/dockLayoutStore.ts +91 -0
  37. package/src/components/dock/panelRegistry.ts +67 -0
  38. package/src/components/editor/AnimationCard.tsx +4 -4
  39. package/src/components/editor/AnimationCardParts.tsx +6 -6
  40. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  41. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  42. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  43. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  44. package/src/components/editor/DomEditOverlay.tsx +1 -1
  45. package/src/components/editor/EaseCurveSection.tsx +1 -1
  46. package/src/components/editor/EaseParamFields.tsx +5 -5
  47. package/src/components/editor/FileTree.tsx +4 -4
  48. package/src/components/editor/FileTreeIcons.tsx +1 -1
  49. package/src/components/editor/FileTreeNodes.tsx +13 -13
  50. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  51. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  52. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  53. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  54. package/src/components/editor/LayersPanel.tsx +3 -3
  55. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  56. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  57. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  58. package/src/components/editor/PreviewGuides.tsx +125 -0
  59. package/src/components/editor/PromotableControl.tsx +2 -2
  60. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  61. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  62. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  63. package/src/components/editor/SnapToolbar.tsx +27 -3
  64. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  65. package/src/components/editor/TopologyLens.tsx +4 -4
  66. package/src/components/editor/Transform3DCube.tsx +2 -2
  67. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  68. package/src/components/editor/domEditing.test.ts +1 -1
  69. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  70. package/src/components/editor/domEditingAgentPrompt.ts +9 -3
  71. package/src/components/editor/previewGuidesStore.ts +26 -0
  72. package/src/components/editor/propertyPanelColor.tsx +11 -11
  73. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  74. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  75. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  76. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  77. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  78. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  79. package/src/components/editor/propertyPanelFill.tsx +4 -4
  80. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  81. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  82. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  83. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  84. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  85. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  86. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  87. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  88. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  89. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  90. package/src/components/editor/propertyPanelFont.tsx +5 -5
  91. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  92. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  93. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  94. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  95. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  96. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  97. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  98. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  99. package/src/components/editor/propertyPanelSections.tsx +8 -8
  100. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  101. package/src/components/feedback/feedbackTrigger.ts +0 -1
  102. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  103. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  104. package/src/components/nle/NLEPreview.test.ts +13 -0
  105. package/src/components/nle/NLEPreview.tsx +12 -7
  106. package/src/components/nle/PreviewOverlays.tsx +6 -6
  107. package/src/components/nle/PreviewPane.test.tsx +52 -0
  108. package/src/components/nle/PreviewPane.tsx +4 -2
  109. package/src/components/nle/TimelinePane.tsx +17 -3
  110. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  111. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  112. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  113. package/src/components/panels/SlideshowPanel.tsx +2 -2
  114. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  115. package/src/components/panels/VariablesBindElement.tsx +2 -2
  116. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  117. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  118. package/src/components/panels/VariablesPanel.tsx +3 -3
  119. package/src/components/panels/VariablesValueControls.tsx +2 -2
  120. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  121. package/src/components/renders/RenderQueue.test.tsx +7 -0
  122. package/src/components/renders/RenderQueue.tsx +6 -6
  123. package/src/components/renders/RenderQueueItem.tsx +6 -6
  124. package/src/components/sidebar/AssetCard.tsx +8 -8
  125. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  126. package/src/components/sidebar/AssetsTab.tsx +2 -2
  127. package/src/components/sidebar/AudioRow.tsx +6 -6
  128. package/src/components/sidebar/BlocksTab.tsx +6 -6
  129. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  130. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  131. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  132. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  133. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  134. package/src/components/ui/Button.test.tsx +60 -0
  135. package/src/components/ui/Button.tsx +4 -4
  136. package/src/components/ui/SearchInput.tsx +2 -2
  137. package/src/components/ui/Tooltip.tsx +1 -1
  138. package/src/hooks/appHotkeysDispatch.ts +11 -0
  139. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  140. package/src/hooks/timelineTrackVisibility.ts +8 -11
  141. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  142. package/src/hooks/useAppHotkeys.ts +4 -1
  143. package/src/hooks/useAskAgentModal.ts +2 -0
  144. package/src/hooks/useClipboard.test.ts +234 -0
  145. package/src/hooks/useClipboard.ts +264 -70
  146. package/src/hooks/useGroupCommits.test.ts +0 -1
  147. package/src/hooks/useLintModal.test.tsx +65 -0
  148. package/src/hooks/useLintModal.ts +3 -0
  149. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  150. package/src/hooks/useRazorSplit.test.ts +0 -2
  151. package/src/hooks/useRazorSplit.test.tsx +0 -1
  152. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  153. package/src/hooks/useSdkSession.ts +43 -10
  154. package/src/hooks/useStudioContextValue.ts +5 -19
  155. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  156. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  157. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  158. package/src/main.tsx +9 -3
  159. package/src/player/components/AudioWaveform.tsx +1 -1
  160. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  161. package/src/player/components/ClipContextMenu.tsx +90 -10
  162. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  163. package/src/player/components/CompositionThumbnail.tsx +3 -4
  164. package/src/player/components/EditModal.tsx +1 -1
  165. package/src/player/components/ImageThumbnail.tsx +1 -1
  166. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  167. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  168. package/src/player/components/PlayerControls.tsx +1 -1
  169. package/src/player/components/ShortcutsPanel.tsx +9 -9
  170. package/src/player/components/SpeedMenu.tsx +3 -5
  171. package/src/player/components/Timeline.tsx +16 -15
  172. package/src/player/components/TimelineCanvas.tsx +45 -8
  173. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  174. package/src/player/components/TimelineEmptyState.tsx +5 -5
  175. package/src/player/components/TimelineFxButton.tsx +6 -6
  176. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  177. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  178. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  179. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  180. package/src/player/components/TimelineGroupRow.tsx +1 -1
  181. package/src/player/components/TimelineLanes.test.tsx +54 -1
  182. package/src/player/components/TimelineLanes.tsx +2 -5
  183. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  184. package/src/player/components/TimelineOverlays.tsx +12 -0
  185. package/src/player/components/TimelineRuler.tsx +1 -1
  186. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  187. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  188. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  189. package/src/player/components/TimelineTypes.ts +6 -1
  190. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  191. package/src/player/components/VideoThumbnail.tsx +4 -2
  192. package/src/player/components/VolumeControl.tsx +2 -2
  193. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  194. package/src/player/components/timelineCallbacks.ts +8 -8
  195. package/src/player/components/timelineClipChildren.test.ts +2 -2
  196. package/src/player/components/timelineClipChildren.tsx +2 -1
  197. package/src/player/components/timelineClipDragCommit.ts +2 -2
  198. package/src/player/components/timelineClipDragPreview.ts +51 -27
  199. package/src/player/components/timelineClipDragTypes.ts +3 -0
  200. package/src/player/components/timelineDragDrop.test.tsx +92 -1
  201. package/src/player/components/timelineDragDrop.ts +82 -33
  202. package/src/player/components/timelineEditCapabilities.ts +1 -2
  203. package/src/player/components/timelineEditing.test.ts +0 -16
  204. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  205. package/src/player/components/timelineGroupEditing.ts +0 -1
  206. package/src/player/components/timelineLaneProps.ts +3 -0
  207. package/src/player/components/timelineSnapping.test.ts +27 -0
  208. package/src/player/components/timelineSnapping.ts +14 -1
  209. package/src/player/components/timelineTrackDisplay.ts +1 -1
  210. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  211. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  212. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  213. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  214. package/src/player/components/useTimelineClipDrag.ts +30 -3
  215. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  216. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  217. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  218. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  219. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  220. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  221. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  222. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  224. package/src/player/lib/describeClips.test.ts +95 -0
  225. package/src/player/lib/describeClips.ts +52 -0
  226. package/src/player/lib/timelineDOM.test.ts +0 -80
  227. package/src/player/lib/timelineDOM.ts +1 -61
  228. package/src/player/lib/timelineElementHelpers.ts +2 -53
  229. package/src/player/store/playerStore.ts +5 -0
  230. package/src/player/store/timelineElement.ts +1 -3
  231. package/src/shims/canvasBrowserStub.js +50 -0
  232. package/src/styles/iconTokens.ts +20 -0
  233. package/src/styles/palette-reset.css +6 -0
  234. package/src/styles/studio.css +24 -21
  235. package/src/styles/tailwind-preset.shared.js +3 -0
  236. package/src/styles/theme.css +213 -0
  237. package/src/styles/theme.test.ts +196 -0
  238. package/src/utils/clipboardPayload.test.ts +42 -2
  239. package/src/utils/clipboardPayload.ts +57 -32
  240. package/src/utils/previewSafeMargins.test.ts +34 -0
  241. package/src/utils/previewSafeMargins.ts +10 -0
  242. package/src/utils/razorSplitTransaction.test.ts +0 -1
  243. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  244. package/src/utils/studioUiPreferences.test.ts +43 -0
  245. package/src/utils/studioUiPreferences.ts +34 -6
  246. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  247. package/src/utils/timelineDropTrackInsert.ts +83 -0
  248. package/src/utils/timelineElementSplit.test.ts +1 -2
  249. package/src/utils/timelineElementSplit.ts +0 -1
  250. package/src/webmcp/tools/lookTools.test.ts +40 -0
  251. package/src/webmcp/tools/lookTools.ts +32 -1
  252. package/dist/assets/index-B9LfWNyM.css +0 -1
  253. package/dist/assets/index-Bgmx2h7W.js +0 -441
  254. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  255. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  256. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  257. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  258. package/src/components/storyboard/FramePoster.tsx +0 -86
  259. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  260. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  261. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  262. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  263. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  264. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  265. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  266. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  267. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  268. package/src/components/storyboard/StoryboardView.tsx +0 -169
  269. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  270. package/src/components/storyboard/frameComments.test.ts +0 -95
  271. package/src/components/storyboard/frameComments.ts +0 -115
  272. package/src/components/storyboard/frameStatus.ts +0 -36
  273. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  274. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  275. package/src/components/storyboard/useFrameComments.ts +0 -98
  276. package/src/contexts/ViewModeContext.tsx +0 -142
  277. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  278. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  279. package/src/hooks/useStoryboard.ts +0 -99
  280. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  281. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -1,231 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { marked } from "marked";
3
- import DOMPurify from "dompurify";
4
- import { SourceEditor } from "../editor/SourceEditor";
5
- import { useFileManagerContext } from "../../contexts/FileManagerContext";
6
-
7
- export interface SourceFile {
8
- path: string;
9
- label: string;
10
- }
11
-
12
- export interface StoryboardSourceEditorProps {
13
- files: SourceFile[];
14
- /** Called after a successful save so the Board can re-parse the updated file. */
15
- onSaved: () => void;
16
- /** Surfaces unsaved-edit state so the parent can guard the Board↔Source toggle. */
17
- onDirtyChange?: (dirty: boolean) => void;
18
- }
19
-
20
- const DISCARD_PROMPT = "Discard unsaved markdown changes?";
21
-
22
- interface EditableFile {
23
- content: string;
24
- setContent: (next: string) => void;
25
- dirty: boolean;
26
- loading: boolean;
27
- saving: boolean;
28
- error: string | null;
29
- save: () => void;
30
- }
31
-
32
- /** Load a project file's raw text and track edits + save state via the shared file manager. */
33
- function useEditableFile(path: string, onSaved: () => void): EditableFile {
34
- const { readProjectFile, writeProjectFile } = useFileManagerContext();
35
- const [content, setContent] = useState("");
36
- const [saved, setSaved] = useState("");
37
- const [loading, setLoading] = useState(true);
38
- const [saving, setSaving] = useState(false);
39
- const [error, setError] = useState<string | null>(null);
40
-
41
- useEffect(() => {
42
- if (!path) return;
43
- let cancelled = false;
44
- setLoading(true);
45
- setError(null);
46
- readProjectFile(path)
47
- .then((text) => {
48
- if (cancelled) return;
49
- setContent(text);
50
- setSaved(text);
51
- })
52
- .catch((err: unknown) => {
53
- if (!cancelled) setError(err instanceof Error ? err.message : "failed to load file");
54
- })
55
- .finally(() => {
56
- if (!cancelled) setLoading(false);
57
- });
58
- return () => {
59
- cancelled = true;
60
- };
61
- }, [path, readProjectFile]);
62
-
63
- const save = useCallback(() => {
64
- if (saving) return; // coalesce a fast double Cmd+S into one PUT
65
- setSaving(true);
66
- setError(null);
67
- writeProjectFile(path, content)
68
- .then(() => {
69
- setSaved(content);
70
- onSaved();
71
- })
72
- .catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
73
- .finally(() => setSaving(false));
74
- }, [writeProjectFile, path, content, onSaved, saving]);
75
-
76
- return { content, setContent, dirty: content !== saved, loading, saving, error, save };
77
- }
78
-
79
- /** Preview links open in a new tab with the `window.opener` back-channel severed. */
80
- function hardenLinks(node: Element): void {
81
- if (node.tagName === "A" && node.hasAttribute("href")) {
82
- node.setAttribute("target", "_blank");
83
- node.setAttribute("rel", "noopener noreferrer");
84
- }
85
- }
86
-
87
- /** Render markdown to sanitized HTML, debounced so we don't re-parse on every keystroke. */
88
- function useMarkdownPreview(source: string): string {
89
- const [debounced, setDebounced] = useState(source);
90
- const primed = useRef(false);
91
- useEffect(() => {
92
- // Paint the first non-empty content immediately (no 200ms blank window after a file
93
- // loads), exactly once, then debounce all subsequent keystrokes.
94
- if (!primed.current && source !== "") {
95
- primed.current = true;
96
- setDebounced(source);
97
- return;
98
- }
99
- const id = window.setTimeout(() => setDebounced(source), 200);
100
- return () => window.clearTimeout(id);
101
- }, [source]);
102
- return useMemo(() => {
103
- // `{ async: false }` pins the synchronous string return (no Promise union to narrow).
104
- const html = marked.parse(debounced, { async: false });
105
- // Scope the link-hardening hook to this call; `finally` guarantees removal even if
106
- // `sanitize` throws, so the hook can never leak into other DOMPurify consumers.
107
- DOMPurify.addHook("afterSanitizeAttributes", hardenLinks);
108
- try {
109
- return DOMPurify.sanitize(html);
110
- } finally {
111
- DOMPurify.removeHook("afterSanitizeAttributes");
112
- }
113
- }, [debounced]);
114
- }
115
-
116
- function isSaveShortcut(event: React.KeyboardEvent): boolean {
117
- return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
118
- }
119
-
120
- // Minimal prose styling for the rendered preview (Tailwind doesn't style raw HTML).
121
- const PREVIEW_PROSE =
122
- "text-sm leading-relaxed text-neutral-300 " +
123
- "[&_h1]:mb-2 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-neutral-100 " +
124
- "[&_h2]:mb-1.5 [&_h2]:mt-5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-neutral-100 " +
125
- "[&_h3]:mt-4 [&_h3]:font-semibold [&_h3]:text-neutral-200 " +
126
- "[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 " +
127
- "[&_code]:rounded [&_code]:bg-neutral-800 [&_code]:px-1 [&_code]:text-[0.85em] [&_code]:text-neutral-200 " +
128
- "[&_pre]:my-3 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-3 " +
129
- "[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
130
- "[&_hr]:my-4 [&_hr]:border-neutral-800 [&_a]:text-sky-400 [&_strong]:text-neutral-100 " +
131
- "[&_img]:my-2 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded " +
132
- "[&_table]:my-3 [&_th]:border [&_th]:border-neutral-800 [&_th]:px-2 [&_th]:py-1 " +
133
- "[&_td]:border [&_td]:border-neutral-800 [&_td]:px-2 [&_td]:py-1";
134
-
135
- /**
136
- * Raw markdown editor + live preview for the storyboard's canonical files
137
- * (STORYBOARD.md / SCRIPT.md). Markdown stays the source of truth: saving writes
138
- * the file and re-parses the Board. Deliberately raw (not WYSIWYG) so the
139
- * structured frame fields can't be mangled; the preview is sanitized before render.
140
- */
141
- // fallow-ignore-next-line complexity
142
- export function StoryboardSourceEditor({
143
- files,
144
- onSaved,
145
- onDirtyChange,
146
- }: StoryboardSourceEditorProps) {
147
- const [selected, setSelected] = useState(files[0]?.path ?? "");
148
- // Reconcile against the current file list so a removed/renamed file can't strand the tab.
149
- const activePath = files.some((f) => f.path === selected) ? selected : (files[0]?.path ?? "");
150
- const file = useEditableFile(activePath, onSaved);
151
- const previewHtml = useMarkdownPreview(file.content);
152
-
153
- // Surface dirty state to the parent (guards the Board↔Source toggle) and warn
154
- // on browser-level navigation while there are unsaved edits.
155
- useEffect(() => onDirtyChange?.(file.dirty), [onDirtyChange, file.dirty]);
156
- useEffect(() => {
157
- if (!file.dirty) return;
158
- const onBeforeUnload = (event: BeforeUnloadEvent) => {
159
- event.preventDefault();
160
- event.returnValue = "";
161
- };
162
- window.addEventListener("beforeunload", onBeforeUnload);
163
- return () => window.removeEventListener("beforeunload", onBeforeUnload);
164
- }, [file.dirty]);
165
-
166
- // Switching files discards the in-memory buffer; confirm when there are unsaved edits.
167
- const selectFile = (path: string) => {
168
- if (path === activePath) return;
169
- if (file.dirty && !window.confirm(DISCARD_PROMPT)) return;
170
- setSelected(path);
171
- };
172
-
173
- return (
174
- <div
175
- className="flex flex-1 min-h-0 flex-col"
176
- onKeyDown={(e) => {
177
- if (!isSaveShortcut(e)) return;
178
- e.preventDefault();
179
- if (file.dirty && !file.saving) file.save();
180
- }}
181
- >
182
- <div className="flex items-center gap-1 border-b border-neutral-800 px-4 py-2">
183
- {files.map((f) => (
184
- <button
185
- key={f.path}
186
- type="button"
187
- onClick={() => selectFile(f.path)}
188
- className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
189
- activePath === f.path
190
- ? "bg-neutral-800 text-neutral-100"
191
- : "text-neutral-400 hover:text-neutral-200"
192
- }`}
193
- >
194
- {f.label}
195
- </button>
196
- ))}
197
- <div className="ml-auto flex items-center gap-3">
198
- {file.error && <span className="text-xs text-red-400">{file.error}</span>}
199
- <span className="text-xs text-neutral-500">
200
- {file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"}
201
- </span>
202
- <button
203
- type="button"
204
- onClick={file.save}
205
- disabled={!file.dirty || file.saving}
206
- className="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-40"
207
- >
208
- Save
209
- </button>
210
- </div>
211
- </div>
212
- <div className="flex flex-1 min-h-0">
213
- <div className="w-1/2 min-w-0 border-r border-neutral-800">
214
- {file.loading ? (
215
- <div className="p-4 text-sm text-neutral-500">Loading {activePath}…</div>
216
- ) : (
217
- <SourceEditor
218
- content={file.content}
219
- language="markdown"
220
- filePath={activePath}
221
- onChange={file.setContent}
222
- />
223
- )}
224
- </div>
225
- <div className="w-1/2 min-w-0 overflow-auto bg-neutral-950 px-6 py-4">
226
- <div className={PREVIEW_PROSE} dangerouslySetInnerHTML={{ __html: previewHtml }} />
227
- </div>
228
- </div>
229
- </div>
230
- );
231
- }
@@ -1,21 +0,0 @@
1
- import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
2
-
3
- /**
4
- * Explains the frame lifecycle: a frame advances outline → built → animated.
5
- * Mirrors the status chips on each tile (shares FRAME_STATUS_META).
6
- */
7
- export function StoryboardStatusLegend() {
8
- return (
9
- <div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
10
- <span className="uppercase tracking-wider text-neutral-600">Status</span>
11
- {FRAME_STATUS_ORDER.map((status, i) => (
12
- <span key={status} className="flex items-center gap-1.5">
13
- {i > 0 && <span className="text-neutral-700">→</span>}
14
- <span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
15
- <span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
16
- <span className="text-neutral-500">— {FRAME_STATUS_META[status].description}</span>
17
- </span>
18
- ))}
19
- </div>
20
- );
21
- }
@@ -1,169 +0,0 @@
1
- import { useState, type ReactNode } from "react";
2
- import { Copy, Check } from "@phosphor-icons/react";
3
- import { useStoryboard } from "../../hooks/useStoryboard";
4
- import { useProjectSignaturePoll } from "../../hooks/useProjectSignaturePoll";
5
- import { copyTextToClipboard } from "../../utils/clipboard";
6
- import { Button } from "../ui/Button";
7
- import { StoryboardLoaded } from "./StoryboardLoaded";
8
-
9
- export interface StoryboardViewProps {
10
- projectId: string;
11
- /** Select a composition in the timeline (used by the frame focus "Open in Preview"). */
12
- onSelectComposition: (path: string) => void;
13
- }
14
-
15
- /**
16
- * Top-level storyboard stage. Replaces the timeline/preview when the view mode
17
- * is `storyboard`. Handles the load states here; once a storyboard exists,
18
- * {@link StoryboardLoaded} owns the Board ↔ Source experience.
19
- */
20
- // fallow-ignore-next-line complexity
21
- export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
22
- const { data, loading, error, reload } = useStoryboard(projectId);
23
- // Keep the board current while an agent writes to the project: when the
24
- // project signature moves past the one `data` was loaded with, refetch. Also
25
- // upgrades the empty state the moment STORYBOARD.md lands on disk.
26
- useProjectSignaturePoll(projectId, data?.signature, reload);
27
-
28
- if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
29
- if (error) {
30
- return (
31
- <StoryboardFrame>
32
- <Message tone="error">Couldn’t load the storyboard: {error}</Message>
33
- <div className="flex justify-center">
34
- <Button size="sm" variant="secondary" onClick={reload}>
35
- Retry
36
- </Button>
37
- </div>
38
- </StoryboardFrame>
39
- );
40
- }
41
- if (!data) return <StoryboardFrame>{null}</StoryboardFrame>;
42
- if (!data.exists) {
43
- return (
44
- <StoryboardFrame>
45
- <EmptyState path={data.path} />
46
- </StoryboardFrame>
47
- );
48
- }
49
-
50
- return (
51
- <StoryboardLoaded
52
- projectId={projectId}
53
- data={data}
54
- reload={reload}
55
- onSelectComposition={onSelectComposition}
56
- />
57
- );
58
- }
59
-
60
- function StoryboardFrame({ children }: { children: ReactNode }) {
61
- return (
62
- <div className="flex-1 min-h-0 overflow-auto bg-neutral-950 text-neutral-200">
63
- <div className="mx-auto max-w-[1400px] px-8 py-8">{children}</div>
64
- </div>
65
- );
66
- }
67
-
68
- function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "muted" | "error" }) {
69
- return (
70
- <div
71
- className={`px-6 py-12 text-center text-sm ${
72
- tone === "error" ? "text-red-400" : "text-neutral-500"
73
- }`}
74
- >
75
- {children}
76
- </div>
77
- );
78
- }
79
-
80
- function handoffPrompt(path: string): string {
81
- return `Create a \`${path}\` at the project root to plan this video frame by frame.
82
-
83
- Use this format:
84
-
85
- ---
86
- format: 1920x1080
87
- message: <the one-line takeaway of the video>
88
- arc: <the narrative shape, e.g. Problem → Solution>
89
- audience: <who it's for>
90
- ---
91
-
92
- ## Frame 1 — <title>
93
- - duration: 5s
94
- - transition_in: crossfade
95
- - status: outline
96
- - src: compositions/frames/01-<slug>.html
97
-
98
- <A sentence or two: what's on screen and what the narration says.>
99
-
100
- Add one \`## Frame N\` section per beat. Keep the arc tight.
101
-
102
- Then run the review loop from the hyperframes skill (references/review-loop.md): present the plan as a proposal, offer wireframe sketches on this board, and build on the confirmed layouts.`;
103
- }
104
-
105
- function EmptyState({ path }: { path: string }) {
106
- const [copied, setCopied] = useState(false);
107
- const prompt = handoffPrompt(path);
108
-
109
- const onCopy = async () => {
110
- if (await copyTextToClipboard(prompt)) {
111
- setCopied(true);
112
- window.setTimeout(() => setCopied(false), 2000);
113
- }
114
- };
115
-
116
- return (
117
- <div>
118
- <div className="rounded-lg border border-dashed border-neutral-800 px-6 py-10 text-center">
119
- <h2 className="text-base font-semibold text-neutral-300">No storyboard yet</h2>
120
- <p className="mx-auto mt-2 max-w-md text-sm text-neutral-500">
121
- Add a <code className="rounded bg-neutral-900 px-1 py-0.5 text-neutral-400">{path}</code>{" "}
122
- at the project root to plan this video frame by frame. Hand this prompt to your coding
123
- agent to scaffold it.
124
- </p>
125
-
126
- <div className="mx-auto mt-6 max-w-2xl overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900 text-left">
127
- <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
128
- <span className="font-mono text-xs text-neutral-500">Prompt for your agent</span>
129
- <Button
130
- size="sm"
131
- variant="secondary"
132
- onClick={onCopy}
133
- icon={copied ? <Check size={14} /> : <Copy size={14} />}
134
- >
135
- {copied ? "Copied" : "Copy prompt"}
136
- </Button>
137
- </div>
138
- <pre className="max-h-64 overflow-auto px-4 py-3 font-mono text-xs leading-relaxed text-neutral-400 whitespace-pre-wrap">
139
- {prompt}
140
- </pre>
141
- </div>
142
- </div>
143
-
144
- <SkeletonPreview />
145
- </div>
146
- );
147
- }
148
-
149
- /** Faded placeholder of a filled board so landing here isn't a dead end —
150
- * it previews the contact-sheet layout {@link StoryboardGrid} renders. */
151
- function SkeletonPreview() {
152
- return (
153
- <div aria-hidden="true" className="mt-10 select-none opacity-40">
154
- <div className="mb-4 text-center text-xs uppercase tracking-wide text-neutral-600">
155
- Preview
156
- </div>
157
- <div className="grid gap-x-6 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
158
- {[0, 1, 2].map((i) => (
159
- <div key={i} className="rounded-lg border border-neutral-800 bg-neutral-900/40 p-3">
160
- <div className="aspect-video w-full rounded bg-neutral-800/60" />
161
- <div className="mt-3 h-3 w-2/3 rounded bg-neutral-800/60" />
162
- <div className="mt-2 h-2.5 w-full rounded bg-neutral-800/40" />
163
- <div className="mt-1.5 h-2.5 w-4/5 rounded bg-neutral-800/40" />
164
- </div>
165
- ))}
166
- </div>
167
- </div>
168
- );
169
- }
@@ -1,187 +0,0 @@
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
- const onNavigate = vi.fn();
30
-
31
- function TestApp() {
32
- const viewMode = useViewModeState();
33
- return (
34
- <ViewModeProvider value={viewMode}>
35
- <span data-view-mode={viewMode.viewMode}>{viewMode.viewMode}</span>
36
- <ViewModeToggle />
37
- {viewMode.viewMode === "storyboard" && (
38
- <StoryboardFrameFocus
39
- projectId="project"
40
- storyboardPath="STORYBOARD.md"
41
- frame={{
42
- index: 1,
43
- number: 1,
44
- title: "Opening",
45
- status: "built",
46
- src: "frames/01-opening.html",
47
- srcExists: true,
48
- voiceover: "Original voiceover",
49
- narrative: "",
50
- extra: {},
51
- }}
52
- frameCount={2}
53
- onBack={vi.fn()}
54
- onNavigate={onNavigate}
55
- onSaved={vi.fn()}
56
- onSelectComposition={onSelectComposition}
57
- scriptExists={false}
58
- commentDraft=""
59
- onCommentDraftChange={vi.fn()}
60
- pendingComment={null}
61
- pendingCommentCount={0}
62
- commentDraftCount={0}
63
- commentsSubmitState="idle"
64
- commentsSubmitError={null}
65
- feedbackMessageCopied={false}
66
- onFeedbackMessageCopied={vi.fn()}
67
- onSaveFeedback={vi.fn()}
68
- />
69
- )}
70
- </ViewModeProvider>
71
- );
72
- }
73
-
74
- function renderApp(): { host: HTMLDivElement; root: Root } {
75
- const host = document.createElement("div");
76
- document.body.append(host);
77
- const root = createRoot(host);
78
- act(() => root.render(<TestApp />));
79
- return { host, root };
80
- }
81
-
82
- function makeVoiceoverDirty(host: HTMLElement): void {
83
- const textarea = host.querySelector<HTMLTextAreaElement>(
84
- 'textarea[placeholder^="What the narrator says"]',
85
- );
86
- if (!textarea) throw new Error("voiceover textarea not rendered");
87
- const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
88
- act(() => {
89
- valueSetter?.call(textarea, "Changed voiceover");
90
- textarea.dispatchEvent(new Event("input", { bubbles: true }));
91
- });
92
- }
93
-
94
- function clickButton(host: HTMLElement, label: string): void {
95
- const button = [...host.querySelectorAll("button")].find(
96
- (candidate) => candidate.textContent?.trim() === label,
97
- );
98
- if (!button) throw new Error(`button not found: ${label}`);
99
- act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
100
- }
101
-
102
- async function goBack(): Promise<void> {
103
- await act(async () => {
104
- window.history.back();
105
- await new Promise((resolve) => setTimeout(resolve, 0));
106
- });
107
- }
108
-
109
- beforeEach(() => {
110
- window.history.replaceState({ entry: "timeline" }, "", "/");
111
- window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
112
- onSelectComposition.mockReset();
113
- onNavigate.mockReset();
114
- });
115
-
116
- afterEach(() => {
117
- document.body.innerHTML = "";
118
- vi.restoreAllMocks();
119
- });
120
-
121
- describe("dirty storyboard voiceover view-mode guard", () => {
122
- it("guards the header Preview transition on decline and allows it on accept", () => {
123
- const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
124
- const { host, root } = renderApp();
125
- makeVoiceoverDirty(host);
126
-
127
- clickButton(host, "Preview");
128
- expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
129
- expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
130
-
131
- confirm.mockReturnValue(true);
132
- clickButton(host, "Preview");
133
- expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
134
- expect(onSelectComposition).not.toHaveBeenCalled();
135
- act(() => root.unmount());
136
- });
137
-
138
- it("guards Open in Preview on decline and selects the frame only after accept", () => {
139
- const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
140
- const { host, root } = renderApp();
141
- makeVoiceoverDirty(host);
142
-
143
- clickButton(host, "Open in Preview →");
144
- expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
145
- expect(onSelectComposition).not.toHaveBeenCalled();
146
-
147
- confirm.mockReturnValue(true);
148
- clickButton(host, "Open in Preview →");
149
- expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
150
- expect(onSelectComposition).toHaveBeenCalledWith("frames/01-opening.html");
151
- act(() => root.unmount());
152
- });
153
-
154
- it("guards browser history transitions on decline and allows them on accept", async () => {
155
- const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
156
- const { host, root } = renderApp();
157
- makeVoiceoverDirty(host);
158
-
159
- await goBack();
160
- expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
161
- expect(window.location.search).toBe("?view=storyboard");
162
- expect(window.history.state).toEqual({ entry: "storyboard" });
163
- expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
164
-
165
- confirm.mockReturnValue(true);
166
- await goBack();
167
- expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
168
- expect(window.location.search).toBe("");
169
- expect(window.history.state).toEqual({ entry: "timeline" });
170
- act(() => root.unmount());
171
- });
172
- });
173
-
174
- describe("storyboard frame keyboard navigation", () => {
175
- it("leaves arrow keys with contenteditable text", () => {
176
- const { host, root } = renderApp();
177
- const editor = document.createElement("div");
178
- editor.contentEditable = "true";
179
- host.append(editor);
180
- editor.focus();
181
-
182
- act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })));
183
-
184
- expect(onNavigate).not.toHaveBeenCalled();
185
- act(() => root.unmount());
186
- });
187
- });