@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
@@ -70,6 +70,8 @@ function keyframedScaleFixture(): GsapAnimation {
70
70
  } as unknown as GsapAnimation;
71
71
  }
72
72
 
73
+ // Resize/rotation hold tests intentionally pin the same no-conversion contract.
74
+ // fallow-ignore-next-line code-duplication
73
75
  it("updates a duration-zero size hold in place instead of converting it to keyframes", async () => {
74
76
  const el = document.createElement("div");
75
77
  el.id = "box";
@@ -96,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
96
98
  commitMutation,
97
99
  );
98
100
 
99
- expect(handled).toBe(true);
101
+ expect(handled).toEqual({ status: "persisted" });
100
102
  expect(commitMutation).toHaveBeenCalledTimes(1);
101
103
  expect(commitMutation.mock.calls[0]![1]).toEqual({
102
104
  type: "update-properties",
@@ -115,6 +117,92 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
115
117
  );
116
118
  });
117
119
 
120
+ // fallow-ignore-next-line code-duplication
121
+ it("requires explicit unroll for helper-authored resize before mutating", async () => {
122
+ const el = document.createElement("div");
123
+ el.id = "box";
124
+ document.body.append(el);
125
+ const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
126
+ const helperSize = {
127
+ id: "#box-to-size",
128
+ targetSelector: "#box",
129
+ propertyGroup: "size",
130
+ method: "to",
131
+ properties: { width: 150, height: 150 },
132
+ duration: 1,
133
+ provenance: { kind: "helper", fn: "grow", callSite: 1 },
134
+ } as unknown as GsapAnimation;
135
+ const commitMutation = vi.fn();
136
+
137
+ await expect(
138
+ tryGsapResizeIntercept(
139
+ selection,
140
+ { width: 344, height: 344 },
141
+ [helperSize],
142
+ null,
143
+ commitMutation,
144
+ ),
145
+ ).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
146
+ expect(commitMutation).not.toHaveBeenCalled();
147
+ });
148
+
149
+ // fallow-ignore-next-line code-duplication
150
+ it("reuses the ownership parse instead of fetching a resolved size group twice", async () => {
151
+ const el = document.createElement("div");
152
+ el.id = "box";
153
+ document.body.append(el);
154
+ const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
155
+ const sizeHold = {
156
+ id: "#box-set-size",
157
+ targetSelector: "#box",
158
+ propertyGroup: "size",
159
+ method: "set",
160
+ properties: { width: 150, height: 150 },
161
+ } as unknown as GsapAnimation;
162
+ const fetchAnimations = vi.fn().mockResolvedValue([sizeHold]);
163
+ const commitMutation = vi.fn();
164
+
165
+ await expect(
166
+ tryGsapResizeIntercept(
167
+ selection,
168
+ { width: 344, height: 344 },
169
+ [],
170
+ null,
171
+ commitMutation,
172
+ fetchAnimations,
173
+ ),
174
+ ).resolves.toEqual({ status: "persisted" });
175
+ expect(fetchAnimations).toHaveBeenCalledTimes(1);
176
+ expect(commitMutation).toHaveBeenCalledWith(
177
+ selection,
178
+ expect.objectContaining({ type: "update-properties", animationId: sizeHold.id }),
179
+ expect.anything(),
180
+ );
181
+ });
182
+
183
+ it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
184
+ const el = document.createElement("div");
185
+ el.id = "box";
186
+ document.body.append(el);
187
+ const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
188
+ const liveSizeTween = {
189
+ targets: () => [el],
190
+ vars: { width: 300, duration: 1 },
191
+ duration: () => 1,
192
+ startTime: () => 0,
193
+ };
194
+ const iframe = {
195
+ contentWindow: { __timelines: { main: { getChildren: () => [liveSizeTween] } } },
196
+ contentDocument: document,
197
+ } as unknown as HTMLIFrameElement;
198
+ const commitMutation = vi.fn();
199
+
200
+ await expect(
201
+ tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
202
+ ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
203
+ expect(commitMutation).not.toHaveBeenCalled();
204
+ });
205
+
118
206
  it("computes a finite zero percentage for a zero-duration tween", () => {
119
207
  const animation = {
120
208
  id: "#box-to-0-size",
@@ -158,7 +246,7 @@ async function runResize(
158
246
  commitMutation as never,
159
247
  async () => [keyframedScaleFixture()],
160
248
  );
161
- expect(handled).toBe(true);
249
+ expect(handled).toEqual({ status: "persisted" });
162
250
  return committed;
163
251
  }
164
252
 
@@ -24,11 +24,16 @@ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
24
24
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
25
25
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
26
26
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
27
- import { isInstantHold, selectorFromSelection } from "./gsapShared";
27
+ import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
28
28
  import { roundTo3 } from "../utils/rounding";
29
29
  import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
30
30
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
31
31
  import { logResize } from "../utils/resizeDebug";
32
+ import {
33
+ animationWritesAnyProperty,
34
+ directEditOutcomeForProperties,
35
+ type GsapEditOutcome,
36
+ } from "./gsapEditOutcome";
32
37
 
33
38
  const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
34
39
 
@@ -54,21 +59,44 @@ export async function tryGsapResizeIntercept(
54
59
  iframe: HTMLIFrameElement | null,
55
60
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
56
61
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
57
- ): Promise<boolean> {
62
+ ): Promise<GsapEditOutcome> {
63
+ const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
64
+ const allKnownAnimations = [...animations, ...fetchedAnimations];
58
65
  // If the element already has a scale-group tween, resize should modify scale
59
66
  // (the user is resizing something whose visual size is driven by scale).
60
67
  // Otherwise, use the size group (width/height).
61
- const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
68
+ const hasScaleGroup = allKnownAnimations.some((a) => a.propertyGroup === "scale");
62
69
  const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
70
+ const resizeProperties =
71
+ resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
72
+ const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
73
+ if (editability.status === "blocked") return editability;
74
+ const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
75
+ // The initial ownership fetch already supplied the complete parse. Only retain
76
+ // the fetch callback when a legacy mixed tween may be split and must then be
77
+ // re-read; otherwise resolveGroupTween would perform the same network read twice.
78
+ const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
79
+ ? fetchFallbackAnimations
80
+ : undefined;
63
81
  const resolved = await resolveGroupTween(
64
82
  resizeGroup,
65
- animations,
83
+ workingAnimations,
66
84
  selection,
67
85
  commitMutation,
68
- fetchFallbackAnimations,
86
+ postSplitFetch,
69
87
  );
70
88
 
71
- let anim = resolved?.anim ?? null;
89
+ let anim =
90
+ resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
91
+ ? resolved.anim
92
+ : null;
93
+ const liveSelector = selectorFromSelection(selection);
94
+ const hasLiveResizeTween = liveSelector
95
+ ? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
96
+ : false;
97
+ if (!anim && hasLiveResizeTween) {
98
+ return { status: "blocked", reason: "source-uneditable" };
99
+ }
72
100
  logResize("intercept-enter", {
73
101
  hasScaleGroup,
74
102
  resizeGroup,
@@ -77,16 +105,16 @@ export async function tryGsapResizeIntercept(
77
105
  size,
78
106
  });
79
107
  if (!anim || isInstantHold(anim)) {
80
- const sel = selectorFromSelection(selection);
81
- if (!sel) return false;
82
- const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
108
+ const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
109
+ if (!sel) return { status: "blocked", reason: "no-selector" };
110
+ const sizeSet = anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element);
83
111
 
84
112
  // If the element is animated (has a real tween, not just a static size
85
113
  // hold), keyframe the size at the playhead so other keyframes keep theirs —
86
114
  // instead of a global set that resizes every frame.
87
115
  if (resizeGroup === "size") {
88
116
  const animatedTween = pickClosestToPlayhead(
89
- animations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
117
+ workingAnimations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
90
118
  );
91
119
  if (animatedTween) {
92
120
  logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
@@ -98,7 +126,7 @@ export async function tryGsapResizeIntercept(
98
126
  animatedTween,
99
127
  { commitMutation, fetchAnimations: fetchFallbackAnimations },
100
128
  );
101
- if (handled) return true;
129
+ if (handled) return { status: "persisted" };
102
130
  }
103
131
  }
104
132
 
@@ -107,11 +135,11 @@ export async function tryGsapResizeIntercept(
107
135
  commitMutation,
108
136
  fetchAnimations: fetchFallbackAnimations,
109
137
  });
110
- return true;
138
+ return { status: "persisted" };
111
139
  }
112
140
 
113
141
  const tweenDuration = resolveTweenDuration(anim);
114
- if (tweenDuration <= 0) return false;
142
+ if (tweenDuration <= 0) return { status: "blocked", reason: "source-uneditable" };
115
143
 
116
144
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
117
145
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -273,7 +301,7 @@ export async function tryGsapResizeIntercept(
273
301
  "Resize animation",
274
302
  );
275
303
  await finalizeScaleResizeCommit();
276
- return true;
304
+ return { status: "persisted" };
277
305
  }
278
306
 
279
307
  const ct = usePlayerStore.getState().currentTime;
@@ -385,7 +413,7 @@ export async function tryGsapResizeIntercept(
385
413
  },
386
414
  );
387
415
  await finalizeScaleResizeCommit();
388
- return true;
416
+ return { status: "persisted" };
389
417
  }
390
418
 
391
419
  const SIZE_PROPS = new Set(["width", "height"]);
@@ -407,7 +435,7 @@ export async function tryGsapResizeIntercept(
407
435
  { label: `Resize (keyframe ${pct}%)`, softReload: true },
408
436
  );
409
437
  await finalizeScaleResizeCommit();
410
- return true;
438
+ return { status: "persisted" };
411
439
  }
412
440
 
413
441
  // ── Rotation intercept ────────────────────────────────────────────────────
@@ -59,6 +59,67 @@ const stalePositionAnim = {
59
59
  afterEach(() => vi.restoreAllMocks());
60
60
 
61
61
  describe("tryGsapDragIntercept — stale-parse guard (no resurrection after delete-all)", () => {
62
+ async function runHelperOwnedDrag(fetchFallbackAnimations?: () => Promise<GsapAnimation[]>) {
63
+ const helperTween = {
64
+ ...stalePositionAnim,
65
+ provenance: { kind: "helper", fn: "slam", callSite: 1 },
66
+ } as GsapAnimation;
67
+ const commitMutation = vi.fn();
68
+ const result = await tryGsapDragIntercept(
69
+ selection,
70
+ { x: 10, y: 10 },
71
+ [helperTween],
72
+ fakeIframe("puck-b", []),
73
+ commitMutation,
74
+ fetchFallbackAnimations,
75
+ );
76
+ return { result, commitMutation };
77
+ }
78
+
79
+ it("blocks a live runtime position tween when no editable source mapping exists", async () => {
80
+ const liveTween = {
81
+ targets: () => [{ id: "puck-b" }],
82
+ vars: { y: 18, duration: 1 },
83
+ duration: () => 1,
84
+ startTime: () => 0,
85
+ };
86
+ const commitMutation = vi.fn();
87
+
88
+ const result = await tryGsapDragIntercept(
89
+ selection,
90
+ { x: 25, y: -10 },
91
+ [],
92
+ fakeIframe("puck-b", [liveTween]),
93
+ commitMutation,
94
+ vi.fn().mockResolvedValue([]),
95
+ );
96
+
97
+ expect(result).toEqual({ status: "blocked", reason: "source-uneditable" });
98
+ expect(commitMutation).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("blocks a selector that cannot safely address one writable element", async () => {
102
+ const selectorless = {
103
+ ...selection,
104
+ id: undefined,
105
+ selector: undefined,
106
+ } as unknown as DomEditSelection;
107
+ const result = await tryGsapDragIntercept(selectorless, { x: 1, y: 1 }, [], null, vi.fn());
108
+ expect(result).toEqual({ status: "blocked", reason: "no-selector" });
109
+ });
110
+
111
+ it("requires explicit unroll for helper-authored source and performs no mutation", async () => {
112
+ const { result, commitMutation } = await runHelperOwnedDrag();
113
+ expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
114
+ expect(commitMutation).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("does not let an empty fallback response bypass cached helper provenance", async () => {
118
+ const { result, commitMutation } = await runHelperOwnedDrag(vi.fn().mockResolvedValue([]));
119
+ expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
120
+ expect(commitMutation).not.toHaveBeenCalled();
121
+ });
122
+
62
123
  it("commits a static set (not the stale tween) when the runtime has no live position motion", async () => {
63
124
  const commitMutation = vi.fn();
64
125
  // Runtime empty (tween deleted) — readRuntimeKeyframes returns null, so the
@@ -73,7 +134,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
73
134
  commitMutation,
74
135
  );
75
136
 
76
- expect(handled).toBe(true);
137
+ expect(handled).toEqual({ status: "persisted" });
77
138
  // No existing `set` for the selector → one `add` mutation with `method:"set"`.
78
139
  expect(commitMutation).toHaveBeenCalledTimes(1);
79
140
  const [, mutation] = commitMutation.mock.calls[0];
@@ -112,7 +173,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
112
173
  commitMutation,
113
174
  );
114
175
 
115
- expect(handled).toBe(true);
176
+ expect(handled).toEqual({ status: "persisted" });
116
177
  const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-properties");
117
178
  expect(updates).toHaveLength(1);
118
179
  expect(updates[0][1]).toEqual({
@@ -151,7 +212,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
151
212
  commitMutation,
152
213
  );
153
214
 
154
- expect(handled).toBe(true);
215
+ expect(handled).toEqual({ status: "persisted" });
155
216
  // One atomic in-place update, NOT an `add`/`add-keyframe`.
156
217
  const types = commitMutation.mock.calls.map(([, m]) => m.type);
157
218
  expect(types).toEqual(["update-properties"]);
@@ -179,6 +240,124 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
179
240
  });
180
241
 
181
242
  describe("tryGsapRotationIntercept — instant holds", () => {
243
+ it("requires explicit unroll for helper-authored rotation before mutating", async () => {
244
+ const helperRotation = {
245
+ id: "#puck-b-to-rotation",
246
+ targetSelector: "#puck-b",
247
+ propertyGroup: "rotation",
248
+ method: "to",
249
+ properties: { rotation: 45 },
250
+ position: 0,
251
+ duration: 1,
252
+ provenance: { kind: "helper", fn: "spin", callSite: 1 },
253
+ } as unknown as GsapAnimation;
254
+ const commitMutation = vi.fn();
255
+
256
+ await expect(
257
+ tryGsapRotationIntercept(selection, 75, [helperRotation], null, commitMutation),
258
+ ).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
259
+ expect(commitMutation).not.toHaveBeenCalled();
260
+ });
261
+
262
+ // Mirrors resize by design: both geometry routes must reuse one ownership parse.
263
+ // fallow-ignore-next-line code-duplication
264
+ it("reuses the ownership parse instead of fetching a resolved rotation group twice", async () => {
265
+ const rotationHold = {
266
+ id: "#puck-b-set-rotation",
267
+ targetSelector: "#puck-b",
268
+ propertyGroup: "rotation",
269
+ method: "set",
270
+ properties: { rotation: 45 },
271
+ } as unknown as GsapAnimation;
272
+ const fetchAnimations = vi.fn().mockResolvedValue([rotationHold]);
273
+ const commitMutation = vi.fn();
274
+
275
+ await expect(
276
+ tryGsapRotationIntercept(selection, 75, [], null, commitMutation, fetchAnimations),
277
+ ).resolves.toEqual({ status: "persisted" });
278
+ expect(fetchAnimations).toHaveBeenCalledTimes(1);
279
+ expect(commitMutation).toHaveBeenCalledWith(
280
+ selection,
281
+ expect.objectContaining({ type: "update-property", animationId: rotationHold.id }),
282
+ expect.anything(),
283
+ );
284
+ });
285
+
286
+ it("rejects a selectorless rotation instead of reporting a handled no-op", async () => {
287
+ const selectorless = {
288
+ ...selection,
289
+ id: undefined,
290
+ selector: undefined,
291
+ } as unknown as DomEditSelection;
292
+ const commitMutation = vi.fn();
293
+
294
+ await expect(
295
+ tryGsapRotationIntercept(selectorless, 75, [], null, commitMutation),
296
+ ).resolves.toEqual({ status: "blocked", reason: "no-selector" });
297
+ expect(commitMutation).not.toHaveBeenCalled();
298
+ });
299
+
300
+ it("blocks helper-authored 3D rotation channels before mutating the group", async () => {
301
+ const helperRotationX = {
302
+ id: "#puck-b-to-rotation",
303
+ targetSelector: "#puck-b",
304
+ propertyGroup: "rotation",
305
+ method: "to",
306
+ properties: { rotationX: 45 },
307
+ duration: 1,
308
+ provenance: { kind: "helper", fn: "tilt", callSite: 1 },
309
+ } as unknown as GsapAnimation;
310
+ const commitMutation = vi.fn();
311
+
312
+ await expect(
313
+ tryGsapRotationIntercept(selection, 75, [helperRotationX], null, commitMutation),
314
+ ).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
315
+ expect(commitMutation).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
319
+ const helperSkew = {
320
+ id: "#puck-b-to-rotation",
321
+ targetSelector: "#puck-b",
322
+ propertyGroup: "rotation",
323
+ method: "to",
324
+ properties: { skewX: 12 },
325
+ duration: 1,
326
+ provenance: { kind: "helper", fn: "skew", callSite: 1 },
327
+ } as unknown as GsapAnimation;
328
+ const commitMutation = vi.fn();
329
+
330
+ await expect(
331
+ tryGsapRotationIntercept(selection, 75, [helperSkew], null, commitMutation),
332
+ ).resolves.toEqual({ status: "persisted" });
333
+ expect(commitMutation).toHaveBeenCalledWith(
334
+ selection,
335
+ expect.objectContaining({ type: "add", properties: { rotation: 75 } }),
336
+ expect.anything(),
337
+ );
338
+ });
339
+
340
+ it("blocks when runtime rotation exists but the authored tween cannot be resolved", async () => {
341
+ const liveRotation = {
342
+ targets: () => [{ id: "puck-b" }],
343
+ vars: { rotation: 45, duration: 1 },
344
+ duration: () => 1,
345
+ startTime: () => 0,
346
+ };
347
+ const commitMutation = vi.fn();
348
+
349
+ await expect(
350
+ tryGsapRotationIntercept(
351
+ selection,
352
+ 75,
353
+ [],
354
+ fakeIframe("puck-b", [liveRotation]),
355
+ commitMutation,
356
+ ),
357
+ ).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
358
+ expect(commitMutation).not.toHaveBeenCalled();
359
+ });
360
+
182
361
  it("updates a duration-zero fromTo hold instead of converting it to keyframes", async () => {
183
362
  const rotationHold = {
184
363
  id: "#puck-b-fromTo-0-rotation",
@@ -201,7 +380,7 @@ describe("tryGsapRotationIntercept — instant holds", () => {
201
380
  commitMutation,
202
381
  );
203
382
 
204
- expect(handled).toBe(true);
383
+ expect(handled).toEqual({ status: "persisted" });
205
384
  expect(commitMutation).toHaveBeenCalledTimes(1);
206
385
  expect(commitMutation.mock.calls[0]![1]).toEqual({
207
386
  type: "update-property",
@@ -243,40 +422,29 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
243
422
  },
244
423
  } as unknown as GsapAnimation;
245
424
 
246
- it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
247
- usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 2 }); // playhead at 100%
425
+ async function runAutoKeyframeDrag(enabled: boolean) {
426
+ usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 2 });
248
427
  const commitMutation = vi.fn();
249
- const iframe = fakeIframe("puck-b", []);
250
-
251
428
  const handled = await tryGsapDragIntercept(
252
429
  selection,
253
430
  { x: -50, y: 0 },
254
431
  [keyframedPositionAnim],
255
- iframe,
432
+ fakeIframe("puck-b", []),
256
433
  commitMutation,
257
434
  );
435
+ return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) };
436
+ }
258
437
 
259
- expect(handled).toBe(true);
260
- const types = commitMutation.mock.calls.map(([, m]) => m.type);
438
+ it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
439
+ const { handled, types } = await runAutoKeyframeDrag(false);
440
+ expect(handled).toEqual({ status: "persisted" });
261
441
  expect(types).toContain("replace-with-keyframes");
262
442
  expect(types).not.toContain("add-keyframe");
263
443
  });
264
444
 
265
445
  it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
266
- usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 2 });
267
- const commitMutation = vi.fn();
268
- const iframe = fakeIframe("puck-b", []);
269
-
270
- const handled = await tryGsapDragIntercept(
271
- selection,
272
- { x: -50, y: 0 },
273
- [keyframedPositionAnim],
274
- iframe,
275
- commitMutation,
276
- );
277
-
278
- expect(handled).toBe(true);
279
- const types = commitMutation.mock.calls.map(([, m]) => m.type);
446
+ const { handled, types } = await runAutoKeyframeDrag(true);
447
+ expect(handled).toEqual({ status: "persisted" });
280
448
  expect(types).not.toContain("replace-with-keyframes");
281
449
  });
282
450
  });
@@ -336,7 +504,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
336
504
  it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
337
505
  const { commitMutation, handled } = await dragMotionPath(null);
338
506
 
339
- expect(handled).toBe(true);
507
+ expect(handled).toEqual({ status: "persisted" });
340
508
  expect(commitMutation).toHaveBeenCalledWith(
341
509
  selection,
342
510
  {
@@ -363,7 +531,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
363
531
  it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
364
532
  const { commitMutation, handled } = await dragMotionPath(50);
365
533
 
366
- expect(handled).toBe(true);
534
+ expect(handled).toEqual({ status: "persisted" });
367
535
  expect(commitMutation).toHaveBeenCalledWith(
368
536
  selection,
369
537
  {