@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
@@ -1,66 +1,70 @@
1
1
  /**
2
2
  * `studio_animate`: author motion.
3
3
  *
4
- * These tools are deliberately less confident than the rest, because the
5
- * handlers underneath them are:
6
- *
7
- * - `handleGsapAddAnimation(method)` takes ONLY a method. Its insert position
8
- * comes from the live playhead, not from the caller, and the call is
9
- * `void ...catch()`, so it returns nothing and cannot be awaited.
10
- * - `handleGsapAddKeyframeBatch` returns a promise but catches its own failure,
11
- * so awaiting it proves the call finished, not that it landed.
12
- * - `handleGsapDeleteAnimation` discards its promise entirely.
13
- * - `handleGsapUpdateMeta` is the one honest signal: it returns a boolean.
14
- * Its `false` is ambiguous though, meaning either no selection or a failed
15
- * write, so the no-selection case is ruled out before dispatch.
16
- *
17
- * U8 solved the same problem by reading the result back. That does not work
18
- * here: the animation list comes from React state that only refreshes on a
19
- * render, and no render happens inside one tool call. So rather than fake a
20
- * verification, these report what was dispatched and tell the agent to call
21
- * `studio_inspect` to see the result. Saying "I asked for this" is honest;
22
- * saying "this happened" would not be.
4
+ * Add, update, and delete await the shared GSAP commit pipeline. Its promise is
5
+ * the single settlement boundary for persistence and live-preview sync, so a
6
+ * successful tool response never races ahead of the pixels the user sees.
7
+ * Keyframe writes still report dispatch because their existing actor catches
8
+ * its own failure instead of returning a landed signal.
23
9
  */
24
10
 
25
11
  import type { DomEditSelection } from "../../components/editor/domEditingTypes";
26
- import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult";
12
+ import { toolFailure, type ToolFailure } from "../toolResult";
13
+ import {
14
+ dispatched,
15
+ runTargetedWrite,
16
+ type StudioWriteResult,
17
+ type TargetedWriteDeps,
18
+ WRITE_RECEIPT_DESCRIPTION,
19
+ } from "../writeCoordinator";
27
20
 
28
21
  export type GsapMethod = "to" | "from" | "set" | "fromTo";
29
22
 
30
23
  const METHODS: readonly GsapMethod[] = ["to", "from", "set", "fromTo"];
31
24
 
32
- export interface AnimationToolDeps {
33
- getCurrentSelection: () => DomEditSelection | null;
34
- getWriteBlockedReason: () => string | null;
25
+ export interface AnimationToolDeps extends TargetedWriteDeps {
26
+ getAnimationsForSelection: (selection: DomEditSelection) => Promise<readonly { id: string }[]>;
35
27
  readPlayhead: () => { currentTime: number; duration: number; isPlaying: boolean };
36
- addAnimation: (method: GsapMethod) => void;
28
+ addAnimation: (selection: DomEditSelection, method: GsapMethod) => Promise<boolean>;
37
29
  updateAnimation: (
30
+ selection: DomEditSelection,
38
31
  animationId: string,
39
32
  updates: { duration?: number; ease?: string; position?: number },
40
33
  ) => Promise<boolean>;
41
34
  addKeyframe: (
35
+ selection: DomEditSelection,
42
36
  animationId: string,
43
37
  percent: number,
44
38
  properties: Record<string, number | string>,
45
39
  ) => Promise<void>;
46
- deleteAnimation: (animationId: string) => void;
40
+ deleteAnimation: (selection: DomEditSelection, animationId: string) => Promise<boolean>;
47
41
  }
48
42
 
49
43
  const INSPECT_HINT = "Call studio_inspect to see the result.";
50
44
 
51
- function guard(deps: AnimationToolDeps): ToolFailure | null {
52
- const blocked = deps.getWriteBlockedReason();
53
- if (blocked) return toolFailure("blocked", blocked, "Resolve it in Studio, then retry.");
54
- if (!deps.getCurrentSelection()) {
55
- return toolFailure("invalid", "nothing is selected", "Call studio_select first.");
56
- }
57
- return null;
45
+ async function animationBelongsToTarget(
46
+ deps: AnimationToolDeps,
47
+ selection: DomEditSelection,
48
+ animationId: string,
49
+ ): Promise<ToolFailure | null> {
50
+ const animations = await deps.getAnimationsForSelection(selection);
51
+ return animations.some((animation) => animation.id === animationId)
52
+ ? null
53
+ : toolFailure(
54
+ "invalid",
55
+ `animation ${animationId} does not belong to the target handle`,
56
+ "Select the target, then call studio_inspect for its current animation ids.",
57
+ );
58
58
  }
59
59
 
60
60
  function readAnimationId(value: unknown): string | null {
61
61
  return typeof value === "string" && value.trim() ? value : null;
62
62
  }
63
63
 
64
+ function isRawGsapExpression(value: unknown): value is string {
65
+ return typeof value === "string" && value.startsWith("__raw:");
66
+ }
67
+
64
68
  export interface StudioAddAnimationResult {
65
69
  method: GsapMethod;
66
70
  /** Where it was inserted, which is the playhead, not a value you supplied. */
@@ -70,26 +74,32 @@ export interface StudioAddAnimationResult {
70
74
 
71
75
  export async function studioAddAnimation(
72
76
  deps: AnimationToolDeps,
73
- input: { method?: unknown },
74
- ): Promise<ToolResult<StudioAddAnimationResult>> {
77
+ input: { handle?: unknown; method?: unknown },
78
+ signal: AbortSignal = new AbortController().signal,
79
+ ): Promise<StudioWriteResult<StudioAddAnimationResult>> {
75
80
  const method = METHODS.find((candidate) => candidate === input.method);
76
81
  if (!method) {
77
- return toolFailure("invalid", `method must be one of ${METHODS.join(", ")}`);
82
+ return preDispatchFailure(
83
+ "add-animation",
84
+ toolFailure("invalid", `method must be one of ${METHODS.join(", ")}`),
85
+ );
78
86
  }
79
-
80
- const blocked = guard(deps);
81
- if (blocked) return blocked;
82
-
83
- // The handler reads the playhead itself. Reporting a position the caller gave
84
- // us would be reporting a number that had no effect, so the tool takes no
85
- // position and reports where the playhead actually is instead.
86
- const { currentTime } = deps.readPlayhead();
87
- deps.addAnimation(method);
88
-
89
- return toolOk<StudioAddAnimationResult>({
90
- method,
91
- insertedAtSeconds: currentTime,
92
- dispatched: true,
87
+ return runTargetedWrite(deps, {
88
+ handle: input.handle,
89
+ operation: "add-animation",
90
+ signal,
91
+ write: async (selection) => {
92
+ const { currentTime } = deps.readPlayhead();
93
+ const landed = await deps.addAnimation(selection, method);
94
+ if (!landed) {
95
+ return toolFailure(
96
+ "failed",
97
+ "the animation did not land",
98
+ "The target may be stale. Call studio_look and try again with its current handle.",
99
+ );
100
+ }
101
+ return dispatched({ method, insertedAtSeconds: currentTime, dispatched: true }, false);
102
+ },
93
103
  });
94
104
  }
95
105
 
@@ -100,42 +110,65 @@ export interface StudioUpdateAnimationResult {
100
110
 
101
111
  export async function studioUpdateAnimation(
102
112
  deps: AnimationToolDeps,
103
- input: { animationId?: unknown; duration?: unknown; ease?: unknown; position?: unknown },
104
- ): Promise<ToolResult<StudioUpdateAnimationResult>> {
113
+ input: {
114
+ handle?: unknown;
115
+ animationId?: unknown;
116
+ duration?: unknown;
117
+ ease?: unknown;
118
+ position?: unknown;
119
+ },
120
+ signal: AbortSignal = new AbortController().signal,
121
+ ): Promise<StudioWriteResult<StudioUpdateAnimationResult>> {
105
122
  const animationId = readAnimationId(input.animationId);
106
123
  if (!animationId) {
107
- return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
124
+ return preDispatchFailure(
125
+ "update-animation",
126
+ toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT),
127
+ );
108
128
  }
109
129
 
110
130
  const updates: { duration?: number; ease?: string; position?: number } = {};
111
131
  if (typeof input.duration === "number" && Number.isFinite(input.duration)) {
112
- if (input.duration < 0) return toolFailure("invalid", "duration must not be negative");
132
+ if (input.duration < 0)
133
+ return preDispatchFailure(
134
+ "update-animation",
135
+ toolFailure("invalid", "duration must not be negative"),
136
+ );
113
137
  updates.duration = input.duration;
114
138
  }
139
+ if (isRawGsapExpression(input.ease)) {
140
+ return preDispatchFailure(
141
+ "update-animation",
142
+ toolFailure("invalid", "raw JavaScript expressions are not accepted"),
143
+ );
144
+ }
115
145
  if (typeof input.ease === "string" && input.ease.trim()) updates.ease = input.ease;
116
146
  if (typeof input.position === "number" && Number.isFinite(input.position)) {
117
147
  updates.position = input.position;
118
148
  }
119
149
  if (Object.keys(updates).length === 0) {
120
- return toolFailure("invalid", "give at least one of duration, ease, position");
121
- }
122
-
123
- // Ruled out BEFORE dispatch on purpose: the handler answers `false` for both
124
- // "nothing selected" and "the write failed", so a false afterwards would be
125
- // ambiguous. Eliminating one of the two makes the other one legible.
126
- const blocked = guard(deps);
127
- if (blocked) return blocked;
128
-
129
- const landed = await deps.updateAnimation(animationId, updates);
130
- if (!landed) {
131
- return toolFailure(
132
- "failed",
133
- `the update to ${animationId} did not land`,
134
- "The animation id may be stale. studio_inspect lists the current ones.",
150
+ return preDispatchFailure(
151
+ "update-animation",
152
+ toolFailure("invalid", "give at least one of duration, ease, position"),
135
153
  );
136
154
  }
137
-
138
- return toolOk<StudioUpdateAnimationResult>({ animationId, updated: updates });
155
+ return runTargetedWrite(deps, {
156
+ handle: input.handle,
157
+ operation: "update-animation",
158
+ signal,
159
+ preflight: (selection) => animationBelongsToTarget(deps, selection, animationId),
160
+ write: async (selection) => {
161
+ const landed = await deps.updateAnimation(selection, animationId, updates);
162
+ if (!landed) {
163
+ return toolFailure(
164
+ "failed",
165
+ `the update to ${animationId} did not land`,
166
+ "The animation id may be stale. studio_inspect lists the current ones.",
167
+ );
168
+ }
169
+ return dispatched({ animationId, updated: updates }, false);
170
+ },
171
+ });
139
172
  }
140
173
 
141
174
  export interface StudioAddKeyframeResult {
@@ -147,36 +180,58 @@ export interface StudioAddKeyframeResult {
147
180
 
148
181
  export async function studioAddKeyframe(
149
182
  deps: AnimationToolDeps,
150
- input: { animationId?: unknown; percent?: unknown; properties?: unknown },
151
- ): Promise<ToolResult<StudioAddKeyframeResult>> {
183
+ input: { handle?: unknown; animationId?: unknown; percent?: unknown; properties?: unknown },
184
+ signal: AbortSignal = new AbortController().signal,
185
+ ): Promise<StudioWriteResult<StudioAddKeyframeResult>> {
152
186
  const animationId = readAnimationId(input.animationId);
153
187
  if (!animationId) {
154
- return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
188
+ return preDispatchFailure(
189
+ "add-keyframe",
190
+ toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT),
191
+ );
155
192
  }
156
193
  const percent = input.percent;
157
194
  if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
158
195
  // Validated here because nothing in the platform checks input against the
159
196
  // schema; the tool receives whatever the agent sent.
160
- return toolFailure("invalid", "percent must be a number between 0 and 100");
197
+ return preDispatchFailure(
198
+ "add-keyframe",
199
+ toolFailure("invalid", "percent must be a number between 0 and 100"),
200
+ );
161
201
  }
162
202
  const raw = input.properties;
163
203
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
164
- return toolFailure("invalid", "properties must be an object of GSAP property to value");
204
+ return preDispatchFailure(
205
+ "add-keyframe",
206
+ toolFailure("invalid", "properties must be an object of GSAP property to value"),
207
+ );
165
208
  }
166
209
  const properties: Record<string, number | string> = {};
167
210
  for (const [key, value] of Object.entries(raw)) {
211
+ if (isRawGsapExpression(value)) {
212
+ return preDispatchFailure(
213
+ "add-keyframe",
214
+ toolFailure("invalid", "raw JavaScript expressions are not accepted"),
215
+ );
216
+ }
168
217
  if (typeof value === "number" || typeof value === "string") properties[key] = value;
169
218
  }
170
219
  if (Object.keys(properties).length === 0) {
171
- return toolFailure("invalid", "properties must contain at least one number or string value");
220
+ return preDispatchFailure(
221
+ "add-keyframe",
222
+ toolFailure("invalid", "properties must contain at least one number or string value"),
223
+ );
172
224
  }
173
-
174
- const blocked = guard(deps);
175
- if (blocked) return blocked;
176
-
177
- await deps.addKeyframe(animationId, percent, properties);
178
-
179
- return toolOk<StudioAddKeyframeResult>({ animationId, percent, properties, dispatched: true });
225
+ return runTargetedWrite(deps, {
226
+ handle: input.handle,
227
+ operation: "add-keyframe",
228
+ signal,
229
+ preflight: (selection) => animationBelongsToTarget(deps, selection, animationId),
230
+ write: async (selection) => {
231
+ await deps.addKeyframe(selection, animationId, percent, properties);
232
+ return dispatched({ animationId, percent, properties, dispatched: true }, false);
233
+ },
234
+ });
180
235
  }
181
236
 
182
237
  export interface StudioDeleteAnimationResult {
@@ -186,59 +241,81 @@ export interface StudioDeleteAnimationResult {
186
241
 
187
242
  export async function studioDeleteAnimation(
188
243
  deps: AnimationToolDeps,
189
- input: { animationId?: unknown },
190
- ): Promise<ToolResult<StudioDeleteAnimationResult>> {
244
+ input: { handle?: unknown; animationId?: unknown },
245
+ signal: AbortSignal = new AbortController().signal,
246
+ ): Promise<StudioWriteResult<StudioDeleteAnimationResult>> {
191
247
  const animationId = readAnimationId(input.animationId);
192
248
  if (!animationId) {
193
- return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT);
249
+ return preDispatchFailure(
250
+ "delete-animation",
251
+ toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT),
252
+ );
194
253
  }
195
-
196
- const blocked = guard(deps);
197
- if (blocked) return blocked;
198
-
199
- deps.deleteAnimation(animationId);
200
- return toolOk<StudioDeleteAnimationResult>({ animationId, dispatched: true });
254
+ return runTargetedWrite(deps, {
255
+ handle: input.handle,
256
+ operation: "delete-animation",
257
+ signal,
258
+ preflight: (selection) => animationBelongsToTarget(deps, selection, animationId),
259
+ write: async (selection) => {
260
+ const landed = await deps.deleteAnimation(selection, animationId);
261
+ if (!landed) {
262
+ return toolFailure(
263
+ "failed",
264
+ `the delete of ${animationId} did not land`,
265
+ "The animation id may be stale. studio_inspect lists the current ones.",
266
+ );
267
+ }
268
+ return dispatched({ animationId, dispatched: true }, false);
269
+ },
270
+ });
201
271
  }
202
272
 
203
- const DISPATCH_CAVEAT = `Reports what was dispatched, not what landed: the handler underneath does not report back. ${INSPECT_HINT}`;
273
+ const SETTLEMENT_NOTE =
274
+ "Success means persistence and live-preview synchronization have finished. Inspect afterward when exact authored values matter.";
275
+ const KEYFRAME_DISPATCH_CAVEAT = `Reports what was dispatched, not what landed: the keyframe actor does not report back. ${INSPECT_HINT}`;
204
276
 
205
277
  export const STUDIO_ADD_ANIMATION_INPUT_SCHEMA = {
206
278
  type: "object",
207
279
  properties: {
280
+ handle: { type: "string", description: "A source-safe element handle from studio_look." },
208
281
  method: { type: "string", enum: METHODS, description: "The GSAP method to add." },
209
282
  },
210
- required: ["method"],
283
+ required: ["handle", "method"],
211
284
  additionalProperties: false,
212
285
  } as const;
213
286
 
214
287
  export const STUDIO_ADD_ANIMATION_DESCRIPTION = [
215
- "Add a GSAP animation to the CURRENTLY SELECTED element. Call studio_select first.",
288
+ "Add a GSAP animation to one element using its source-safe handle from studio_look.",
216
289
  "It is inserted AT THE PLAYHEAD, which this tool does not control: call studio_seek first",
217
290
  "to choose when it starts. The result reports where the playhead actually was.",
218
- DISPATCH_CAVEAT,
291
+ SETTLEMENT_NOTE,
292
+ WRITE_RECEIPT_DESCRIPTION,
219
293
  ].join(" ");
220
294
 
221
295
  export const STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA = {
222
296
  type: "object",
223
297
  properties: {
298
+ handle: { type: "string", description: "A source-safe element handle from studio_look." },
224
299
  animationId: { type: "string", description: "An animation id from studio_inspect." },
225
300
  duration: { type: "number", minimum: 0, description: "Duration in seconds." },
226
301
  ease: { type: "string", description: "A GSAP ease, for example power2.out." },
227
302
  position: { type: "number", description: "Start position in seconds." },
228
303
  },
229
- required: ["animationId"],
304
+ required: ["handle", "animationId"],
230
305
  additionalProperties: false,
231
306
  } as const;
232
307
 
233
308
  export const STUDIO_UPDATE_ANIMATION_DESCRIPTION = [
234
309
  "Change an existing animation's duration, ease or position.",
235
- "This is the one animation tool that CONFIRMS its write, so a failure here is real",
236
- "and usually means a stale animationId. Get current ids from studio_inspect.",
310
+ "It waits for persistence and live-preview synchronization before reporting success.",
311
+ "Get current ids from studio_inspect.",
312
+ WRITE_RECEIPT_DESCRIPTION,
237
313
  ].join(" ");
238
314
 
239
315
  export const STUDIO_ADD_KEYFRAME_INPUT_SCHEMA = {
240
316
  type: "object",
241
317
  properties: {
318
+ handle: { type: "string", description: "A source-safe element handle from studio_look." },
242
319
  animationId: { type: "string", description: "An animation id from studio_inspect." },
243
320
  percent: {
244
321
  type: "number",
@@ -251,26 +328,36 @@ export const STUDIO_ADD_KEYFRAME_INPUT_SCHEMA = {
251
328
  description: 'GSAP property to value, for example {"y": -50, "opacity": 0}.',
252
329
  },
253
330
  },
254
- required: ["animationId", "percent", "properties"],
331
+ required: ["handle", "animationId", "percent", "properties"],
255
332
  additionalProperties: false,
256
333
  } as const;
257
334
 
258
335
  export const STUDIO_ADD_KEYFRAME_DESCRIPTION = [
259
336
  "Add a keyframe to an existing animation at a percentage through it.",
260
337
  "All the properties land in one commit, so they are one undo entry.",
261
- DISPATCH_CAVEAT,
338
+ KEYFRAME_DISPATCH_CAVEAT,
339
+ WRITE_RECEIPT_DESCRIPTION,
262
340
  ].join(" ");
263
341
 
264
342
  export const STUDIO_DELETE_ANIMATION_INPUT_SCHEMA = {
265
343
  type: "object",
266
344
  properties: {
345
+ handle: { type: "string", description: "A source-safe element handle from studio_look." },
267
346
  animationId: { type: "string", description: "An animation id from studio_inspect." },
268
347
  },
269
- required: ["animationId"],
348
+ required: ["handle", "animationId"],
270
349
  additionalProperties: false,
271
350
  } as const;
272
351
 
273
352
  export const STUDIO_DELETE_ANIMATION_DESCRIPTION = [
274
- "Remove an animation from the currently selected element. Undo reverses it.",
275
- DISPATCH_CAVEAT,
353
+ "Remove an animation from the element named by handle. Undo reverses it.",
354
+ SETTLEMENT_NOTE,
355
+ WRITE_RECEIPT_DESCRIPTION,
276
356
  ].join(" ");
357
+
358
+ function preDispatchFailure<T extends object>(
359
+ operation: "add-animation" | "update-animation" | "add-keyframe" | "delete-animation",
360
+ failure: ToolFailure,
361
+ ): StudioWriteResult<T> {
362
+ return { ...failure, stage: "refused", operation };
363
+ }