@hyperframes/studio 0.8.114 → 0.8.116

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 (266) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
  4. package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
  7. package/dist/chunk-3F47EFCT.js.map +1 -0
  8. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  9. package/dist/chunk-5EKWJZLM.js.map +1 -0
  10. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
  12. package/dist/index.d.ts +146 -40
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +4021 -3591
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/captions/hooks/useCaptionSync.ts +10 -6
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  23. package/src/components/dock/Dock.tsx +5 -7
  24. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  25. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  27. package/src/components/editor/DomEditOverlay.tsx +5 -1
  28. package/src/components/editor/FileTreeNodes.tsx +13 -10
  29. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  30. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  31. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  32. package/src/components/editor/SnapToolbar.tsx +43 -5
  33. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  34. package/src/components/editor/audioFxTelemetry.ts +2 -3
  35. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  36. package/src/components/editor/cropResize.ts +4 -3
  37. package/src/components/editor/domEditingDom.ts +4 -0
  38. package/src/components/editor/domEditingLayers.ts +1 -5
  39. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  40. package/src/components/editor/domGroupMoveBy.ts +95 -0
  41. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  42. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  43. package/src/components/editor/manualEditingAvailability.ts +8 -0
  44. package/src/components/editor/manualEdits.ts +1 -0
  45. package/src/components/editor/manualEditsParsing.ts +7 -0
  46. package/src/components/editor/manualOffsetDrag.ts +5 -6
  47. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  48. package/src/components/editor/marqueeCommit.ts +24 -8
  49. package/src/components/editor/motionPathCommit.test.ts +29 -1
  50. package/src/components/editor/motionPathCommit.ts +21 -9
  51. package/src/components/editor/motionPathSelection.test.ts +6 -31
  52. package/src/components/editor/probeSourceElement.test.ts +45 -0
  53. package/src/components/editor/probeSourceElement.ts +41 -16
  54. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  55. package/src/components/editor/propertyPanelFont.tsx +3 -2
  56. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  57. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  58. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  59. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  60. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  61. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  62. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  63. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  64. package/src/components/editor/propertyPanelTypes.ts +6 -3
  65. package/src/components/editor/useColorGradingController.ts +2 -1
  66. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  67. package/src/components/editor/useDomEditNudge.ts +2 -0
  68. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  69. package/src/components/editor/useDomEditZOrder.ts +12 -2
  70. package/src/components/editor/useFxCarveNodes.ts +2 -1
  71. package/src/components/editor/useFxLevelling.ts +2 -1
  72. package/src/components/feedback/projectProvenance.ts +2 -1
  73. package/src/components/nle/NLEContext.test.ts +1 -0
  74. package/src/components/nle/NLEContext.tsx +2 -1
  75. package/src/components/nle/NLEPreview.tsx +12 -10
  76. package/src/components/nle/PreviewPoster.tsx +2 -1
  77. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  78. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  79. package/src/components/renders/useFfmpegStatus.ts +2 -1
  80. package/src/components/renders/useRenderQueue.ts +5 -4
  81. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  82. package/src/components/sidebar/AssetsTab.tsx +2 -1
  83. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  84. package/src/components/ui/menuStyle.test.ts +61 -0
  85. package/src/components/ui/menuStyle.ts +10 -0
  86. package/src/contexts/DomEditContext.tsx +5 -1
  87. package/src/contexts/TimelineEditContext.tsx +1 -0
  88. package/src/domEditExports.test.tsx +9 -1
  89. package/src/hooks/domEditCommitRunner.ts +1 -1
  90. package/src/hooks/domSelectionTestHarness.ts +23 -0
  91. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  92. package/src/hooks/domStyleCommit.ts +8 -1
  93. package/src/hooks/draggedGsapPosition.ts +10 -0
  94. package/src/hooks/elementOffsetStager.ts +5 -4
  95. package/src/hooks/gsapDragCommit.test.ts +75 -164
  96. package/src/hooks/gsapDragCommit.ts +4 -125
  97. package/src/hooks/gsapDragPositionCommit.ts +36 -384
  98. package/src/hooks/gsapEditOutcome.ts +22 -2
  99. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  100. package/src/hooks/gsapGestureOutcome.ts +64 -0
  101. package/src/hooks/gsapGroupPreflight.ts +34 -0
  102. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  103. package/src/hooks/gsapMutationClient.ts +4 -3
  104. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  105. package/src/hooks/gsapParsedTween.test.ts +62 -0
  106. package/src/hooks/gsapParsedTween.ts +243 -0
  107. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  108. package/src/hooks/gsapPositionDetection.ts +52 -33
  109. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  110. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  111. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  112. package/src/hooks/gsapResizeIntercept.test.ts +96 -11
  113. package/src/hooks/gsapResizeIntercept.ts +79 -168
  114. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  115. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  116. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  117. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  118. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  119. package/src/hooks/gsapScriptCommitTypes.ts +13 -0
  120. package/src/hooks/gsapShared.ts +7 -0
  121. package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
  122. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  123. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  124. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  125. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  126. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  127. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  128. package/src/hooks/keyframeCacheAstLoad.ts +8 -6
  129. package/src/hooks/newTweenTarget.test.ts +41 -76
  130. package/src/hooks/plainRotation.ts +3 -2
  131. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  132. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  133. package/src/hooks/refreshTimelineRowText.ts +29 -0
  134. package/src/hooks/timelineTimingSync.test.ts +1 -0
  135. package/src/hooks/timelineTimingSync.ts +2 -1
  136. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  137. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  138. package/src/hooks/useAskAgentModal.ts +2 -1
  139. package/src/hooks/useBlockCatalog.ts +2 -1
  140. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  141. package/src/hooks/useCaptionDetection.ts +2 -1
  142. package/src/hooks/useCompositionContentLoader.ts +2 -1
  143. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  144. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  145. package/src/hooks/useDomEditSession.test.tsx +30 -1
  146. package/src/hooks/useDomEditSession.ts +10 -0
  147. package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
  148. package/src/hooks/useDomEditTextCommits.ts +13 -3
  149. package/src/hooks/useDomEditWiring.ts +4 -2
  150. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  151. package/src/hooks/useDomGeometryCommit.ts +8 -4
  152. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  153. package/src/hooks/useDomGeometryCommits.ts +3 -2
  154. package/src/hooks/useDomSelection.test.ts +26 -0
  155. package/src/hooks/useDomSelection.ts +10 -3
  156. package/src/hooks/useDomSelectionTypes.ts +6 -1
  157. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  158. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  159. package/src/hooks/useEditHistoryActions.ts +16 -5
  160. package/src/hooks/useElementLifecycleOps.ts +2 -1
  161. package/src/hooks/useEnableKeyframes.test.ts +34 -3
  162. package/src/hooks/useEnableKeyframes.ts +7 -4
  163. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
  164. package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
  165. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  166. package/src/hooks/useFileManager.ts +14 -10
  167. package/src/hooks/useFileTree.ts +3 -2
  168. package/src/hooks/useFrameCapture.ts +5 -1
  169. package/src/hooks/useFreezeFrame.ts +3 -2
  170. package/src/hooks/useGestureCommit.test.tsx +225 -55
  171. package/src/hooks/useGestureCommit.ts +86 -55
  172. package/src/hooks/useGroupCommits.ts +2 -1
  173. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  174. package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
  175. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  176. package/src/hooks/useGsapAwareEditing.ts +101 -195
  177. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  178. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  179. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  180. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  181. package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
  182. package/src/hooks/useGsapScriptCommits.ts +15 -10
  183. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  184. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  185. package/src/hooks/useGsapTweenCache.ts +18 -12
  186. package/src/hooks/useLintModal.ts +2 -1
  187. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  188. package/src/hooks/usePersistentEditHistory.ts +47 -8
  189. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  190. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  191. package/src/hooks/usePreviewInteraction.ts +2 -0
  192. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  193. package/src/hooks/usePreviewPersistence.ts +13 -11
  194. package/src/hooks/useProjectFileWriter.ts +20 -19
  195. package/src/hooks/useRemoveBackground.ts +2 -1
  196. package/src/hooks/useRenderClipContent.test.ts +18 -0
  197. package/src/hooks/useRenderClipContent.ts +4 -5
  198. package/src/hooks/useSdkSession.ts +2 -1
  199. package/src/hooks/useServerConnection.ts +4 -3
  200. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  201. package/src/hooks/useThumbnailStill.test.ts +100 -0
  202. package/src/hooks/useThumbnailStill.ts +37 -0
  203. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  204. package/src/hooks/useTimelineDeleteOps.ts +22 -27
  205. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  206. package/src/index.ts +4 -0
  207. package/src/player/components/AudioWaveform.tsx +3 -2
  208. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  209. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  210. package/src/player/components/ClipContextMenu.tsx +33 -31
  211. package/src/player/components/CompositionThumbnail.tsx +2 -1
  212. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  213. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  214. package/src/player/components/TextClipContent.test.ts +19 -0
  215. package/src/player/components/TextClipContent.tsx +27 -0
  216. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  217. package/src/player/components/clipMenuNormalize.ts +2 -1
  218. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  219. package/src/player/components/clipMenuToolItems.tsx +13 -9
  220. package/src/player/components/clipPeakMap.ts +2 -1
  221. package/src/player/components/timelineCallbacks.ts +1 -0
  222. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  223. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  224. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  225. package/src/player/components/timelineEditing.ts +3 -1
  226. package/src/player/components/timelineLinkSelection.ts +35 -1
  227. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  228. package/src/player/components/useTimelineClipDrag.ts +6 -7
  229. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  230. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  231. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  232. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  233. package/src/player/lib/thumbnailScheduler.ts +10 -2
  234. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  235. package/src/player/lib/timelineElementHelpers.ts +6 -15
  236. package/src/player/lib/timelineText.ts +40 -0
  237. package/src/player/sceneSwap.ts +2 -1
  238. package/src/player/store/timelineElement.ts +12 -0
  239. package/src/styles/theme.css +3 -0
  240. package/src/telemetry/client.ts +2 -1
  241. package/src/utils/blockInstaller.ts +2 -1
  242. package/src/utils/elementGsap.test.ts +9 -0
  243. package/src/utils/elementGsap.ts +7 -0
  244. package/src/utils/gsapSoftReload.ts +2 -3
  245. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  246. package/src/utils/gsapUndoRestore.ts +3 -6
  247. package/src/utils/keyframeUsage.ts +79 -0
  248. package/src/utils/previewFeatureUsage.test.ts +32 -0
  249. package/src/utils/previewFeatureUsage.ts +46 -0
  250. package/src/utils/previewPrefetch.ts +2 -1
  251. package/src/utils/razorSplitTransaction.ts +12 -8
  252. package/src/utils/softReloadTargets.ts +1 -3
  253. package/src/utils/studioApiFetch.test.ts +75 -0
  254. package/src/utils/studioApiFetch.ts +13 -0
  255. package/src/utils/studioFileHistory.ts +4 -1
  256. package/src/utils/studioHelpers.ts +7 -1
  257. package/src/utils/studioPendingEdits.test.ts +23 -6
  258. package/src/utils/studioPendingEdits.ts +32 -8
  259. package/src/utils/studioTelemetry.test.ts +107 -0
  260. package/src/utils/studioTelemetry.ts +51 -5
  261. package/src/utils/timelineCompositionInsert.ts +3 -2
  262. package/src/webmcp/StudioAgentTools.tsx +4 -3
  263. package/dist/chunk-BIUXP3PG.js.map +0 -1
  264. package/dist/chunk-K47E2ZJA.js.map +0 -1
  265. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  266. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -9,12 +9,13 @@
9
9
  * easing, or seek position.
10
10
  */
11
11
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
12
- import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
12
+ import { isXYPositionWrite, PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
13
13
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
14
  import { usePlayerStore } from "../player/store/playerStore";
15
15
 
16
16
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
17
- import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
17
+ import { commitGsapPositionFromDrag, gsapPositionFromDragOutcome } from "./gsapDragPositionCommit";
18
+ import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
18
19
  import {
19
20
  commitStaticGsapPosition,
20
21
  commitStaticGsapRotation,
@@ -22,7 +23,6 @@ import {
22
23
  computeCurrentPercentage,
23
24
  findExistingPositionWrite,
24
25
  findRotationSetAnimation,
25
- materializeIfDynamic,
26
26
  } from "./gsapDragCommit";
27
27
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
28
28
  import { isGestureTransactionCommit } from "./gestureTransaction";
@@ -54,64 +54,29 @@ const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
54
54
  // ── Property-group tween resolution ───────────────────────────────────────
55
55
 
56
56
  /**
57
- * Find the tween for a given property group, splitting a legacy mixed tween
58
- * if necessary. Returns the resolved animation or null if none exists.
59
- *
60
- * Resolution order:
61
- * 1. Tween already tagged with `propertyGroup === group`
62
- * 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
63
- * re-fetch, then return the group tween
64
- * 3. null — caller must handle the missing-tween case
57
+ * The tween to edit for a property group: a tween tagged with it, else a legacy tween that mixes
58
+ * it with other groups. A mixed tween is edited in place, never split first: inside a gesture the
59
+ * split is only buffered, so ids read after it are stale and the edit lands beside the old tween.
65
60
  */
66
61
  export async function resolveGroupTween(
67
62
  group: PropertyGroupName,
68
63
  animations: GsapAnimation[],
69
- selection: DomEditSelection,
70
- commitMutation: GsapDragCommitCallbacks["commitMutation"],
64
+ _selection: DomEditSelection,
65
+ _commitMutation: GsapDragCommitCallbacks["commitMutation"],
71
66
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
72
67
  ): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
73
- // 1. Already-split group tween — pick the one closest to the current
74
- // playhead so a drag at t=6s edits the tween at 4s, not the one at 1.5s.
75
- const groupAnims = animations.filter((a) => a.propertyGroup === group);
76
- const groupAnim = pickClosestToPlayhead(groupAnims);
77
- if (groupAnim) return { anim: groupAnim, animations };
78
-
79
- // 2. Legacy mixed tween — split it, then re-fetch
80
- const legacyMixed = animations.find((a) => !a.propertyGroup);
81
- if (legacyMixed) {
82
- await commitMutation(
83
- selection,
84
- { type: "split-into-property-groups", animationId: legacyMixed.id },
85
- { label: "Split mixed tween into property groups", skipReload: true },
86
- );
87
- if (fetchFallbackAnimations) {
88
- const fresh = await fetchFallbackAnimations();
89
- const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
90
- if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
91
- }
92
- }
93
-
94
- // 3. Try fallback fetch (no split needed, just wasn't in the initial list)
95
- if (!legacyMixed && fetchFallbackAnimations) {
96
- const fresh = await fetchFallbackAnimations();
97
- const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
98
- if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
99
-
100
- // Fallback: legacy mixed in the fresh list
101
- const freshLegacy = fresh.find((a) => !a.propertyGroup);
102
- if (freshLegacy) {
103
- await commitMutation(
104
- selection,
105
- { type: "split-into-property-groups", animationId: freshLegacy.id },
106
- { label: "Split mixed tween into property groups", skipReload: true },
107
- );
108
- const reFetched = await fetchFallbackAnimations();
109
- const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
110
- if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
111
- }
112
- }
113
-
114
- return null;
68
+ const inGroup = (list: GsapAnimation[]) => {
69
+ const tagged = list.filter((a) => a.propertyGroup === group);
70
+ const props = new Set(PROPERTY_GROUPS[group]);
71
+ const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
72
+ return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
73
+ };
74
+ const anim = inGroup(animations);
75
+ if (anim) return { anim, animations };
76
+ if (!fetchFallbackAnimations) return null;
77
+ const fresh = await fetchFallbackAnimations();
78
+ const freshAnim = inGroup(fresh);
79
+ return freshAnim ? { anim: freshAnim, animations: fresh } : null;
115
80
  }
116
81
 
117
82
  // ── High-level intercept ───────────────────────────────────────────────────
@@ -205,6 +170,59 @@ function oneUndoStep(
205
170
  commit(selection, mutation, { ...options, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY });
206
171
  }
207
172
 
173
+ const isPositionWriteOf = (selector: string) => (a: GsapAnimation) =>
174
+ a.targetSelector === selector && isXYPositionWrite(a);
175
+
176
+ /** The one position write a self-heal keeps when holds fight over `selector`, else null. */
177
+ function positionWriteKeeper(animations: GsapAnimation[], selector: string): GsapAnimation | null {
178
+ const dupes = animations.filter(isPositionWriteOf(selector));
179
+ // Real tweens one after another are a motion, not a conflict: only holds can fight.
180
+ if (dupes.length < 2 || dupes.filter((a) => !isInstantHold(a)).length > 1) return null;
181
+ return dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
182
+ }
183
+
184
+ /** Where a drag writes. With no live motion and no keyframed tween (a hold, or a
185
+ * zero-length keyframed tween) the position belongs in a `tl.set`, never keyframes. */
186
+ function dragRoute(
187
+ posAnim: GsapAnimation | null,
188
+ iframe: HTMLIFrameElement | null,
189
+ selector: string,
190
+ altKey?: boolean,
191
+ ): "static" | "whole-path" | "at-playhead" {
192
+ const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
193
+ const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
194
+ if (!hasNonHold && !hasKeyframedPosTween) return "static";
195
+ // Alt-drag shifts the whole path; with auto-keyframe off (#1808) that is the default.
196
+ return altKey || !usePlayerStore.getState().autoKeyframeEnabled ? "whole-path" : "at-playhead";
197
+ }
198
+
199
+ /** The commit's route and keyframe plan, writing nothing, so a group refuses whole. */
200
+ async function planDrag(
201
+ selection: DomEditSelection,
202
+ offset: { x: number; y: number },
203
+ allAnimations: GsapAnimation[],
204
+ iframe: HTMLIFrameElement | null,
205
+ options: { altKey?: boolean },
206
+ ): Promise<GsapEditOutcome> {
207
+ const selector = selectorFromSelection(selection);
208
+ if (!selector) return { status: "blocked", reason: "no-selector" };
209
+ const own = tweensForThisElement(selection, allAnimations);
210
+ const keeper = positionWriteKeeper(own, selector);
211
+ const animations = keeper
212
+ ? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
213
+ : own;
214
+ const resolved = await resolveGroupTween("position", animations, selection, async () => {});
215
+ const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector);
216
+ const route = dragRoute(posAnim, iframe, selector, options.altKey);
217
+ if (route === "static") return { status: "persisted" };
218
+ if (!posAnim) {
219
+ return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
220
+ }
221
+ if (route === "whole-path") return { status: "persisted" };
222
+ const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
223
+ return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
224
+ }
225
+
208
226
  /** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
209
227
  * restores its drafts) and save `element-offset` on the element itself. */
210
228
  export async function tryGsapDragIntercept(
@@ -229,7 +247,12 @@ export async function tryGsapDragIntercept(
229
247
  fetchAllAnimations,
230
248
  options?.group,
231
249
  );
232
- if (preflight.status !== "persisted" || options?.preflightOnly) return preflight;
250
+ if (preflight.status !== "persisted") return preflight;
251
+ if (options?.preflightOnly) {
252
+ return options.group
253
+ ? planDrag(selection, offset, allAnimations, iframe, options)
254
+ : preflight;
255
+ }
233
256
  }
234
257
  const animations = tweensForThisElement(selection, allAnimations);
235
258
  const fetchFallbackAnimations =
@@ -246,13 +269,10 @@ export async function tryGsapDragIntercept(
246
269
  // the live keyframed/real tween if present (else any), strip the rest, so the
247
270
  // commit below updates ONE write instead of fighting duplicates.
248
271
  let workingAnimations = animations;
249
- const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && isXYPositionWrite(a);
250
- if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
272
+ if (animations.filter(isPositionWriteOf(selector)).length > 1 && fetchFallbackAnimations) {
251
273
  const fresh = await fetchFallbackAnimations();
252
- const dupes = fresh.filter(isPosWrite);
253
- if (dupes.length > 1) {
254
- const keeper =
255
- dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
274
+ const keeper = positionWriteKeeper(fresh, selector);
275
+ if (keeper) {
256
276
  await commitMutation(
257
277
  selection,
258
278
  {
@@ -288,26 +308,8 @@ export async function tryGsapDragIntercept(
288
308
  }
289
309
 
290
310
  const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
291
-
292
- // STATIC case (single source of truth = GSAP timeline): the element has no LIVE
293
- // keyframed/tweened position motion. Use the strict non-hold check — a leftover
294
- // position-hold `set` (after a delete-all, or a stale parse that lags it) must
295
- // NOT count as live motion. Either way the position belongs in a
296
- // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
297
- // place (idempotent), else add a new one. This also covers the stale-cache
298
- // phantom — committing a set is correct because the element genuinely has no live motion.
299
- const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
300
- // A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
301
- // e.g. 0% and 100% identical) — is still an animation the user is building, so a
302
- // drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
303
- // dragging an element whose position tween is constant writes a `gsap.set` that
304
- // fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
305
- // static path is only for elements with NO keyframed position tween (truly static,
306
- // or just a leftover position-hold `set`).
307
- // A zero-duration keyframed tween is a static HOLD, not a live animation —
308
- // treat it as static so the drag heals it instead of feeding it more keyframes.
309
- const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
310
- if (!hasNonHold && !hasKeyframedPosTween) {
311
+ const route = dragRoute(posAnim, iframe, selector, options?.altKey);
312
+ if (route === "static") {
311
313
  const existingSet =
312
314
  posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
313
315
  ? posAnim
@@ -329,27 +331,26 @@ export async function tryGsapDragIntercept(
329
331
  // current ID and avoid a stale-ID remove that creates duplicate tweens.
330
332
  if (fetchFallbackAnimations) {
331
333
  const fresh = await fetchFallbackAnimations();
332
- const freshMatch = fresh.find(
333
- (a) =>
334
- a.targetSelector === posAnim!.targetSelector &&
335
- a.propertyGroup === posAnim!.propertyGroup &&
336
- isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
337
- );
334
+ const freshMatch =
335
+ fresh.find((a) => a.id === posAnim!.id) ??
336
+ pickClosestToPlayhead(
337
+ fresh.filter(
338
+ (a) =>
339
+ a.targetSelector === posAnim!.targetSelector &&
340
+ a.propertyGroup === posAnim!.propertyGroup &&
341
+ isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
342
+ ),
343
+ );
338
344
  if (freshMatch && freshMatch.id !== posAnim.id) {
339
345
  posAnim = freshMatch;
340
346
  }
341
347
  }
342
348
 
343
349
  const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
344
- // Alt-drag already means "shift the whole path" — the global auto-keyframe
345
- // toggle (#1808) just makes that the default while it's off, so a manual
346
- // edit on an already-animated element nudges the animation instead of
347
- // inserting/updating a keyframe at the playhead.
348
- const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
349
- if (options?.altKey || !autoKeyframeEnabled) {
350
+ if (route === "whole-path") {
350
351
  await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
351
352
  } else {
352
- await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
353
+ return commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, cbs);
353
354
  }
354
355
  return { status: "persisted" };
355
356
  }
@@ -428,8 +429,9 @@ export async function tryGsapRotationIntercept(
428
429
  ? resolved.anim
429
430
  : null;
430
431
  if (!anim) {
431
- anim =
432
- workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
432
+ anim = pickClosestToPlayhead(
433
+ workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
434
+ );
433
435
  }
434
436
 
435
437
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -469,42 +471,14 @@ export async function tryGsapRotationIntercept(
469
471
  return { status: "persisted" };
470
472
  }
471
473
 
472
- // fallow-ignore-next-line code-duplication
473
- if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
474
- const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
475
- if (newId) anim = { ...anim, id: newId };
476
- } else if (!anim.keyframes) {
477
- const resolvedFromValues = selector
478
- ? readAllAnimatedProperties(iframe, selector, anim, "rotation")
479
- : undefined;
480
- await commitMutation(
481
- selection,
482
- { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
483
- { label: "Convert to keyframes for rotation", skipReload: true },
484
- );
485
- }
486
-
487
- const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
488
-
489
- const backfillDefaults: Record<string, number> = { ...runtimeProps };
490
- if (!("rotation" in runtimeProps)) {
491
- backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
492
- }
493
-
494
- const properties = { ...runtimeProps, rotation: newRotation };
495
-
496
- await commitMutation(
474
+ return commitValueAtPlayhead(
497
475
  selection,
498
- {
499
- type: "add-keyframe",
500
- animationId: anim.id,
501
- percentage: pct,
502
- properties,
503
- backfillDefaults,
504
- },
505
- { label: `Rotate (keyframe ${pct}%)`, softReload: true },
476
+ anim,
477
+ { rotation: newRotation },
478
+ iframe,
479
+ { commitMutation, fetchAnimations: fetchFallbackAnimations },
480
+ { label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
506
481
  );
507
- return { status: "persisted" };
508
482
  }
509
483
 
510
484
  export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  export { PROPERTY_DEFAULTS } from "./gsapShared";
5
5
  import { idSelector, matchesExactlyOne } from "./gsapShared";
6
6
  import { studioWriteHeaders } from "../utils/studioFileVersion";
7
+ import { studioApiFetch } from "../utils/studioApiFetch";
7
8
 
8
9
  /**
9
10
  * The selector to author a NEW tween against, minting an id on the element when
@@ -116,7 +117,7 @@ export async function assignGsapTargetAutoIdIfNeeded({
116
117
  showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
117
118
  return false;
118
119
  }
119
- const res = await fetch(
120
+ const res = await studioApiFetch(
120
121
  `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
121
122
  {
122
123
  method: "POST",
@@ -3,10 +3,20 @@ import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { PublishSdkSession } from "../utils/sdkCutover";
5
5
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
6
+ import type { KeyframeUsageAction } from "../utils/keyframeUsage";
7
+
8
+ /** The file a selection's GSAP write goes to. */
9
+ export function gsapWriteFile(
10
+ selection: Pick<DomEditSelection, "sourceFile">,
11
+ activeCompPath: string | null | undefined,
12
+ ): string {
13
+ return selection.sourceFile || activeCompPath || "index.html";
14
+ }
6
15
 
7
16
  export interface MutationResult {
8
17
  ok: boolean;
9
18
  changed?: boolean;
19
+ mutationChanges?: boolean[];
10
20
  parsed?: ParsedGsap;
11
21
  before?: string;
12
22
  after?: string;
@@ -15,6 +25,9 @@ export interface MutationResult {
15
25
 
16
26
  export interface CommitMutationOptions {
17
27
  label: string;
28
+ keyframeTelemetry?: boolean;
29
+ keyframeAction?: KeyframeUsageAction;
30
+ keyframeProperty?: string;
18
31
  /** Observe the durable writer result without duplicating the request path. */
19
32
  onResult?: (result: MutationResult) => void;
20
33
  coalesceKey?: string;
@@ -34,6 +34,13 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
34
34
  height: 100,
35
35
  };
36
36
 
37
+ /** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach,
38
+ * since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
39
+ export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
40
+ if (!anim.keyframes) return { easeEach: anim.ease };
41
+ return { ease: anim.ease, easeEach: anim.keyframes.easeEach };
42
+ }
43
+
37
44
  /**
38
45
  * A timeline write that applies an instantaneous value and then holds it.
39
46
  * `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
@@ -3,47 +3,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import { parseGsapScript } from "@hyperframes/core/gsap-parser";
4
4
  import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
5
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
- import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
6
+ import { mountGroupSiblings, stableSelectionFor } from "./domSelectionTestHarness";
7
7
  import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
8
8
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
9
+ import { trackStudioEvent } from "../utils/studioTelemetry";
10
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
11
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
12
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
9
13
  import { promoteSetToKeyframes } from "./useEnableKeyframes";
10
14
 
11
15
  afterEach(() => {
12
16
  document.body.innerHTML = "";
13
17
  });
14
18
 
15
- /**
16
- * A selection built the way production builds it (getDomLayerPatchTarget), so
17
- * the class-only case under test is the real one: buildStableSelector hands back
18
- * a BARE class for an element with no id / hf-id / composition id.
19
- */
20
- function selectionFor(el: HTMLElement): DomEditSelection {
21
- const selector = buildStableSelector(el);
22
- return {
23
- element: el,
24
- id: el.id || undefined,
25
- hfId: el.getAttribute("data-hf-id") || undefined,
26
- selector,
27
- selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
28
- sourceFile: "index.html",
29
- dataAttributes: { start: "0", duration: "2" },
30
- } as unknown as DomEditSelection;
31
- }
32
-
33
- /** Five class-only siblings — the shape that made one add collapse the timeline. */
34
- function mountGroupSiblings(): HTMLElement[] {
35
- document.body.innerHTML = `
36
- <div id="scene" class="clip" data-start="0" data-duration="2">
37
- <div class="group"></div>
38
- <div class="group"></div>
39
- <div class="group"></div>
40
- <div class="group"></div>
41
- <div class="group"></div>
42
- </div>
43
- `;
44
- return Array.from(document.querySelectorAll<HTMLElement>(".group"));
45
- }
46
-
47
19
  const BASE_SCRIPT = `
48
20
  const tl = gsap.timeline({ paused: true });
49
21
  tl.to("#scene", { opacity: 1, duration: 0.5 }, 0);
@@ -64,8 +36,8 @@ function roundTripTargets(selector: string): { script: string; targets: string[]
64
36
 
65
37
  describe("writeTargetSelector", () => {
66
38
  it("addresses exactly one element when the only identity is a shared class", () => {
67
- const groups = mountGroupSiblings();
68
- const selection = selectionFor(groups[2]);
39
+ const groups = mountGroupSiblings(5);
40
+ const selection = stableSelectionFor(groups[2]);
69
41
 
70
42
  // Precondition: this is the defect's input — a bare class hitting all five.
71
43
  expect(selection.selector).toBe(".group");
@@ -82,7 +54,7 @@ describe("writeTargetSelector", () => {
82
54
  document.body.innerHTML = `<div id="box" class="card"></div>`;
83
55
  const el = document.querySelector<HTMLElement>("#box")!;
84
56
 
85
- expect(writeTargetSelector(selectionFor(el))).toBe("#box");
57
+ expect(writeTargetSelector(stableSelectionFor(el))).toBe("#box");
86
58
  });
87
59
 
88
60
  it("prefers data-hf-id over a generated structural selector", () => {
@@ -91,7 +63,7 @@ describe("writeTargetSelector", () => {
91
63
  `;
92
64
  const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
93
65
 
94
- const written = writeTargetSelector(selectionFor(el));
66
+ const written = writeTargetSelector(stableSelectionFor(el));
95
67
 
96
68
  expect(written).toBe('[data-hf-id="hf-42"]');
97
69
  expect(document.querySelector(written!)).toBe(el);
@@ -101,7 +73,7 @@ describe("writeTargetSelector", () => {
101
73
  document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
102
74
  const el = document.querySelector<HTMLElement>(".header")!;
103
75
 
104
- expect(writeTargetSelector(selectionFor(el))).toBe(".header");
76
+ expect(writeTargetSelector(stableSelectionFor(el))).toBe(".header");
105
77
  });
106
78
 
107
79
  it("hands identity back to a data-hf-id ancestor part way up a deep chain", () => {
@@ -121,7 +93,7 @@ describe("writeTargetSelector", () => {
121
93
  `;
122
94
  const el = document.querySelectorAll<HTMLElement>(".cell")[2]!;
123
95
 
124
- const written = writeTargetSelector(selectionFor(el));
96
+ const written = writeTargetSelector(stableSelectionFor(el));
125
97
 
126
98
  expect(written).toBe('[data-hf-id="mid"] > div:nth-child(3)');
127
99
  expect(document.querySelectorAll(written!)).toHaveLength(1);
@@ -133,7 +105,7 @@ describe("writeTargetSelector", () => {
133
105
  const el = document.querySelectorAll<HTMLElement>("#scene > div")[1]!;
134
106
  // querySelectorAll throws on this, so the "does it match exactly one?" rung
135
107
  // must read as a miss rather than crashing the add.
136
- const selection = { ...selectionFor(el), selector: "div[unclosed" } as DomEditSelection;
108
+ const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
137
109
 
138
110
  expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
139
111
  });
@@ -141,7 +113,7 @@ describe("writeTargetSelector", () => {
141
113
  it("returns null when a live DOM is present and no rung addresses one element", () => {
142
114
  document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
143
115
  const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
144
- const selection = selectionFor(el);
116
+ const selection = stableSelectionFor(el);
145
117
  expect(selection.selector).toBe(".group");
146
118
  // Detached between selecting and committing: ownerDocument still resolves,
147
119
  // but there is no parent to count a :nth-child position in. Returning
@@ -163,11 +135,11 @@ describe("writeTargetSelector", () => {
163
135
 
164
136
  describe("writeTargetSelector — write/read round trip", () => {
165
137
  it("re-parses and attributes the new tween to the one element it targeted", () => {
166
- const groups = mountGroupSiblings();
138
+ const groups = mountGroupSiblings(5);
167
139
  // Ids let resolveSelectorElementIds name the attributed elements; the
168
140
  // SELECTION still has none, so the write path still faces the bare class.
169
141
  groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
170
- const selection = { ...selectionFor(groups[2]), id: undefined } as DomEditSelection;
142
+ const selection = { ...stableSelectionFor(groups[2]), id: undefined } as DomEditSelection;
171
143
 
172
144
  const written = writeTargetSelector(selection);
173
145
  const { targets } = roundTripTargets(written!);
@@ -176,9 +148,9 @@ describe("writeTargetSelector — write/read round trip", () => {
176
148
  });
177
149
 
178
150
  it("does not widen the add to the element's four class siblings", () => {
179
- const groups = mountGroupSiblings();
151
+ const groups = mountGroupSiblings(5);
180
152
  groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
181
- const selection = { ...selectionFor(groups[0]), id: undefined } as DomEditSelection;
153
+ const selection = { ...stableSelectionFor(groups[0]), id: undefined } as DomEditSelection;
182
154
 
183
155
  // The old bare-class write attributed the one add to every sibling — the
184
156
  // attribution blow-up behind "one add collapsed the timeline to a single row".
@@ -190,8 +162,8 @@ describe("writeTargetSelector — write/read round trip", () => {
190
162
 
191
163
  describe("existingTweenTargetSelector", () => {
192
164
  it("keeps the narrowed target when a replace re-authors the tween", async () => {
193
- const groups = mountGroupSiblings();
194
- const selection = selectionFor(groups[2]);
165
+ const groups = mountGroupSiblings(5);
166
+ const selection = stableSelectionFor(groups[2]);
195
167
  const commitMutation = vi.fn(async () => undefined);
196
168
  // A tween a previous add already narrowed to one sibling. Re-deriving the
197
169
  // target from the selection would widen it back to all five.
@@ -211,8 +183,8 @@ describe("existingTweenTargetSelector", () => {
211
183
  });
212
184
 
213
185
  it("falls back to the selection when the tween's own target did not resolve", async () => {
214
- const groups = mountGroupSiblings();
215
- const selection = selectionFor(groups[2]);
186
+ const groups = mountGroupSiblings(5);
187
+ const selection = stableSelectionFor(groups[2]);
216
188
  const commitMutation = vi.fn(async () => undefined);
217
189
  const setAnim = {
218
190
  id: "a1",
@@ -232,8 +204,8 @@ describe("existingTweenTargetSelector", () => {
232
204
 
233
205
  describe("commitKeyframeAtTimeImpl — new-tween target", () => {
234
206
  it("authors a one-element selector when no tween exists at the playhead", async () => {
235
- const groups = mountGroupSiblings();
236
- const selection = selectionFor(groups[3]);
207
+ const groups = mountGroupSiblings(5);
208
+ const selection = stableSelectionFor(groups[3]);
237
209
  const commitMutation = vi.fn(async () => undefined);
238
210
 
239
211
  await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
@@ -247,7 +219,7 @@ describe("commitKeyframeAtTimeImpl — new-tween target", () => {
247
219
 
248
220
  describe("tweenTargetsElement", () => {
249
221
  it("matches a tween narrowed to this element by a selector the selection does not use", () => {
250
- const groups = mountGroupSiblings();
222
+ const groups = mountGroupSiblings(5);
251
223
  groups[2]!.id = "narrowed";
252
224
 
253
225
  // The write half authored "#narrowed"; the read half still has ".group".
@@ -255,7 +227,7 @@ describe("tweenTargetsElement", () => {
255
227
  });
256
228
 
257
229
  it("does not hand an individual element the group tween it merely inherits", () => {
258
- const groups = mountGroupSiblings();
230
+ const groups = mountGroupSiblings(5);
259
231
 
260
232
  // Selecting one sibling by its own address must not let an edit mutate the
261
233
  // ".group" tween: that write moves all five, not the one being nudged.
@@ -263,13 +235,13 @@ describe("tweenTargetsElement", () => {
263
235
  });
264
236
 
265
237
  it("still edits a group tween when the group itself is the selection", () => {
266
- const groups = mountGroupSiblings();
238
+ const groups = mountGroupSiblings(5);
267
239
 
268
240
  expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
269
241
  });
270
242
 
271
243
  it("does not match a target that is not a selector matches() understands", () => {
272
- const groups = mountGroupSiblings();
244
+ const groups = mountGroupSiblings(5);
273
245
 
274
246
  expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
275
247
  });
@@ -277,8 +249,8 @@ describe("tweenTargetsElement", () => {
277
249
 
278
250
  describe("commitKeyframeAtTimeImpl — no one-element target", () => {
279
251
  it("drops the keyframe rather than authoring the bare class", async () => {
280
- const groups = mountGroupSiblings();
281
- const selection = selectionFor(groups[3]!);
252
+ const groups = mountGroupSiblings(5);
253
+ const selection = stableSelectionFor(groups[3]!);
282
254
  groups[3]!.remove();
283
255
  const commitMutation = vi.fn(async () => undefined);
284
256
 
@@ -287,3 +259,19 @@ describe("commitKeyframeAtTimeImpl — no one-element target", () => {
287
259
  expect(commitMutation).not.toHaveBeenCalled();
288
260
  });
289
261
  });
262
+
263
+ it("counts a keyframe-at-time conversion and insertion as one add gesture", async () => {
264
+ vi.clearAllMocks();
265
+ const el = document.createElement("div");
266
+ el.id = "target";
267
+ document.body.append(el);
268
+ const selection = stableSelectionFor(el);
269
+ const animations = parseGsapScript(
270
+ 'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
271
+ ).animations;
272
+ const commit: CommitMutation = async (_selection, mutation, options) => {
273
+ trackKeyframeCommit([mutation], { ok: true, changed: true }, options);
274
+ };
275
+ await commitKeyframeAtTimeImpl(selection, 1, animations, { x: 25 }, commit);
276
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
277
+ });