@hyperframes/studio 0.8.24 → 0.8.25

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 (90) hide show
  1. package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-okdE01ky.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Bq3M0sjr.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-9WJFcXTP.js → index-Di-8Jxqc.js} +1 -1
  5. package/dist/assets/index-DnRfAiK2.css +1 -0
  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 +6206 -5011
  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/useDomEditAttributeCommits.ts +4 -1
  36. package/src/hooks/useDomEditCommits.test.tsx +207 -8
  37. package/src/hooks/useDomEditCommits.ts +54 -14
  38. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  40. package/src/hooks/useDomEditSession.ts +6 -2
  41. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  42. package/src/hooks/useDomEditTextCommits.ts +64 -86
  43. package/src/hooks/useDomEditWiring.ts +1 -1
  44. package/src/hooks/useDomSelection.ts +2 -0
  45. package/src/hooks/useDomSelectionTypes.ts +2 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  48. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  50. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  51. package/src/hooks/useGsapAnimationOps.ts +4 -4
  52. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  53. package/src/hooks/useGsapAwareEditing.ts +31 -10
  54. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  55. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  56. package/src/hooks/useRenderClipContent.test.ts +27 -0
  57. package/src/hooks/useRenderClipContent.ts +5 -0
  58. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  59. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  60. package/src/player/components/CompositionThumbnail.tsx +6 -0
  61. package/src/player/store/playerStore.test.ts +24 -0
  62. package/src/player/store/thumbnailSlice.ts +6 -0
  63. package/src/styles/studio.css +179 -0
  64. package/src/utils/domEditSaveQueue.test.ts +5 -3
  65. package/src/utils/domEditSaveQueue.ts +6 -1
  66. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  67. package/src/webmcp/StudioAgentTools.tsx +89 -21
  68. package/src/webmcp/handles.test.ts +172 -15
  69. package/src/webmcp/handles.ts +152 -35
  70. package/src/webmcp/tools/animationTools.test.ts +241 -60
  71. package/src/webmcp/tools/animationTools.ts +191 -104
  72. package/src/webmcp/tools/contentTools.test.ts +103 -41
  73. package/src/webmcp/tools/contentTools.ts +149 -105
  74. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  75. package/src/webmcp/tools/inspectTools.ts +59 -19
  76. package/src/webmcp/tools/lookTools.test.ts +185 -38
  77. package/src/webmcp/tools/lookTools.ts +134 -21
  78. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  79. package/src/webmcp/tools/selectionTools.ts +34 -19
  80. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  81. package/src/webmcp/tools/transformTools.test.ts +140 -54
  82. package/src/webmcp/tools/transformTools.ts +215 -56
  83. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  84. package/src/webmcp/useStudioAgentTools.ts +27 -15
  85. package/src/webmcp/webmcpTestUtils.ts +37 -0
  86. package/src/webmcp/writeCoordinator.test.ts +375 -0
  87. package/src/webmcp/writeCoordinator.ts +473 -0
  88. package/dist/assets/index-yGhfxxoL.css +0 -1
  89. package/dist/chunk-6BT6DTB4.js.map +0 -1
  90. /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
@@ -10,46 +10,89 @@ import {
10
10
  type StudioAddKeyframeResult,
11
11
  type StudioUpdateAnimationResult,
12
12
  } from "./animationTools";
13
- import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils";
13
+ import {
14
+ expectFailure,
15
+ expectOk,
16
+ previewElement,
17
+ selectionFor,
18
+ sourceHandle,
19
+ targetedWriteDeps,
20
+ } from "../webmcpTestUtils";
21
+
22
+ const animationInput = (input: Record<string, unknown>) => ({
23
+ handle: sourceHandle("headline"),
24
+ ...input,
25
+ });
14
26
 
15
27
  function animationDeps(overrides: Partial<AnimationToolDeps> = {}): AnimationToolDeps {
16
28
  const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
29
+ const selection = selectionFor(element);
17
30
  return {
18
- getCurrentSelection: () => selectionFor(element),
19
- getWriteBlockedReason: () => null,
31
+ ...targetedWriteDeps(selection),
32
+ getAnimationsForSelection: async () => [{ id: "anim-1" }, { id: "anim-gone" }, { id: "a" }],
20
33
  readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }),
21
- addAnimation: () => undefined,
34
+ addAnimation: async () => true,
22
35
  updateAnimation: async () => true,
23
36
  addKeyframe: async () => undefined,
24
- deleteAnimation: () => undefined,
37
+ deleteAnimation: async () => true,
25
38
  ...overrides,
26
39
  };
27
40
  }
28
41
 
29
42
  describe("studioAddAnimation", () => {
43
+ it("does not report success before persistence and preview sync settle", async () => {
44
+ let release!: (landed: boolean) => void;
45
+ let actorStarted!: () => void;
46
+ const pending = new Promise<boolean>((resolve) => {
47
+ release = resolve;
48
+ });
49
+ const started = new Promise<void>((resolve) => {
50
+ actorStarted = resolve;
51
+ });
52
+ let settled = false;
53
+
54
+ const resultPromise = studioAddAnimation(
55
+ animationDeps({
56
+ addAnimation: () => {
57
+ actorStarted();
58
+ return pending;
59
+ },
60
+ }),
61
+ animationInput({ method: "from" }),
62
+ ).then((result) => {
63
+ settled = true;
64
+ return result;
65
+ });
66
+
67
+ await started;
68
+ await Promise.resolve();
69
+ expect(settled).toBe(false);
70
+
71
+ release(true);
72
+ expect((await resultPromise).ok).toBe(true);
73
+ });
74
+
30
75
  it("reports where the playhead actually was, not a position the caller chose", async () => {
31
76
  // The handler reads the playhead itself and ignores any position argument,
32
77
  // so echoing one back would report a number that had no effect.
33
- const addAnimation = vi.fn();
78
+ const addAnimation = vi.fn(async () => true);
34
79
 
35
80
  const result = await studioAddAnimation(
36
81
  animationDeps({
37
82
  addAnimation,
38
83
  readPlayhead: () => ({ currentTime: 7.25, duration: 10, isPlaying: false }),
39
84
  }),
40
- { method: "from" },
85
+ animationInput({ method: "from" }),
41
86
  );
42
87
 
43
88
  const ok = expectOk<StudioAddAnimationResult>(result);
44
89
  expect(ok.insertedAtSeconds).toBe(7.25);
45
90
  expect(ok.method).toBe("from");
46
- expect(addAnimation).toHaveBeenCalledWith("from");
91
+ expect(addAnimation).toHaveBeenCalledWith(expect.anything(), "from");
47
92
  });
48
93
 
49
- it("marks the result as dispatched rather than claiming it landed", async () => {
50
- // `handleGsapAddAnimation` is fire-and-forget and returns nothing, so there
51
- // is no honest success signal to report.
52
- const result = await studioAddAnimation(animationDeps(), { method: "to" });
94
+ it("marks the result as dispatched after the underlying write settles", async () => {
95
+ const result = await studioAddAnimation(animationDeps(), animationInput({ method: "to" }));
53
96
 
54
97
  expect(expectOk<StudioAddAnimationResult>(result).dispatched).toBe(true);
55
98
  });
@@ -58,7 +101,10 @@ describe("studioAddAnimation", () => {
58
101
  const addAnimation = vi.fn();
59
102
 
60
103
  const result = expectFailure(
61
- await studioAddAnimation(animationDeps({ addAnimation }), { method: "wiggle" }),
104
+ await studioAddAnimation(
105
+ animationDeps({ addAnimation }),
106
+ animationInput({ method: "wiggle" }),
107
+ ),
62
108
  );
63
109
 
64
110
  expect(result.kind).toBe("invalid");
@@ -71,45 +117,67 @@ describe("studioAddAnimation", () => {
71
117
  const paused = expectFailure(
72
118
  await studioAddAnimation(
73
119
  animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", addAnimation }),
74
- { method: "to" },
120
+ animationInput({ method: "to" }),
75
121
  ),
76
122
  );
77
123
  const unselected = expectFailure(
78
- await studioAddAnimation(animationDeps({ getCurrentSelection: () => null, addAnimation }), {
79
- method: "to",
80
- }),
124
+ await studioAddAnimation(
125
+ animationDeps({ buildSelection: async () => null, addAnimation }),
126
+ animationInput({ method: "to" }),
127
+ ),
81
128
  );
82
129
 
83
130
  expect(paused.kind).toBe("blocked");
84
- expect(unselected.kind).toBe("invalid");
131
+ expect(unselected.kind).toBe("blocked");
85
132
  expect(addAnimation).not.toHaveBeenCalled();
86
133
  });
87
134
  });
88
135
 
89
136
  describe("studioUpdateAnimation", () => {
90
- it("confirms the write, because this handler actually reports back", async () => {
137
+ it("dispatches the write after the handler reports acceptance", async () => {
91
138
  const updateAnimation = vi.fn(async () => true);
92
139
 
93
- const result = await studioUpdateAnimation(animationDeps({ updateAnimation }), {
94
- animationId: "anim-1",
95
- ease: "power2.out",
96
- duration: 1.5,
97
- });
140
+ const result = await studioUpdateAnimation(
141
+ animationDeps({ updateAnimation }),
142
+ animationInput({
143
+ animationId: "anim-1",
144
+ ease: "power2.out",
145
+ duration: 1.5,
146
+ }),
147
+ );
98
148
 
99
149
  const ok = expectOk<StudioUpdateAnimationResult>(result);
150
+ expect(ok.changed).toBe(false);
100
151
  expect(ok.updated).toEqual({ duration: 1.5, ease: "power2.out" });
101
- expect(updateAnimation).toHaveBeenCalledWith("anim-1", {
152
+ expect(updateAnimation).toHaveBeenCalledWith(expect.anything(), "anim-1", {
102
153
  duration: 1.5,
103
154
  ease: "power2.out",
104
155
  });
105
156
  });
106
157
 
158
+ it("refuses an animation id owned by another target before calling the actor", async () => {
159
+ const updateAnimation = vi.fn(async () => true);
160
+ const result = await studioUpdateAnimation(
161
+ animationDeps({
162
+ getAnimationsForSelection: async () => [{ id: "other-animation" }],
163
+ updateAnimation,
164
+ }),
165
+ animationInput({ animationId: "anim-1", duration: 2 }),
166
+ );
167
+
168
+ expect(result).toMatchObject({ ok: false, stage: "refused", kind: "invalid" });
169
+ expect(updateAnimation).not.toHaveBeenCalled();
170
+ });
171
+
107
172
  it("reports a false return as a real failure", async () => {
108
173
  const result = expectFailure(
109
- await studioUpdateAnimation(animationDeps({ updateAnimation: async () => false }), {
110
- animationId: "anim-gone",
111
- ease: "none",
112
- }),
174
+ await studioUpdateAnimation(
175
+ animationDeps({ updateAnimation: async () => false }),
176
+ animationInput({
177
+ animationId: "anim-gone",
178
+ ease: "none",
179
+ }),
180
+ ),
113
181
  );
114
182
 
115
183
  expect(result.kind).toBe("failed");
@@ -123,24 +191,26 @@ describe("studioUpdateAnimation", () => {
123
191
 
124
192
  const result = expectFailure(
125
193
  await studioUpdateAnimation(
126
- animationDeps({ getCurrentSelection: () => null, updateAnimation }),
127
- { animationId: "anim-1", ease: "none" },
194
+ animationDeps({ buildSelection: async () => null, updateAnimation }),
195
+ animationInput({ animationId: "anim-1", ease: "none" }),
128
196
  ),
129
197
  );
130
198
 
131
- expect(result.kind).toBe("invalid");
132
- expect(result.reason).toMatch(/nothing is selected/);
199
+ expect(result.kind).toBe("blocked");
200
+ expect(result.reason).toMatch(/cannot edit/);
133
201
  expect(updateAnimation).not.toHaveBeenCalled();
134
202
  });
135
203
 
136
204
  it("requires at least one field, and rejects a negative duration", async () => {
137
205
  const deps = animationDeps();
138
206
 
139
- expect(expectFailure(await studioUpdateAnimation(deps, { animationId: "a" })).reason).toMatch(
140
- /at least one/,
141
- );
142
207
  expect(
143
- expectFailure(await studioUpdateAnimation(deps, { animationId: "a", duration: -1 })).reason,
208
+ expectFailure(await studioUpdateAnimation(deps, animationInput({ animationId: "a" }))).reason,
209
+ ).toMatch(/at least one/);
210
+ expect(
211
+ expectFailure(
212
+ await studioUpdateAnimation(deps, animationInput({ animationId: "a", duration: -1 })),
213
+ ).reason,
144
214
  ).toMatch(/negative/);
145
215
  });
146
216
 
@@ -148,32 +218,53 @@ describe("studioUpdateAnimation", () => {
148
218
  const updateAnimation = vi.fn();
149
219
 
150
220
  const result = expectFailure(
151
- await studioUpdateAnimation(animationDeps({ updateAnimation }), {
152
- animationId: " ",
153
- ease: "none",
154
- }),
221
+ await studioUpdateAnimation(
222
+ animationDeps({ updateAnimation }),
223
+ animationInput({
224
+ animationId: " ",
225
+ ease: "none",
226
+ }),
227
+ ),
155
228
  );
156
229
 
157
230
  expect(result.kind).toBe("invalid");
158
231
  expect(updateAnimation).not.toHaveBeenCalled();
159
232
  });
233
+
234
+ it("rejects raw JavaScript ease expressions before dispatch", async () => {
235
+ const updateAnimation = vi.fn();
236
+
237
+ const result = await studioUpdateAnimation(
238
+ animationDeps({ updateAnimation }),
239
+ animationInput({ animationId: "anim-1", ease: "__raw:(()=>alert(1))()" }),
240
+ );
241
+
242
+ expect(result).toMatchObject({ ok: false, stage: "refused", kind: "invalid" });
243
+ expect(updateAnimation).not.toHaveBeenCalled();
244
+ });
160
245
  });
161
246
 
162
247
  describe("studioAddKeyframe", () => {
163
248
  it("passes every property through in one commit", async () => {
164
249
  const addKeyframe = vi.fn(async () => undefined);
165
250
 
166
- const result = await studioAddKeyframe(animationDeps({ addKeyframe }), {
167
- animationId: "anim-1",
168
- percent: 50,
169
- properties: { y: -50, opacity: 0 },
170
- });
251
+ const result = await studioAddKeyframe(
252
+ animationDeps({ addKeyframe }),
253
+ animationInput({
254
+ animationId: "anim-1",
255
+ percent: 50,
256
+ properties: { y: -50, opacity: 0 },
257
+ }),
258
+ );
171
259
 
172
260
  const ok = expectOk<StudioAddKeyframeResult>(result);
173
261
  expect(ok.properties).toEqual({ y: -50, opacity: 0 });
174
262
  // One call, so one undo entry, rather than one per property.
175
263
  expect(addKeyframe).toHaveBeenCalledTimes(1);
176
- expect(addKeyframe).toHaveBeenCalledWith("anim-1", 50, { y: -50, opacity: 0 });
264
+ expect(addKeyframe).toHaveBeenCalledWith(expect.anything(), "anim-1", 50, {
265
+ y: -50,
266
+ opacity: 0,
267
+ });
177
268
  });
178
269
 
179
270
  it("validates percent itself, because the platform does not", async () => {
@@ -184,7 +275,10 @@ describe("studioAddKeyframe", () => {
184
275
 
185
276
  for (const percent of [-1, 101, Number.NaN, "50"]) {
186
277
  const result = expectFailure(
187
- await studioAddKeyframe(deps, { animationId: "a", percent, properties: { y: 1 } }),
278
+ await studioAddKeyframe(
279
+ deps,
280
+ animationInput({ animationId: "a", percent, properties: { y: 1 } }),
281
+ ),
188
282
  );
189
283
  expect(result.kind).toBe("invalid");
190
284
  }
@@ -197,35 +291,107 @@ describe("studioAddKeyframe", () => {
197
291
 
198
292
  for (const properties of [{}, { y: null }, [], "y:1"]) {
199
293
  const result = expectFailure(
200
- await studioAddKeyframe(deps, { animationId: "a", percent: 50, properties }),
294
+ await studioAddKeyframe(
295
+ deps,
296
+ animationInput({ animationId: "a", percent: 50, properties }),
297
+ ),
201
298
  );
202
299
  expect(result.kind).toBe("invalid");
203
300
  }
204
301
  expect(addKeyframe).not.toHaveBeenCalled();
205
302
  });
206
303
 
304
+ it("rejects raw JavaScript keyframe values before dispatch", async () => {
305
+ const addKeyframe = vi.fn();
306
+
307
+ const result = await studioAddKeyframe(
308
+ animationDeps({ addKeyframe }),
309
+ animationInput({
310
+ animationId: "anim-1",
311
+ percent: 50,
312
+ properties: { x: "__raw:(()=>alert(1))()" },
313
+ }),
314
+ );
315
+
316
+ expect(result).toMatchObject({ ok: false, stage: "refused", kind: "invalid" });
317
+ expect(addKeyframe).not.toHaveBeenCalled();
318
+ });
319
+
207
320
  it("accepts 0 and 100 as the ends of the tween", async () => {
208
321
  for (const percent of [0, 100]) {
209
- const result = await studioAddKeyframe(animationDeps(), {
210
- animationId: "a",
211
- percent,
212
- properties: { y: 1 },
213
- });
322
+ const result = await studioAddKeyframe(
323
+ animationDeps(),
324
+ animationInput({
325
+ animationId: "a",
326
+ percent,
327
+ properties: { y: 1 },
328
+ }),
329
+ );
214
330
  expect(expectOk<StudioAddKeyframeResult>(result).percent).toBe(percent);
215
331
  }
216
332
  });
333
+
334
+ it("refuses an animation id owned by another target before calling the actor", async () => {
335
+ const addKeyframe = vi.fn(async () => undefined);
336
+
337
+ const result = await studioAddKeyframe(
338
+ animationDeps({
339
+ getAnimationsForSelection: async () => [{ id: "other-animation" }],
340
+ addKeyframe,
341
+ }),
342
+ animationInput({ animationId: "anim-1", percent: 50, properties: { y: 1 } }),
343
+ );
344
+
345
+ expect(result).toMatchObject({ ok: false, stage: "refused", kind: "invalid" });
346
+ expect(addKeyframe).not.toHaveBeenCalled();
347
+ });
217
348
  });
218
349
 
219
350
  describe("studioDeleteAnimation", () => {
220
- it("dispatches the delete and says so", async () => {
221
- const deleteAnimation = vi.fn();
351
+ it("does not report success before persistence and preview sync settle", async () => {
352
+ let release!: (landed: boolean) => void;
353
+ let actorStarted!: () => void;
354
+ const pending = new Promise<boolean>((resolve) => {
355
+ release = resolve;
356
+ });
357
+ const started = new Promise<void>((resolve) => {
358
+ actorStarted = resolve;
359
+ });
360
+ let settled = false;
222
361
 
223
- const result = await studioDeleteAnimation(animationDeps({ deleteAnimation }), {
224
- animationId: "anim-1",
362
+ const resultPromise = studioDeleteAnimation(
363
+ animationDeps({
364
+ deleteAnimation: () => {
365
+ actorStarted();
366
+ return pending;
367
+ },
368
+ }),
369
+ animationInput({ animationId: "anim-1" }),
370
+ ).then((result) => {
371
+ settled = true;
372
+ return result;
225
373
  });
226
374
 
375
+ await started;
376
+ await Promise.resolve();
377
+ expect(settled).toBe(false);
378
+
379
+ release(true);
380
+ expect((await resultPromise).ok).toBe(true);
381
+ });
382
+
383
+ it("dispatches the delete and says so", async () => {
384
+ const deleteAnimation = vi.fn(async () => true);
385
+
386
+ const result = await studioDeleteAnimation(
387
+ animationDeps({ deleteAnimation }),
388
+ animationInput({
389
+ animationId: "anim-1",
390
+ }),
391
+ );
392
+
227
393
  expect(result.ok).toBe(true);
228
- expect(deleteAnimation).toHaveBeenCalledWith("anim-1");
394
+ expect(deleteAnimation).toHaveBeenCalledWith(expect.anything(), "anim-1");
229
395
  });
230
396
 
231
397
  it("refuses while a write is blocked", async () => {
@@ -234,11 +400,26 @@ describe("studioDeleteAnimation", () => {
234
400
  const result = expectFailure(
235
401
  await studioDeleteAnimation(
236
402
  animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", deleteAnimation }),
237
- { animationId: "anim-1" },
403
+ animationInput({ animationId: "anim-1" }),
238
404
  ),
239
405
  );
240
406
 
241
407
  expect(result.kind).toBe("blocked");
242
408
  expect(deleteAnimation).not.toHaveBeenCalled();
243
409
  });
410
+
411
+ it("refuses an animation id owned by another target before calling the actor", async () => {
412
+ const deleteAnimation = vi.fn();
413
+
414
+ const result = await studioDeleteAnimation(
415
+ animationDeps({
416
+ getAnimationsForSelection: async () => [{ id: "other-animation" }],
417
+ deleteAnimation,
418
+ }),
419
+ animationInput({ animationId: "anim-1" }),
420
+ );
421
+
422
+ expect(result).toMatchObject({ ok: false, stage: "refused", kind: "invalid" });
423
+ expect(deleteAnimation).not.toHaveBeenCalled();
424
+ });
244
425
  });