@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,6 +1,16 @@
1
1
  import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
3
3
 
4
+ /** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
5
+ function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
6
+ expect(state.isPlaying).toBe(false);
7
+ expect(state.currentTime).toBe(0);
8
+ expect(state.duration).toBe(0);
9
+ expect(state.timelineReady).toBe(false);
10
+ expect(state.elements).toEqual([]);
11
+ expect(state.selectedElementId).toBeNull();
12
+ }
13
+
4
14
  describe("usePlayerStore", () => {
5
15
  beforeEach(() => {
6
16
  usePlayerStore.getState().reset();
@@ -9,12 +19,7 @@ describe("usePlayerStore", () => {
9
19
  describe("initial state", () => {
10
20
  it("has correct defaults", () => {
11
21
  const state = usePlayerStore.getState();
12
- expect(state.isPlaying).toBe(false);
13
- expect(state.currentTime).toBe(0);
14
- expect(state.duration).toBe(0);
15
- expect(state.timelineReady).toBe(false);
16
- expect(state.elements).toEqual([]);
17
- expect(state.selectedElementId).toBeNull();
22
+ expectResettableDefaults(state);
18
23
  expect(state.playbackRate).toBe(1);
19
24
  expect(state.audioMuted).toBe(false);
20
25
  expect(state.loopEnabled).toBe(false);
@@ -384,6 +389,32 @@ describe("usePlayerStore", () => {
384
389
  });
385
390
  });
386
391
 
392
+ describe("clipRevealRequest", () => {
393
+ it("starts null and carries the requested element id", () => {
394
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
395
+ usePlayerStore.getState().requestClipReveal("el-1");
396
+ expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
397
+ });
398
+
399
+ it("bumps the nonce on repeat requests for the same clip", () => {
400
+ usePlayerStore.getState().requestClipReveal("el-1");
401
+ const first = usePlayerStore.getState().clipRevealRequest;
402
+ usePlayerStore.getState().requestClipReveal("el-1");
403
+ const second = usePlayerStore.getState().clipRevealRequest;
404
+ expect(second?.nonce).not.toBe(first?.nonce);
405
+ });
406
+
407
+ it("clears via clearClipRevealRequest and on reset", () => {
408
+ usePlayerStore.getState().requestClipReveal("el-1");
409
+ usePlayerStore.getState().clearClipRevealRequest();
410
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
411
+
412
+ usePlayerStore.getState().requestClipReveal("el-2");
413
+ usePlayerStore.getState().reset();
414
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
415
+ });
416
+ });
417
+
387
418
  describe("reset", () => {
388
419
  it("resets all state to defaults", () => {
389
420
  // Mutate everything
@@ -398,13 +429,7 @@ describe("usePlayerStore", () => {
398
429
  // Reset
399
430
  usePlayerStore.getState().reset();
400
431
 
401
- const state = usePlayerStore.getState();
402
- expect(state.isPlaying).toBe(false);
403
- expect(state.currentTime).toBe(0);
404
- expect(state.duration).toBe(0);
405
- expect(state.timelineReady).toBe(false);
406
- expect(state.elements).toEqual([]);
407
- expect(state.selectedElementId).toBeNull();
432
+ expectResettableDefaults(usePlayerStore.getState());
408
433
  });
409
434
 
410
435
  it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
@@ -3,6 +3,7 @@ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
3
3
  import type { BeatEditState } from "../../utils/beatEditing";
4
4
  import type { ClipManifestClip } from "../lib/playbackTypes";
5
5
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
6
+ import { computePinnedZoomPercent } from "../components/timelineZoom";
6
7
 
7
8
  /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
8
9
  export interface KeyframeCacheEntry {
@@ -28,6 +29,17 @@ export interface TimelineElement {
28
29
  start: number;
29
30
  duration: number;
30
31
  track: number;
32
+ /**
33
+ * The data-track-index as written in the source file. Set at the manifest
34
+ * translation boundary (createTimelineElementFromManifestClip) from the
35
+ * runtime clip's verbatim track, and preserved through display-lane remaps
36
+ * (normalizeToZones packs sparse authored tracks onto contiguous display
37
+ * lanes; expanded sub-comp children get synthetic display rows). Lane edits
38
+ * must persist THIS space — writing a display-lane number into a sparse file
39
+ * re-targets the wrong track. For an expanded child the value is in its OWN
40
+ * source file's coordinate space, not the host timeline's.
41
+ */
42
+ authoredTrack?: number;
31
43
  /** Resolved z-index for stacking-aware timeline ordering. */
32
44
  zIndex?: number;
33
45
  /** True when the effective z-index was authored inline or through CSS, not auto. */
@@ -75,6 +87,10 @@ export interface TimelineElement {
75
87
  export type ZoomMode = "fit" | "manual";
76
88
  type TimelineTool = "select" | "razor";
77
89
 
90
+ export interface SelectElementOptions {
91
+ preserveSet?: boolean;
92
+ }
93
+
78
94
  function resolveElementSelection(
79
95
  ids: Iterable<string>,
80
96
  anchor?: string | null,
@@ -142,6 +158,28 @@ interface PlayerState {
142
158
 
143
159
  /** Multi-select: additional selected elements beyond selectedElementId. */
144
160
  selectedElementIds: Set<string>;
161
+ clearSelectedElementIds: () => void;
162
+ /** Replace the whole multi-selection at once (marquee live updates). */
163
+ setSelectedElementIds: (ids: Set<string>) => void;
164
+ /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
165
+ timelineSnapEnabled: boolean;
166
+ setTimelineSnapEnabled: (enabled: boolean) => void;
167
+ /** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
168
+ timeDisplayMode: "time" | "frame";
169
+ setTimeDisplayMode: (mode: "time" | "frame") => void;
170
+ /**
171
+ * Pin the timeline zoom to its current visual scale before a duration-changing
172
+ * edit, so a subsequent duration change (which recomputes fit-pps) stops
173
+ * rescaling every clip. No-op once already pinned (mode is "manual").
174
+ */
175
+ pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
176
+ /**
177
+ * The timeline's live pixels-per-second + fit basis, published by <Timeline> on
178
+ * every render. Non-reactive scratch state (never read as a render input).
179
+ */
180
+ timelinePps: number;
181
+ timelineFitPps: number;
182
+ setTimelineScale: (pps: number, fitPps: number) => void;
145
183
  setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
146
184
  addSelectedElementId: (id: string) => void;
147
185
  toggleSelectedElementId: (id: string) => void;
@@ -160,7 +198,7 @@ interface PlayerState {
160
198
  setTimelineReady: (ready: boolean) => void;
161
199
  setBeatDragging: (dragging: boolean) => void;
162
200
  setElements: (elements: TimelineElement[]) => void;
163
- setSelectedElementId: (id: string | null) => void;
201
+ setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
164
202
  /** Move the selection anchor within an active multi-selection without collapsing it. */
165
203
  setSelectionAnchor: (id: string | null) => void;
166
204
  updateElement: (
@@ -186,6 +224,16 @@ interface PlayerState {
186
224
  requestSeek: (time: number) => void;
187
225
  clearSeekRequest: () => void;
188
226
 
227
+ /**
228
+ * Request the timeline to scroll a clip into view (e.g. clicking an
229
+ * already-added asset card in the sidebar). Consumed and cleared by
230
+ * useTimelineRevealClip. The nonce makes repeat requests for the same
231
+ * clip observable so a second click re-reveals after the user scrolls away.
232
+ */
233
+ clipRevealRequest: { elementId: string; nonce: number } | null;
234
+ requestClipReveal: (elementId: string) => void;
235
+ clearClipRevealRequest: () => void;
236
+
189
237
  lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
190
238
  setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
191
239
 
@@ -260,6 +308,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
260
308
  loopEnabled: false,
261
309
  zoomMode: "fit",
262
310
  manualZoomPercent: 100,
311
+ timelinePps: 100,
312
+ timelineFitPps: 100,
263
313
  inPoint: null,
264
314
  outPoint: null,
265
315
 
@@ -315,6 +365,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
315
365
  requestSeek: (time) => set({ requestedSeekTime: time }),
316
366
  clearSeekRequest: () => set({ requestedSeekTime: null }),
317
367
 
368
+ clipRevealRequest: null,
369
+ requestClipReveal: (elementId) =>
370
+ set((s) => ({
371
+ clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
372
+ })),
373
+ clearClipRevealRequest: () => set({ clipRevealRequest: null }),
374
+
318
375
  lintFindingsByElement: new Map(),
319
376
  setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
320
377
 
@@ -383,6 +440,37 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
383
440
  },
384
441
  setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
385
442
  setZoomMode: (mode) => set({ zoomMode: mode }),
443
+ clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
444
+ setSelectedElementIds: (ids: Set<string>) => set({ selectedElementIds: new Set(ids) }),
445
+ timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
446
+ setTimelineSnapEnabled: (enabled) => {
447
+ writeStudioUiPreferences({ timelineSnapEnabled: enabled });
448
+ set({ timelineSnapEnabled: enabled });
449
+ },
450
+ timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
451
+ setTimeDisplayMode: (mode) => {
452
+ writeStudioUiPreferences({ timeDisplayMode: mode });
453
+ set({ timeDisplayMode: mode });
454
+ },
455
+ pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
456
+ set((s) => {
457
+ // Already pinned (or the user manually zoomed) — never clobber that.
458
+ if (s.zoomMode === "manual") return {};
459
+ const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond);
460
+ writeStudioUiPreferences({
461
+ timelineZoomMode: "manual",
462
+ timelineManualZoomPercent: percent,
463
+ });
464
+ return { zoomMode: "manual", manualZoomPercent: percent };
465
+ }),
466
+ setTimelineScale: (pps, fitPps) => {
467
+ // Non-reactive publish: mutate in place + reuse the same object identity so no
468
+ // subscriber re-renders (these fields are never a render input, only read
469
+ // imperatively before pinning).
470
+ const state = get();
471
+ state.timelinePps = pps;
472
+ state.timelineFitPps = fitPps;
473
+ },
386
474
  setInPoint: (time) =>
387
475
  set((state) => {
388
476
  const t = time !== null && Number.isFinite(time) ? time : null;
@@ -414,9 +502,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
414
502
  // A genuine single selection: always collapse the set to just this element. User
415
503
  // intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
416
504
  // echoes that must preserve a group go through setSelectionAnchor instead.
417
- setSelectedElementId: (id) =>
505
+ setSelectedElementId: (id, options) =>
418
506
  set((s) => {
419
- const selectedElementIds = id ? new Set([id]) : new Set<string>();
507
+ const preserveSet = Boolean(options?.preserveSet && id && s.selectedElementIds.has(id));
508
+ const selectedElementIds = preserveSet
509
+ ? new Set(s.selectedElementIds)
510
+ : options?.preserveSet
511
+ ? new Set<string>()
512
+ : id
513
+ ? new Set([id])
514
+ : new Set<string>();
420
515
  // Selecting a different element drops any active keyframe selection — otherwise
421
516
  // a stale activeKeyframePct from a prior diamond click would force the next drag
422
517
  // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
@@ -463,6 +558,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
463
558
  activeTool: "select",
464
559
  selectedKeyframes: new Set(),
465
560
  selectedElementIds: new Set(),
561
+ clipRevealRequest: null,
466
562
  keyframeCache: new Map(),
467
563
  beatAnalysis: null,
468
564
  beatEdits: null,
@@ -141,6 +141,22 @@ body {
141
141
  opacity: 1;
142
142
  }
143
143
 
144
+ /* Audio clips read as a distinct kind — a persistent violet tint vs the teal-
145
+ accented visual clips — so the audio zone at the bottom is visually separate
146
+ at a glance (the timeline's kinds: visual on top, audio below). */
147
+ .timeline-clip.is-audio {
148
+ background-color: rgba(167, 139, 250, 0.16);
149
+ border-color: rgba(167, 139, 250, 0.4);
150
+ }
151
+
152
+ .timeline-clip.is-audio.is-hovered {
153
+ background-color: rgba(167, 139, 250, 0.24);
154
+ }
155
+
156
+ .timeline-clip.is-audio.is-dragging {
157
+ background-color: rgba(60, 52, 84, 0.96);
158
+ }
159
+
144
160
  .timeline-clip.is-hovered {
145
161
  background-color: rgba(255, 255, 255, 0.09);
146
162
  }
@@ -158,9 +174,20 @@ body {
158
174
  }
159
175
 
160
176
  .timeline-clip.is-dragging {
177
+ /* Solid background so the picked-up clip reads clearly while dragging — audio
178
+ clips (waveform drawn on a separate layer) would otherwise look transparent. */
179
+ background-color: rgba(38, 42, 52, 0.96);
161
180
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
162
181
  }
163
182
 
183
+ /* Keep the white selection outline while dragging (a plain box-shadow alone gets
184
+ overridden by .is-dragging's drop shadow, so re-state both together). */
185
+ .timeline-clip.is-selected.is-dragging {
186
+ box-shadow:
187
+ 0 0 0 1.5px rgba(255, 255, 255, 0.85),
188
+ 0 8px 24px rgba(0, 0, 0, 0.4);
189
+ }
190
+
164
191
  .timeline-clip[data-active].is-selected.is-dragging {
165
192
  box-shadow:
166
193
  0 0 0 1.5px rgba(255, 255, 255, 0.85),
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
3
+
4
+ describe("shouldDismissAssetPreview", () => {
5
+ const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
6
+
7
+ it("keeps the preview open while nothing moves", () => {
8
+ expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
9
+ });
10
+
11
+ it("tolerates sub-epsilon float noise in currentTime echoes", () => {
12
+ expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
13
+ });
14
+
15
+ it("dismisses when playback starts", () => {
16
+ expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
17
+ });
18
+
19
+ it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
20
+ expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
21
+ expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
22
+ });
23
+
24
+ it("dismisses on a pending out-of-loop seek request", () => {
25
+ expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
26
+ });
27
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
3
+ * the preview is a transient "look at this asset" state, so any playhead
4
+ * activity — starting playback, or seeking/scrubbing away from where the
5
+ * playhead sat when the preview opened — hands focus back to the canvas and
6
+ * closes it.
7
+ *
8
+ * Pure — unit-tested. The overlay captures `openedTime` when the preview
9
+ * opens and feeds every subsequent player-store snapshot through this.
10
+ */
11
+
12
+ export interface AssetPreviewDismissSnapshot {
13
+ isPlaying: boolean;
14
+ currentTime: number;
15
+ /** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
16
+ requestedSeekTime: number | null;
17
+ }
18
+
19
+ /** Tolerance for float noise in currentTime echoes (well under one frame). */
20
+ const TIME_EPSILON_S = 1e-6;
21
+
22
+ export function shouldDismissAssetPreview(
23
+ openedTime: number,
24
+ snapshot: AssetPreviewDismissSnapshot,
25
+ ): boolean {
26
+ if (snapshot.isPlaying) return true;
27
+ if (snapshot.requestedSeekTime !== null) return true;
28
+ return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
29
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tiny Zustand slice that carries the "asset preview overlay" state.
3
+ *
4
+ * When a user clicks an asset card that has NOT yet been added to the
5
+ * timeline the overlay fires up: a dark scrim + centered media element
6
+ * (img / video / audio) + filename label rendered inside PreviewPane.
7
+ *
8
+ * State lives here so AssetsTab (sidebar) and PreviewPane (preview column)
9
+ * can communicate without prop-drilling through the multi-layer EditorShell
10
+ * tree. The store is project-scoped: NLEProvider (NLEContext.tsx) clears it
11
+ * whenever `projectId` changes, so a preview opened in one project can't
12
+ * bleed into another (the overlay itself stays mounted across project
13
+ * switches — EditorShell isn't keyed by projectId).
14
+ */
15
+ import { create } from "zustand";
16
+
17
+ interface AssetPreviewState {
18
+ /** Project-relative asset path currently being previewed, or null. */
19
+ previewAsset: string | null;
20
+ /** projectId for which the preview was opened (used to build the serve URL). */
21
+ previewProjectId: string | null;
22
+ /** Open a media preview for the given asset. */
23
+ setPreviewAsset: (asset: string, projectId: string) => void;
24
+ /** Close the preview overlay. */
25
+ clearPreviewAsset: () => void;
26
+ }
27
+
28
+ export const useAssetPreviewStore = create<AssetPreviewState>((set) => ({
29
+ previewAsset: null,
30
+ previewProjectId: null,
31
+ setPreviewAsset: (asset, projectId) => set({ previewAsset: asset, previewProjectId: projectId }),
32
+ clearPreviewAsset: () => set({ previewAsset: null, previewProjectId: null }),
33
+ }));
@@ -2,9 +2,10 @@ import type { RegistryItem } from "@hyperframes/core/registry";
2
2
  import type { TimelineElement } from "../player";
3
3
  import {
4
4
  insertTimelineAssetIntoSource,
5
- resolveTimelineAssetInitialGeometry,
5
+ resolveTimelineAssetCompositionSize,
6
6
  } from "./timelineAssetDrop";
7
7
  import { collectHtmlIds } from "./studioHelpers";
8
+ import { generateId } from "./generateId";
8
9
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
9
10
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
10
11
  import type { EditHistoryKind } from "./editHistory";
@@ -120,7 +121,9 @@ export async function addBlockToProject(
120
121
  );
121
122
 
122
123
  const isBlock = block.type === "hyperframes:block";
123
- const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
124
+ const { width: hostWidth, height: hostHeight } =
125
+ resolveTimelineAssetCompositionSize(originalContent);
126
+ const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight };
124
127
 
125
128
  const currentTime = opts.currentTime ?? 0;
126
129
  const start = placement
@@ -152,6 +155,11 @@ export async function addBlockToProject(
152
155
 
153
156
  const subCompHtml = [
154
157
  `<div`,
158
+ // A stable id (+ hf-id) is what authored sub-comps carry; without it the
159
+ // timeline can't dedup the host and renders duplicate clips that multiply
160
+ // on every interaction. Matches the authored-comp shape.
161
+ ` id="${compId}"`,
162
+ ` data-hf-id="hf-${generateId()}"`,
155
163
  ` data-composition-id="${compId}"`,
156
164
  ` data-composition-src="${compositionFile}"`,
157
165
  ` data-start="${formatTimelineAttributeNumber(start)}"`,
@@ -209,6 +209,41 @@ describe("edit history", () => {
209
209
  expect(state.undo[0].files["index.html"].after).toBe("c");
210
210
  });
211
211
 
212
+ it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => {
213
+ // The z entry records only after the move persist's round-trip — often >300ms.
214
+ // Both sides pass coalesceMs: 5000 with the shared gesture key so the pair
215
+ // still folds into ONE undo step.
216
+ const move = buildEditHistoryEntry({
217
+ projectId: "project-1",
218
+ label: "Move timeline clips",
219
+ kind: "timeline",
220
+ coalesceKey: "clip-lane-move:1",
221
+ coalesceMs: 5000,
222
+ files: { "index.html": { before: "a", after: "b" } },
223
+ now: 100,
224
+ id: "move-entry",
225
+ });
226
+ const zReorder = buildEditHistoryEntry({
227
+ projectId: "project-1",
228
+ label: "Reorder layers",
229
+ kind: "manual",
230
+ coalesceKey: "clip-lane-move:1",
231
+ coalesceMs: 5000,
232
+ files: { "index.html": { before: "b", after: "c" } },
233
+ now: 500,
234
+ id: "z-entry",
235
+ });
236
+
237
+ const state = pushEditHistoryEntry(
238
+ pushEditHistoryEntry(createEmptyEditHistory(), move),
239
+ zReorder,
240
+ );
241
+
242
+ expect(state.undo).toHaveLength(1);
243
+ expect(state.undo[0].files["index.html"].before).toBe("a");
244
+ expect(state.undo[0].files["index.html"].after).toBe("c");
245
+ });
246
+
212
247
  it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
213
248
  const timing = buildEditHistoryEntry({
214
249
  projectId: "project-1",
@@ -1,7 +1,12 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, it, expect, vi } from "vitest";
4
- import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
4
+ import {
5
+ applySoftReload,
6
+ ensureMotionPathPluginLoaded,
7
+ diffSoftReloadableRestore,
8
+ applyUndoRestoreToPreview,
9
+ } from "./gsapSoftReload";
5
10
 
6
11
  const SCRIPT_TEXT = `
7
12
  window.__timelines = window.__timelines || {};
@@ -447,3 +452,116 @@ describe("applySoftReload authored-opacity restore", () => {
447
452
  expect(restoreOpacity(el)).toBe("");
448
453
  });
449
454
  });
455
+
456
+ // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
457
+
458
+ const wrap = (body: string) => `<html><body>${body}</body></html>`;
459
+
460
+ describe("diffSoftReloadableRestore", () => {
461
+ it("reports the changed id for an attribute/inline-style-only diff", () => {
462
+ const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
463
+ const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
464
+ expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
465
+ });
466
+
467
+ it("treats a structural change (added element) as NOT soft-reloadable", () => {
468
+ const prev = wrap(`<div id="a">t</div>`);
469
+ const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
470
+ expect(diffSoftReloadableRestore(prev, next)).toBeNull();
471
+ });
472
+
473
+ it("treats an element text/child change as NOT soft-reloadable", () => {
474
+ const prev = wrap(`<div id="a">one</div>`);
475
+ const next = wrap(`<div id="a">two</div>`);
476
+ expect(diffSoftReloadableRestore(prev, next)).toBeNull();
477
+ });
478
+
479
+ it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
480
+ const prev = wrap(
481
+ `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
482
+ );
483
+ const next = wrap(
484
+ `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
485
+ );
486
+ expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
487
+ });
488
+ });
489
+
490
+ function buildLiveIframe(bodyHtml: string) {
491
+ const doc = document.implementation.createHTMLDocument("");
492
+ doc.body.innerHTML = bodyHtml;
493
+ const contentWindow = {
494
+ gsap: { timeline: () => {} },
495
+ __hfForceTimelineRebind: () => {},
496
+ __timelines: {} as Record<string, unknown>,
497
+ __player: { getTime: () => 3, seek: vi.fn() },
498
+ __hfStudioManualEditsApply: vi.fn(),
499
+ };
500
+ return {
501
+ iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
502
+ contentWindow,
503
+ doc,
504
+ };
505
+ }
506
+
507
+ describe("applyUndoRestoreToPreview", () => {
508
+ const ROOT = "index.html";
509
+
510
+ it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
511
+ const { iframe, contentWindow, doc } = buildLiveIframe(
512
+ `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
513
+ );
514
+ const reloadPreview = vi.fn();
515
+ const files = {
516
+ [ROOT]: {
517
+ previous: wrap(
518
+ `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
519
+ ),
520
+ restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
521
+ },
522
+ };
523
+ const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
524
+ expect(outcome).toBe("soft");
525
+ expect(reloadPreview).not.toHaveBeenCalled();
526
+ // Live element reverted to the restored inline style.
527
+ expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
528
+ // No GSAP script in the restore → the manual-edit reapply runs, playhead held.
529
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
530
+ expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
531
+ });
532
+
533
+ it("full-reloads a multi-file restore", () => {
534
+ const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
535
+ const reloadPreview = vi.fn();
536
+ const files = {
537
+ [ROOT]: {
538
+ previous: wrap(`<div id="a" style="x">t</div>`),
539
+ restored: wrap(`<div id="a">t</div>`),
540
+ },
541
+ "scenes/intro.html": { previous: "a", restored: "b" },
542
+ };
543
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
544
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
545
+ });
546
+
547
+ it("full-reloads a structural restore (split/delete undo)", () => {
548
+ const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
549
+ const reloadPreview = vi.fn();
550
+ const files = {
551
+ [ROOT]: {
552
+ previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
553
+ restored: wrap(`<div id="a">t</div>`),
554
+ },
555
+ };
556
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
557
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
558
+ });
559
+
560
+ it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
561
+ const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
562
+ const reloadPreview = vi.fn();
563
+ const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
564
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
565
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
566
+ });
567
+ });