@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -55,24 +55,24 @@ export interface UseDomEditWiringParams {
55
55
  sel: DomEditSelection,
56
56
  animId: string,
57
57
  updates: { duration?: number; ease?: string; position?: number },
58
- ) => void;
59
- deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
60
- deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
58
+ ) => Promise<void>;
59
+ deleteGsapAnimation: (sel: DomEditSelection, animId: string) => Promise<void>;
60
+ deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => Promise<void>;
61
61
  addGsapAnimation: (
62
62
  sel: DomEditSelection,
63
63
  method: "to" | "from" | "set" | "fromTo",
64
64
  time: number,
65
65
  ) => Promise<void>;
66
- addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
67
- removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
66
+ addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
67
+ removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
68
68
  updateGsapFromProperty: (
69
69
  sel: DomEditSelection,
70
70
  animId: string,
71
71
  prop: string,
72
72
  value: number | string,
73
- ) => void;
74
- addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
75
- removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
73
+ ) => Promise<void>;
74
+ addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
75
+ removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
76
76
  addKeyframe: (
77
77
  sel: DomEditSelection,
78
78
  animId: string,
@@ -105,7 +105,7 @@ export interface UseDomEditWiringParams {
105
105
  animId: string,
106
106
  resolvedFromValues?: Record<string, number | string>,
107
107
  ) => Promise<void>;
108
- removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
108
+ removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
109
109
  handleDomManualEditsReset: (sel: DomEditSelection) => void;
110
110
  }
111
111
 
@@ -245,6 +245,7 @@ export function useDomEditWiring({
245
245
  removeAllKeyframes,
246
246
  handleDomManualEditsReset,
247
247
  selectedGsapAnimations,
248
+ showToast,
248
249
  });
249
250
 
250
251
  // ── Preview sync side-effects ──
@@ -3,9 +3,22 @@
3
3
  import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { usePlayerStore } from "../player";
6
- import type { DomEditPatchBatch } from "./domEditCommitTypes";
6
+ import {
7
+ LAYER_REVEAL_PRIOR_POSITION_ATTR,
8
+ LAYER_REVEAL_PRIOR_Z_ATTR,
9
+ } from "../player/lib/timelineElementHelpers";
10
+ import {
11
+ LAYER_REVEAL_LIFT_Z,
12
+ LAYER_REVEAL_PENDING_COMMIT_ATTR,
13
+ liftElementToTop,
14
+ restoreLiftedElement,
15
+ useLayerRevealOverride,
16
+ } from "../components/editor/useLayerRevealOverride";
17
+ import type { DomEditPatchBatch, DomEditPatchBatchesResult } from "./domEditCommitTypes";
7
18
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
19
+ import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils";
8
20
  import { mountReactHarness } from "./domSelectionTestHarness";
21
+ import { runZLaneGesture } from "../components/nle/zLaneGesture";
9
22
 
10
23
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
24
 
@@ -13,11 +26,13 @@ afterEach(() => {
13
26
  document.body.innerHTML = "";
14
27
  usePlayerStore.getState().setElements([]);
15
28
  vi.unstubAllGlobals();
29
+ vi.useRealTimers();
16
30
  });
17
31
 
18
32
  interface BatchOptions {
19
33
  label: string;
20
34
  coalesceKey: string;
35
+ coalesceMs?: number;
21
36
  skipReload?: boolean;
22
37
  }
23
38
 
@@ -38,26 +53,21 @@ type ReorderCommit = (
38
53
  }>,
39
54
  coalesceKeyOverride?: string,
40
55
  actionKind?: string,
41
- ) => Promise<void>;
56
+ ) => Promise<DomEditPatchBatchesResult | undefined | void>;
42
57
 
43
58
  function renderReorderHook(
44
59
  capturedCalls: CapturedBatchCall[],
45
60
  onReady: (commit: ReorderCommit) => void,
46
61
  ) {
47
62
  function Harness() {
48
- const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
49
- activeCompPath: "index.html",
50
- showToast: vi.fn(),
51
- writeProjectFile: vi.fn(async () => {}),
52
- domEditSaveTimestampRef: { current: 0 },
53
- editHistory: { recordEdit: vi.fn(async () => {}) },
54
- projectIdRef: { current: null },
55
- reloadPreview: vi.fn(),
56
- clearDomSelection: vi.fn(),
57
- commitDomEditPatchBatches: async (batches, options) => {
58
- capturedCalls.push({ batches, options });
59
- },
60
- });
63
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
64
+ makeLifecycleOpsParams({
65
+ commitDomEditPatchBatches: async (batches, options) => {
66
+ capturedCalls.push({ batches, options });
67
+ return { durable: true, allMatched: true, changed: true };
68
+ },
69
+ }),
70
+ );
61
71
  onReady(handleDomZIndexReorderCommit);
62
72
  return null;
63
73
  }
@@ -219,10 +229,15 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
219
229
  });
220
230
 
221
231
  // Same element set, different actions — the keys must differ so the two
222
- // edits never coalesce into one undo step within the coalesce window.
232
+ // edits never coalesce into one undo step. Each key also carries a fresh
233
+ // gesture sequence, which is what makes the commit's unbounded per-gesture
234
+ // coalesce window safe (see zReorderCoalesceKey).
223
235
  expect(captured).toHaveLength(2);
224
- expect(captured[0]?.options.coalesceKey).toBe("z-reorder:bring-forward:clip-a");
225
- expect(captured[1]?.options.coalesceKey).toBe("z-reorder:send-backward:clip-a");
236
+ expect(captured[0]?.options.coalesceKey).toMatch(/^z-reorder:bring-forward:clip-a:g\d+$/);
237
+ expect(captured[1]?.options.coalesceKey).toMatch(/^z-reorder:send-backward:clip-a:g\d+$/);
238
+ expect(captured[0]?.options.coalesceKey).not.toBe(captured[1]?.options.coalesceKey);
239
+ // The two-phase gesture fold rides an unbounded window on both records.
240
+ expect(captured[0]?.options.coalesceMs).toBe(Number.POSITIVE_INFINITY);
226
241
  act(() => root.unmount());
227
242
  });
228
243
 
@@ -245,26 +260,22 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
245
260
 
246
261
  let commit: ReorderCommit | undefined;
247
262
  let resolveBatch: (() => void) | undefined;
263
+ const batchResult = { durable: true, allMatched: true, changed: true };
248
264
  function Harness() {
249
- const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
250
- activeCompPath: "index.html",
251
- showToast: vi.fn(),
252
- writeProjectFile: vi.fn(async () => {}),
253
- domEditSaveTimestampRef: { current: 0 },
254
- editHistory: { recordEdit: vi.fn(async () => {}) },
255
- projectIdRef: { current: null },
256
- reloadPreview: vi.fn(),
257
- clearDomSelection: vi.fn(),
258
- // Persist stays pending so the assertion below can only be satisfied
259
- // by the SYNCHRONOUS store update (the lane-sync path's requirement).
260
- commitDomEditPatchBatches: () => new Promise((resolve) => (resolveBatch = resolve)),
261
- });
265
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
266
+ makeLifecycleOpsParams({
267
+ // Persist stays pending so the assertion below can only be satisfied
268
+ // by the SYNCHRONOUS store update (the lane-sync path's requirement).
269
+ commitDomEditPatchBatches: () =>
270
+ new Promise((resolve) => (resolveBatch = () => resolve(batchResult))),
271
+ }),
272
+ );
262
273
  commit = handleDomZIndexReorderCommit;
263
274
  return null;
264
275
  }
265
276
  const root = mountReactHarness(<Harness />);
266
277
 
267
- let pending: Promise<void> | undefined;
278
+ let pending: Promise<unknown> | undefined;
268
279
  act(() => {
269
280
  pending = commit!([
270
281
  {
@@ -301,22 +312,16 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
301
312
 
302
313
  let commit: ReorderCommit | undefined;
303
314
  function Harness() {
304
- const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
305
- activeCompPath: "index.html",
306
- showToast: vi.fn(),
307
- writeProjectFile: vi.fn(async () => {}),
308
- domEditSaveTimestampRef: { current: 0 },
309
- editHistory: { recordEdit: vi.fn(async () => {}) },
310
- projectIdRef: { current: null },
311
- reloadPreview: vi.fn(),
312
- clearDomSelection: vi.fn(),
313
- commitDomEditPatchBatches: vi.fn(async () => {
314
- // The live styles were applied by the hook before persist ran.
315
- expect(el.style.zIndex).toBe("2");
316
- expect(el.style.position).toBe("relative");
317
- throw failure;
315
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
316
+ makeLifecycleOpsParams({
317
+ commitDomEditPatchBatches: vi.fn(async () => {
318
+ // The live styles were applied by the hook before persist ran.
319
+ expect(el.style.zIndex).toBe("2");
320
+ expect(el.style.position).toBe("relative");
321
+ throw failure;
322
+ }),
318
323
  }),
319
- });
324
+ );
320
325
  commit = handleDomZIndexReorderCommit;
321
326
  return null;
322
327
  }
@@ -341,6 +346,370 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
341
346
  act(() => root.unmount());
342
347
  });
343
348
 
349
+ it("returns an active reveal lift and its original styles to their owner on failure", async () => {
350
+ const el = document.createElement("div");
351
+ el.id = "clip-a";
352
+ el.style.zIndex = "4";
353
+ el.style.position = "static";
354
+ document.body.appendChild(el);
355
+ const lift = liftElementToTop(el)!;
356
+ const failure = new Error("persist failed");
357
+
358
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
359
+ expect(el.style.position).toBe("relative");
360
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
361
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe("static");
362
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
363
+
364
+ let commit: ReorderCommit | undefined;
365
+ function Harness() {
366
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
367
+ makeLifecycleOpsParams({
368
+ commitDomEditPatchBatches: vi.fn(async () => {
369
+ expect(el.style.zIndex).toBe("8");
370
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
371
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe(false);
372
+ throw failure;
373
+ }),
374
+ }),
375
+ );
376
+ commit = handleDomZIndexReorderCommit;
377
+ return null;
378
+ }
379
+ const root = mountReactHarness(<Harness />);
380
+
381
+ let rejection: unknown;
382
+ await act(async () => {
383
+ try {
384
+ await commit!([{ element: el, zIndex: 8, id: "clip-a", sourceFile: "index.html" }]);
385
+ } catch (error) {
386
+ rejection = error;
387
+ }
388
+ });
389
+ expect(rejection).toBe(failure);
390
+
391
+ // The failed optimistic commit must put the still-active lift back exactly
392
+ // as it found it, including the metadata that lets reveal cleanup restore
393
+ // the authored z/position rather than abandoning the temporary lift.
394
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
395
+ expect(el.style.position).toBe("relative");
396
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
397
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe("static");
398
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
399
+
400
+ restoreLiftedElement(el, lift);
401
+ expect(el.style.zIndex).toBe("4");
402
+ expect(el.style.position).toBe("static");
403
+ act(() => root.unmount());
404
+ });
405
+
406
+ it("reconciles live, store, and reveal state when no patch target matched", async () => {
407
+ const el = document.createElement("div");
408
+ el.id = "clip-a";
409
+ el.style.zIndex = "4";
410
+ el.style.position = "static";
411
+ document.body.appendChild(el);
412
+ usePlayerStore.getState().setElements([
413
+ {
414
+ id: "clip-a",
415
+ tag: "div",
416
+ start: 0,
417
+ duration: 1,
418
+ track: 0,
419
+ zIndex: 4,
420
+ hasExplicitZIndex: true,
421
+ },
422
+ ]);
423
+ const lift = liftElementToTop(el)!;
424
+ const unmatched = { durable: false, allMatched: false, changed: false };
425
+ let commit: ReorderCommit | undefined;
426
+
427
+ function Harness() {
428
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
429
+ makeLifecycleOpsParams({
430
+ commitDomEditPatchBatches: vi.fn(async () => unmatched),
431
+ }),
432
+ ));
433
+ return null;
434
+ }
435
+ const root = mountReactHarness(<Harness />);
436
+
437
+ let result: unknown;
438
+ await act(async () => {
439
+ result = await commit!([
440
+ {
441
+ element: el,
442
+ zIndex: 8,
443
+ id: "clip-a",
444
+ sourceFile: "index.html",
445
+ key: "clip-a",
446
+ },
447
+ ]);
448
+ });
449
+
450
+ expect(result).toEqual(unmatched);
451
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
452
+ expect(el.style.position).toBe("relative");
453
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
454
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe("static");
455
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
456
+ expect(usePlayerStore.getState().elements[0]).toMatchObject({
457
+ zIndex: 4,
458
+ hasExplicitZIndex: true,
459
+ });
460
+
461
+ restoreLiftedElement(el, lift);
462
+ expect(el.style.zIndex).toBe("4");
463
+ expect(el.style.position).toBe("static");
464
+ act(() => root.unmount());
465
+ });
466
+
467
+ it.each(["rejection", "unmatched"] as const)(
468
+ "defers a delayed reveal until a pending z commit finishes with %s",
469
+ async (outcome) => {
470
+ vi.useFakeTimers();
471
+ const el = document.createElement("div");
472
+ el.id = "clip-a";
473
+ el.style.zIndex = "4";
474
+ el.style.position = "absolute";
475
+ document.body.appendChild(el);
476
+ usePlayerStore.getState().setElements([
477
+ {
478
+ id: "clip-a",
479
+ tag: "div",
480
+ start: 0,
481
+ duration: 1,
482
+ track: 0,
483
+ zIndex: 4,
484
+ hasExplicitZIndex: true,
485
+ },
486
+ ]);
487
+ let resolvePersist: ((result: DomEditPatchBatchesResult) => void) | undefined;
488
+ let rejectPersist: ((error: Error) => void) | undefined;
489
+ const persist = vi.fn(
490
+ () =>
491
+ new Promise<DomEditPatchBatchesResult>((resolve, reject) => {
492
+ resolvePersist = resolve;
493
+ rejectPersist = reject;
494
+ }),
495
+ );
496
+ let commit: ReorderCommit | undefined;
497
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
498
+
499
+ function Harness() {
500
+ ({ scheduleReveal } = useLayerRevealOverride({
501
+ isPlaying: false,
502
+ selectedElement: el,
503
+ }));
504
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
505
+ makeLifecycleOpsParams({ commitDomEditPatchBatches: persist }),
506
+ ));
507
+ return null;
508
+ }
509
+ const root = mountReactHarness(<Harness />);
510
+
511
+ let pending: Promise<unknown> | undefined;
512
+ act(() => {
513
+ scheduleReveal!(el, 10);
514
+ pending = commit!([
515
+ { element: el, zIndex: 8, id: "clip-a", sourceFile: "index.html", key: "clip-a" },
516
+ ]);
517
+ vi.advanceTimersByTime(10);
518
+ });
519
+
520
+ // The timer elapsed, but it must not capture optimistic z=8 as authored.
521
+ expect(el.style.zIndex).toBe("8");
522
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
523
+
524
+ let rejection: unknown;
525
+ await act(async () => {
526
+ if (outcome === "rejection") rejectPersist!(new Error("save failed"));
527
+ else resolvePersist!({ durable: false, allMatched: false, changed: false });
528
+ try {
529
+ await pending;
530
+ } catch (error) {
531
+ rejection = error;
532
+ }
533
+ });
534
+ if (outcome === "rejection") expect(rejection).toBeInstanceOf(Error);
535
+
536
+ act(() => vi.advanceTimersByTime(16));
537
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
538
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
539
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(4);
540
+ act(() => root.unmount());
541
+ },
542
+ );
543
+
544
+ it("serializes overlapping commits through the shared gesture owner", async () => {
545
+ const el = document.createElement("div");
546
+ el.id = "clip-a";
547
+ el.style.zIndex = "1";
548
+ document.body.appendChild(el);
549
+ usePlayerStore.getState().setElements([
550
+ {
551
+ id: "clip-a",
552
+ tag: "div",
553
+ start: 0,
554
+ duration: 1,
555
+ track: 0,
556
+ zIndex: 1,
557
+ hasExplicitZIndex: true,
558
+ },
559
+ ]);
560
+ const firstFailure = new Error("first persist failed");
561
+ let rejectFirst: ((error: Error) => void) | undefined;
562
+ let resolveSecond: (() => void) | undefined;
563
+ const persist = vi
564
+ .fn()
565
+ .mockImplementationOnce(
566
+ () =>
567
+ new Promise<never>((_resolve, reject) => {
568
+ rejectFirst = reject;
569
+ }),
570
+ )
571
+ .mockImplementationOnce(
572
+ () =>
573
+ new Promise((resolve) => {
574
+ resolveSecond = () => resolve({ durable: true, allMatched: true, changed: true });
575
+ }),
576
+ );
577
+ let commit: ReorderCommit | undefined;
578
+
579
+ function Harness() {
580
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
581
+ makeLifecycleOpsParams({ commitDomEditPatchBatches: persist }),
582
+ ));
583
+ return null;
584
+ }
585
+ const root = mountReactHarness(<Harness />);
586
+
587
+ let firstPending: Promise<unknown> | undefined;
588
+ let secondPending: Promise<unknown> | undefined;
589
+ act(() => {
590
+ firstPending = runZLaneGesture({
591
+ commitZ: () =>
592
+ commit!([
593
+ { element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html", key: "clip-a" },
594
+ ]),
595
+ mirror: async () => true,
596
+ });
597
+ secondPending = runZLaneGesture({
598
+ commitZ: () =>
599
+ commit!([
600
+ { element: el, zIndex: 3, id: "clip-a", sourceFile: "index.html", key: "clip-a" },
601
+ ]),
602
+ mirror: async () => true,
603
+ });
604
+ });
605
+
606
+ expect(persist).toHaveBeenCalledTimes(1);
607
+ expect(el.style.zIndex).toBe("2");
608
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(2);
609
+
610
+ let rejection: unknown;
611
+ await act(async () => {
612
+ rejectFirst!(firstFailure);
613
+ try {
614
+ await firstPending;
615
+ } catch (error) {
616
+ rejection = error;
617
+ }
618
+ await Promise.resolve();
619
+ });
620
+
621
+ expect(rejection).toBe(firstFailure);
622
+ expect(persist).toHaveBeenCalledTimes(2);
623
+ expect(el.style.zIndex).toBe("3");
624
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(3);
625
+
626
+ await act(async () => {
627
+ resolveSecond!();
628
+ await secondPending;
629
+ });
630
+ expect(el.style.zIndex).toBe("3");
631
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(3);
632
+ act(() => root.unmount());
633
+ });
634
+
635
+ it.each(["deselection", "playback"] as const)(
636
+ "does not resurrect a reveal released by %s while persistence is pending",
637
+ async (releaseBy) => {
638
+ vi.useFakeTimers();
639
+ const el = document.createElement("div");
640
+ el.id = "clip-a";
641
+ el.style.zIndex = "4";
642
+ el.style.position = "static";
643
+ const other = document.createElement("div");
644
+ document.body.append(el, other);
645
+ const failure = new Error("persist failed");
646
+ let rejectPersist: ((error: Error) => void) | undefined;
647
+ const persist = vi.fn(
648
+ () =>
649
+ new Promise<never>((_resolve, reject) => {
650
+ rejectPersist = reject;
651
+ }),
652
+ );
653
+ let commit: ReorderCommit | undefined;
654
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
655
+
656
+ function Harness({
657
+ selectedElement,
658
+ isPlaying,
659
+ }: {
660
+ selectedElement: HTMLElement | null;
661
+ isPlaying: boolean;
662
+ }) {
663
+ ({ scheduleReveal } = useLayerRevealOverride({ isPlaying, selectedElement }));
664
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
665
+ makeLifecycleOpsParams({ commitDomEditPatchBatches: persist }),
666
+ ));
667
+ return null;
668
+ }
669
+
670
+ const root = mountReactHarness(<Harness selectedElement={el} isPlaying={false} />);
671
+ act(() => {
672
+ scheduleReveal!(el, 0);
673
+ vi.advanceTimersByTime(0);
674
+ });
675
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
676
+
677
+ let pending: Promise<unknown> | undefined;
678
+ act(() => {
679
+ pending = commit!([{ element: el, zIndex: 8, id: el.id, sourceFile: "index.html" }]);
680
+ });
681
+ expect(el.style.zIndex).toBe("8");
682
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(true);
683
+
684
+ act(() => {
685
+ root.render(
686
+ <Harness
687
+ selectedElement={releaseBy === "deselection" ? other : el}
688
+ isPlaying={releaseBy === "playback"}
689
+ />,
690
+ );
691
+ });
692
+
693
+ let rejection: unknown;
694
+ await act(async () => {
695
+ rejectPersist!(failure);
696
+ try {
697
+ await pending;
698
+ } catch (error) {
699
+ rejection = error;
700
+ }
701
+ });
702
+
703
+ expect(rejection).toBe(failure);
704
+ expect(el.style.zIndex).toBe("4");
705
+ expect(el.style.position).toBe("static");
706
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
707
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe(false);
708
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
709
+ act(() => root.unmount());
710
+ },
711
+ );
712
+
344
713
  it("rolls back only live and store state after an atomic reorder failure", async () => {
345
714
  const writeProjectFile = vi.fn(async () => {});
346
715
  const recordEdit = vi.fn(async () => {});
@@ -367,20 +736,17 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
367
736
 
368
737
  let commit: ReorderCommit | undefined;
369
738
  function Harness() {
370
- const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
371
- activeCompPath: "index.html",
372
- showToast: vi.fn(),
373
- writeProjectFile,
374
- domEditSaveTimestampRef: { current: 0 },
375
- editHistory: { recordEdit },
376
- projectIdRef: { current: "demo" },
377
- reloadPreview: vi.fn(),
378
- clearDomSelection: vi.fn(),
379
- forceReloadSdkSession,
380
- commitDomEditPatchBatches: vi.fn(async () => {
381
- throw originalError;
739
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
740
+ makeLifecycleOpsParams({
741
+ writeProjectFile,
742
+ editHistory: { recordEdit },
743
+ projectIdRef: { current: "demo" },
744
+ forceReloadSdkSession,
745
+ commitDomEditPatchBatches: vi.fn(async () => {
746
+ throw originalError;
747
+ }),
382
748
  }),
383
- });
749
+ );
384
750
  commit = handleDomZIndexReorderCommit;
385
751
  return null;
386
752
  }