@hyperframes/studio 0.7.92 → 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 (108) hide show
  1. package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CDTN2ZVa.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-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +20 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7055 -5321
  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/editor/propertyPanel3dTransform.tsx +1 -1
  16. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  19. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  20. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  21. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  22. package/src/components/editor/propertyPanelTypes.ts +9 -3
  23. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  24. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  25. package/src/components/sidebar/AssetCard.tsx +4 -3
  26. package/src/components/sidebar/AudioRow.tsx +4 -3
  27. package/src/contexts/FileManagerContext.tsx +12 -0
  28. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  29. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  30. package/src/hooks/externalFileReloadBus.ts +18 -0
  31. package/src/hooks/gsapDragCommit.test.ts +39 -1
  32. package/src/hooks/gsapDragCommit.ts +12 -40
  33. package/src/hooks/gsapEditOutcome.ts +72 -0
  34. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  35. package/src/hooks/gsapResizeIntercept.ts +44 -16
  36. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  37. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  38. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  39. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  40. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  42. package/src/hooks/useDomGeometryCommits.ts +20 -0
  43. package/src/hooks/useEditorSave.test.tsx +131 -0
  44. package/src/hooks/useEditorSave.ts +113 -39
  45. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  47. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  48. package/src/hooks/useFileManager.ts +32 -3
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  50. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  51. package/src/hooks/useGsapAwareEditing.ts +88 -27
  52. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  54. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  55. package/src/hooks/usePreviewPersistence.ts +22 -58
  56. package/src/hooks/useSdkSession.ts +8 -52
  57. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  58. package/src/hooks/useStudioSdkSessions.ts +2 -7
  59. package/src/hooks/useStudioSessionStart.ts +15 -0
  60. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  61. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  62. package/src/player/components/Timeline.test.ts +24 -5
  63. package/src/player/components/Timeline.tsx +40 -40
  64. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  65. package/src/player/components/TimelineClip.test.tsx +17 -2
  66. package/src/player/components/TimelineClip.tsx +15 -3
  67. package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
  68. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  69. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  70. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  71. package/src/player/components/TimelineLanes.test.tsx +64 -6
  72. package/src/player/components/TimelineLanes.tsx +81 -64
  73. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  74. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  75. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  77. package/src/player/components/TimelineTrackRow.tsx +58 -7
  78. package/src/player/components/timelineDiamondTypes.ts +14 -7
  79. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  80. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  81. package/src/player/components/timelineLaneProps.ts +3 -0
  82. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  83. package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
  84. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  85. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  86. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  87. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  88. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  89. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  90. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  91. package/src/player/components/useTimelineRowVirtualization.ts +16 -26
  92. package/src/player/store/liveTime.ts +11 -0
  93. package/src/player/store/playerStore.test.ts +29 -22
  94. package/src/player/store/playerStore.ts +24 -29
  95. package/src/player/store/timelineFocusState.ts +15 -0
  96. package/src/utils/domEditSaveQueue.test.ts +37 -1
  97. package/src/utils/domEditSaveQueue.ts +22 -4
  98. package/src/utils/externalConflictStorage.test.ts +95 -0
  99. package/src/utils/externalConflictStorage.ts +172 -0
  100. package/src/utils/studioFileVersion.test.ts +28 -1
  101. package/src/utils/studioFileVersion.ts +30 -0
  102. package/src/utils/studioPendingEdits.test.ts +108 -1
  103. package/src/utils/studioPendingEdits.ts +39 -2
  104. package/src/utils/studioSaveDiagnostics.ts +5 -0
  105. package/dist/assets/index-BXkzp_OU.css +0 -1
  106. package/dist/assets/index-DIntkrQl.js +0 -428
  107. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  108. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
20
20
  useEditorSave: () => ({
21
21
  saveRafRef: { current: null },
22
22
  handleContentChange: vi.fn(),
23
+ getPendingCandidate: vi.fn(() => null),
24
+ flushPendingSave: vi.fn(async () => ({ status: "clean" as const })),
25
+ discardPendingSave: vi.fn(),
23
26
  }),
24
27
  }));
25
28
 
26
29
  import { useFileManager } from "./useFileManager";
30
+ import { resetStudioWriteTokens, studioFileContentVersion } from "../utils/studioFileVersion";
31
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
27
32
 
28
33
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
29
34
 
35
+ function useTestFileManager(projectId: string) {
36
+ return useFileManager({
37
+ projectId,
38
+ showToast: vi.fn(),
39
+ recordEdit: vi.fn(async () => {}),
40
+ domEditSaveTimestampRef: { current: 0 },
41
+ setRefreshKey: vi.fn(),
42
+ });
43
+ }
44
+
45
+ async function mountTestFileManager(projectId = "project-a") {
46
+ const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
47
+ function Probe() {
48
+ captured.manager = useTestFileManager(projectId);
49
+ return null;
50
+ }
51
+ const root = createRoot(document.createElement("div"));
52
+ await act(async () => root.render(<Probe />));
53
+ const manager = captured.manager;
54
+ if (!manager) throw new Error("file manager did not render");
55
+ return { manager, root };
56
+ }
57
+
58
+ async function mountOverwriteRequest(response: Response) {
59
+ const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
60
+ if (init?.method !== "PUT") {
61
+ throw new Error("overwrite unexpectedly performed a preflight read");
62
+ }
63
+ return Promise.resolve(response);
64
+ });
65
+ vi.stubGlobal("fetch", fetchMock);
66
+ return { ...(await mountTestFileManager()), fetchMock };
67
+ }
68
+
69
+ function createOverwriteConflict(currentVersion: string | null, currentContent: string | null) {
70
+ return new StudioFileConflictError({
71
+ filePath: "index.html",
72
+ currentVersion,
73
+ currentContent,
74
+ attemptedContent: "STUDIO",
75
+ });
76
+ }
77
+
30
78
  describe("useFileManager project ownership", () => {
31
79
  afterEach(() => {
80
+ resetStudioWriteTokens();
81
+ vi.useRealTimers();
32
82
  vi.unstubAllGlobals();
33
83
  });
34
84
 
@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
54
104
 
55
105
  const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
56
106
  function Probe({ projectId }: { projectId: string }) {
57
- captured.manager = useFileManager({
58
- projectId,
59
- showToast: vi.fn(),
60
- recordEdit: vi.fn(async () => {}),
61
- domEditSaveTimestampRef: { current: 0 },
62
- setRefreshKey: vi.fn(),
63
- });
107
+ captured.manager = useTestFileManager(projectId);
64
108
  return null;
65
109
  }
66
110
 
@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
106
150
 
107
151
  await act(async () => root.unmount());
108
152
  });
153
+
154
+ it("uses a fresh write token when a lost response retries a committed save", async () => {
155
+ vi.useFakeTimers();
156
+ let putAttempt = 0;
157
+ const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
158
+ if (!init?.method) {
159
+ return Promise.resolve({
160
+ ok: true,
161
+ json: async () => ({ content: "BEFORE", version: "v1" }),
162
+ } as Response);
163
+ }
164
+ putAttempt += 1;
165
+ if (putAttempt === 1) return Promise.reject(new TypeError("response lost"));
166
+ return Promise.resolve({ ok: true, json: async () => ({ version: "v2" }) } as Response);
167
+ });
168
+ vi.stubGlobal("fetch", fetchMock);
169
+
170
+ const { manager, root } = await mountTestFileManager();
171
+ await manager.readProjectFile("index.html");
172
+
173
+ const write = manager.writeProjectFile("index.html", "AFTER");
174
+ await vi.runAllTimersAsync();
175
+ await write;
176
+
177
+ const writeTokens = fetchMock.mock.calls
178
+ .filter(([, init]) => init?.method === "PUT")
179
+ .map(([, init]) => new Headers(init?.headers).get("X-Hyperframes-Write-Token"));
180
+ expect(writeTokens).toHaveLength(2);
181
+ expect(writeTokens[0]).toBeTruthy();
182
+ expect(writeTokens[1]).not.toBe(writeTokens[0]);
183
+
184
+ await act(async () => root.unmount());
185
+ });
186
+
187
+ it("overwrites the exact external content version with an If-Match precondition", async () => {
188
+ const { manager, root, fetchMock } = await mountOverwriteRequest({
189
+ ok: true,
190
+ json: async () => ({ version: "v3" }),
191
+ } as Response);
192
+ const conflict = createOverwriteConflict("v2", "EXTERNAL");
193
+
194
+ await manager.overwriteExternalConflict(conflict);
195
+
196
+ const [, init] = fetchMock.mock.calls[0] ?? [];
197
+ const headers = new Headers(init?.headers);
198
+ expect(init).toMatchObject({ method: "PUT", body: "STUDIO" });
199
+ expect(headers.get("If-Match")).toBe(await studioFileContentVersion("EXTERNAL"));
200
+ expect(headers.get("If-None-Match")).toBeNull();
201
+ await act(async () => root.unmount());
202
+ });
203
+
204
+ it("preserves a newer third-party edit when a content-less conflict version is stale", async () => {
205
+ const { manager, root, fetchMock } = await mountOverwriteRequest({
206
+ ok: false,
207
+ status: 409,
208
+ json: async () => ({ currentVersion: "v3", currentContent: "THIRD PARTY" }),
209
+ } as Response);
210
+ const conflict = createOverwriteConflict("v2", null);
211
+
212
+ await expect(manager.overwriteExternalConflict(conflict)).rejects.toMatchObject({
213
+ name: "StudioFileConflictError",
214
+ currentVersion: "v3",
215
+ currentContent: "THIRD PARTY",
216
+ attemptedContent: "STUDIO",
217
+ });
218
+
219
+ const [, init] = fetchMock.mock.calls[0] ?? [];
220
+ const headers = new Headers(init?.headers);
221
+ expect(headers.get("If-Match")).toBe("v2");
222
+ expect(headers.get("If-None-Match")).toBeNull();
223
+ await act(async () => root.unmount());
224
+ });
225
+
226
+ it("uses create-only semantics when the conflicted file was deleted", async () => {
227
+ const { manager, root, fetchMock } = await mountOverwriteRequest({
228
+ ok: true,
229
+ json: async () => ({ version: "v1" }),
230
+ } as Response);
231
+ const conflict = createOverwriteConflict(null, null);
232
+
233
+ await manager.overwriteExternalConflict(conflict);
234
+
235
+ const [, init] = fetchMock.mock.calls[0] ?? [];
236
+ const headers = new Headers(init?.headers);
237
+ expect(headers.get("If-Match")).toBeNull();
238
+ expect(headers.get("If-None-Match")).toBe("*");
239
+ await act(async () => root.unmount());
240
+ });
109
241
  });
@@ -10,7 +10,11 @@ import {
10
10
  StudioFileConflictError,
11
11
  StudioSaveNetworkError,
12
12
  } from "../utils/studioSaveDiagnostics";
13
- import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
13
+ import {
14
+ createStudioWriteToken,
15
+ markStudioWriteToken,
16
+ studioExpectedFileVersion,
17
+ } from "../utils/studioFileVersion";
14
18
  import { useFileTree } from "./useFileTree";
15
19
  import { useEditorSave } from "./useEditorSave";
16
20
 
@@ -117,8 +121,11 @@ export function useFileManager({
117
121
  throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
118
122
  }
119
123
  }
120
- const writeToken = createStudioWriteToken();
121
124
  await retryStudioSave(async () => {
125
+ // Each request gets its own receipt identity. If a committed request loses its response,
126
+ // the retry can produce a second filesystem receipt that must be suppressed independently.
127
+ const writeToken = createStudioWriteToken();
128
+ markStudioWriteToken(writeToken);
122
129
  let response: Response;
123
130
  try {
124
131
  response = await fetch(
@@ -191,7 +198,7 @@ export function useFileManager({
191
198
 
192
199
  // ── Editor save (debounced content change) ──
193
200
 
194
- const { saveRafRef, handleContentChange } = useEditorSave({
201
+ const editorSave = useEditorSave({
195
202
  editingPathRef,
196
203
  projectIdRef,
197
204
  readProjectFile,
@@ -201,6 +208,24 @@ export function useFileManager({
201
208
  setRefreshKey,
202
209
  showToast,
203
210
  });
211
+ const { saveRafRef, handleContentChange } = editorSave;
212
+
213
+ const overwriteExternalConflict = useCallback(
214
+ async (conflict: StudioFileConflictError) => {
215
+ if (conflict.currentContent != null) {
216
+ await writeProjectFile(
217
+ conflict.filePath,
218
+ conflict.attemptedContent,
219
+ conflict.currentContent,
220
+ );
221
+ } else {
222
+ fileVersions.set(conflict.filePath, conflict.currentVersion);
223
+ await writeProjectFile(conflict.filePath, conflict.attemptedContent);
224
+ }
225
+ updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
226
+ },
227
+ [fileVersions, updateEditingFileContent, writeProjectFile],
228
+ );
204
229
 
205
230
  // ── File select ──
206
231
 
@@ -491,11 +516,15 @@ export function useFileManager({
491
516
  editingPathRef,
492
517
  projectIdRef,
493
518
  saveRafRef,
519
+ flushPendingSourceSave: editorSave.flushPendingSave,
520
+ discardPendingSourceSave: editorSave.discardPendingSave,
521
+ getPendingSourceCandidate: editorSave.getPendingCandidate,
494
522
  importedFontAssetsRef,
495
523
 
496
524
  // Core I/O
497
525
  readProjectFile,
498
526
  writeProjectFile,
527
+ overwriteExternalConflict,
499
528
  readOptionalProjectFile,
500
529
  observeProjectFileVersion,
501
530
  updateEditingFileContent,
@@ -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 }));