@hyperframes/studio 0.8.114 → 0.8.115

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
  4. package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +43 -19
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +2939 -2596
  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/components/LinkedSelectionToggle.tsx +3 -2
  21. package/src/components/dock/Dock.tsx +5 -7
  22. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  23. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  24. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  25. package/src/components/editor/DomEditOverlay.tsx +5 -1
  26. package/src/components/editor/FileTreeNodes.tsx +13 -10
  27. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  28. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  29. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  30. package/src/components/editor/SnapToolbar.tsx +43 -5
  31. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  32. package/src/components/editor/audioFxTelemetry.ts +2 -3
  33. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  34. package/src/components/editor/cropResize.ts +4 -3
  35. package/src/components/editor/domEditingDom.ts +4 -0
  36. package/src/components/editor/domEditingLayers.ts +1 -5
  37. package/src/components/editor/manualEdits.ts +1 -0
  38. package/src/components/editor/manualEditsParsing.ts +7 -0
  39. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  40. package/src/components/editor/marqueeCommit.ts +24 -8
  41. package/src/components/editor/motionPathCommit.test.ts +29 -1
  42. package/src/components/editor/motionPathCommit.ts +21 -9
  43. package/src/components/editor/probeSourceElement.test.ts +45 -0
  44. package/src/components/editor/probeSourceElement.ts +39 -15
  45. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  46. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  47. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  48. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  49. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  50. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  51. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  52. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  53. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  54. package/src/components/editor/propertyPanelTypes.ts +6 -3
  55. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  56. package/src/components/editor/useDomEditNudge.ts +2 -0
  57. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  58. package/src/components/editor/useDomEditZOrder.ts +12 -2
  59. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  60. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  61. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  62. package/src/components/ui/menuStyle.test.ts +61 -0
  63. package/src/components/ui/menuStyle.ts +10 -0
  64. package/src/contexts/DomEditContext.tsx +1 -1
  65. package/src/contexts/TimelineEditContext.tsx +1 -0
  66. package/src/domEditExports.test.tsx +9 -1
  67. package/src/hooks/domEditCommitRunner.ts +1 -1
  68. package/src/hooks/elementOffsetStager.ts +5 -4
  69. package/src/hooks/gsapDragCommit.ts +2 -20
  70. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  71. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  72. package/src/hooks/gsapGestureOutcome.ts +64 -0
  73. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  74. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  75. package/src/hooks/gsapResizeIntercept.ts +1 -0
  76. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  77. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  78. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  79. package/src/hooks/plainRotation.ts +3 -2
  80. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  81. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  82. package/src/hooks/refreshTimelineRowText.ts +29 -0
  83. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  84. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  85. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  86. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  87. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  88. package/src/hooks/useDomEditTextCommits.ts +5 -1
  89. package/src/hooks/useDomEditWiring.ts +4 -2
  90. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  91. package/src/hooks/useDomGeometryCommit.ts +7 -4
  92. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  93. package/src/hooks/useDomGeometryCommits.ts +3 -2
  94. package/src/hooks/useDomSelection.test.ts +26 -0
  95. package/src/hooks/useDomSelection.ts +10 -3
  96. package/src/hooks/useDomSelectionTypes.ts +6 -1
  97. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  98. package/src/hooks/useEnableKeyframes.ts +5 -3
  99. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  100. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  101. package/src/hooks/useGestureCommit.test.tsx +225 -55
  102. package/src/hooks/useGestureCommit.ts +86 -55
  103. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  104. package/src/hooks/useGsapAwareEditing.ts +96 -195
  105. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  106. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  107. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  108. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  109. package/src/hooks/useGsapScriptCommits.ts +3 -0
  110. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  111. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  112. package/src/hooks/useGsapTweenCache.ts +18 -12
  113. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  114. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  115. package/src/hooks/usePreviewInteraction.ts +2 -0
  116. package/src/hooks/useRenderClipContent.test.ts +18 -0
  117. package/src/hooks/useRenderClipContent.ts +4 -5
  118. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  119. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  120. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  121. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  122. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  123. package/src/player/components/ClipContextMenu.tsx +33 -31
  124. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  125. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  126. package/src/player/components/TextClipContent.test.ts +19 -0
  127. package/src/player/components/TextClipContent.tsx +27 -0
  128. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  129. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  130. package/src/player/components/clipMenuToolItems.tsx +13 -9
  131. package/src/player/components/timelineCallbacks.ts +1 -0
  132. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  133. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  134. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  135. package/src/player/components/timelineEditing.ts +3 -1
  136. package/src/player/components/timelineLinkSelection.ts +35 -1
  137. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  138. package/src/player/components/useTimelineClipDrag.ts +6 -7
  139. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  140. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  141. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  142. package/src/player/lib/timelineElementHelpers.ts +6 -15
  143. package/src/player/lib/timelineText.ts +40 -0
  144. package/src/player/store/timelineElement.ts +12 -0
  145. package/src/styles/theme.css +3 -0
  146. package/src/utils/keyframeUsage.ts +79 -0
  147. package/src/utils/previewFeatureUsage.test.ts +32 -0
  148. package/src/utils/previewFeatureUsage.ts +46 -0
  149. package/src/utils/studioTelemetry.test.ts +107 -0
  150. package/src/utils/studioTelemetry.ts +49 -4
  151. package/src/webmcp/StudioAgentTools.tsx +2 -2
  152. package/dist/chunk-BIUXP3PG.js.map +0 -1
  153. package/dist/chunk-K47E2ZJA.js.map +0 -1
  154. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  155. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -314,7 +314,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
314
314
  ],
315
315
  ease: "none",
316
316
  },
317
- { label: "Add keyframe", softReload: true },
317
+ { label: "Add keyframe", softReload: true, keyframeAction: "add" },
318
318
  );
319
319
  });
320
320
 
@@ -336,7 +336,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
336
336
  duration: 10,
337
337
  keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
338
338
  }),
339
- { label: "Add keyframe", softReload: true },
339
+ { label: "Add keyframe", softReload: true, keyframeAction: "add" },
340
340
  );
341
341
  });
342
342
  });
@@ -159,7 +159,7 @@ async function replaceSetWithSingleKeyframe(
159
159
  keyframes: [{ percentage: 0, properties: position }],
160
160
  ease: setAnim.ease,
161
161
  },
162
- { label: "Enable keyframes", softReload: true },
162
+ { label: "Enable keyframes", keyframeAction: "add", softReload: true },
163
163
  );
164
164
  }
165
165
 
@@ -270,6 +270,7 @@ async function extendKeyframedTweenToPlayhead(
270
270
  },
271
271
  {
272
272
  label: "Add keyframe",
273
+ keyframeAction: "add",
273
274
  softReload: true,
274
275
  ...commitOverrides,
275
276
  },
@@ -377,7 +378,7 @@ export async function promoteSetToKeyframes(
377
378
  ],
378
379
  ease: setAnim.ease,
379
380
  },
380
- { label: "Add keyframe", softReload: true },
381
+ { label: "Add keyframe", keyframeAction: "add", softReload: true },
381
382
  );
382
383
  }
383
384
 
@@ -454,7 +455,7 @@ export async function applyArcKeyframeAtPlayhead(
454
455
  }),
455
456
  ease: "none",
456
457
  },
457
- { label: "Add keyframe", softReload: true },
458
+ { label: "Add keyframe", keyframeAction: "add", softReload: true },
458
459
  );
459
460
  }
460
461
 
@@ -503,6 +504,7 @@ export function useEnableKeyframes(
503
504
  enableKeyframesTransactionCounter += 1;
504
505
  const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
505
506
  const convertCommitOverrides: Partial<CommitMutationOptions> = {
507
+ keyframeTelemetry: false,
506
508
  skipReload: true,
507
509
  coalesceKey,
508
510
  coalesceMs: Number.POSITIVE_INFINITY,
@@ -143,6 +143,85 @@ describe("external file change coordinator", () => {
143
143
  expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
144
144
  });
145
145
 
146
+ it("keeps a waiting head edit's thumbnail refresh when a later write replaces it", async () => {
147
+ let release = () => {};
148
+ const inFlight = new Promise<void>((resolve) => (release = resolve));
149
+ const onAcceptedPersistedFileChange = vi.fn();
150
+ let drains = 0;
151
+ await mountCoordinator({
152
+ onAcceptedPersistedFileChange,
153
+ drainPendingChanges: async () => {
154
+ if (drains++ === 0) await inFlight;
155
+ return { status: "clean" as const };
156
+ },
157
+ });
158
+ await act(async () => handler?.({ path: "other.html", content: "a", version: "v1" }));
159
+ await act(async () => handler?.({ path: "index.html", content: "b", version: "v2" }));
160
+ await act(async () =>
161
+ handler?.({
162
+ path: "index.html",
163
+ content: "c",
164
+ version: "v3",
165
+ affectedCompositions: ["index.html"],
166
+ }),
167
+ );
168
+ await act(async () => release());
169
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
170
+ });
171
+
172
+ it("keeps a head edit's thumbnail refresh through a conflict that a later write replaces", async () => {
173
+ const onAcceptedPersistedFileChange = vi.fn();
174
+ const conflict = new StudioFileConflictError({
175
+ filePath: "index.html",
176
+ currentVersion: "v1",
177
+ currentContent: "theirs",
178
+ attemptedContent: "mine",
179
+ });
180
+ const { captured } = await mountCoordinator({
181
+ onAcceptedPersistedFileChange,
182
+ drainPendingChanges: async () => ({ status: "conflict" as const, error: conflict }),
183
+ });
184
+ await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
185
+ await act(async () =>
186
+ handler?.({
187
+ path: "index.html",
188
+ content: "body",
189
+ version: "v2",
190
+ affectedCompositions: ["index.html"],
191
+ }),
192
+ );
193
+ await act(async () => captured.handle?.useExternalFile());
194
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
195
+ });
196
+
197
+ it("keeps a held head edit's thumbnail refresh when a later write saves cleanly", async () => {
198
+ const onAcceptedPersistedFileChange = vi.fn();
199
+ const conflict = new StudioFileConflictError({
200
+ filePath: "index.html",
201
+ currentVersion: "v1",
202
+ currentContent: "theirs",
203
+ attemptedContent: "mine",
204
+ });
205
+ let drains = 0;
206
+ await mountCoordinator({
207
+ onAcceptedPersistedFileChange,
208
+ drainPendingChanges: async () =>
209
+ drains++ === 0
210
+ ? { status: "conflict" as const, error: conflict }
211
+ : { status: "clean" as const },
212
+ });
213
+ await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
214
+ await act(async () =>
215
+ handler?.({
216
+ path: "index.html",
217
+ content: "body",
218
+ version: "v2",
219
+ affectedCompositions: ["index.html"],
220
+ }),
221
+ );
222
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
223
+ });
224
+
146
225
  it("does not refresh the tree for a suppressed self-write echo", async () => {
147
226
  const refreshFileTree = vi.fn();
148
227
  await mountCoordinator({ refreshFileTree });
@@ -333,6 +412,29 @@ describe("external file change coordinator", () => {
333
412
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
334
413
  });
335
414
 
415
+ it("keeps the failed file's payload when a snapshot delete fails for another file", async () => {
416
+ const { captured } = await mountCoordinator({
417
+ drainPendingChanges: vi
418
+ .fn()
419
+ .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
420
+ .mockResolvedValueOnce({ status: "clean" as const }),
421
+ getPendingCandidate: () => ({ path: "scene.html", content: "studio" }),
422
+ deleteConflictSnapshot: vi.fn(async () => {
423
+ throw new Error("delete failed");
424
+ }),
425
+ });
426
+ await act(async () =>
427
+ handler?.({ path: "scene.html", content: "scene-external", version: "s1" }),
428
+ );
429
+ await act(async () =>
430
+ handler?.({ path: "index.html", content: "index-external", version: "i1" }),
431
+ );
432
+ expect(captured.handle?.blocked).toMatchObject({
433
+ status: "failed",
434
+ payload: { path: "scene.html", content: "scene-external", version: "s1" },
435
+ });
436
+ });
437
+
336
438
  it("restores and overwrites from a durable failed draft", async () => {
337
439
  const overwriteConflict = vi.fn(async () => undefined);
338
440
  const onAcceptedPersistedFileChange = vi.fn();
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
2
  import {
3
+ mergeFileChangeAffectedCompositions,
3
4
  readFileChangeAffectedCompositions,
4
5
  readFileChangeAffectsPreview,
5
6
  readFileChangeField,
@@ -163,7 +164,7 @@ export function useExternalFileChangeCoordinator({
163
164
  onAcceptedPersistedFileChange,
164
165
  refreshFileTree,
165
166
  }: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
166
- const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
167
+ const [blocked, setBlockedState] = useState<ExternalFileChangeBlockedState | null>(null);
167
168
  const generationRef = useRef(0);
168
169
  const mountedRef = useRef(true);
169
170
  const lastEventIdentityRef = useRef<string | null>(null);
@@ -172,6 +173,14 @@ export function useExternalFileChangeCoordinator({
172
173
  const drainingRef = useRef(false);
173
174
  const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
174
175
  blockedRef.current = blocked;
176
+ // A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed.
177
+ const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => {
178
+ setBlockedState((held) =>
179
+ next && held
180
+ ? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) }
181
+ : next,
182
+ );
183
+ }, []);
175
184
 
176
185
  useEffect(() => {
177
186
  mountedRef.current = true;
@@ -185,7 +194,7 @@ export function useExternalFileChangeCoordinator({
185
194
  generationRef.current += 1;
186
195
  setBlocked(null);
187
196
  lastEventIdentityRef.current = null;
188
- }, [projectId, activeCompPath]);
197
+ }, [projectId, activeCompPath, setBlocked]);
189
198
 
190
199
  useEffect(() => {
191
200
  if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
@@ -229,7 +238,7 @@ export function useExternalFileChangeCoordinator({
229
238
  return () => {
230
239
  cancelled = true;
231
240
  };
232
- }, [loadConflictSnapshot, projectId, recoveryFilePath]);
241
+ }, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]);
233
242
 
234
243
  const reloadAcceptedGeneration = useCallback(
235
244
  (path: string, affectsPreview = true) => {
@@ -275,14 +284,22 @@ export function useExternalFileChangeCoordinator({
275
284
  await deleteConflictSnapshot(projectId!, path);
276
285
  } catch (error) {
277
286
  if (mountedRef.current && generation === generationRef.current) {
278
- setBlocked({ ...previousBlocked, generation, error });
287
+ setBlocked({
288
+ ...previousBlocked,
289
+ generation,
290
+ error,
291
+ payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload),
292
+ });
279
293
  }
280
294
  return;
281
295
  }
282
296
  }
283
297
  if (!mountedRef.current || generation !== generationRef.current) return;
284
298
  setBlocked(null);
285
- onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
299
+ const owed = previousBlocked
300
+ ? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload)
301
+ : payload;
302
+ onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
286
303
  reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
287
304
  return;
288
305
  }
@@ -344,6 +361,7 @@ export function useExternalFileChangeCoordinator({
344
361
  },
345
362
  [
346
363
  drainPendingChanges,
364
+ setBlocked,
347
365
  projectId,
348
366
  deleteConflictSnapshot,
349
367
  getPendingCandidate,
@@ -419,7 +437,12 @@ export function useExternalFileChangeCoordinator({
419
437
  waiting != null &&
420
438
  readFileChangeAffectsPreview(waiting) &&
421
439
  !readFileChangeAffectsPreview(payload);
422
- if (!waitingChangeOutranksThis) pendingPayloadRef.current = { payload };
440
+ if (!waitingChangeOutranksThis) {
441
+ pendingPayloadRef.current = {
442
+ payload:
443
+ waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload),
444
+ };
445
+ }
423
446
  void startDrainLoop();
424
447
  },
425
448
  [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
@@ -469,6 +492,7 @@ export function useExternalFileChangeCoordinator({
469
492
  },
470
493
  [
471
494
  deleteConflictSnapshot,
495
+ setBlocked,
472
496
  discardPendingChanges,
473
497
  onUseExternalFile,
474
498
  onAcceptedPersistedFileChange,
@@ -527,6 +551,7 @@ export function useExternalFileChangeCoordinator({
527
551
  reloadAcceptedGeneration(conflict.filePath);
528
552
  }, [
529
553
  deleteConflictSnapshot,
554
+ setBlocked,
530
555
  discardPendingChanges,
531
556
  overwriteConflict,
532
557
  projectId,
@@ -1,12 +1,18 @@
1
1
  // @vitest-environment jsdom
2
- import { act } from "react";
2
+ import { act, useLayoutEffect } from "react";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
4
7
  import type { DomEditSelection } from "../components/editor/domEditing";
5
8
  import { usePlayerStore } from "../player";
6
9
  import { mountReactHarness } from "./domSelectionTestHarness";
7
10
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
11
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
8
12
  import { useGestureCommit } from "./useGestureCommit";
9
13
 
14
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
15
+
10
16
  const gestureRecording = vi.hoisted(() => ({
11
17
  startRecording: vi.fn(),
12
18
  stopRecording: vi.fn(() => [
@@ -84,44 +90,88 @@ function makeSelection(element: HTMLElement): DomEditSelection {
84
90
  };
85
91
  }
86
92
 
93
+ function mountRecording(
94
+ writer: (
95
+ mutation: Record<string, unknown>,
96
+ options: CommitMutationOptions,
97
+ selection?: DomEditSelection,
98
+ ) => Promise<void>,
99
+ animations: GsapAnimation[] = [],
100
+ readOnlyPreview = false,
101
+ ) {
102
+ const iframe = document.createElement("iframe");
103
+ document.body.append(iframe);
104
+ const element = document.createElement("div");
105
+ element.id = "card";
106
+ const session = {
107
+ current: {
108
+ domEditSelection: makeSelection(element),
109
+ selectedGsapAnimations: animations,
110
+ commitMutation: writer,
111
+ },
112
+ };
113
+ let hook: ReturnType<typeof useGestureCommit> | null = null;
114
+ function Probe() {
115
+ const editing = useGsapAwareEditing({
116
+ domEditSelection: session.current.domEditSelection,
117
+ selectedGsapAnimations: session.current.selectedGsapAnimations,
118
+ gsapCommitMutation: (selection, mutation, options) => writer(mutation, options, selection),
119
+ previewIframeRef: { current: iframe },
120
+ showToast: vi.fn(),
121
+ bumpGsapCache: vi.fn(),
122
+ makeFetchFallback: () => vi.fn(),
123
+ trackGsapInteractionFailure: vi.fn(),
124
+ stageElementPositionOffset: vi.fn(),
125
+ handleDomBoxSizeCommit: vi.fn(),
126
+ handleDomRotationCommit: vi.fn(),
127
+ commitPositionPatchToHtml: vi.fn(),
128
+ addGsapAnimation: vi.fn(),
129
+ convertToKeyframes: vi.fn(),
130
+ setArcPath: vi.fn(),
131
+ updateArcSegment: vi.fn(),
132
+ });
133
+ const currentHook = useGestureCommit({
134
+ domEditSessionRef: session,
135
+ previewIframeRef: { current: iframe },
136
+ showToast: vi.fn(),
137
+ isGestureRecordingRef: { current: false },
138
+ readOnlyPreview,
139
+ });
140
+ useLayoutEffect(() => {
141
+ session.current.commitMutation = editing.commitMutation;
142
+ hook = currentHook;
143
+ });
144
+ return null;
145
+ }
146
+ const root = mountReactHarness(<Probe />);
147
+ cleanup = () => act(() => root.unmount());
148
+ return Object.assign(
149
+ () => {
150
+ if (!hook) throw new Error("hook did not initialize");
151
+ return hook;
152
+ },
153
+ {
154
+ select: (selection: DomEditSelection, nextAnimations: GsapAnimation[]) => {
155
+ session.current.domEditSelection = selection;
156
+ session.current.selectedGsapAnimations = nextAnimations;
157
+ act(() => root.render(<Probe />));
158
+ },
159
+ },
160
+ );
161
+ }
162
+
87
163
  describe("useGestureCommit", () => {
88
164
  it("coalesces property-group commits and reloads only the terminal group", async () => {
89
- const iframe = document.createElement("iframe");
90
- document.body.append(iframe);
91
- const element = document.createElement("div");
92
- element.id = "card";
93
- const commitMutation = vi.fn<
165
+ const writer = vi.fn<
94
166
  (mutation: Record<string, unknown>, options: CommitMutationOptions) => Promise<void>
95
167
  >(async () => {});
96
- const sessionRef = {
97
- current: {
98
- domEditSelection: makeSelection(element),
99
- selectedGsapAnimations: [],
100
- commitMutation,
101
- },
102
- };
103
- const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
104
- function Probe() {
105
- captured.hook = useGestureCommit({
106
- domEditSessionRef: sessionRef,
107
- previewIframeRef: { current: iframe },
108
- showToast: vi.fn(),
109
- isGestureRecordingRef: { current: false },
110
- readOnlyPreview: false,
111
- });
112
- return null;
113
- }
114
- const root = mountReactHarness(<Probe />);
115
- cleanup = () => act(() => root.unmount());
116
- if (!captured.hook) throw new Error("hook did not initialize");
117
-
118
- act(() => captured.hook?.handleToggleRecording());
119
- act(() => captured.hook?.handleToggleRecording());
168
+ const hook = mountRecording(writer);
169
+ act(() => hook().handleToggleRecording());
170
+ act(() => hook().handleToggleRecording());
120
171
  await act(async () => {
121
- await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(2));
172
+ await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(2));
122
173
  });
123
-
124
- const options = commitMutation.mock.calls.map((call) => call[1]);
174
+ const options = writer.mock.calls.map((call) => call[1]);
125
175
  expect(new Set(options.map((entry) => entry.coalesceKey)).size).toBe(1);
126
176
  expect(options[0]).toEqual(expect.objectContaining({ coalesceMs: Infinity, skipReload: true }));
127
177
  expect(options[0]).not.toHaveProperty("softReload");
@@ -130,30 +180,150 @@ describe("useGestureCommit", () => {
130
180
  });
131
181
 
132
182
  it("does not start a recording while the preview is read-only", () => {
133
- const element = document.createElement("div");
134
- const commitMutation = vi.fn(async () => {});
135
- const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
136
- function Probe() {
137
- captured.hook = useGestureCommit({
138
- domEditSessionRef: {
139
- current: {
140
- domEditSelection: makeSelection(element),
141
- selectedGsapAnimations: [],
142
- commitMutation,
143
- },
144
- },
145
- previewIframeRef: { current: document.createElement("iframe") },
146
- showToast: vi.fn(),
147
- isGestureRecordingRef: { current: false },
148
- readOnlyPreview: true,
183
+ const writer = vi.fn(async () => {});
184
+ const hook = mountRecording(writer, [], true);
185
+ act(() => hook().handleToggleRecording());
186
+ expect(gestureRecording.startRecording).not.toHaveBeenCalled();
187
+ expect(writer).not.toHaveBeenCalled();
188
+ });
189
+ });
190
+
191
+ it.each([
192
+ ["new mixed-property recording", undefined, true, true, 1, "button"],
193
+ ["keyboard recording", undefined, true, true, 1, "keyboard"],
194
+ ["static hold replacement", "set", true, true, 1, "button"],
195
+ ["overlapping tween replacement", "to", true, true, 1, "button"],
196
+ ["unchanged recording", undefined, true, false, 0, "button"],
197
+ ["refused recording", undefined, false, true, 0, "button"],
198
+ ] as const)(
199
+ "counts writer receipts once for %s",
200
+ async (_name, method, ok, changed, count, inputMethod) => {
201
+ const animations = method
202
+ ? [
203
+ {
204
+ id: "card-position",
205
+ targetSelector: "#card",
206
+ propertyGroup: "position",
207
+ method,
208
+ properties: { x: 0, y: 0 },
209
+ resolvedStart: 0,
210
+ position: 0,
211
+ duration: method === "set" ? 0 : 2,
212
+ keyframes: {
213
+ keyframes: [
214
+ { percentage: 0, properties: { x: 0 } },
215
+ { percentage: 100, properties: { x: 100 } },
216
+ ],
217
+ },
218
+ } as unknown as GsapAnimation,
219
+ ]
220
+ : [];
221
+ const writer = vi.fn(
222
+ async (mutation: Record<string, unknown>, options: CommitMutationOptions) => {
223
+ const result = { ok, changed };
224
+ trackKeyframeCommit([mutation], result, options);
225
+ options.onResult?.(result);
226
+ },
227
+ );
228
+ const hook = mountRecording(writer, animations);
229
+ act(() => hook().handleToggleRecording(inputMethod));
230
+ await act(async () => {
231
+ hook().handleToggleRecording();
232
+ await vi.waitFor(() => expect(writer).toHaveBeenCalled());
233
+ });
234
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count * 2);
235
+ if (count) {
236
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
237
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
238
+ feature: "gesture_recording",
239
+ surface: "preview",
240
+ method: inputMethod,
149
241
  });
150
- return null;
151
242
  }
152
- const root = mountReactHarness(<Probe />);
153
- cleanup = () => act(() => root.unmount());
243
+ },
244
+ );
154
245
 
155
- act(() => captured.hook?.handleToggleRecording());
156
- expect(gestureRecording.startRecording).not.toHaveBeenCalled();
157
- expect(commitMutation).not.toHaveBeenCalled();
246
+ it("keeps a pending recording isolated until its writer settles", async () => {
247
+ let release!: () => void;
248
+ const pending = new Promise<void>((resolve) => {
249
+ release = resolve;
250
+ });
251
+ const writer = vi.fn(
252
+ async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
253
+ await pending;
254
+ options.onResult?.({ ok: true, changed: true });
255
+ },
256
+ );
257
+ const hook = mountRecording(writer);
258
+ act(() => hook().handleToggleRecording("keyboard"));
259
+ act(() => hook().handleToggleRecording());
260
+ await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(1));
261
+ act(() => hook().handleToggleRecording("button"));
262
+ expect(gestureRecording.startRecording).toHaveBeenCalledTimes(1);
263
+ await act(async () => {
264
+ release();
265
+ await vi.waitFor(() =>
266
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
267
+ feature: "gesture_recording",
268
+ surface: "preview",
269
+ method: "keyboard",
270
+ }),
271
+ );
272
+ });
273
+ expect(gestureRecording.clearSamples).toHaveBeenCalledTimes(1);
274
+ act(() => hook().handleToggleRecording("button"));
275
+ expect(gestureRecording.startRecording).toHaveBeenCalledTimes(2);
276
+ });
277
+
278
+ it("writes one recording to its original selection after selecting another source", async () => {
279
+ const original = {
280
+ id: "original-position",
281
+ targetSelector: "#card",
282
+ propertyGroup: "position",
283
+ method: "set",
284
+ properties: { x: 0, y: 0 },
285
+ resolvedStart: 0,
286
+ position: 0,
287
+ duration: 0,
288
+ } as GsapAnimation;
289
+ const writer = vi.fn(
290
+ async (
291
+ _mutation: Record<string, unknown>,
292
+ options: CommitMutationOptions,
293
+ _selection?: DomEditSelection,
294
+ ) => {
295
+ options.onResult?.({ ok: true, changed: true });
296
+ },
297
+ );
298
+ const hook = mountRecording(writer, [original]);
299
+ act(() => hook().handleToggleRecording("keyboard"));
300
+ const other = document.createElement("div");
301
+ other.id = "other";
302
+ hook.select(
303
+ { ...makeSelection(other), sourceFile: "other.html", compositionPath: "other.html" },
304
+ [],
305
+ );
306
+ await act(async () => {
307
+ hook().handleToggleRecording();
308
+ await vi.waitFor(() => expect(trackStudioEvent).toHaveBeenCalledTimes(2));
309
+ });
310
+ expect(writer).toHaveBeenCalledExactlyOnceWith(
311
+ expect.objectContaining({
312
+ type: "replace-with-keyframes",
313
+ animationId: "original-position",
314
+ targetSelector: "#card",
315
+ }),
316
+ expect.objectContaining({ keyframeAction: "add" }),
317
+ expect.objectContaining({
318
+ id: "card",
319
+ sourceFile: "index.html",
320
+ compositionPath: "index.html",
321
+ }),
322
+ );
323
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
324
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
325
+ feature: "gesture_recording",
326
+ surface: "preview",
327
+ method: "keyboard",
158
328
  });
159
329
  });