@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -26,18 +26,23 @@ import {
26
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
27
27
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
28
28
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
29
- import { isInstantHold, selectorFromSelection } from "./gsapShared";
29
+ import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
30
30
  import {
31
31
  findGsapPositionAnimation,
32
32
  pickClosestToPlayhead,
33
33
  readGsapPositionFromIframe,
34
34
  } from "./gsapPositionDetection";
35
35
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
36
+ import {
37
+ animationWritesAnyProperty,
38
+ directEditOutcomeForProperties,
39
+ type GsapEditOutcome,
40
+ } from "./gsapEditOutcome";
36
41
 
37
42
  // Position channels — used to scope the "has a live position tween?" check so a
38
43
  // sibling rotation/scale animation never forces a static position hold into the
39
44
  // keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
40
- export const POSITION_CHANNELS = [
45
+ export const POSITION_CHANNELS: string[] = [
41
46
  "x",
42
47
  "y",
43
48
  "xPercent",
@@ -50,6 +55,10 @@ export const POSITION_CHANNELS = [
50
55
  "translateX",
51
56
  "translateY",
52
57
  ];
58
+ const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
59
+
60
+ const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
61
+ const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
53
62
 
54
63
  // ── Property-group tween resolution ───────────────────────────────────────
55
64
 
@@ -121,11 +130,40 @@ export type { GsapDragCommitCallbacks };
121
130
  /**
122
131
  * Attempt to handle a drag commit via the GSAP script mutation path.
123
132
  *
124
- * Returns a Promise that resolves to true if the drag was handled via GSAP
125
- * (caller should skip the CSS path), or false if no GSAP position animation
126
- * exists.
133
+ * Returns an explicit persisted/blocked outcome. Callers must reject blocked
134
+ * outcomes so the gesture layer restores its runtime and overlay drafts.
127
135
  */
128
136
  // fallow-ignore-next-line complexity
137
+ async function preflightGsapDragIntercept(
138
+ selection: DomEditSelection,
139
+ animations: GsapAnimation[],
140
+ iframe: HTMLIFrameElement | null,
141
+ fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
142
+ ): Promise<GsapEditOutcome> {
143
+ const selector = selectorFromSelection(selection);
144
+ if (!selector) return { status: "blocked", reason: "no-selector" };
145
+
146
+ const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
147
+ // The fallback API currently represents both a definitive empty parse and an
148
+ // exhausted fetch failure as `[]`. Keep the selected cache in the preflight
149
+ // set as well: ignoring it would let a transient fetch failure bypass helper /
150
+ // runtime-source ownership and reach a destructive split or property write.
151
+ const allKnownAnimations = [...animations, ...fetchedAnimations];
152
+ const editability = directEditOutcomeForProperties(allKnownAnimations, POSITION_CHANNEL_SET);
153
+ if (editability.status === "blocked") return editability;
154
+ const sourceAnimations = fetchedAnimations.length > 0 ? fetchedAnimations : animations;
155
+ const posAnim = findGsapPositionAnimation(sourceAnimations, selector);
156
+ const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
157
+
158
+ if (hasLivePosition && !posAnim) {
159
+ return { status: "blocked", reason: "source-uneditable" };
160
+ }
161
+ if (!posAnim && !writeTargetSelector(selection)) {
162
+ return { status: "blocked", reason: "no-selector" };
163
+ }
164
+ return { status: "persisted" };
165
+ }
166
+
129
167
  export async function tryGsapDragIntercept(
130
168
  selection: DomEditSelection,
131
169
  offset: { x: number; y: number },
@@ -133,12 +171,20 @@ export async function tryGsapDragIntercept(
133
171
  iframe: HTMLIFrameElement | null,
134
172
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
135
173
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
136
- options?: { altKey?: boolean },
137
- ): Promise<boolean> {
138
- const selector = selectorFromSelection(selection);
139
- if (!selector) {
140
- return false;
174
+ options?: { altKey?: boolean; preflightOnly?: boolean; preflightPassed?: boolean },
175
+ ): Promise<GsapEditOutcome> {
176
+ if (!options?.preflightPassed) {
177
+ const preflight = await preflightGsapDragIntercept(
178
+ selection,
179
+ animations,
180
+ iframe,
181
+ fetchFallbackAnimations,
182
+ );
183
+ if (preflight.status === "blocked" || options?.preflightOnly) return preflight;
141
184
  }
185
+ const selector = selectorFromSelection(selection);
186
+ // The preflight above proves this; retain a defensive result for DOM churn.
187
+ if (!selector) return { status: "blocked", reason: "no-selector" };
142
188
 
143
189
  // Self-heal: enforce a single position write BEFORE committing. A corrupted
144
190
  // file can carry 2+ conflicting position writes for one selector (e.g. a
@@ -218,11 +264,11 @@ export async function tryGsapDragIntercept(
218
264
  commitMutation,
219
265
  fetchAnimations: fetchFallbackAnimations,
220
266
  });
221
- return true;
267
+ return { status: "persisted" };
222
268
  }
223
269
 
224
270
  if (!posAnim) {
225
- return false;
271
+ return { status: "blocked", reason: "source-uneditable" };
226
272
  }
227
273
 
228
274
  // Verify the anim ID is still valid in the current file. The React-state
@@ -251,7 +297,7 @@ export async function tryGsapDragIntercept(
251
297
  } else {
252
298
  await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
253
299
  }
254
- return true;
300
+ return { status: "persisted" };
255
301
  }
256
302
 
257
303
  // ── Runtime property readers (re-exported for external callers) ───────────
@@ -267,28 +313,47 @@ export async function tryGsapRotationIntercept(
267
313
  iframe: HTMLIFrameElement | null,
268
314
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
269
315
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
270
- ): Promise<boolean> {
271
- const selector = selectorFromSelection(selection);
272
- if (!selector) return false;
316
+ ): Promise<GsapEditOutcome> {
317
+ const selector = selectorFromSelection(selection) ?? writeTargetSelector(selection);
318
+ if (!selector) return { status: "blocked", reason: "no-selector" };
319
+
320
+ const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
321
+ const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
322
+ const editability = directEditOutcomeForProperties(
323
+ [...animations, ...fetchedAnimations],
324
+ ROTATION_CHANNEL_SET,
325
+ );
326
+ if (editability.status === "blocked") return editability;
327
+ const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
328
+ ? fetchFallbackAnimations
329
+ : undefined;
273
330
 
274
331
  // Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
275
332
  const resolved = await resolveGroupTween(
276
333
  "rotation",
277
- animations,
334
+ workingAnimations,
278
335
  selection,
279
336
  commitMutation,
280
- fetchFallbackAnimations,
337
+ postSplitFetch,
281
338
  );
282
- const resolvedAnimations = resolved?.animations ?? animations;
339
+ const resolvedAnimations = resolved?.animations ?? workingAnimations;
283
340
 
284
341
  // Fallback: legacy heuristic for hand-written scripts
285
- let anim = resolved?.anim ?? null;
342
+ let anim =
343
+ resolved?.anim && animationWritesAnyProperty(resolved.anim, ROTATION_CHANNEL_SET)
344
+ ? resolved.anim
345
+ : null;
286
346
  if (!anim) {
287
- anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
288
- if (!anim && fetchFallbackAnimations) {
289
- const fresh = await fetchFallbackAnimations();
290
- anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
291
- }
347
+ anim =
348
+ workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
349
+ }
350
+
351
+ const liveSelector = selectorFromSelection(selection);
352
+ const hasLiveRotationTween = liveSelector
353
+ ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
354
+ : false;
355
+ if (!anim && hasLiveRotationTween) {
356
+ return { status: "blocked", reason: "source-uneditable" };
292
357
  }
293
358
 
294
359
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -307,7 +372,7 @@ export async function tryGsapRotationIntercept(
307
372
  commitMutation,
308
373
  fetchAnimations: fetchFallbackAnimations,
309
374
  });
310
- return true;
375
+ return { status: "persisted" };
311
376
  }
312
377
 
313
378
  const pct = computeCurrentPercentage(selection, anim);
@@ -325,7 +390,7 @@ export async function tryGsapRotationIntercept(
325
390
  { commitMutation, fetchAnimations: fetchFallbackAnimations },
326
391
  "Rotate animation",
327
392
  );
328
- return true;
393
+ return { status: "persisted" };
329
394
  }
330
395
 
331
396
  // fallow-ignore-next-line code-duplication
@@ -363,7 +428,7 @@ export async function tryGsapRotationIntercept(
363
428
  },
364
429
  { label: `Rotate (keyframe ${pct}%)`, softReload: true },
365
430
  );
366
- return true;
431
+ return { status: "persisted" };
367
432
  }
368
433
 
369
434
  export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
@@ -41,19 +41,45 @@ type Commit = (
41
41
  /** Renders the hook and hands its commit function to the caller via a ref callback. */
42
42
  function renderHookWith(
43
43
  animations: GsapAnimation[],
44
- onMutation: (mutation: Record<string, unknown>, label: string) => void,
44
+ onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
45
45
  onReady: (commit: Commit) => void,
46
+ bumpGsapCache = vi.fn(),
47
+ onBatch?: (
48
+ calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>,
49
+ label: string,
50
+ ) => unknown | Promise<unknown>,
46
51
  ) {
47
52
  function Harness() {
53
+ const gsapCommitMutation = Object.assign(
54
+ async (
55
+ _sel: DomEditSelection,
56
+ mutation: Record<string, unknown>,
57
+ options: { label: string },
58
+ ) => {
59
+ await onMutation(mutation, options.label);
60
+ },
61
+ onBatch
62
+ ? {
63
+ batch: async (
64
+ calls: Array<{
65
+ selection: DomEditSelection;
66
+ mutation: Record<string, unknown>;
67
+ options: { label: string };
68
+ }>,
69
+ options: { label: string },
70
+ ) => {
71
+ await onBatch(calls, options.label);
72
+ },
73
+ }
74
+ : {},
75
+ );
48
76
  const { commitAnimatedProperties } = useAnimatedPropertyCommit({
49
77
  selectedGsapAnimations: animations,
50
- gsapCommitMutation: async (_sel, mutation, options) => {
51
- onMutation(mutation, options.label);
52
- },
78
+ gsapCommitMutation,
53
79
  addGsapAnimation: vi.fn(),
54
80
  convertToKeyframes: vi.fn(),
55
81
  previewIframeRef: { current: null },
56
- bumpGsapCache: vi.fn(),
82
+ bumpGsapCache,
57
83
  });
58
84
  onReady(commitAnimatedProperties);
59
85
  return null;
@@ -61,11 +87,106 @@ function renderHookWith(
61
87
  return mountReactHarness(<Harness />);
62
88
  }
63
89
 
90
+ describe("useAnimatedPropertyCommit — ownership and rejection propagation", () => {
91
+ it("rejects a helper-authored property before sending a mutation", async () => {
92
+ const helperRotation = {
93
+ id: "#box-to-rotation",
94
+ targetSelector: "#box",
95
+ propertyGroup: "rotation",
96
+ method: "to",
97
+ properties: { rotationX: 10 },
98
+ provenance: { kind: "helper", fn: "spin", callSite: 1 },
99
+ } as unknown as GsapAnimation;
100
+ const mutations: Array<Record<string, unknown>> = [];
101
+ let commit!: Commit;
102
+ const root = renderHookWith(
103
+ [helperRotation],
104
+ (mutation) => mutations.push(mutation),
105
+ (ready) => (commit = ready),
106
+ );
107
+
108
+ await expect(commit(selection, { rotationX: 12 })).rejects.toMatchObject({
109
+ name: "GsapEditBlockedError",
110
+ reason: "unroll-required",
111
+ });
112
+ expect(mutations).toHaveLength(0);
113
+ act(() => root.unmount());
114
+ });
115
+
116
+ it("rejects runtime-computed property ownership before sending a mutation", async () => {
117
+ const runtimePosition = {
118
+ ...keyframedAnim,
119
+ hasUnresolvedKeyframes: true,
120
+ } as GsapAnimation;
121
+ const mutations: Array<Record<string, unknown>> = [];
122
+ let commit!: Commit;
123
+ const root = renderHookWith(
124
+ [runtimePosition],
125
+ (mutation) => mutations.push(mutation),
126
+ (ready) => (commit = ready),
127
+ );
128
+
129
+ await expect(commit(selection, { x: 50 })).rejects.toMatchObject({
130
+ reason: "source-uneditable",
131
+ });
132
+ expect(mutations).toHaveLength(0);
133
+ act(() => root.unmount());
134
+ });
135
+
136
+ it("rejects every property before a mixed-group commit can partially persist", async () => {
137
+ const helperOpacity = {
138
+ id: "#box-to-visual",
139
+ targetSelector: "#box",
140
+ propertyGroup: "visual",
141
+ method: "to",
142
+ properties: { opacity: 0.5 },
143
+ provenance: { kind: "helper", fn: "fade", callSite: 1 },
144
+ } as unknown as GsapAnimation;
145
+ const mutations: Array<Record<string, unknown>> = [];
146
+ let commit!: Commit;
147
+ const root = renderHookWith(
148
+ [helperOpacity],
149
+ (mutation) => mutations.push(mutation),
150
+ (ready) => (commit = ready),
151
+ );
152
+
153
+ await expect(commit(selection, { x: 50, opacity: 0.8 })).rejects.toMatchObject({
154
+ reason: "unroll-required",
155
+ });
156
+ expect(mutations).toHaveLength(0);
157
+ act(() => root.unmount());
158
+ });
159
+
160
+ it("rethrows a persistence failure to the telemetry wrapper", async () => {
161
+ const failure = new Error("save failed");
162
+ const bumpGsapCache = vi.fn();
163
+ let commit!: Commit;
164
+ const root = renderHookWith(
165
+ [keyframedAnim],
166
+ async () => {
167
+ throw failure;
168
+ },
169
+ (ready) => (commit = ready),
170
+ bumpGsapCache,
171
+ );
172
+
173
+ await expect(commit(selection, { x: 50 })).rejects.toBe(failure);
174
+ expect(bumpGsapCache).toHaveBeenCalledTimes(1);
175
+ act(() => root.unmount());
176
+ });
177
+ });
178
+
64
179
  function renderCommitHook(
65
180
  mutations: Array<Record<string, unknown>>,
66
181
  onReady: (commit: Commit) => void,
67
182
  ) {
68
- return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady);
183
+ return renderHookWith(
184
+ [keyframedAnim],
185
+ (mutation) => {
186
+ mutations.push(mutation);
187
+ },
188
+ onReady,
189
+ );
69
190
  }
70
191
 
71
192
  // Regression (#1808): a "3D transform" / design-panel property edit on an
@@ -74,35 +195,28 @@ function renderCommitHook(
74
195
  // off, it must shift the whole tween instead of adding/updating a keyframe
75
196
  // at the playhead.
76
197
  describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
77
- it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
78
- usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
198
+ async function runCommitWithAutoKeyframe(enabled: boolean) {
199
+ usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 });
79
200
  const mutations: Array<Record<string, unknown>> = [];
80
201
  let commit: Commit | undefined;
81
202
  const root = renderCommitHook(mutations, (fn) => (commit = fn));
203
+ await act(async () => commit!(selection, { x: 50 }));
204
+ act(() => root.unmount());
205
+ return mutations;
206
+ }
82
207
 
83
- await act(async () => {
84
- await commit!(selection, { x: 50 });
85
- });
86
-
208
+ it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
209
+ const mutations = await runCommitWithAutoKeyframe(false);
87
210
  expect(mutations).toHaveLength(1);
88
211
  expect(mutations[0]!.type).toBe("replace-with-keyframes");
89
- act(() => root.unmount());
90
212
  });
91
213
 
92
214
  it("still updates a keyframe at the playhead when the toggle is on (default)", async () => {
93
- const mutations: Array<Record<string, unknown>> = [];
94
- let commit: Commit | undefined;
95
- const root = renderCommitHook(mutations, (fn) => (commit = fn));
96
-
97
- await act(async () => {
98
- await commit!(selection, { x: 50 });
99
- });
100
-
215
+ const mutations = await runCommitWithAutoKeyframe(true);
101
216
  expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe(
102
217
  true,
103
218
  );
104
219
  expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false);
105
- act(() => root.unmount());
106
220
  });
107
221
  });
108
222
 
@@ -125,7 +239,9 @@ describe("commitStaticSet group routing", () => {
125
239
  ) {
126
240
  return renderHookWith(
127
241
  [positionSet],
128
- (mutation, label) => committed.push({ mutation, label }),
242
+ (mutation, label) => {
243
+ committed.push({ mutation, label });
244
+ },
129
245
  onReady,
130
246
  );
131
247
  }
@@ -173,7 +289,9 @@ describe("commitStaticSet group routing", () => {
173
289
  let commit!: Commit;
174
290
  renderHookWith(
175
291
  [positionSet, instantSizeHold],
176
- (mutation, label) => committed.push({ mutation, label }),
292
+ (mutation, label) => {
293
+ committed.push({ mutation, label });
294
+ },
177
295
  (c) => (commit = c),
178
296
  );
179
297
 
@@ -191,4 +309,56 @@ describe("commitStaticSet group routing", () => {
191
309
  expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false);
192
310
  expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id);
193
311
  });
312
+
313
+ it("persists multiple property groups in one atomic batch", async () => {
314
+ const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
315
+ const batches: Array<{
316
+ calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>;
317
+ label: string;
318
+ }> = [];
319
+ let commit!: Commit;
320
+ const root = renderHookWith(
321
+ [positionSet],
322
+ (mutation, label) => committed.push({ mutation, label }),
323
+ (ready) => (commit = ready),
324
+ vi.fn(),
325
+ (calls, label) => batches.push({ calls, label }),
326
+ );
327
+
328
+ await act(async () => {
329
+ await commit(selection, { x: 400, width: 500 });
330
+ });
331
+
332
+ expect(committed).toHaveLength(0);
333
+ expect(batches).toHaveLength(1);
334
+ expect(batches[0]!.label).toBe("Set properties");
335
+ expect(batches[0]!.calls.map(({ mutation }) => mutation)).toEqual([
336
+ {
337
+ type: "update-properties",
338
+ animationId: positionSet.id,
339
+ properties: { x: 400 },
340
+ },
341
+ {
342
+ type: "add",
343
+ targetSelector: "#box",
344
+ method: "set",
345
+ position: 0,
346
+ properties: { width: 500 },
347
+ global: true,
348
+ },
349
+ ]);
350
+ act(() => root.unmount());
351
+ });
352
+
353
+ it("fails before sending anything when an atomic multi-group batch is unavailable", async () => {
354
+ const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
355
+ let commit!: Commit;
356
+ const root = renderStaticHook(committed, (ready) => (commit = ready));
357
+
358
+ await expect(commit(selection, { x: 400, width: 500 })).rejects.toThrow(
359
+ "Atomic GSAP property batch is unavailable",
360
+ );
361
+ expect(committed).toHaveLength(0);
362
+ act(() => root.unmount());
363
+ });
194
364
  });