@hyperframes/studio 0.8.24 → 0.8.26

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 (93) hide show
  1. package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
  2. package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
  3. package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
  4. package/dist/assets/index-DnRfAiK2.css +1 -0
  5. package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
  6. package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
  7. package/dist/chunk-A7S5SGNA.js.map +1 -0
  8. package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6230 -5019
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/editor/TopologyLens.test.tsx +294 -0
  15. package/src/components/editor/TopologyLens.tsx +206 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  17. package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
  18. package/src/components/editor/domEditingLayers.test.ts +9 -0
  19. package/src/components/editor/domEditingLayers.ts +8 -8
  20. package/src/components/editor/topologyLensGeometry.test.ts +44 -0
  21. package/src/components/editor/topologyLensGeometry.ts +119 -0
  22. package/src/components/editor/topologyLensState.test.ts +116 -0
  23. package/src/components/editor/topologyLensState.ts +72 -0
  24. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
  25. package/src/components/nle/PreviewOverlays.tsx +24 -18
  26. package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
  27. package/src/components/sidebar/CompositionsTab.tsx +10 -3
  28. package/src/components/ui/HyperframesLoader.tsx +3 -48
  29. package/src/components/ui/HyperframesMark.tsx +53 -0
  30. package/src/contexts/DomEditContext.tsx +12 -0
  31. package/src/hooks/domEditCommitRunner.test.ts +59 -0
  32. package/src/hooks/domEditCommitRunner.ts +55 -20
  33. package/src/hooks/domEditCommitTypes.ts +8 -1
  34. package/src/hooks/domEditTextCommitPlan.ts +47 -0
  35. package/src/hooks/timelineTrackVisibility.test.ts +7 -1
  36. package/src/hooks/timelineTrackVisibility.ts +5 -5
  37. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  38. package/src/hooks/useDomEditCommits.test.tsx +243 -8
  39. package/src/hooks/useDomEditCommits.ts +69 -14
  40. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  41. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  42. package/src/hooks/useDomEditSession.ts +6 -2
  43. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  44. package/src/hooks/useDomEditTextCommits.ts +64 -86
  45. package/src/hooks/useDomEditWiring.ts +1 -1
  46. package/src/hooks/useDomSelection.ts +2 -0
  47. package/src/hooks/useDomSelectionTypes.ts +2 -0
  48. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  49. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  50. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  51. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  52. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  53. package/src/hooks/useGsapAnimationOps.ts +4 -4
  54. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  55. package/src/hooks/useGsapAwareEditing.ts +31 -10
  56. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  57. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  58. package/src/hooks/useRenderClipContent.test.ts +27 -0
  59. package/src/hooks/useRenderClipContent.ts +5 -0
  60. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  61. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  62. package/src/player/components/CompositionThumbnail.tsx +6 -0
  63. package/src/player/hooks/timelineSyncHydration.ts +10 -2
  64. package/src/player/store/playerStore.test.ts +24 -0
  65. package/src/player/store/thumbnailSlice.ts +6 -0
  66. package/src/styles/studio.css +179 -0
  67. package/src/utils/domEditSaveQueue.test.ts +5 -3
  68. package/src/utils/domEditSaveQueue.ts +6 -1
  69. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  70. package/src/webmcp/StudioAgentTools.tsx +89 -21
  71. package/src/webmcp/handles.test.ts +172 -15
  72. package/src/webmcp/handles.ts +152 -35
  73. package/src/webmcp/tools/animationTools.test.ts +241 -60
  74. package/src/webmcp/tools/animationTools.ts +191 -104
  75. package/src/webmcp/tools/contentTools.test.ts +103 -41
  76. package/src/webmcp/tools/contentTools.ts +149 -105
  77. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  78. package/src/webmcp/tools/inspectTools.ts +59 -19
  79. package/src/webmcp/tools/lookTools.test.ts +185 -38
  80. package/src/webmcp/tools/lookTools.ts +134 -21
  81. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  82. package/src/webmcp/tools/selectionTools.ts +34 -19
  83. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  84. package/src/webmcp/tools/transformTools.test.ts +140 -54
  85. package/src/webmcp/tools/transformTools.ts +215 -56
  86. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  87. package/src/webmcp/useStudioAgentTools.ts +27 -15
  88. package/src/webmcp/webmcpTestUtils.ts +37 -0
  89. package/src/webmcp/writeCoordinator.test.ts +375 -0
  90. package/src/webmcp/writeCoordinator.ts +473 -0
  91. package/dist/assets/index-yGhfxxoL.css +0 -1
  92. package/dist/chunk-6BT6DTB4.js.map +0 -1
  93. /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
6
6
  import { markStudioWriteToken, resetStudioWriteTokens } from "../utils/studioFileVersion";
7
+ import { markSelfWrite, resetSelfWriteRegistry } from "./sdkSelfWriteRegistry";
7
8
  import {
8
9
  useExternalFileChangeCoordinator,
9
10
  type ExternalFileChangeCoordinatorHandle,
@@ -29,6 +30,7 @@ async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
29
30
  discardPendingChanges: vi.fn(),
30
31
  overwriteConflict: vi.fn(async () => undefined),
31
32
  readProjectFile: vi.fn(async () => "external"),
33
+ onAcceptedPersistedFileChange: vi.fn(),
32
34
  };
33
35
  const options = { ...defaults, ...overrides };
34
36
  const root = createRoot(document.createElement("div"));
@@ -45,6 +47,7 @@ describe("external file change coordinator", () => {
45
47
  beforeEach(() => {
46
48
  handler = null;
47
49
  resetStudioWriteTokens();
50
+ resetSelfWriteRegistry();
48
51
  vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", {
49
52
  on: (_event: string, next: HotHandler) => {
50
53
  handler = next;
@@ -67,23 +70,61 @@ describe("external file change coordinator", () => {
67
70
  order.push("drain");
68
71
  return { status: "clean" };
69
72
  },
73
+ onAcceptedPersistedFileChange: () => order.push("thumbnail"),
70
74
  reloadPreview: () => order.push("preview"),
71
75
  reloadSdkSession: () => order.push("sdk"),
72
76
  });
73
77
  await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
74
- expect(order).toEqual(["drain", "preview", "sdk"]);
78
+ expect(order).toEqual(["drain", "thumbnail", "preview", "sdk"]);
75
79
  expect(captured.handle?.blocked).toBeNull();
76
80
  });
77
81
 
78
- it("suppresses an exact Studio write receipt", async () => {
82
+ it("refreshes thumbnails once but suppresses Preview reload for an exact Studio write", async () => {
79
83
  const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
80
84
  const reloadPreview = vi.fn();
81
85
  const reloadSdkSession = vi.fn();
82
- await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
86
+ const onAcceptedPersistedFileChange = vi.fn();
87
+ await mountCoordinator({
88
+ drainPendingChanges,
89
+ reloadPreview,
90
+ reloadSdkSession,
91
+ onAcceptedPersistedFileChange,
92
+ });
83
93
  markStudioWriteToken("studio-write-1");
94
+ const payload = {
95
+ path: "index.html",
96
+ content: "studio",
97
+ version: "v2",
98
+ writeToken: "studio-write-1",
99
+ };
100
+ await act(async () => handler?.(payload));
101
+ await act(async () => handler?.(payload));
102
+ expect(drainPendingChanges).not.toHaveBeenCalled();
103
+ expect(reloadPreview).not.toHaveBeenCalled();
104
+ expect(reloadSdkSession).not.toHaveBeenCalled();
105
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
106
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
107
+ });
108
+
109
+ it("accepts a matching content echo without a write token and suppresses every reload", async () => {
110
+ const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
111
+ const reloadPreview = vi.fn();
112
+ const reloadSdkSession = vi.fn();
113
+ const onAcceptedPersistedFileChange = vi.fn();
114
+ await mountCoordinator({
115
+ drainPendingChanges,
116
+ reloadPreview,
117
+ reloadSdkSession,
118
+ onAcceptedPersistedFileChange,
119
+ });
120
+ markSelfWrite("index.html", "studio content");
121
+
84
122
  await act(async () =>
85
- handler?.({ path: "index.html", content: "studio", writeToken: "studio-write-1" }),
123
+ handler?.({ path: "index.html", content: "studio content", version: "v2" }),
86
124
  );
125
+
126
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
127
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
87
128
  expect(drainPendingChanges).not.toHaveBeenCalled();
88
129
  expect(reloadPreview).not.toHaveBeenCalled();
89
130
  expect(reloadSdkSession).not.toHaveBeenCalled();
@@ -115,15 +156,23 @@ describe("external file change coordinator", () => {
115
156
  attemptedContent: "studio",
116
157
  });
117
158
  const persistConflictSnapshot = vi.fn(async () => undefined);
159
+ const onAcceptedPersistedFileChange = vi.fn();
118
160
  const { captured, options } = await mountCoordinator({
119
161
  drainPendingChanges: async () => ({ status: "conflict", error: conflict }),
120
162
  persistConflictSnapshot,
163
+ onAcceptedPersistedFileChange,
121
164
  });
122
165
  await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
123
166
  expect(persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
124
167
  expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
125
168
  expect(options.reloadPreview).not.toHaveBeenCalled();
126
169
  expect(options.reloadSdkSession).not.toHaveBeenCalled();
170
+ expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
171
+
172
+ await act(async () => captured.handle?.useExternalFile());
173
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
174
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
175
+ expect(options.reloadSdkSession).toHaveBeenCalledOnce();
127
176
  });
128
177
 
129
178
  it("ignores stale drain completion after a newer generation", async () => {
@@ -165,6 +214,7 @@ describe("external file change coordinator", () => {
165
214
  const failure = new Error("network unavailable");
166
215
  const persistFailureSnapshot = vi.fn(async () => undefined);
167
216
  const deleteConflictSnapshot = vi.fn(async () => undefined);
217
+ const onAcceptedPersistedFileChange = vi.fn();
168
218
  const { captured } = await mountCoordinator({
169
219
  drainPendingChanges: vi
170
220
  .fn()
@@ -173,6 +223,7 @@ describe("external file change coordinator", () => {
173
223
  getPendingCandidate: () => ({ path: "index.html", content: "final local candidate" }),
174
224
  persistFailureSnapshot,
175
225
  deleteConflictSnapshot,
226
+ onAcceptedPersistedFileChange,
176
227
  });
177
228
  await act(async () => handler?.({ path: "index.html" }));
178
229
  expect(captured.handle?.blocked).toMatchObject({
@@ -188,15 +239,19 @@ describe("external file change coordinator", () => {
188
239
  null,
189
240
  failure,
190
241
  );
242
+ expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
191
243
  await act(async () => captured.handle?.retry());
192
244
  expect(deleteConflictSnapshot).toHaveBeenCalledWith("project-a", "index.html");
245
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
193
246
  });
194
247
 
195
248
  it("restores and overwrites from a durable failed draft", async () => {
196
249
  const overwriteConflict = vi.fn(async () => undefined);
250
+ const onAcceptedPersistedFileChange = vi.fn();
197
251
  const { captured } = await mountCoordinator({
198
252
  recoveryFilePath: "index.html",
199
253
  overwriteConflict,
254
+ onAcceptedPersistedFileChange,
200
255
  loadConflictSnapshot: vi.fn(async () => ({
201
256
  kind: "failed" as const,
202
257
  projectId: "project-a",
@@ -217,5 +272,17 @@ describe("external file change coordinator", () => {
217
272
  expect(overwriteConflict).toHaveBeenCalledWith(
218
273
  expect.objectContaining({ attemptedContent: "recover me", currentVersion: "v2" }),
219
274
  );
275
+ expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
276
+
277
+ markStudioWriteToken("keep-studio-write");
278
+ await act(async () =>
279
+ handler?.({
280
+ path: "index.html",
281
+ content: "recover me",
282
+ version: "v3",
283
+ writeToken: "keep-studio-write",
284
+ }),
285
+ );
286
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
220
287
  });
221
288
  });
@@ -56,6 +56,7 @@ interface ExternalFileChangeCoordinatorOptions {
56
56
  readProjectFile: (path: string) => Promise<string>;
57
57
  onUseExternalFile?: (path: string, content: string) => void;
58
58
  resetSaveQueues?: () => void;
59
+ onAcceptedPersistedFileChange: (path: string) => void;
59
60
  }
60
61
 
61
62
  export interface ExternalFileChangeCoordinatorHandle {
@@ -126,6 +127,7 @@ export function useExternalFileChangeCoordinator({
126
127
  readProjectFile,
127
128
  onUseExternalFile,
128
129
  resetSaveQueues,
130
+ onAcceptedPersistedFileChange,
129
131
  }: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
130
132
  const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
131
133
  const generationRef = useRef(0);
@@ -225,21 +227,24 @@ export function useExternalFileChangeCoordinator({
225
227
  const content = readFileChangeContent(payload);
226
228
  const token = readFileChangeWriteToken(payload);
227
229
  logReload("file-change", { path, token: token ?? null, hasContent: content != null });
228
- if (consumeStudioWriteToken(token)) {
229
- logReload("suppressed", { path, why: "own write token" });
230
- return;
231
- }
232
- if (content != null && isSelfWriteEcho(path, content)) {
233
- logReload("suppressed", { path, why: "own content echo" });
234
- return;
235
- }
236
-
237
230
  const identity = eventIdentity(path, payload);
238
231
  if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
239
232
  logReload("suppressed", { path, why: "duplicate event" });
240
233
  return;
241
234
  }
242
235
  lastEventIdentityRef.current = identity;
236
+
237
+ const ownWriteToken = consumeStudioWriteToken(token);
238
+ const ownContentEcho = content != null && isSelfWriteEcho(path, content);
239
+ if (ownWriteToken || ownContentEcho) {
240
+ onAcceptedPersistedFileChange(path);
241
+ logReload("suppressed", {
242
+ path,
243
+ why: ownWriteToken ? "own write token" : "own content echo",
244
+ });
245
+ return;
246
+ }
247
+
243
248
  const generation = ++generationRef.current;
244
249
  const result = await drainPendingChanges();
245
250
  if (!mountedRef.current || generation !== generationRef.current) return;
@@ -258,6 +263,7 @@ export function useExternalFileChangeCoordinator({
258
263
  }
259
264
  if (!mountedRef.current || generation !== generationRef.current) return;
260
265
  setBlocked(null);
266
+ onAcceptedPersistedFileChange(path);
261
267
  reloadAcceptedGeneration(path);
262
268
  return;
263
269
  }
@@ -326,6 +332,7 @@ export function useExternalFileChangeCoordinator({
326
332
  persistFailureSnapshot,
327
333
  persistSnapshotInOrder,
328
334
  reloadAcceptedGeneration,
335
+ onAcceptedPersistedFileChange,
329
336
  ],
330
337
  );
331
338
 
@@ -369,12 +376,14 @@ export function useExternalFileChangeCoordinator({
369
376
  onUseExternalFile?.(path, external);
370
377
  await deleteConflictSnapshot?.(projectId, path);
371
378
  setBlocked(null);
379
+ onAcceptedPersistedFileChange(path);
372
380
  reloadAcceptedGeneration(path);
373
381
  },
374
382
  [
375
383
  deleteConflictSnapshot,
376
384
  discardPendingChanges,
377
385
  onUseExternalFile,
386
+ onAcceptedPersistedFileChange,
378
387
  projectId,
379
388
  readProjectFile,
380
389
  reloadAcceptedGeneration,
@@ -1,6 +1,18 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
- import { selectElementAnimationsOrRetry } from "./useGsapAnimationFetchFallback";
3
+ import {
4
+ gsapSourceFileForSelection,
5
+ selectElementAnimationsOrRetry,
6
+ } from "./useGsapAnimationFetchFallback";
7
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
8
+
9
+ describe("gsapSourceFileForSelection", () => {
10
+ it("uses the explicit target source instead of ambient selection state", () => {
11
+ expect(
12
+ gsapSourceFileForSelection({ sourceFile: "compositions/card.html" } as DomEditSelection),
13
+ ).toBe("compositions/card.html");
14
+ });
15
+ });
4
16
 
5
17
  const anim = (targetSelector: string): GsapAnimation =>
6
18
  ({ id: targetSelector, targetSelector, properties: {} }) as unknown as GsapAnimation;
@@ -37,6 +37,10 @@ export interface GsapAnimationFetchOptions {
37
37
  fresh?: boolean;
38
38
  }
39
39
 
40
+ export function gsapSourceFileForSelection(selection: DomEditSelection): string {
41
+ return selection.sourceFile || "index.html";
42
+ }
43
+
40
44
  /**
41
45
  * Classify a parse result for one element. Differentiates a hard fetch failure
42
46
  * (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
@@ -82,7 +86,7 @@ async function fetchElementAnimationsWithRetry(
82
86
  }
83
87
  }
84
88
 
85
- export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
89
+ export function useGsapAnimationFetchFallback(projectId: string | null) {
86
90
  return useCallback(
87
91
  (selection: DomEditSelection, options?: GsapAnimationFetchOptions) =>
88
92
  async (): Promise<GsapAnimation[]> => {
@@ -90,12 +94,12 @@ export function useGsapAnimationFetchFallback(projectId: string | null, gsapSour
90
94
  const target = { id: selection.id ?? null, selector: selection.selector ?? null };
91
95
  return fetchElementAnimationsWithRetry(
92
96
  projectId,
93
- gsapSourceFile,
97
+ gsapSourceFileForSelection(selection),
94
98
  target,
95
99
  options?.failOnFetchError === true,
96
100
  options?.fresh === true,
97
101
  );
98
102
  },
99
- [projectId, gsapSourceFile],
103
+ [projectId],
100
104
  );
101
105
  }
@@ -0,0 +1,101 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
+ import { useGsapAnimationOps } from "./useGsapAnimationOps";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ type HookApi = ReturnType<typeof useGsapAnimationOps>;
11
+
12
+ let cleanup: (() => void) | null = null;
13
+ afterEach(() => {
14
+ cleanup?.();
15
+ cleanup = null;
16
+ });
17
+
18
+ const selection = { id: "box", selector: "#box" } as DomEditSelection;
19
+
20
+ function renderOps(
21
+ commitMutationSafely: (...args: unknown[]) => Promise<void>,
22
+ commitMutation: (...args: unknown[]) => Promise<void> = vi.fn(async () => undefined),
23
+ ): HookApi {
24
+ const captured: { api: HookApi | null } = { api: null };
25
+ function Probe() {
26
+ captured.api = useGsapAnimationOps({
27
+ projectIdRef: { current: "project" },
28
+ activeCompPath: "index.html",
29
+ commitMutation,
30
+ commitMutationSafely,
31
+ showToast: vi.fn(),
32
+ sdkSession: null,
33
+ sdkDeps: null,
34
+ });
35
+ return null;
36
+ }
37
+
38
+ const root = createRoot(document.createElement("div"));
39
+ act(() => root.render(<Probe />));
40
+ cleanup = () => act(() => root.unmount());
41
+ if (!captured.api) throw new Error("hook did not initialize");
42
+ return captured.api;
43
+ }
44
+
45
+ function deferredCommit() {
46
+ let release!: () => void;
47
+ const promise = new Promise<void>((resolve) => {
48
+ release = resolve;
49
+ });
50
+ return { commitMutationSafely: vi.fn(() => promise), release };
51
+ }
52
+
53
+ describe("useGsapAnimationOps settlement", () => {
54
+ it.each([
55
+ ["update", (api: HookApi) => api.updateGsapMeta(selection, "anim-1", { duration: 2 })],
56
+ ["delete", (api: HookApi) => api.deleteGsapAnimation(selection, "anim-1")],
57
+ ])("keeps %s pending until the shared preview synchronizer settles", async (_name, run) => {
58
+ const deferred = deferredCommit();
59
+ const api = renderOps(deferred.commitMutationSafely);
60
+ let settled = false;
61
+
62
+ const resultPromise = run(api).then(() => {
63
+ settled = true;
64
+ });
65
+ await Promise.resolve();
66
+ expect(settled).toBe(false);
67
+
68
+ deferred.release();
69
+ await resultPromise;
70
+ expect(settled).toBe(true);
71
+ });
72
+
73
+ it("soft-reloads the preview when adding an animation", async () => {
74
+ const commitMutation = vi.fn(async () => undefined);
75
+ const api = renderOps(
76
+ vi.fn(async () => undefined),
77
+ commitMutation,
78
+ );
79
+
80
+ await api.addGsapAnimation(selection, "from");
81
+
82
+ expect(commitMutation).toHaveBeenCalledWith(
83
+ selection,
84
+ expect.objectContaining({ type: "add" }),
85
+ expect.objectContaining({ softReload: true }),
86
+ );
87
+ });
88
+
89
+ it("soft-reloads the preview when deleting an animation", async () => {
90
+ const commitMutationSafely = vi.fn(async () => undefined);
91
+ const api = renderOps(commitMutationSafely);
92
+
93
+ await api.deleteGsapAnimation(selection, "anim-1");
94
+
95
+ expect(commitMutationSafely).toHaveBeenCalledWith(
96
+ selection,
97
+ expect.objectContaining({ type: "delete" }),
98
+ expect.objectContaining({ softReload: true }),
99
+ );
100
+ });
101
+ });
@@ -55,7 +55,7 @@ export function useGsapAnimationOps({
55
55
  );
56
56
  if (cutoverCommittedOrThrow(handled)) return;
57
57
  }
58
- commitMutationSafely(
58
+ return commitMutationSafely(
59
59
  selection,
60
60
  { type: "update-meta", animationId, updates },
61
61
  { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, softReload: true },
@@ -77,10 +77,10 @@ export function useGsapAnimationOps({
77
77
  );
78
78
  if (cutoverCommittedOrThrow(handled)) return;
79
79
  }
80
- commitMutationSafely(
80
+ return commitMutationSafely(
81
81
  selection,
82
82
  { type: "delete", animationId, stripStudioEdits: true },
83
- { label: "Delete GSAP animation" },
83
+ { label: "Delete GSAP animation", softReload: true },
84
84
  );
85
85
  },
86
86
  [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
@@ -178,7 +178,7 @@ export function useGsapAnimationOps({
178
178
  properties: toDefaults[method] ?? { opacity: 1 },
179
179
  fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
180
180
  },
181
- { label: `Add GSAP ${method} animation` },
181
+ { label: `Add GSAP ${method} animation`, softReload: true },
182
182
  );
183
183
  },
184
184
  [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
@@ -45,7 +45,10 @@ afterEach(() => {
45
45
  vi.clearAllMocks();
46
46
  });
47
47
 
48
- function mountResizeHandler(animations: GsapAnimation[]) {
48
+ function mountResizeHandler(
49
+ animations: GsapAnimation[],
50
+ targetAnimations: GsapAnimation[] = animations,
51
+ ) {
49
52
  const element = document.createElement("div");
50
53
  const selection = { element, id: "clip", selector: "#clip" } as unknown as DomEditSelection;
51
54
  const fallback = vi.fn().mockResolvedValue(undefined);
@@ -66,7 +69,7 @@ function mountResizeHandler(animations: GsapAnimation[]) {
66
69
  previewIframeRef: { current: null },
67
70
  showToast: vi.fn(),
68
71
  bumpGsapCache: vi.fn(),
69
- makeFetchFallback: () => vi.fn().mockResolvedValue(animations),
72
+ makeFetchFallback: () => vi.fn().mockResolvedValue(targetAnimations),
70
73
  trackGsapInteractionFailure: vi.fn(),
71
74
  handleDomBoxSizeCommit: fallback,
72
75
  addGsapAnimation: vi.fn(),
@@ -112,6 +115,31 @@ function mountGroupHandler({
112
115
  }
113
116
 
114
117
  describe("useGsapAwareEditing anchored resize", () => {
118
+ it("uses the explicit target's animations instead of the human selection cache", async () => {
119
+ const humanAnimation = { id: "human", propertyGroup: "scale" } as GsapAnimation;
120
+ const targetAnimation = { id: "target", propertyGroup: "size" } as GsapAnimation;
121
+ mocks.resize.mockResolvedValue({ status: "persisted" });
122
+ const h = mountResizeHandler([humanAnimation], [targetAnimation]);
123
+ const target = {
124
+ ...h.selection,
125
+ element: document.createElement("div"),
126
+ id: "agent-target",
127
+ selector: "#agent-target",
128
+ } as DomEditSelection;
129
+
130
+ await act(() => h.resize(target, { width: 300, height: 200 }));
131
+
132
+ expect(mocks.resize).toHaveBeenCalledWith(
133
+ target,
134
+ { width: 300, height: 200 },
135
+ [targetAnimation],
136
+ null,
137
+ expect.any(Function),
138
+ expect.any(Function),
139
+ );
140
+ act(() => h.root.unmount());
141
+ });
142
+
115
143
  it("rejects a blocked resize instead of falling through to a competing DOM write", async () => {
116
144
  mocks.resize.mockResolvedValue({ status: "blocked", reason: "source-uneditable" });
117
145
  const h = mountResizeHandler([]);
@@ -125,6 +125,14 @@ export function useGsapAwareEditing({
125
125
  }: UseGsapAwareEditingParams) {
126
126
  // ── GSAP-aware geometry commits ──
127
127
 
128
+ const getGsapAnimationsForSelection = useCallback(
129
+ (selection: DomEditSelection): GsapAnimation[] | Promise<GsapAnimation[]> => {
130
+ if (domEditSelection?.element === selection.element) return selectedGsapAnimations;
131
+ return makeFetchFallback(selection, { failOnFetchError: true })();
132
+ },
133
+ [domEditSelection, selectedGsapAnimations, makeFetchFallback],
134
+ );
135
+
128
136
  const handleGsapAwarePathOffsetCommit = useCallback(
129
137
  async (
130
138
  selection: DomEditSelection,
@@ -133,10 +141,14 @@ export function useGsapAwareEditing({
133
141
  ) => {
134
142
  if (gsapCommitMutation) {
135
143
  try {
144
+ const ownedAnimations = getGsapAnimationsForSelection(selection);
145
+ const targetAnimations = Array.isArray(ownedAnimations)
146
+ ? ownedAnimations
147
+ : await ownedAnimations;
136
148
  const outcome = await tryGsapDragIntercept(
137
149
  selection,
138
150
  next,
139
- selectedGsapAnimations,
151
+ targetAnimations,
140
152
  previewIframeRef.current,
141
153
  gsapCommitMutation,
142
154
  makeFetchFallback(selection),
@@ -150,11 +162,11 @@ export function useGsapAwareEditing({
150
162
  }
151
163
  },
152
164
  [
153
- selectedGsapAnimations,
154
165
  gsapCommitMutation,
155
166
  previewIframeRef,
156
167
  makeFetchFallback,
157
168
  trackGsapInteractionFailure,
169
+ getGsapAnimationsForSelection,
158
170
  ],
159
171
  );
160
172
 
@@ -286,7 +298,11 @@ export function useGsapAwareEditing({
286
298
  offset?: { x: number; y: number },
287
299
  restore: () => void = () => undefined,
288
300
  ) => {
289
- const scaleRoute = selectedGsapAnimations.some((anim) => anim.propertyGroup === "scale");
301
+ const ownedAnimations = getGsapAnimationsForSelection(selection);
302
+ const targetAnimations = Array.isArray(ownedAnimations)
303
+ ? ownedAnimations
304
+ : await ownedAnimations;
305
+ const scaleRoute = targetAnimations.some((anim) => anim.propertyGroup === "scale");
290
306
  const selector = selectorFromSelection(selection);
291
307
  const hasLivePositionTween = selector
292
308
  ? hasNonHoldTweenForElement(
@@ -300,8 +316,8 @@ export function useGsapAwareEditing({
300
316
  next,
301
317
  offset: offset ?? null,
302
318
  scaleRoute,
303
- animCount: selectedGsapAnimations.length,
304
- animGroups: selectedGsapAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
319
+ animCount: targetAnimations.length,
320
+ animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
305
321
  });
306
322
  return runGestureTransaction({
307
323
  element: selection.element,
@@ -325,7 +341,7 @@ export function useGsapAwareEditing({
325
341
  const outcome = await tryGsapResizeIntercept(
326
342
  selection,
327
343
  next,
328
- selectedGsapAnimations,
344
+ targetAnimations,
329
345
  previewIframeRef.current,
330
346
  commitMutation,
331
347
  makeFetchFallback(selection),
@@ -349,7 +365,7 @@ export function useGsapAwareEditing({
349
365
  const dragOutcome = await tryGsapDragIntercept(
350
366
  selection,
351
367
  offset,
352
- selectedGsapAnimations,
368
+ targetAnimations,
353
369
  previewIframeRef.current,
354
370
  commitMutation,
355
371
  makeFetchFallback(selection),
@@ -377,11 +393,11 @@ export function useGsapAwareEditing({
377
393
  },
378
394
  [
379
395
  handleDomBoxSizeCommit,
380
- selectedGsapAnimations,
381
396
  gsapCommitMutation,
382
397
  previewIframeRef,
383
398
  makeFetchFallback,
384
399
  trackGsapInteractionFailure,
400
+ getGsapAnimationsForSelection,
385
401
  ],
386
402
  );
387
403
 
@@ -389,6 +405,10 @@ export function useGsapAwareEditing({
389
405
  async (selection: DomEditSelection, next: { angle: number }) => {
390
406
  if (gsapCommitMutation) {
391
407
  try {
408
+ const ownedAnimations = getGsapAnimationsForSelection(selection);
409
+ const targetAnimations = Array.isArray(ownedAnimations)
410
+ ? ownedAnimations
411
+ : await ownedAnimations;
392
412
  // Single source of truth for rotation too: tryGsapRotationIntercept handles
393
413
  // tweened elements (keyframes) and static ones (a tl.set), so there's no
394
414
  // CSS-var fallback. Selectorless/computed source rejects so the gesture
@@ -396,7 +416,7 @@ export function useGsapAwareEditing({
396
416
  const outcome = await tryGsapRotationIntercept(
397
417
  selection,
398
418
  next.angle,
399
- selectedGsapAnimations,
419
+ targetAnimations,
400
420
  previewIframeRef.current,
401
421
  gsapCommitMutation,
402
422
  makeFetchFallback(selection),
@@ -409,11 +429,11 @@ export function useGsapAwareEditing({
409
429
  }
410
430
  },
411
431
  [
412
- selectedGsapAnimations,
413
432
  gsapCommitMutation,
414
433
  previewIframeRef,
415
434
  makeFetchFallback,
416
435
  trackGsapInteractionFailure,
436
+ getGsapAnimationsForSelection,
417
437
  ],
418
438
  );
419
439
 
@@ -507,6 +527,7 @@ export function useGsapAwareEditing({
507
527
  }, [commitMutation]);
508
528
 
509
529
  return {
530
+ getGsapAnimationsForSelection,
510
531
  handleGsapAwarePathOffsetCommit,
511
532
  handleGsapAwareGroupPathOffsetCommit,
512
533
  handleGsapAwareBoxSizeCommit,
@@ -113,7 +113,9 @@ describe("useGsapSelectionHandlers save failures", () => {
113
113
  makeParams({ addGsapAnimation: vi.fn().mockRejectedValue(error), showToast }),
114
114
  );
115
115
 
116
- act(() => rendered.handlers().handleGsapAddAnimation("to"));
116
+ act(() => {
117
+ void rendered.handlers().handleGsapAddAnimation("to");
118
+ });
117
119
  await flushRejection();
118
120
 
119
121
  expect(showToast).not.toHaveBeenCalled();