@hyperframes/studio 0.8.50 → 0.8.52

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 (280) hide show
  1. package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-I5YRzB02.js} +3 -3
  2. package/dist/assets/{index-Dt3qBI-9.js → index-BTAfgesM.js} +1 -1
  3. package/dist/assets/index-CxAu_Okg.css +1 -0
  4. package/dist/assets/index-DocJlW2v.js +376 -0
  5. package/dist/assets/{index-DUAIWxkI.js → index-Du5x7KUR.js} +1 -1
  6. package/dist/index.d.ts +419 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7496 -8288
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +13 -12
  12. package/src/App.tsx +131 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  17. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  18. package/src/captions/components/CaptionTimeline.tsx +1 -1
  19. package/src/captions/components/shared.tsx +2 -2
  20. package/src/components/AskAgentModal.tsx +5 -5
  21. package/src/components/EditorShell.tsx +8 -7
  22. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  23. package/src/components/LintModal.tsx +6 -16
  24. package/src/components/MediaPreview.tsx +2 -2
  25. package/src/components/RightPanelTabs.test.tsx +151 -0
  26. package/src/components/RightPanelTabs.tsx +59 -0
  27. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  28. package/src/components/StudioHeader.dom.test.tsx +181 -0
  29. package/src/components/StudioHeader.tsx +110 -210
  30. package/src/components/StudioLeftSidebar.tsx +7 -4
  31. package/src/components/StudioOverlays.tsx +1 -1
  32. package/src/components/StudioRightPanel.tsx +51 -39
  33. package/src/components/StudioToast.tsx +2 -2
  34. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  35. package/src/components/TimelineHistoryButtons.tsx +54 -0
  36. package/src/components/TimelineToolbar.test.tsx +8 -0
  37. package/src/components/TimelineToolbar.tsx +8 -12
  38. package/src/components/dock/Dock.test.tsx +260 -0
  39. package/src/components/dock/Dock.tsx +281 -0
  40. package/src/components/dock/dock.css +28 -0
  41. package/src/components/dock/dockAccessibility.test.ts +166 -0
  42. package/src/components/dock/dockAccessibility.ts +98 -0
  43. package/src/components/dock/dockLayout.test.ts +134 -0
  44. package/src/components/dock/dockLayout.ts +105 -0
  45. package/src/components/dock/dockLayoutSchema.ts +66 -0
  46. package/src/components/dock/dockLayoutStore.ts +91 -0
  47. package/src/components/dock/dockTestHarness.ts +11 -0
  48. package/src/components/dock/panelRegistry.ts +67 -0
  49. package/src/components/editor/AnimationCard.tsx +4 -4
  50. package/src/components/editor/AnimationCardParts.tsx +6 -6
  51. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  52. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  53. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  54. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  55. package/src/components/editor/DomEditOverlay.tsx +1 -1
  56. package/src/components/editor/EaseCurveSection.tsx +1 -1
  57. package/src/components/editor/EaseParamFields.tsx +5 -5
  58. package/src/components/editor/FileTree.tsx +4 -4
  59. package/src/components/editor/FileTreeIcons.tsx +1 -1
  60. package/src/components/editor/FileTreeNodes.tsx +13 -13
  61. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  62. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  63. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  64. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  65. package/src/components/editor/LayersPanel.test.ts +97 -1
  66. package/src/components/editor/LayersPanel.tsx +7 -5
  67. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  68. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  69. package/src/components/editor/PreviewGuides.tsx +1 -1
  70. package/src/components/editor/PromotableControl.tsx +2 -2
  71. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  72. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  73. package/src/components/editor/SnapToolbar.tsx +2 -2
  74. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  75. package/src/components/editor/TopologyLens.test.tsx +28 -0
  76. package/src/components/editor/TopologyLens.tsx +7 -5
  77. package/src/components/editor/Transform3DCube.tsx +2 -2
  78. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  79. package/src/components/editor/domEditing.test.ts +1 -1
  80. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  81. package/src/components/editor/domEditingAgentPrompt.ts +4 -3
  82. package/src/components/editor/propertyPanelColor.tsx +11 -11
  83. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  84. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  85. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  86. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  87. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  88. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  89. package/src/components/editor/propertyPanelFill.tsx +4 -4
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  92. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  93. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  94. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  95. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  96. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  97. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  98. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  99. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.tsx +5 -5
  101. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  102. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  106. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  107. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  108. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  109. package/src/components/editor/propertyPanelSections.tsx +8 -8
  110. package/src/components/editor/useColorGradingController.test.ts +38 -0
  111. package/src/components/editor/useColorGradingController.ts +9 -1
  112. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  113. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  114. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  115. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  116. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  117. package/src/components/feedback/feedbackTrigger.ts +0 -1
  118. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  119. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  120. package/src/components/nle/NLEPreview.tsx +3 -3
  121. package/src/components/nle/PreviewOverlays.tsx +6 -6
  122. package/src/components/nle/PreviewPane.tsx +3 -3
  123. package/src/components/nle/TimelinePane.tsx +3 -3
  124. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  125. package/src/components/panels/SlideshowPanel.tsx +2 -2
  126. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  127. package/src/components/panels/VariablesBindElement.tsx +2 -2
  128. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  129. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  130. package/src/components/panels/VariablesPanel.tsx +3 -3
  131. package/src/components/panels/VariablesValueControls.tsx +2 -2
  132. package/src/components/pointerTargetSize.test.tsx +8 -0
  133. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  134. package/src/components/renders/RenderQueue.test.tsx +7 -0
  135. package/src/components/renders/RenderQueue.tsx +7 -7
  136. package/src/components/renders/RenderQueueItem.tsx +6 -6
  137. package/src/components/sidebar/AssetCard.tsx +8 -8
  138. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  139. package/src/components/sidebar/AssetsTab.tsx +2 -2
  140. package/src/components/sidebar/AudioRow.tsx +6 -6
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  143. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  144. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  145. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/timelineToolbarStyles.ts +5 -0
  148. package/src/components/ui/Button.test.tsx +252 -0
  149. package/src/components/ui/Button.tsx +89 -126
  150. package/src/components/ui/IconButton.tsx +38 -0
  151. package/src/components/ui/Input.tsx +115 -0
  152. package/src/components/ui/Menu.test.tsx +374 -0
  153. package/src/components/ui/Menu.tsx +213 -0
  154. package/src/components/ui/NumberField.tsx +122 -0
  155. package/src/components/ui/Popover.tsx +74 -0
  156. package/src/components/ui/SearchInput.tsx +2 -2
  157. package/src/components/ui/Select.tsx +100 -0
  158. package/src/components/ui/Slider.tsx +153 -0
  159. package/src/components/ui/Tabs.test.tsx +98 -0
  160. package/src/components/ui/Tabs.tsx +71 -0
  161. package/src/components/ui/Toggle.tsx +63 -0
  162. package/src/components/ui/Tooltip.test.tsx +110 -0
  163. package/src/components/ui/Tooltip.tsx +38 -112
  164. package/src/components/ui/cn.test.ts +30 -0
  165. package/src/components/ui/cn.ts +25 -0
  166. package/src/components/ui/index.ts +29 -2
  167. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  168. package/src/components/ui/valueControls.test.tsx +524 -0
  169. package/src/hooks/useAppHotkeys.ts +21 -80
  170. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  171. package/src/hooks/useCaptionDetection.ts +3 -1
  172. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  173. package/src/hooks/useEditHistoryActions.ts +106 -0
  174. package/src/hooks/useLintModal.test.tsx +65 -0
  175. package/src/hooks/useLintModal.ts +3 -0
  176. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  177. package/src/hooks/useSdkSession.ts +43 -10
  178. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  179. package/src/hooks/useServerConnection.ts +43 -10
  180. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  181. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  182. package/src/index.ts +51 -0
  183. package/src/main.tsx +9 -3
  184. package/src/player/components/AudioWaveform.tsx +1 -1
  185. package/src/player/components/AutomationSelectionMenu.tsx +3 -3
  186. package/src/player/components/ClipContextMenu.tsx +3 -3
  187. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  188. package/src/player/components/CompositionThumbnail.tsx +3 -4
  189. package/src/player/components/EditModal.tsx +1 -1
  190. package/src/player/components/ImageThumbnail.tsx +1 -1
  191. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  192. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  193. package/src/player/components/PlayerControls.tsx +1 -1
  194. package/src/player/components/ShortcutsPanel.tsx +9 -9
  195. package/src/player/components/SpeedMenu.tsx +3 -5
  196. package/src/player/components/Timeline.tsx +2 -1
  197. package/src/player/components/TimelineCanvas.tsx +21 -14
  198. package/src/player/components/TimelineClip.tsx +0 -1
  199. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  200. package/src/player/components/TimelineEmptyState.tsx +5 -5
  201. package/src/player/components/TimelineFxButton.tsx +6 -6
  202. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  203. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  204. package/src/player/components/TimelineGroupRow.tsx +1 -1
  205. package/src/player/components/TimelineLanes.test.tsx +32 -0
  206. package/src/player/components/TimelineLanes.tsx +2 -5
  207. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  208. package/src/player/components/TimelineRuler.tsx +1 -1
  209. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  210. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  211. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  212. package/src/player/components/TimelineTypes.ts +2 -1
  213. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  214. package/src/player/components/VideoThumbnail.tsx +4 -2
  215. package/src/player/components/VolumeControl.tsx +2 -2
  216. package/src/player/components/timelineCallbacks.ts +8 -8
  217. package/src/player/components/timelineClipDragPreview.ts +51 -27
  218. package/src/player/components/timelineClipDragTypes.ts +3 -0
  219. package/src/player/components/timelineDragDrop.test.tsx +61 -1
  220. package/src/player/components/timelineDragDrop.ts +57 -15
  221. package/src/player/components/timelineLaneProps.ts +3 -0
  222. package/src/player/components/timelineSnapping.test.ts +27 -0
  223. package/src/player/components/timelineSnapping.ts +14 -1
  224. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  225. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  226. package/src/player/components/useTimelineClipDrag.ts +30 -3
  227. package/src/player/lib/describeClips.ts +3 -54
  228. package/src/player/lib/playbackShortcuts.ts +2 -0
  229. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  230. package/src/player/store/previewIframeStore.test.tsx +29 -0
  231. package/src/player/store/previewIframeStore.ts +25 -0
  232. package/src/renderQueueExports.test.tsx +58 -0
  233. package/src/styles/classCandidates.test.ts +105 -0
  234. package/src/styles/classCandidates.ts +281 -0
  235. package/src/styles/hex-baseline.json +84 -0
  236. package/src/styles/hexRatchet.test.ts +201 -0
  237. package/src/styles/iconTokens.ts +20 -0
  238. package/src/styles/palette-reset.css +6 -0
  239. package/src/styles/studio.css +24 -21
  240. package/src/styles/styleSources.test.ts +20 -0
  241. package/src/styles/styleSources.ts +47 -0
  242. package/src/styles/tailwind-preset.shared.js +3 -0
  243. package/src/styles/theme.css +243 -0
  244. package/src/styles/theme.test.ts +183 -0
  245. package/src/styles/tokenGate.test.ts +109 -0
  246. package/src/utils/sourcePatcher.ts +1 -1
  247. package/src/utils/studioHelpers.test.ts +18 -0
  248. package/src/utils/studioHelpers.ts +11 -1
  249. package/src/utils/studioUiPreferences.ts +7 -0
  250. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  251. package/src/utils/timelineDropTrackInsert.ts +83 -0
  252. package/src/utils/typingTarget.ts +4 -0
  253. package/dist/assets/index-n2ml4tUn.js +0 -442
  254. package/dist/assets/index-pO6p0H27.css +0 -1
  255. package/src/components/PanelTabButton.tsx +0 -31
  256. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  257. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  258. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  259. package/src/components/storyboard/FramePoster.tsx +0 -86
  260. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  261. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  262. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  263. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  264. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  265. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  266. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  267. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  268. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  269. package/src/components/storyboard/StoryboardView.tsx +0 -169
  270. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  271. package/src/components/storyboard/frameComments.test.ts +0 -95
  272. package/src/components/storyboard/frameComments.ts +0 -115
  273. package/src/components/storyboard/frameStatus.ts +0 -36
  274. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  275. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  276. package/src/components/storyboard/useFrameComments.ts +0 -98
  277. package/src/contexts/ViewModeContext.tsx +0 -142
  278. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  279. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  280. package/src/hooks/useStoryboard.ts +0 -99
@@ -3,6 +3,7 @@ import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { expect, it, vi } from "vitest";
5
5
  import { useCaptionDetection } from "./useCaptionDetection";
6
+ import { usePreviewIframeStore } from "../player/store/previewIframeStore";
6
7
 
7
8
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
9
 
@@ -52,3 +53,48 @@ it("does not let a foreign window trigger caption activation", () => {
52
53
  vi.unstubAllGlobals();
53
54
  }
54
55
  });
56
+
57
+ it("re-runs caption activation on the promoted iframe's document", () => {
58
+ const host = document.createElement("div");
59
+ const a = document.createElement("iframe");
60
+ const b = document.createElement("iframe");
61
+ document.body.append(host, a, b);
62
+ const ref = { current: a };
63
+ const root = createRoot(host);
64
+ const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
65
+ vi.stubGlobal("fetch", fetchMock);
66
+ const params = {
67
+ projectId: "demo",
68
+ activeCompPath: "captions.html",
69
+ compIdToSrc: new Map<string, string>(),
70
+ captionEditMode: false,
71
+ captionHasSelection: false,
72
+ previewIframeRef: ref,
73
+ captionSync: { save: vi.fn(), loadOverrides: vi.fn(async () => {}) },
74
+ setRightCollapsed: vi.fn(),
75
+ };
76
+ function Harness() {
77
+ useCaptionDetection(params);
78
+ return null;
79
+ }
80
+ try {
81
+ act(() => usePreviewIframeStore.getState().setIframe(a));
82
+ act(() => root.render(<Harness />));
83
+ const group = b.contentDocument!.createElement("div");
84
+ group.className = "caption-group";
85
+ b.contentDocument!.body.append(group);
86
+ expect(fetchMock).not.toHaveBeenCalled();
87
+ act(() => {
88
+ ref.current = b;
89
+ usePreviewIframeStore.getState().setIframe(b);
90
+ });
91
+ expect(fetchMock).toHaveBeenCalledOnce();
92
+ } finally {
93
+ act(() => root.unmount());
94
+ usePreviewIframeStore.setState({ iframe: null });
95
+ host.remove();
96
+ a.remove();
97
+ b.remove();
98
+ vi.unstubAllGlobals();
99
+ }
100
+ });
@@ -1,3 +1,4 @@
1
+ import { useLivePreviewIframe } from "../player/store/previewIframeStore";
1
2
  import { buildProjectApiPath } from "../utils/projectRouting";
2
3
  import { useEffect, useRef } from "react";
3
4
  import { useCaptionStore } from "../captions/store";
@@ -26,6 +27,7 @@ export function useCaptionDetection({
26
27
  captionSync,
27
28
  setRightCollapsed,
28
29
  }: UseCaptionDetectionParams) {
30
+ const livePreviewIframe = useLivePreviewIframe();
29
31
  // Switching compositions must drop the previous comp's caption state — a
30
32
  // stale model + full-canvas overlay otherwise blocks normal element editing
31
33
  // on the new composition (and edit mode could never be exited).
@@ -145,7 +147,7 @@ export function useCaptionDetection({
145
147
  return () => {
146
148
  window.removeEventListener("message", handleMessage);
147
149
  };
148
- }, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef]);
150
+ }, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef, livePreviewIframe]);
149
151
 
150
152
  // eslint-disable-next-line no-restricted-syntax
151
153
  useEffect(() => {
@@ -0,0 +1,90 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, createElement } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
+ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let root: Root | null = null;
12
+ afterEach(() => act(() => root?.unmount()));
13
+
14
+ function mount(result: { ok: boolean; reason?: string; label?: string; paths?: string[] }) {
15
+ const editHistory = {
16
+ undo: vi.fn<EditHistoryHandle["undo"]>(async (cb) => {
17
+ if (result.ok) await cb.writeFile("index.html", "before");
18
+ return result;
19
+ }),
20
+ redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
21
+ };
22
+ const deps = {
23
+ editHistory,
24
+ readOptionalProjectFile: vi.fn(async () => ""),
25
+ readProjectFile: vi.fn(async () => ""),
26
+ writeProjectFile: vi.fn(async () => undefined),
27
+ showToast: vi.fn(),
28
+ syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
29
+ waitForPendingDomEditSaves: vi.fn(async () => undefined),
30
+ onAfterUndoRedo: vi.fn(),
31
+ activeCompPath: "index.html",
32
+ forceReloadSdkSession: vi.fn(),
33
+ };
34
+ let actions!: ReturnType<typeof useEditHistoryActions>;
35
+ function Probe() {
36
+ actions = useEditHistoryActions(deps);
37
+ return null;
38
+ }
39
+ root = createRoot(document.createElement("div"));
40
+ act(() => root!.render(createElement(Probe)));
41
+ return { deps, actions };
42
+ }
43
+
44
+ describe("useEditHistoryActions", () => {
45
+ it("undo writes through the host writer, resyncs the preview and toasts the label", async () => {
46
+ const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
47
+ await act(() => actions.undo());
48
+ expect(deps.waitForPendingDomEditSaves).toHaveBeenCalled();
49
+ expect(deps.writeProjectFile).toHaveBeenCalledWith("index.html", "before");
50
+ expect(deps.onAfterUndoRedo).toHaveBeenCalled();
51
+ expect(deps.forceReloadSdkSession).toHaveBeenCalled();
52
+ expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalled();
53
+ expect(deps.showToast).toHaveBeenCalledWith("Undid Move clip", "info");
54
+ });
55
+
56
+ it("redo reports the redone label and skips the SDK reload for other files", async () => {
57
+ const { deps, actions } = mount({ ok: true, label: "Split clip", paths: ["other.html"] });
58
+ await act(() => actions.redo());
59
+ expect(deps.forceReloadSdkSession).not.toHaveBeenCalled();
60
+ expect(deps.showToast).toHaveBeenCalledWith("Redid Split clip", "info");
61
+ });
62
+
63
+ it("explains a refused undo when the file changed on disk", async () => {
64
+ const { deps, actions } = mount({ ok: false, reason: "content-mismatch" });
65
+ await act(() => actions.undo());
66
+ expect(deps.showToast).toHaveBeenCalledWith(
67
+ "File changed outside Studio. Undo history was not applied.",
68
+ "info",
69
+ );
70
+ expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("waits for pending saves first and reads the motion file through the optional reader", async () => {
74
+ const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
75
+ const order: string[] = [];
76
+ deps.waitForPendingDomEditSaves.mockImplementation(async () => void order.push("wait"));
77
+ deps.editHistory.undo.mockImplementation(async (cb) => {
78
+ order.push("undo");
79
+ await cb.readFile(STUDIO_MOTION_PATH);
80
+ await cb.readFile("index.html");
81
+ await cb.serialize?.(["index.html"], async () => order.push("serialized"));
82
+ return { ok: true };
83
+ });
84
+ await act(() => actions.undo());
85
+ expect(order).toEqual(["wait", "undo", "serialized"]);
86
+ expect(deps.readOptionalProjectFile).toHaveBeenCalledWith(STUDIO_MOTION_PATH);
87
+ expect(deps.readProjectFile).toHaveBeenCalledWith("index.html");
88
+ expect(deps.readProjectFile).not.toHaveBeenCalledWith(STUDIO_MOTION_PATH);
89
+ });
90
+ });
@@ -0,0 +1,106 @@
1
+ // fallow-ignore-file complexity
2
+ import { useCallback, useMemo } from "react";
3
+ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
+
6
+ interface HistoryResult {
7
+ ok: boolean;
8
+ reason?: string;
9
+ label?: string;
10
+ paths?: string[];
11
+ /** Per-file restored/previous content, used to soft-apply the preview. */
12
+ files?: Record<string, { previous: string; restored: string }>;
13
+ }
14
+ interface HistoryFileCallbacks {
15
+ readFile: (path: string) => Promise<string>;
16
+ writeFile: (path: string, content: string) => Promise<void>;
17
+ serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
18
+ }
19
+ export interface EditHistoryHandle {
20
+ undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
21
+ redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
22
+ state: {
23
+ undo: ReadonlyArray<{ createdAt: number }>;
24
+ redo: ReadonlyArray<{ createdAt: number }>;
25
+ };
26
+ }
27
+
28
+ export interface UseEditHistoryActionsOptions {
29
+ editHistory: Pick<EditHistoryHandle, "undo" | "redo">;
30
+ readOptionalProjectFile: (path: string) => Promise<string>;
31
+ readProjectFile: (path: string) => Promise<string>;
32
+ writeProjectFile: (path: string, content: string) => Promise<void>;
33
+ showToast: (message: string, tone?: "error" | "info") => void;
34
+ syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
35
+ waitForPendingDomEditSaves: () => Promise<void>;
36
+ onAfterUndoRedo?: () => void;
37
+ /** Active composition path — decides whether undo/redo must resync the SDK session. */
38
+ activeCompPath?: string | null;
39
+ /** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */
40
+ forceReloadSdkSession?: () => void;
41
+ }
42
+
43
+ /** Applies one persisted file-history step: the single owner of undo/redo over project files. */
44
+ export function useEditHistoryActions({
45
+ editHistory,
46
+ readOptionalProjectFile,
47
+ readProjectFile,
48
+ writeProjectFile,
49
+ showToast,
50
+ syncHistoryPreviewAfterApply,
51
+ waitForPendingDomEditSaves,
52
+ onAfterUndoRedo,
53
+ activeCompPath,
54
+ forceReloadSdkSession,
55
+ }: UseEditHistoryActionsOptions) {
56
+ const readHistoryFile = useCallback(
57
+ (path: string): Promise<string> =>
58
+ path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path),
59
+ [readOptionalProjectFile, readProjectFile],
60
+ );
61
+ const serializeHistoryFiles = useCallback(
62
+ <T>(paths: readonly string[], task: () => Promise<T>) =>
63
+ serializeStudioFileMutations(writeProjectFile, paths, task),
64
+ [writeProjectFile],
65
+ );
66
+
67
+ const apply = useCallback(
68
+ async (direction: "undo" | "redo") => {
69
+ const [noun, verb] = direction === "undo" ? ["Undo", "Undid"] : ["Redo", "Redid"];
70
+ await waitForPendingDomEditSaves();
71
+ const result = await editHistory[direction]({
72
+ readFile: readHistoryFile,
73
+ writeFile: writeProjectFile,
74
+ serialize: serializeHistoryFiles,
75
+ });
76
+ if (!result.ok && result.reason === "content-mismatch") {
77
+ showToast(`File changed outside Studio. ${noun} history was not applied.`, "info");
78
+ return;
79
+ }
80
+ if (result.ok && result.label) {
81
+ onAfterUndoRedo?.();
82
+ if (activeCompPath && result.paths?.includes(activeCompPath)) {
83
+ forceReloadSdkSession?.();
84
+ }
85
+ await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
86
+ showToast(`${verb} ${result.label}`, "info");
87
+ }
88
+ },
89
+ [
90
+ editHistory,
91
+ readHistoryFile,
92
+ showToast,
93
+ syncHistoryPreviewAfterApply,
94
+ waitForPendingDomEditSaves,
95
+ writeProjectFile,
96
+ serializeHistoryFiles,
97
+ onAfterUndoRedo,
98
+ activeCompPath,
99
+ forceReloadSdkSession,
100
+ ],
101
+ );
102
+
103
+ const undo = useCallback(() => apply("undo"), [apply]);
104
+ const redo = useCallback(() => apply("redo"), [apply]);
105
+ return useMemo(() => ({ undo, redo }), [undo, redo]);
106
+ }
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useLintModal } from "./useLintModal";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ let root: Root | null = null;
11
+ let latest: ReturnType<typeof useLintModal> | null = null;
12
+
13
+ function Probe() {
14
+ latest = useLintModal("demo");
15
+ return null;
16
+ }
17
+
18
+ afterEach(() => {
19
+ if (root) act(() => root?.unmount());
20
+ root = null;
21
+ latest = null;
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ async function mountWithLint(response: () => Promise<unknown>) {
26
+ vi.stubGlobal("fetch", vi.fn(response));
27
+ root = createRoot(document.createElement("div"));
28
+ await act(async () => root?.render(<Probe />));
29
+ await act(async () => {
30
+ await latest?.handleLint();
31
+ });
32
+ }
33
+
34
+ const lintResponse = (findings: Array<{ severity: string; message: string }>) => async () => ({
35
+ json: async () => ({ findings }),
36
+ });
37
+
38
+ describe("useLintModal hasLintError", () => {
39
+ it("is true when a finding is an error and false for warnings only", async () => {
40
+ await mountWithLint(lintResponse([{ severity: "error", message: "nested" }]));
41
+ expect(latest?.hasLintError).toBe(true);
42
+ act(() => root?.unmount());
43
+ root = null;
44
+ await mountWithLint(lintResponse([{ severity: "warning", message: "soft" }]));
45
+ expect(latest?.hasLintError).toBe(false);
46
+ });
47
+
48
+ it("is true for a mixed error and warning list", async () => {
49
+ await mountWithLint(
50
+ lintResponse([
51
+ { severity: "warning", message: "soft" },
52
+ { severity: "error", message: "nested" },
53
+ ]),
54
+ );
55
+ expect(latest?.hasLintError).toBe(true);
56
+ });
57
+
58
+ it("is true when the manual lint itself fails, matching the badge count", async () => {
59
+ await mountWithLint(async () => {
60
+ throw new Error("offline");
61
+ });
62
+ expect(latest?.lintModal).toHaveLength(1);
63
+ expect(latest?.hasLintError).toBe(true);
64
+ });
65
+ });
@@ -96,6 +96,8 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
96
96
 
97
97
  const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
98
98
  const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
99
+ // Reads the list the badge count reads: the manual result when present, else the background one.
100
+ const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
99
101
 
100
102
  // Sync lint findings directly to the player store — eliminates the
101
103
  // mirroring useEffect that was previously in App.tsx.
@@ -111,5 +113,6 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
111
113
  backgroundFindings,
112
114
  findingsByElement,
113
115
  findingsByFile,
116
+ hasLintError,
114
117
  };
115
118
  }
@@ -164,17 +164,62 @@ describe("useSdkSession unavailable telemetry", () => {
164
164
 
165
165
  // Every cutover chokepoint silently takes the server path when there is no
166
166
  // session, and the shadow never runs either — so a missing session is a total,
167
- // otherwise-invisible SDK bypass. These three exits are its only origin.
168
- it("reports an unreadable composition", async () => {
167
+ // otherwise-invisible SDK bypass. These exits are its only origin.
168
+ //
169
+ // `stage: read` carries a reason because it was the largest remaining class
170
+ // and a single opaque string: 56 users hit it in a 7-day window and never
171
+ // landed one successful SDK edit between them, with no way to tell a genuinely
172
+ // absent file from a request that never reached one.
173
+ it("reports the HTTP status when the read fails", async () => {
169
174
  vi.stubGlobal(
170
175
  "fetch",
171
- vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
176
+ vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response),
172
177
  );
173
178
  const root = createRoot(document.createElement("div"));
174
179
  await act(async () => root.render(<Probe projectId="project-a" />));
175
180
  await flushAsyncEffects();
176
181
 
177
- expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
182
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
183
+ stage: "read",
184
+ reason: "http_error",
185
+ status: 404,
186
+ });
187
+ await act(async () => root.unmount());
188
+ });
189
+
190
+ it("separates an unexpected response shape from a failed request", async () => {
191
+ vi.stubGlobal(
192
+ "fetch",
193
+ vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response),
194
+ );
195
+ const root = createRoot(document.createElement("div"));
196
+ await act(async () => root.render(<Probe projectId="project-a" />));
197
+ await flushAsyncEffects();
198
+
199
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
200
+ stage: "read",
201
+ reason: "missing_content",
202
+ });
203
+ await act(async () => root.unmount());
204
+ });
205
+
206
+ // `optional=1` answers a file that is not on disk with 200 + an empty string,
207
+ // so this is what "the composition genuinely is not there" looks like on the
208
+ // wire — previously indistinguishable from a broken request. The shim and a
209
+ // real 0-byte file are the same response, hence the name.
210
+ it("separates a file that is not on disk", async () => {
211
+ vi.stubGlobal(
212
+ "fetch",
213
+ vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
214
+ );
215
+ const root = createRoot(document.createElement("div"));
216
+ await act(async () => root.render(<Probe projectId="project-a" />));
217
+ await flushAsyncEffects();
218
+
219
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
220
+ stage: "read",
221
+ reason: "absent_or_empty",
222
+ });
178
223
  await act(async () => root.unmount());
179
224
  });
180
225
 
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
7
7
  import { addExternalFileReloadListener } from "./externalFileReloadBus";
8
8
 
9
9
  /**
10
- * Read a project file's content, or undefined on a non-2xx (optional read).
11
- * Replaces the removed SDK http adapter's `read()` — the only thing Studio used
12
- * it for (Studio is the sole writer, so the adapter's write path was dead).
10
+ * Why an optional project-file read produced no usable content. `stage: "read"`
11
+ * was a single opaque reason covering all of these, which made the largest
12
+ * remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
13
+ * window hit it and, between them, never landed a single successful SDK edit.
14
+ * Knowing which branch fired is the difference between "the file legitimately
15
+ * is not there" and "the request never reached the file".
16
+ *
17
+ * Every reason lives in this union so the full surface is readable from one
18
+ * place — `absent_or_empty` included, even though it is a 2xx.
19
+ */
20
+ type ProjectFileReadFailure =
21
+ | { ok: false; reason: "unsafe_path" }
22
+ | { ok: false; reason: "http_error"; status: number }
23
+ | { ok: false; reason: "missing_content" }
24
+ | { ok: false; reason: "absent_or_empty" };
25
+
26
+ type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
+
28
+ /**
29
+ * Read a project file's content (optional read — a missing file is not an
30
+ * error). Replaces the removed SDK http adapter's `read()` — the only thing
31
+ * Studio used it for (Studio is the sole writer, so the adapter's write path
32
+ * was dead).
13
33
  */
14
34
  async function readProjectFileOptional(
15
35
  projectId: string,
16
36
  path: string,
17
- ): Promise<string | undefined> {
37
+ ): Promise<ProjectFileReadResult> {
18
38
  // Reject traversal / NUL before building the request URL — `path` is a
19
39
  // user-influenced composition path (mirrors the guard in timelineEditingHelpers,
20
40
  // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
21
41
  // already confines both values to single segments of this same-origin URL.
22
- if (path.includes("\0") || path.includes("..")) return undefined;
42
+ if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
23
43
  const res = await fetch(
24
44
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
25
45
  );
26
- if (!res.ok) return undefined;
46
+ if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
27
47
  const data = (await res.json()) as { content?: string };
28
- return typeof data.content === "string" ? data.content : undefined;
48
+ // `optional=1` answers a missing file with 200 + `content: ""`, so a
49
+ // non-string here means a response shape we did not expect, not absence.
50
+ if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
51
+ // An empty body parses into a session with no elements, which declines every
52
+ // edit wholesale — not a session worth opening. The absent-file shim and a
53
+ // genuinely 0-byte file are the same 200 on the wire and cannot be told
54
+ // apart here, hence the name; for a composition it is always the former.
55
+ if (data.content === "") return { ok: false, reason: "absent_or_empty" };
56
+ return { ok: true, content: data.content };
29
57
  }
30
58
 
31
59
  /**
@@ -180,16 +208,21 @@ export function useSdkSession(
180
208
  };
181
209
 
182
210
  readProjectFileOptional(projectId, activeCompPath)
183
- .then(async (content) => {
211
+ .then(async (read) => {
184
212
  if (cancelled) return;
185
- if (typeof content !== "string") {
213
+ if (!read.ok) {
186
214
  // No SDK session follows, so EVERY cutover chokepoint below takes the
187
215
  // server path and emits nothing — the shadow never runs either. This
188
216
  // is the only place a missing session can originate, so a broken read
189
217
  // would otherwise be a silent, total SDK bypass.
190
- trackStudioEvent("sdk_session_unavailable", { stage: "read" });
218
+ trackStudioEvent("sdk_session_unavailable", {
219
+ stage: "read",
220
+ reason: read.reason,
221
+ ...(read.reason === "http_error" ? { status: read.status } : {}),
222
+ });
191
223
  return;
192
224
  }
225
+ const content = read.content;
193
226
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
194
227
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
195
228
  // queue too would double-write the file (queue auto-writes on every
@@ -0,0 +1,94 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useServerConnection } from "./useServerConnection";
6
+
7
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ const captured: { projectId: string | null } = { projectId: null };
10
+
11
+ function Probe() {
12
+ captured.projectId = useServerConnection().projectId;
13
+ return null;
14
+ }
15
+
16
+ async function flush(): Promise<void> {
17
+ await act(async () => {
18
+ await new Promise((resolve) => setTimeout(resolve, 0));
19
+ });
20
+ }
21
+
22
+ /**
23
+ * The hash project id outlives the project it names — a renamed folder, or a
24
+ * bookmark from a project that is gone. It used to be trusted unconditionally,
25
+ * so every later /api/projects/<id>/... request 404'd for the life of the tab,
26
+ * including the composition read that opens the SDK session. Telemetry after
27
+ * the read-reason split: 120 http_error/404 reads across 5 users in 24h.
28
+ */
29
+ function stubFetch(projectRoute: { status: number } | "reject") {
30
+ vi.stubGlobal(
31
+ "fetch",
32
+ vi.fn(async (url: string) => {
33
+ if (url === "/api/projects") {
34
+ return { ok: true, json: async () => ({ projects: [{ id: "real-project" }] }) } as Response;
35
+ }
36
+ if (projectRoute === "reject") throw new TypeError("Failed to fetch");
37
+ return { ok: projectRoute.status === 200, status: projectRoute.status } as Response;
38
+ }),
39
+ );
40
+ }
41
+
42
+ async function renderWithHash(hash: string) {
43
+ window.location.hash = hash;
44
+ const root = createRoot(document.createElement("div"));
45
+ await act(async () => root.render(<Probe />));
46
+ await flush();
47
+ return root;
48
+ }
49
+
50
+ describe("useServerConnection hash project id", () => {
51
+ beforeEach(() => {
52
+ captured.projectId = null;
53
+ window.location.hash = "";
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ it("keeps a hash id the server can resolve", async () => {
61
+ stubFetch({ status: 200 });
62
+ const root = await renderWithHash("#project/stale-or-not");
63
+
64
+ expect(captured.projectId).toBe("stale-or-not");
65
+ await act(async () => root.unmount());
66
+ });
67
+
68
+ it("falls back to the first project when the hash id is gone", async () => {
69
+ stubFetch({ status: 404 });
70
+ const root = await renderWithHash("#project/deleted-project");
71
+
72
+ expect(captured.projectId).toBe("real-project");
73
+ expect(window.location.hash).toContain("real-project");
74
+ await act(async () => root.unmount());
75
+ });
76
+
77
+ // The important one: a blip must not rewrite the user's hash out from under a
78
+ // project that is actually fine. Only a definite 404 is "missing".
79
+ it("keeps the hash id when the check itself fails", async () => {
80
+ stubFetch("reject");
81
+ const root = await renderWithHash("#project/unreachable-check");
82
+
83
+ expect(captured.projectId).toBe("unreachable-check");
84
+ await act(async () => root.unmount());
85
+ });
86
+
87
+ it("keeps the hash id on a non-404 error status", async () => {
88
+ stubFetch({ status: 500 });
89
+ const root = await renderWithHash("#project/server-erroring");
90
+
91
+ expect(captured.projectId).toBe("server-erroring");
92
+ await act(async () => root.unmount());
93
+ });
94
+ });