@hyperframes/studio 0.8.19 → 0.8.21

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 (45) hide show
  1. package/dist/assets/hyperframes-player-CIXNA_bj.js +460 -0
  2. package/dist/assets/{index-Dd0fsIIL.js → index-44gfDvkh.js} +1 -1
  3. package/dist/assets/{index-DxccOrkZ.js → index-9VfE7bZe.js} +1 -1
  4. package/dist/assets/{index-Bjd2hioj.js → index-CgGY8dAv.js} +164 -164
  5. package/dist/index.d.ts +28 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1206 -234
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +2 -0
  11. package/src/contexts/StudioContext.tsx +10 -0
  12. package/src/hooks/useEditorSave.test.tsx +81 -3
  13. package/src/hooks/useEditorSave.ts +31 -6
  14. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +48 -16
  15. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -2
  16. package/src/hooks/useStudioContextValue.ts +9 -0
  17. package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
  18. package/src/player/hooks/timelineSyncHydration.ts +47 -1
  19. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  20. package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
  21. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
  22. package/src/player/store/playerStore.ts +17 -2
  23. package/src/player/store/timelineElement.ts +21 -0
  24. package/src/utils/studioFileVersion.test.ts +37 -1
  25. package/src/utils/studioFileVersion.ts +12 -2
  26. package/src/utils/studioSaveDiagnostics.test.ts +29 -0
  27. package/src/utils/studioSaveDiagnostics.ts +4 -0
  28. package/src/webmcp/StudioAgentTools.tsx +105 -10
  29. package/src/webmcp/toolResult.ts +1 -1
  30. package/src/webmcp/tools/animationTools.test.ts +244 -0
  31. package/src/webmcp/tools/animationTools.ts +276 -0
  32. package/src/webmcp/tools/contentTools.test.ts +275 -0
  33. package/src/webmcp/tools/contentTools.ts +217 -0
  34. package/src/webmcp/tools/frameTools.test.ts +136 -0
  35. package/src/webmcp/tools/frameTools.ts +133 -0
  36. package/src/webmcp/tools/inspectTools.test.ts +197 -0
  37. package/src/webmcp/tools/inspectTools.ts +208 -0
  38. package/src/webmcp/tools/selectionTools.test.ts +164 -0
  39. package/src/webmcp/tools/selectionTools.ts +154 -0
  40. package/src/webmcp/tools/transformTools.test.ts +179 -0
  41. package/src/webmcp/tools/transformTools.ts +205 -0
  42. package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
  43. package/src/webmcp/useStudioAgentTools.ts +195 -1
  44. package/src/webmcp/webmcpTestUtils.ts +91 -0
  45. package/dist/assets/hyperframes-player-BA-QO9CQ.js +0 -459
@@ -1,5 +1,10 @@
1
1
  import { useMemo } from "react";
2
- import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
2
+ import {
3
+ usePlayerStore,
4
+ type TimelineElement,
5
+ type DomClipChild,
6
+ type SubCompositionHostState,
7
+ } from "../store/playerStore";
3
8
  import type { ClipManifestClip } from "../lib/playbackTypes";
4
9
  import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
5
10
  import { buildTimelineElementKey, splitTimelineElementKey } from "../lib/timelineElementHelpers";
@@ -131,18 +136,6 @@ interface DisplayBounds {
131
136
  track: number;
132
137
  }
133
138
 
134
- /**
135
- * State that lives on the live host element, not in the clip manifest:
136
- * `data-hidden`, `data-timeline-locked`, `data-timeline-role`. A child row is
137
- * built from a manifest clip with no hostEl to read, so
138
- * createTimelineElementFromManifestClip cannot see any of it. The flat store
139
- * element for the same child WAS built with one, so it is inherited from there.
140
- *
141
- * Without this the eye on an expanded child always reported the row visible, so
142
- * clicking it wrote data-hidden again instead of removing it, and a hidden child
143
- * could never be shown again (not even after a reload, since the attribute is in
144
- * the source).
145
- */
146
139
  /**
147
140
  * Audio-group membership for an expanded child, from whichever source has it.
148
141
  *
@@ -169,20 +162,34 @@ function childGroupState(
169
162
  };
170
163
  }
171
164
 
172
- function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
173
- if (!flat) return {};
174
- return {
175
- hidden: flat.hidden,
176
- timelineLocked: flat.timelineLocked,
177
- timelineRole: flat.timelineRole,
178
- // Same reason as the three above: these are read off the host element, which
179
- // an expanded child is built without. Missing them, an audio child inside a
180
- // sub-composition reserved no automation height and drew no lanes, while the
181
- // property panel reading the live DOM selection rather than this row
182
- // still showed the chain and its toggles.
183
- fxChain: flat.fxChain,
184
- automation: flat.automation,
185
- };
165
+ /**
166
+ * State that lives on the live host element, not in the clip manifest:
167
+ * `data-hidden`, `data-timeline-locked`, `data-timeline-role`, `data-fx-chain`,
168
+ * `data-automation`. A child row is built from a manifest clip with no hostEl to
169
+ * read, so createTimelineElementFromManifestClip cannot see any of it.
170
+ *
171
+ * `live` is the reading taken off the element itself and is authoritative. For a
172
+ * child of a REAL sub-composition it is also the only source: such a clip is
173
+ * filtered out of the manifest before the flat store is built, so no twin exists
174
+ * to inherit from. The flat twin still covers the phantom-wrapper case, where
175
+ * the child does keep a store entry of its own.
176
+ *
177
+ * Without a reading of the element, the eye on an expanded child always reported
178
+ * the row visible, so clicking it wrote data-hidden again instead of removing
179
+ * it, and a hidden child could never be shown again (not even after a reload,
180
+ * since the attribute is in the source). Missing fxChain and automation, an
181
+ * audio child inside a sub-composition reserved no automation height and drew no
182
+ * lanes, while the property panel, which reads the live DOM selection rather
183
+ * than this row, still showed the chain and its toggles.
184
+ */
185
+ function hostElementState(
186
+ flat: TimelineElement | undefined,
187
+ live: SubCompositionHostState | undefined,
188
+ ): Partial<TimelineElement> {
189
+ if (!flat) return { ...live };
190
+ const { hidden, timelineLocked, timelineRole, fxChain, automation } = flat;
191
+ // `live` last: it is the reading off the element, so it wins wherever it has one.
192
+ return { hidden, timelineLocked, timelineRole, fxChain, automation, ...live };
186
193
  }
187
194
 
188
195
  // `display` bounds come from the top-level scene clip (where the expanded row is
@@ -196,6 +203,7 @@ function buildChildElements(
196
203
  expandedHostKey: string,
197
204
  elements: readonly TimelineElement[],
198
205
  domChildrenById: ReadonlyMap<string, DomClipChild>,
206
+ hostStateById: ReadonlyMap<string, SubCompositionHostState>,
199
207
  ): TimelineElement[] {
200
208
  const result: TimelineElement[] = [];
201
209
  for (const child of siblings) {
@@ -223,7 +231,10 @@ function buildChildElements(
223
231
  });
224
232
  result.push({
225
233
  ...base,
226
- ...hostElementState(elements.find((element) => element.key === key)),
234
+ ...hostElementState(
235
+ elements.find((element) => element.key === key),
236
+ domId ? hostStateById.get(domId) : undefined,
237
+ ),
227
238
  ...childGroupState(
228
239
  elements.find((element) => element.key === key),
229
240
  domId ? domChildrenById.get(domId) : undefined,
@@ -309,6 +320,7 @@ export function buildExpandedElements(
309
320
  topLevelId: string,
310
321
  siblingParentId: string,
311
322
  domClipChildren: DomClipChild[] = [],
323
+ subCompositionHostState: ReadonlyMap<string, SubCompositionHostState> = new Map(),
312
324
  ): TimelineElement[] {
313
325
  const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
314
326
  if (!topLevelElement) return filterToTopLevel(elements, parentMap);
@@ -348,6 +360,7 @@ export function buildExpandedElements(
348
360
  parentKey,
349
361
  elements,
350
362
  domChildrenById,
363
+ subCompositionHostState,
351
364
  );
352
365
  if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
353
366
 
@@ -391,6 +404,7 @@ export function useExpandedTimelineElements(): TimelineElement[] {
391
404
  const clipManifest = usePlayerStore((s) => s.clipManifest);
392
405
  const clipParentMap = usePlayerStore((s) => s.clipParentMap);
393
406
  const domClipChildren = usePlayerStore((s) => s.domClipChildren);
407
+ const subCompositionHostState = usePlayerStore((s) => s.subCompositionHostState);
394
408
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
395
409
  const currentTime = usePlayerStore((s) => s.currentTime);
396
410
 
@@ -432,6 +446,15 @@ export function useExpandedTimelineElements(): TimelineElement[] {
432
446
  topLevel,
433
447
  immediateParent,
434
448
  domClipChildren,
449
+ subCompositionHostState,
435
450
  );
436
- }, [elements, clipManifest, clipParentMap, domClipChildren, rawId, selectedRawId]);
451
+ }, [
452
+ elements,
453
+ clipManifest,
454
+ clipParentMap,
455
+ domClipChildren,
456
+ subCompositionHostState,
457
+ rawId,
458
+ selectedRawId,
459
+ ]);
437
460
  }
@@ -19,6 +19,7 @@ import {
19
19
  buildTimelineElementsFromClips,
20
20
  clipTreeParentMap,
21
21
  collectSubCompositionDomChildren,
22
+ collectSubCompositionHostState,
22
23
  hydrateTimelineFromPreview,
23
24
  isPreviewReadinessMessage,
24
25
  safeContentDocument,
@@ -131,6 +132,9 @@ export function useTimelineSyncCallbacks({
131
132
  const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
132
133
  usePlayerStore.getState().setClipParentMap(parentMap);
133
134
  usePlayerStore.getState().setDomClipChildren(domClipChildren);
135
+ usePlayerStore
136
+ .getState()
137
+ .setSubCompositionHostState(collectSubCompositionHostState(iframeDoc, data.clips));
134
138
  } catch {
135
139
  // cross-origin or __clipTree not available — maps stay empty
136
140
  }
@@ -23,9 +23,13 @@ import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
23
23
  export type { KeyframeCacheEntry } from "./keyframeSlice";
24
24
  export { liveTime } from "./liveTime";
25
25
 
26
- import type { TimelineElement, TimelineElementPatch } from "./timelineElement";
26
+ import type {
27
+ TimelineElement,
28
+ TimelineElementPatch,
29
+ SubCompositionHostState,
30
+ } from "./timelineElement";
27
31
 
28
- export type { TimelineElement };
32
+ export type { TimelineElement, SubCompositionHostState };
29
33
  export type ZoomMode = "fit" | "manual";
30
34
  type TimelineTool = "select" | "razor";
31
35
 
@@ -212,6 +216,14 @@ interface PlayerState
212
216
  */
213
217
  domClipChildren: DomClipChild[];
214
218
  setDomClipChildren: (children: DomClipChild[]) => void;
219
+ /**
220
+ * Host-element state for every id'd element inside a sub-composition, keyed by
221
+ * dom id. Collected from the live preview because it is the only place that
222
+ * sees it: these elements are filtered out of `elements` before the flat store
223
+ * is built, and the clip manifest carries timing, not attributes.
224
+ */
225
+ subCompositionHostState: Map<string, SubCompositionHostState>;
226
+ setSubCompositionHostState: (state: Map<string, SubCompositionHostState>) => void;
215
227
  }
216
228
 
217
229
  /** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */
@@ -284,6 +296,7 @@ export function createTimelineResetState() {
284
296
  clipManifest: null,
285
297
  clipParentMap: new Map<string, string>(),
286
298
  domClipChildren: [],
299
+ subCompositionHostState: new Map<string, SubCompositionHostState>(),
287
300
  };
288
301
  }
289
302
 
@@ -430,6 +443,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
430
443
  setClipParentMap: (map) => set({ clipParentMap: map }),
431
444
  domClipChildren: [],
432
445
  setDomClipChildren: (children) => set({ domClipChildren: children }),
446
+ subCompositionHostState: new Map(),
447
+ setSubCompositionHostState: (state) => set({ subCompositionHostState: state }),
433
448
 
434
449
  setIsPlaying: (playing) => {
435
450
  if (get().isPlaying === playing) return;
@@ -118,3 +118,24 @@ export type TimelineElementPatch = Partial<
118
118
  | "audioGroupAutomation"
119
119
  >
120
120
  >;
121
+
122
+ /**
123
+ * The `data-*` state an expanded sub-composition child needs but cannot reach.
124
+ *
125
+ * A child row is synthesized from a manifest clip with no element to read, and
126
+ * for a real sub-composition it has no flat store twin either: such clips are
127
+ * dropped before the flat store is built. Read off the live preview instead
128
+ * (`collectSubCompositionHostState`) and carried on the store by dom id.
129
+ *
130
+ * Without it the eye reported every hidden child visible, so clicking it wrote
131
+ * `data-hidden` a second time instead of removing it, and the element could
132
+ * never be shown again, not even after a reload, since the attribute is in the
133
+ * source.
134
+ */
135
+ export interface SubCompositionHostState {
136
+ hidden?: boolean;
137
+ timelineLocked?: boolean;
138
+ timelineRole?: string;
139
+ fxChain?: string;
140
+ automation?: string;
141
+ }
@@ -1,12 +1,16 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  consumeStudioWriteToken,
4
+ createStudioWriteToken,
4
5
  markStudioWriteToken,
5
6
  resetStudioWriteTokens,
6
7
  studioExpectedFileVersion,
7
8
  studioFileContentVersion,
9
+ studioWriteHeaders,
8
10
  } from "./studioFileVersion";
9
11
 
12
+ afterEach(() => vi.unstubAllGlobals());
13
+
10
14
  describe("studioFileContentVersion", () => {
11
15
  it("matches the strong SHA-256 ETag format used by studio-server", async () => {
12
16
  await expect(studioFileContentVersion("abc")).resolves.toBe(
@@ -42,6 +46,38 @@ describe("studioFileContentVersion", () => {
42
46
  });
43
47
 
44
48
  describe("studio write-token echo identity", () => {
49
+ it("prefers the platform randomUUID implementation", () => {
50
+ const randomUUID = vi.fn(() => "11111111-2222-4333-8444-555555555555");
51
+ vi.stubGlobal("crypto", { randomUUID });
52
+ resetStudioWriteTokens();
53
+
54
+ expect(studioWriteHeaders()).toEqual({
55
+ "X-Hyperframes-Write-Token": "11111111-2222-4333-8444-555555555555",
56
+ });
57
+ expect(randomUUID).toHaveBeenCalledOnce();
58
+ expect(consumeStudioWriteToken("11111111-2222-4333-8444-555555555555")).toBe(true);
59
+ });
60
+
61
+ it("creates an RFC 4122 UUID-v4 token from getRandomValues when randomUUID is unavailable", () => {
62
+ const source = Uint8Array.from({ length: 16 }, (_, index) => index);
63
+ vi.stubGlobal("crypto", {
64
+ getRandomValues: vi.fn((target: Uint8Array) => {
65
+ target.set(source);
66
+ return target;
67
+ }),
68
+ });
69
+
70
+ expect(createStudioWriteToken()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
71
+ });
72
+
73
+ it("fails explicitly when Web Crypto cannot provide secure random bytes", () => {
74
+ vi.stubGlobal("crypto", {});
75
+
76
+ expect(() => createStudioWriteToken()).toThrow(
77
+ "Web Crypto getRandomValues is required for Studio write identity",
78
+ );
79
+ });
80
+
45
81
  it("suppresses exactly one matching API write receipt without hiding path-only external writes", () => {
46
82
  resetStudioWriteTokens();
47
83
  markStudioWriteToken("studio-write-1");
@@ -48,8 +48,18 @@ export async function studioExpectedFileVersion(
48
48
  return versions.get(path);
49
49
  }
50
50
 
51
- function createStudioWriteToken(): string {
52
- return globalThis.crypto.randomUUID();
51
+ export function createStudioWriteToken(): string {
52
+ const webCrypto = globalThis.crypto;
53
+ if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
54
+ if (typeof webCrypto?.getRandomValues !== "function") {
55
+ throw new Error("Web Crypto getRandomValues is required for Studio write identity");
56
+ }
57
+
58
+ const bytes = webCrypto.getRandomValues(new Uint8Array(16));
59
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
60
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
61
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
62
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
53
63
  }
54
64
 
55
65
  /**
@@ -1,4 +1,8 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
+
3
+ const trackStudioEvent = vi.hoisted(() => vi.fn());
4
+ vi.mock("./studioTelemetry", () => ({ trackStudioEvent }));
5
+
2
6
  import {
3
7
  StudioFileConflictError,
4
8
  StudioSaveHttpError,
@@ -6,6 +10,7 @@ import {
6
10
  buildStudioSaveFailureProperties,
7
11
  getStudioSaveStatusCode,
8
12
  retryStudioSave,
13
+ trackStudioEditBlocked,
9
14
  } from "./studioSaveDiagnostics";
10
15
 
11
16
  describe("studio save diagnostics", () => {
@@ -51,6 +56,30 @@ describe("studio save diagnostics", () => {
51
56
  });
52
57
  });
53
58
 
59
+ it("emits expected direct-edit refusals on edit_blocked", () => {
60
+ const error = new Error("This animation is computed at runtime");
61
+
62
+ trackStudioEditBlocked({
63
+ source: "gsap_commit",
64
+ error,
65
+ filePath: "index.html",
66
+ mutationType: "drag",
67
+ });
68
+
69
+ expect(trackStudioEvent).toHaveBeenCalledWith("edit_blocked", {
70
+ source: "gsap_commit",
71
+ error_message: error.message,
72
+ status_code: null,
73
+ file_path: "index.html",
74
+ mutation_type: "drag",
75
+ attempt: undefined,
76
+ label: undefined,
77
+ target_id: undefined,
78
+ target_selector: undefined,
79
+ target_source_file: undefined,
80
+ });
81
+ });
82
+
54
83
  it("reads nested status codes from error causes", () => {
55
84
  const cause = new StudioSaveHttpError("Too many requests", 429);
56
85
  const error = new Error("retry wrapper") as Error & { cause?: unknown };
@@ -172,6 +172,10 @@ export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
172
172
  trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
173
173
  }
174
174
 
175
+ export function trackStudioEditBlocked(input: StudioSaveFailureInput): void {
176
+ trackStudioEvent("edit_blocked", buildStudioSaveFailureProperties(input));
177
+ }
178
+
175
179
  export async function createStudioSaveHttpError(
176
180
  response: Response,
177
181
  fallbackMessage: string,
@@ -1,8 +1,8 @@
1
- import { useCallback } from "react";
2
- import { useDomEditSelectionContext } from "../contexts/DomEditContext";
1
+ import { useCallback, useMemo } from "react";
2
+ import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
3
3
  import { useStudioShellContext } from "../contexts/StudioContext";
4
4
  import { usePlayerStore } from "../player";
5
- import { useStudioAgentTools } from "./useStudioAgentTools";
5
+ import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
6
6
  import type { StudioLookSnapshot } from "./tools/lookTools";
7
7
 
8
8
  /**
@@ -12,14 +12,32 @@ import type { StudioLookSnapshot } from "./tools/lookTools";
12
12
  * contexts are only readable below `DomEditProvider`, which `App` renders, and
13
13
  * `App.tsx` sits three lines under the 600-line cap.
14
14
  *
15
- * The player store is read IMPERATIVELY through `getState()` inside the
16
- * snapshot callback rather than subscribed to. Subscribing to `currentTime`
17
- * would re-render this component on every animation frame during playback for
18
- * a value nothing here displays.
15
+ * The player store is read IMPERATIVELY through `getState()` rather than
16
+ * subscribed to. Subscribing to `currentTime` would re-render this component on
17
+ * every animation frame during playback for a value nothing here displays.
19
18
  */
20
19
  export function StudioAgentTools() {
21
- const { projectId, activeCompPath, editHistory } = useStudioShellContext();
22
- const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
20
+ const { projectId, activeCompPath, editHistory, writeBlockedReason } = useStudioShellContext();
21
+ const {
22
+ domEditSelection,
23
+ selectedGsapAnimations,
24
+ gsapMultipleTimelines,
25
+ gsapUnsupportedTimelinePattern,
26
+ } = useDomEditSelectionContext();
27
+ const {
28
+ previewIframeRef,
29
+ buildDomSelectionFromTarget,
30
+ applyDomSelection,
31
+ handleDomTextCommit,
32
+ handleDomStyleCommit,
33
+ handleDomPathOffsetCommit,
34
+ handleDomBoxSizeCommit,
35
+ handleDomRotationCommit,
36
+ handleGsapAddAnimation,
37
+ handleGsapUpdateMeta,
38
+ handleGsapAddKeyframeBatch,
39
+ handleGsapDeleteAnimation,
40
+ } = useDomEditActionsContext();
23
41
 
24
42
  const getSnapshot = useCallback((): StudioLookSnapshot => {
25
43
  const player = usePlayerStore.getState();
@@ -41,6 +59,83 @@ export function StudioAgentTools() {
41
59
  };
42
60
  }, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
43
61
 
44
- useStudioAgentTools({ getSnapshot });
62
+ const deps = useMemo<StudioAgentToolsDeps>(
63
+ () => ({
64
+ getSnapshot,
65
+ getPreviewDocument: () => previewIframeRef.current?.contentDocument ?? null,
66
+ buildSelection: (element) => buildDomSelectionFromTarget(element),
67
+ applySelection: (selection) => applyDomSelection(selection, { revealPanel: true }),
68
+ requestSeek: (time) => usePlayerStore.getState().requestSeek(time),
69
+ readPlayhead: () => {
70
+ const player = usePlayerStore.getState();
71
+ return {
72
+ currentTime: player.currentTime,
73
+ duration: player.duration,
74
+ isPlaying: player.isPlaying,
75
+ };
76
+ },
77
+ getProjectId: () => projectId,
78
+ getCompositionPath: () => activeCompPath,
79
+ // HEAD, not GET: the tool only needs to know the frame renders. Pulling
80
+ // the PNG here would download it once for nothing, since the agent
81
+ // fetches the URL itself.
82
+ probeFrame: async (url) => {
83
+ try {
84
+ const response = await fetch(url, { method: "HEAD" });
85
+ return { ok: response.ok, status: response.status };
86
+ } catch {
87
+ return { ok: false, status: 0 };
88
+ }
89
+ },
90
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
91
+ getCurrentSelection: () => domEditSelection,
92
+ getWriteBlockedReason: () => writeBlockedReason,
93
+ setText: (value, fieldKey) => handleDomTextCommit(value, fieldKey),
94
+ setStyle: (property, value) => handleDomStyleCommit(property, value),
95
+ // Measured, not authored: the tool compares this before and after to
96
+ // tell a real change from a handler that did nothing and resolved.
97
+ readBox: (selection) => {
98
+ const rect = selection.element.getBoundingClientRect();
99
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
100
+ },
101
+ moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next),
102
+ resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next),
103
+ rotateTo: (selection, next) => handleDomRotationCommit(selection, next),
104
+ addAnimation: (method) => handleGsapAddAnimation(method),
105
+ updateAnimation: (animationId, updates) => handleGsapUpdateMeta(animationId, updates),
106
+ addKeyframe: (animationId, percent, properties) =>
107
+ handleGsapAddKeyframeBatch(animationId, percent, properties),
108
+ deleteAnimation: (animationId) => handleGsapDeleteAnimation(animationId),
109
+ getGsapDiagnostics: () => ({
110
+ animations: selectedGsapAnimations,
111
+ multipleTimelines: gsapMultipleTimelines,
112
+ unsupportedTimelinePattern: gsapUnsupportedTimelinePattern,
113
+ }),
114
+ }),
115
+ [
116
+ getSnapshot,
117
+ previewIframeRef,
118
+ buildDomSelectionFromTarget,
119
+ applyDomSelection,
120
+ projectId,
121
+ activeCompPath,
122
+ writeBlockedReason,
123
+ handleDomTextCommit,
124
+ handleDomStyleCommit,
125
+ handleDomPathOffsetCommit,
126
+ handleDomBoxSizeCommit,
127
+ handleDomRotationCommit,
128
+ handleGsapAddAnimation,
129
+ handleGsapUpdateMeta,
130
+ handleGsapAddKeyframeBatch,
131
+ handleGsapDeleteAnimation,
132
+ domEditSelection,
133
+ selectedGsapAnimations,
134
+ gsapMultipleTimelines,
135
+ gsapUnsupportedTimelinePattern,
136
+ ],
137
+ );
138
+
139
+ useStudioAgentTools(deps);
45
140
  return null;
46
141
  }
@@ -36,7 +36,7 @@ export function toolOk<T extends object>(value: T): { ok: true } & T {
36
36
  return { ok: true, ...value };
37
37
  }
38
38
 
39
- function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
39
+ export function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
40
40
  return hint ? { ok: false, kind, reason, hint } : { ok: false, kind, reason };
41
41
  }
42
42