@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
@@ -30,6 +30,11 @@ export type ElementAnimationsOutcome =
30
30
  | { kind: "fetch-error" }
31
31
  | { kind: "cold" };
32
32
 
33
+ export interface GsapAnimationFetchOptions {
34
+ /** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
35
+ failOnFetchError?: boolean;
36
+ }
37
+
33
38
  /**
34
39
  * Classify a parse result for one element. Differentiates a hard fetch failure
35
40
  * (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
@@ -44,32 +49,48 @@ export function selectElementAnimationsOrRetry(
44
49
  return { kind: "resolved", animations: getAnimationsForElement(parsed.animations, target) };
45
50
  }
46
51
 
52
+ // Retry policy deliberately distinguishes cold parses from hard fetch errors.
53
+ // fallow-ignore-next-line complexity
54
+ async function fetchElementAnimationsWithRetry(
55
+ projectId: string,
56
+ gsapSourceFile: string,
57
+ target: { id: string | null; selector: string | null },
58
+ failOnFetchError: boolean,
59
+ ): Promise<GsapAnimation[]> {
60
+ let coldAttempts = 0;
61
+ let errorAttempts = 0;
62
+ for (;;) {
63
+ const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
64
+ const outcome = selectElementAnimationsOrRetry(parsed, target);
65
+ if (outcome.kind === "resolved") return outcome.animations;
66
+ if (outcome.kind === "fetch-error") {
67
+ if (errorAttempts >= FETCH_ERROR_RETRIES) {
68
+ if (failOnFetchError) throw new Error("GSAP animation ownership could not be verified");
69
+ return [];
70
+ }
71
+ errorAttempts++;
72
+ await delay(FETCH_ERROR_DELAY_MS);
73
+ continue;
74
+ }
75
+ if (coldAttempts >= COLD_PARSE_RETRIES) return [];
76
+ coldAttempts++;
77
+ await delay(COLD_PARSE_DELAY_MS);
78
+ }
79
+ }
80
+
47
81
  export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
48
82
  return useCallback(
49
- (selection: DomEditSelection) => async (): Promise<GsapAnimation[]> => {
50
- if (!projectId) return [];
51
- const target = { id: selection.id ?? null, selector: selection.selector ?? null };
52
- // A drag can fire before the async parse is warm; a cold parse must retry
53
- // rather than fall through to the no-animation path (which duplicates the
54
- // tween). A hard fetch error is a different failure — retry only briefly.
55
- let coldAttempts = 0;
56
- let errorAttempts = 0;
57
- for (;;) {
58
- const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
59
- const outcome = selectElementAnimationsOrRetry(parsed, target);
60
- if (outcome.kind === "resolved") return outcome.animations;
61
- if (outcome.kind === "fetch-error") {
62
- if (errorAttempts >= FETCH_ERROR_RETRIES) return [];
63
- errorAttempts++;
64
- await delay(FETCH_ERROR_DELAY_MS);
65
- continue;
66
- }
67
- // cold
68
- if (coldAttempts >= COLD_PARSE_RETRIES) return [];
69
- coldAttempts++;
70
- await delay(COLD_PARSE_DELAY_MS);
71
- }
72
- },
83
+ (selection: DomEditSelection, options?: GsapAnimationFetchOptions) =>
84
+ async (): Promise<GsapAnimation[]> => {
85
+ if (!projectId) return [];
86
+ const target = { id: selection.id ?? null, selector: selection.selector ?? null };
87
+ return fetchElementAnimationsWithRetry(
88
+ projectId,
89
+ gsapSourceFile,
90
+ target,
91
+ options?.failOnFetchError === true,
92
+ );
93
+ },
73
94
  [projectId, gsapSourceFile],
74
95
  );
75
96
  }
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
14
14
  drag: vi.fn(),
15
15
  readPosition: vi.fn(),
16
16
  setPosition: vi.fn(),
17
+ commitAnimatedProperty: vi.fn(),
18
+ commitAnimatedProperties: vi.fn(),
17
19
  }));
18
20
 
19
21
  vi.mock("./gsapResizeIntercept", () => ({ tryGsapResizeIntercept: mocks.resize }));
@@ -28,8 +30,8 @@ vi.mock("./gsapPositionDetection", () => ({
28
30
  vi.mock("../utils/elementGsap", () => ({ setElementGsapPosition: mocks.setPosition }));
29
31
  vi.mock("./useAnimatedPropertyCommit", () => ({
30
32
  useAnimatedPropertyCommit: () => ({
31
- commitAnimatedProperty: vi.fn(),
32
- commitAnimatedProperties: vi.fn(),
33
+ commitAnimatedProperty: mocks.commitAnimatedProperty,
34
+ commitAnimatedProperties: mocks.commitAnimatedProperties,
33
35
  }),
34
36
  }));
35
37
  vi.mock("./useSafeGsapCommitMutation", () => ({
@@ -78,22 +80,51 @@ function mountResizeHandler(animations: GsapAnimation[]) {
78
80
  return { selection, fallback, commitMutation, resize: resize!, root };
79
81
  }
80
82
 
83
+ type AwareEditingParams = Parameters<typeof useGsapAwareEditing>[0];
84
+
85
+ function mountGroupHandler({
86
+ gsapCommitMutation,
87
+ makeFetchFallback,
88
+ trackGsapInteractionFailure = vi.fn(),
89
+ }: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
90
+ Partial<Pick<AwareEditingParams, "trackGsapInteractionFailure">>) {
91
+ let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
92
+ function Harness() {
93
+ groupCommit = useGsapAwareEditing({
94
+ domEditSelection: null,
95
+ selectedGsapAnimations: [],
96
+ gsapCommitMutation,
97
+ previewIframeRef: { current: null },
98
+ showToast: vi.fn(),
99
+ bumpGsapCache: vi.fn(),
100
+ makeFetchFallback,
101
+ trackGsapInteractionFailure,
102
+ handleDomBoxSizeCommit: vi.fn(),
103
+ addGsapAnimation: vi.fn(),
104
+ convertToKeyframes: vi.fn(),
105
+ setArcPath: vi.fn(),
106
+ updateArcSegment: vi.fn(),
107
+ }).handleGsapAwareGroupPathOffsetCommit;
108
+ return null;
109
+ }
110
+ const root = mountReactHarness(<Harness />);
111
+ return { groupCommit: (updates: DomEditGroupPathOffsetCommit[]) => groupCommit(updates), root };
112
+ }
113
+
81
114
  describe("useGsapAwareEditing anchored resize", () => {
82
- it("forwards the anchor offset to the DOM fallback when GSAP does not handle resize", async () => {
83
- mocks.resize.mockResolvedValue(false);
115
+ it("rejects a blocked resize instead of falling through to a competing DOM write", async () => {
116
+ mocks.resize.mockResolvedValue({ status: "blocked", reason: "source-uneditable" });
84
117
  const h = mountResizeHandler([]);
85
- await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
86
- expect(h.fallback).toHaveBeenCalledWith(
87
- h.selection,
88
- { width: 300, height: 200 },
89
- { x: -50, y: -25 },
90
- );
118
+ await expect(
119
+ act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 })),
120
+ ).rejects.toMatchObject({ reason: "source-uneditable" });
121
+ expect(h.fallback).not.toHaveBeenCalled();
91
122
  act(() => h.root.unmount());
92
123
  });
93
124
 
94
125
  it("persists the anchor exactly once through GSAP position when size route handles resize", async () => {
95
- mocks.resize.mockResolvedValue(true);
96
- mocks.drag.mockResolvedValue(true);
126
+ mocks.resize.mockResolvedValue({ status: "persisted" });
127
+ mocks.drag.mockResolvedValue({ status: "persisted" });
97
128
  const h = mountResizeHandler([]);
98
129
  await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
99
130
  expect(h.fallback).not.toHaveBeenCalled();
@@ -103,12 +134,12 @@ describe("useGsapAwareEditing anchored resize", () => {
103
134
  });
104
135
 
105
136
  it("settles the live GSAP position before resize persistence reaches its first await", async () => {
106
- let resolveResize!: (handled: boolean) => void;
107
- const pendingResize = new Promise<boolean>((resolve) => {
137
+ let resolveResize!: (outcome: { status: "persisted" }) => void;
138
+ const pendingResize = new Promise<{ status: "persisted" }>((resolve) => {
108
139
  resolveResize = resolve;
109
140
  });
110
141
  mocks.resize.mockReturnValue(pendingResize);
111
- mocks.drag.mockResolvedValue(true);
142
+ mocks.drag.mockResolvedValue({ status: "persisted" });
112
143
  mocks.readPosition.mockReturnValue({ x: 120.4, y: 80.2 });
113
144
  const h = mountResizeHandler([]);
114
145
  h.selection.element.setAttribute("data-hf-drag-gsap-base-x", "120.4");
@@ -126,7 +157,7 @@ describe("useGsapAwareEditing anchored resize", () => {
126
157
  mocks.resize.mock.invocationCallOrder[0]!,
127
158
  );
128
159
 
129
- resolveResize(true);
160
+ resolveResize({ status: "persisted" });
130
161
  await act(() => commit);
131
162
  act(() => h.root.unmount());
132
163
  });
@@ -134,7 +165,7 @@ describe("useGsapAwareEditing anchored resize", () => {
134
165
  it("passes a transaction-scoped commit wrapper into the resize path", async () => {
135
166
  mocks.resize.mockImplementation(async (selection, _size, _animations, _iframe, commit) => {
136
167
  await commit(selection, { type: "resize" }, { label: "Resize", softReload: true });
137
- return true;
168
+ return { status: "persisted" };
138
169
  });
139
170
  const h = mountResizeHandler([]);
140
171
 
@@ -164,9 +195,12 @@ describe("useGsapAwareEditing anchored resize", () => {
164
195
  m: unknown,
165
196
  o: { coalesceKey?: string; label?: string; softReload?: boolean },
166
197
  ) => Promise<void>,
198
+ _fetch: unknown,
199
+ options?: { preflightOnly?: boolean },
167
200
  ) => {
201
+ if (options?.preflightOnly) return { status: "persisted" };
168
202
  await commit(selection, { type: "move" }, { label: "Move", softReload: true });
169
- return true;
203
+ return { status: "persisted" };
170
204
  },
171
205
  );
172
206
  const commitMutation = vi.fn(
@@ -175,26 +209,10 @@ describe("useGsapAwareEditing anchored resize", () => {
175
209
  return Promise.resolve();
176
210
  },
177
211
  );
178
- let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
179
- function Harness() {
180
- groupCommit = useGsapAwareEditing({
181
- domEditSelection: null,
182
- selectedGsapAnimations: [],
183
- gsapCommitMutation: commitMutation,
184
- previewIframeRef: { current: null },
185
- showToast: vi.fn(),
186
- bumpGsapCache: vi.fn(),
187
- makeFetchFallback: () => vi.fn().mockResolvedValue([]),
188
- trackGsapInteractionFailure: vi.fn(),
189
- handleDomBoxSizeCommit: vi.fn(),
190
- addGsapAnimation: vi.fn(),
191
- convertToKeyframes: vi.fn(),
192
- setArcPath: vi.fn(),
193
- updateArcSegment: vi.fn(),
194
- }).handleGsapAwareGroupPathOffsetCommit;
195
- return null;
196
- }
197
- const root = mountReactHarness(<Harness />);
212
+ const { groupCommit, root } = mountGroupHandler({
213
+ gsapCommitMutation: commitMutation,
214
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
215
+ });
198
216
  const updates = [
199
217
  {
200
218
  selection: { element: document.createElement("div"), id: "a", selector: "#a" },
@@ -215,6 +233,74 @@ describe("useGsapAwareEditing anchored resize", () => {
215
233
  act(() => root.unmount());
216
234
  });
217
235
 
236
+ it("preflights every group member before the first mutation", async () => {
237
+ const commitMutation = vi.fn().mockResolvedValue(undefined);
238
+ const makeFetchFallback = vi.fn(() => vi.fn().mockResolvedValue([]));
239
+ mocks.drag.mockImplementation(
240
+ async (_selection, _next, _animations, _iframe, _commit, _fetch, options) => {
241
+ if (options?.preflightOnly) {
242
+ return _selection.id === "blocked"
243
+ ? { status: "blocked", reason: "source-uneditable" }
244
+ : { status: "persisted" };
245
+ }
246
+ await _commit(_selection, { type: "move" }, { label: "Move" });
247
+ return { status: "persisted" };
248
+ },
249
+ );
250
+ const { groupCommit, root } = mountGroupHandler({
251
+ gsapCommitMutation: commitMutation,
252
+ makeFetchFallback,
253
+ });
254
+ const updates = [
255
+ {
256
+ selection: { element: document.createElement("div"), id: "ok", selector: "#ok" },
257
+ next: { x: 10, y: 10 },
258
+ },
259
+ {
260
+ selection: {
261
+ element: document.createElement("div"),
262
+ id: "blocked",
263
+ selector: "#blocked",
264
+ },
265
+ next: { x: 10, y: 10 },
266
+ },
267
+ ] as unknown as DomEditGroupPathOffsetCommit[];
268
+
269
+ await expect(groupCommit(updates)).rejects.toMatchObject({
270
+ name: "GsapEditBlockedError",
271
+ reason: "source-uneditable",
272
+ });
273
+ expect(commitMutation).not.toHaveBeenCalled();
274
+ expect(mocks.drag).toHaveBeenCalledTimes(2);
275
+ expect(makeFetchFallback).toHaveBeenNthCalledWith(1, updates[0]!.selection, {
276
+ failOnFetchError: true,
277
+ });
278
+ expect(makeFetchFallback).toHaveBeenNthCalledWith(2, updates[1]!.selection, {
279
+ failOnFetchError: true,
280
+ });
281
+ act(() => root.unmount());
282
+ });
283
+
284
+ it("fails a group preflight closed when ownership cannot be fetched", async () => {
285
+ const fetchError = new Error("parse endpoint unavailable");
286
+ const commitMutation = vi.fn().mockResolvedValue(undefined);
287
+ const { groupCommit, root } = mountGroupHandler({
288
+ gsapCommitMutation: commitMutation,
289
+ makeFetchFallback: () => vi.fn().mockRejectedValue(fetchError),
290
+ });
291
+ const updates = [
292
+ {
293
+ selection: { element: document.createElement("div"), id: "a", selector: "#a" },
294
+ next: { x: 10, y: 10 },
295
+ },
296
+ ] as unknown as DomEditGroupPathOffsetCommit[];
297
+
298
+ await expect(groupCommit(updates)).rejects.toBe(fetchError);
299
+ expect(mocks.drag).not.toHaveBeenCalled();
300
+ expect(commitMutation).not.toHaveBeenCalled();
301
+ act(() => root.unmount());
302
+ });
303
+
218
304
  it("restores once when resize persistence fails", async () => {
219
305
  const error = new Error("resize failed");
220
306
  const restore = vi.fn();
@@ -228,7 +314,7 @@ describe("useGsapAwareEditing anchored resize", () => {
228
314
  });
229
315
 
230
316
  it("does not apply the anchor twice when scale route already settles the drop point", async () => {
231
- mocks.resize.mockResolvedValue(true);
317
+ mocks.resize.mockResolvedValue({ status: "persisted" });
232
318
  const scale = { propertyGroup: "scale" } as GsapAnimation;
233
319
  const h = mountResizeHandler([scale]);
234
320
  await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
@@ -30,6 +30,8 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
30
30
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
31
31
  import { runGestureTransaction } from "./gestureTransaction";
32
32
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
33
+ import { assertGsapEditPersisted } from "./gsapEditOutcome";
34
+ import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
33
35
 
34
36
  // Distinct coalesceKey per group drag so consecutive group drags don't fold
35
37
  // into one another's undo entry (module-local counter, not Date.now()).
@@ -42,7 +44,10 @@ export interface UseGsapAwareEditingParams {
42
44
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
43
45
  showToast: (message: string, tone?: "error" | "info") => void;
44
46
  bumpGsapCache: () => void;
45
- makeFetchFallback: (selection: DomEditSelection) => () => Promise<GsapAnimation[]>;
47
+ makeFetchFallback: (
48
+ selection: DomEditSelection,
49
+ options?: GsapAnimationFetchOptions,
50
+ ) => () => Promise<GsapAnimation[]>;
46
51
  trackGsapInteractionFailure: (
47
52
  error: unknown,
48
53
  selection: DomEditSelection,
@@ -112,7 +117,7 @@ export function useGsapAwareEditing({
112
117
  ) => {
113
118
  if (gsapCommitMutation) {
114
119
  try {
115
- await tryGsapDragIntercept(
120
+ const outcome = await tryGsapDragIntercept(
116
121
  selection,
117
122
  next,
118
123
  selectedGsapAnimations,
@@ -121,6 +126,7 @@ export function useGsapAwareEditing({
121
126
  makeFetchFallback(selection),
122
127
  modifiers,
123
128
  );
129
+ assertGsapEditPersisted(outcome);
124
130
  } catch (error) {
125
131
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
126
132
  throw error;
@@ -155,16 +161,42 @@ export function useGsapAwareEditing({
155
161
  coalesceKey,
156
162
  coalesceMs: Number.POSITIVE_INFINITY,
157
163
  });
164
+ const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
165
+ // Editability is user-atomic: prove every member can be written before
166
+ // the first source mutation. Network failures after this point retain the
167
+ // existing multi-request semantics, but a blocked member can never leave
168
+ // earlier siblings partially moved.
169
+ for (const { selection } of updates) {
170
+ try {
171
+ const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
172
+ preflightAnimations.set(selection, animations);
173
+ const outcome = await tryGsapDragIntercept(
174
+ selection,
175
+ { x: 0, y: 0 },
176
+ animations,
177
+ previewIframeRef.current,
178
+ coalescedCommit,
179
+ undefined,
180
+ { preflightOnly: true },
181
+ );
182
+ assertGsapEditPersisted(outcome);
183
+ } catch (error) {
184
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
185
+ throw error;
186
+ }
187
+ }
158
188
  for (const { selection, next } of updates) {
159
189
  try {
160
- await tryGsapDragIntercept(
190
+ const outcome = await tryGsapDragIntercept(
161
191
  selection,
162
192
  next,
163
- [],
193
+ preflightAnimations.get(selection) ?? [],
164
194
  previewIframeRef.current,
165
195
  coalescedCommit,
166
196
  makeFetchFallback(selection),
197
+ { preflightPassed: true },
167
198
  );
199
+ assertGsapEditPersisted(outcome);
168
200
  } catch (error) {
169
201
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
170
202
  throw error;
@@ -217,7 +249,7 @@ export function useGsapAwareEditing({
217
249
  if (gsapCommitMutation) {
218
250
  const commitMutation = commit(gsapCommitMutation);
219
251
  try {
220
- const handled = await tryGsapResizeIntercept(
252
+ const outcome = await tryGsapResizeIntercept(
221
253
  selection,
222
254
  next,
223
255
  selectedGsapAnimations,
@@ -225,26 +257,26 @@ export function useGsapAwareEditing({
225
257
  commitMutation,
226
258
  makeFetchFallback(selection),
227
259
  );
228
- if (handled) {
229
- logResize("intercept-handled", {
230
- scaleRoute,
231
- willForwardOffset: !!(offset && !scaleRoute),
232
- });
233
- // Scale-route resize persists its residual position internally.
234
- // Width/height persists the already-settled anchor through drag.
235
- if (offset && !scaleRoute) {
236
- await tryGsapDragIntercept(
237
- selection,
238
- offset,
239
- selectedGsapAnimations,
240
- previewIframeRef.current,
241
- commitMutation,
242
- makeFetchFallback(selection),
243
- );
244
- }
245
- logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
246
- return;
260
+ assertGsapEditPersisted(outcome);
261
+ logResize("intercept-handled", {
262
+ scaleRoute,
263
+ willForwardOffset: !!(offset && !scaleRoute),
264
+ });
265
+ // Scale-route resize persists its residual position internally.
266
+ // Width/height persists the already-settled anchor through drag.
267
+ if (offset && !scaleRoute) {
268
+ const dragOutcome = await tryGsapDragIntercept(
269
+ selection,
270
+ offset,
271
+ selectedGsapAnimations,
272
+ previewIframeRef.current,
273
+ commitMutation,
274
+ makeFetchFallback(selection),
275
+ );
276
+ assertGsapEditPersisted(dragOutcome);
247
277
  }
278
+ logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
279
+ return;
248
280
  } catch (error) {
249
281
  trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
250
282
  throw error;
@@ -278,8 +310,9 @@ export function useGsapAwareEditing({
278
310
  try {
279
311
  // Single source of truth for rotation too: tryGsapRotationIntercept handles
280
312
  // tweened elements (keyframes) and static ones (a tl.set), so there's no
281
- // CSS-var fallback. It returns false only for a selectorless element (no-op).
282
- await tryGsapRotationIntercept(
313
+ // CSS-var fallback. Selectorless/computed source rejects so the gesture
314
+ // transaction can restore its draft instead of reporting a false success.
315
+ const outcome = await tryGsapRotationIntercept(
283
316
  selection,
284
317
  next.angle,
285
318
  selectedGsapAnimations,
@@ -287,6 +320,7 @@ export function useGsapAwareEditing({
287
320
  gsapCommitMutation,
288
321
  makeFetchFallback(selection),
289
322
  );
323
+ assertGsapEditPersisted(outcome);
290
324
  } catch (error) {
291
325
  trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
292
326
  throw error;
@@ -304,7 +338,10 @@ export function useGsapAwareEditing({
304
338
 
305
339
  // ── Animated property commit ──
306
340
 
307
- const { commitAnimatedProperty, commitAnimatedProperties } = useAnimatedPropertyCommit({
341
+ const {
342
+ commitAnimatedProperty: commitAnimatedPropertyRaw,
343
+ commitAnimatedProperties: commitAnimatedPropertiesRaw,
344
+ } = useAnimatedPropertyCommit({
308
345
  selectedGsapAnimations,
309
346
  gsapCommitMutation,
310
347
  addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
@@ -313,6 +350,30 @@ export function useGsapAwareEditing({
313
350
  bumpGsapCache,
314
351
  });
315
352
 
353
+ const commitAnimatedProperties = useCallback(
354
+ async (selection: DomEditSelection, properties: Record<string, number | string>) => {
355
+ try {
356
+ await commitAnimatedPropertiesRaw(selection, properties);
357
+ } catch (error) {
358
+ trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
359
+ throw error;
360
+ }
361
+ },
362
+ [commitAnimatedPropertiesRaw, trackGsapInteractionFailure],
363
+ );
364
+
365
+ const commitAnimatedProperty = useCallback(
366
+ async (selection: DomEditSelection, property: string, value: number | string) => {
367
+ try {
368
+ await commitAnimatedPropertyRaw(selection, property, value);
369
+ } catch (error) {
370
+ trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
371
+ throw error;
372
+ }
373
+ },
374
+ [commitAnimatedPropertyRaw, trackGsapInteractionFailure],
375
+ );
376
+
316
377
  // ── Arc path wrappers ──
317
378
 
318
379
  const handleSetArcPath = useCallback(
@@ -0,0 +1,42 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
+ import { mountReactHarness } from "./domSelectionTestHarness";
7
+ import { GsapEditBlockedError } from "./gsapEditOutcome";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
12
+ vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
13
+
14
+ import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
15
+
16
+ describe("useGsapInteractionFailureTelemetry", () => {
17
+ it("surfaces the blocked reason instead of a generic save failure", () => {
18
+ const showToast = vi.fn();
19
+ const selection = {
20
+ id: "clip",
21
+ selector: "#clip",
22
+ element: document.createElement("div"),
23
+ } as unknown as DomEditSelection;
24
+ let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
25
+ function Harness() {
26
+ report = useGsapInteractionFailureTelemetry("index.html", showToast);
27
+ return null;
28
+ }
29
+ const root = mountReactHarness(<Harness />);
30
+
31
+ act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
32
+
33
+ expect(showToast).toHaveBeenCalledWith(
34
+ "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
35
+ "error",
36
+ );
37
+ expect(trackStudioSaveFailure).toHaveBeenCalledWith(
38
+ expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
39
+ );
40
+ act(() => root.unmount());
41
+ });
42
+ });
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
+ import { isGsapEditBlockedError } from "./gsapEditOutcome";
4
5
 
5
6
  export function useGsapInteractionFailureTelemetry(
6
7
  activeCompPath: string | null,
@@ -18,7 +19,10 @@ export function useGsapInteractionFailureTelemetry(
18
19
  targetSelector: selection.selector,
19
20
  targetSourceFile: selection.sourceFile,
20
21
  });
21
- showToast("Failed to save animated edit.", "error");
22
+ showToast(
23
+ isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",
24
+ "error",
25
+ );
22
26
  },
23
27
  [activeCompPath, showToast],
24
28
  );