@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
@@ -8,11 +8,20 @@
8
8
  * values. Gathering the snapshot is the React layer's job.
9
9
  */
10
10
 
11
- import type { DomEditSelection } from "../../components/editor/domEditingTypes";
11
+ import { collectDomEditLayerItems } from "../../components/editor/domEditingLayers";
12
+ import type { DomEditLayerItem, DomEditSelection } from "../../components/editor/domEditingTypes";
12
13
  import type { TimelineElement } from "../../player/store/timelineElement";
13
14
  import { mintElementHandle, patchTargetAddress, timelineElementAddress } from "../handles";
14
15
  import { toolOk, type ToolResult } from "../toolResult";
15
16
 
17
+ export type StudioLookSceneSnapshot =
18
+ | { status: "loading" }
19
+ | {
20
+ status: "ready";
21
+ items: readonly DomEditLayerItem[];
22
+ drillInItem: DomEditLayerItem | null;
23
+ };
24
+
16
25
  export interface StudioLookSnapshot {
17
26
  projectId: string | null;
18
27
  compositionPath: string | null;
@@ -20,6 +29,7 @@ export interface StudioLookSnapshot {
20
29
  duration: number;
21
30
  isPlaying: boolean;
22
31
  elements: readonly TimelineElement[];
32
+ scene: StudioLookSceneSnapshot;
23
33
  selection: DomEditSelection | null;
24
34
  /** Live animations for the current selection arrive outside DomEditSelection. */
25
35
  selectionAnimationCount: number;
@@ -41,16 +51,24 @@ export interface StudioLookSnapshot {
41
51
  };
42
52
  }
43
53
 
44
- interface LookElement {
45
- /** Pass back to any tool that takes a handle. Null means unaddressable. */
46
- handle: string | null;
47
- label: string | null;
48
- tag: string;
49
- kind: string | null;
54
+ interface LookTimeline {
50
55
  start: number;
51
56
  duration: number;
52
57
  track: number;
53
58
  zIndex: number | null;
59
+ kind: string | null;
60
+ }
61
+
62
+ interface LookElement {
63
+ /** Pass back unchanged to tools that take a handle. */
64
+ handle: string;
65
+ parentHandle: string | null;
66
+ label: string;
67
+ tag: string;
68
+ depth: number;
69
+ childCount: number;
70
+ sourceFile: string;
71
+ timeline: LookTimeline | null;
54
72
  }
55
73
 
56
74
  interface LookSelection {
@@ -84,7 +102,10 @@ export interface StudioLook {
84
102
  isPlaying: boolean;
85
103
  history: StudioLookSnapshot["history"];
86
104
  selection: LookSelection | null;
105
+ sceneStatus: StudioLookSceneSnapshot["status"];
106
+ drillIn: { handle: string; label: string; sourceFile: string } | null;
87
107
  elementCount: number;
108
+ truncated: boolean;
88
109
  elements: LookElement[];
89
110
  }
90
111
 
@@ -98,23 +119,25 @@ export interface StudioLookInput {
98
119
  const DEFAULT_LIMIT = 200;
99
120
  const MAX_FILTER_LENGTH = 128;
100
121
 
101
- function describeElement(element: TimelineElement): LookElement {
122
+ function describeTimeline(element: TimelineElement): LookTimeline {
102
123
  return {
103
- handle: mintElementHandle(timelineElementAddress(element)),
104
- label: element.label ?? null,
105
- tag: element.tag,
106
- kind: element.kind ?? null,
107
124
  start: element.start,
108
125
  duration: element.duration,
109
126
  track: element.track,
110
127
  zIndex: element.zIndex ?? null,
128
+ kind: element.kind ?? null,
111
129
  };
112
130
  }
113
131
 
114
- function describeSelection(selection: DomEditSelection, animationCount: number): LookSelection {
132
+ function describeSelection(
133
+ selection: DomEditSelection,
134
+ animationCount: number,
135
+ activeCompositionPath: string,
136
+ projectId: string | null,
137
+ ): LookSelection {
115
138
  const { capabilities } = selection;
116
139
  return {
117
- handle: mintElementHandle(patchTargetAddress(selection)),
140
+ handle: mintElementHandle(patchTargetAddress(selection, activeCompositionPath, projectId)),
118
141
  label: selection.label,
119
142
  tagName: selection.tagName,
120
143
  sourceFile: selection.sourceFile,
@@ -133,21 +156,102 @@ function describeSelection(selection: DomEditSelection, animationCount: number):
133
156
 
134
157
  function matchesFilter(element: LookElement, needle: string): boolean {
135
158
  return (
136
- (element.label?.toLowerCase().includes(needle) ?? false) ||
159
+ element.label.toLowerCase().includes(needle) ||
137
160
  element.tag.toLowerCase().includes(needle) ||
138
- (element.handle?.toLowerCase().includes(needle) ?? false)
161
+ element.handle.toLowerCase().includes(needle)
139
162
  );
140
163
  }
141
164
 
165
+ /** Gather the live scene once per tool call. The response never retains DOM nodes. */
166
+ export function collectStudioLookScene(
167
+ doc: Document | null,
168
+ activeCompositionPath: string | null,
169
+ activeGroupElement: HTMLElement | null,
170
+ ): StudioLookSceneSnapshot {
171
+ if (!doc) return { status: "loading" };
172
+
173
+ const root = doc.querySelector<HTMLElement>("[data-composition-id]");
174
+ if (!root) return { status: "loading" };
175
+
176
+ const options = {
177
+ activeCompositionPath,
178
+ isMasterView: !activeCompositionPath || activeCompositionPath === "index.html",
179
+ activeGroupElement: null,
180
+ };
181
+ const items = collectDomEditLayerItems(root, options);
182
+ const liveGroup =
183
+ activeGroupElement?.isConnected && activeGroupElement.ownerDocument === doc
184
+ ? activeGroupElement
185
+ : null;
186
+ return {
187
+ status: "ready",
188
+ items,
189
+ drillInItem: liveGroup ? (items.find((item) => item.element === liveGroup) ?? null) : null,
190
+ };
191
+ }
192
+
193
+ function describeScene(snapshot: StudioLookSnapshot): {
194
+ elements: LookElement[];
195
+ drillIn: StudioLook["drillIn"];
196
+ } {
197
+ if (snapshot.scene.status === "loading") return { elements: [], drillIn: null };
198
+
199
+ const activeCompositionPath = snapshot.compositionPath ?? "index.html";
200
+ const timelineByHandle = new Map<string, LookTimeline>();
201
+ for (const element of snapshot.elements) {
202
+ const handle = mintElementHandle(
203
+ timelineElementAddress(element, activeCompositionPath, snapshot.projectId),
204
+ );
205
+ if (handle) timelineByHandle.set(handle, describeTimeline(element));
206
+ }
207
+
208
+ const parentHandles: string[] = [];
209
+ const elements = snapshot.scene.items.map((item) => {
210
+ const handle = mintElementHandle(
211
+ patchTargetAddress(item, activeCompositionPath, snapshot.projectId),
212
+ );
213
+ if (!handle) throw new Error("collectDomEditLayerItems returned an unaddressable layer");
214
+ const parentHandle = item.depth > 0 ? (parentHandles[item.depth - 1] ?? null) : null;
215
+ parentHandles[item.depth] = handle;
216
+ parentHandles.length = item.depth + 1;
217
+ return {
218
+ handle,
219
+ parentHandle,
220
+ label: item.label,
221
+ tag: item.tagName,
222
+ depth: item.depth,
223
+ childCount: item.childCount,
224
+ sourceFile: item.sourceFile,
225
+ timeline: timelineByHandle.get(handle) ?? null,
226
+ };
227
+ });
228
+
229
+ const drillInItem = snapshot.scene.drillInItem;
230
+ const drillInHandle = drillInItem
231
+ ? mintElementHandle(patchTargetAddress(drillInItem, activeCompositionPath, snapshot.projectId))
232
+ : null;
233
+ return {
234
+ elements,
235
+ drillIn:
236
+ drillInItem && drillInHandle
237
+ ? {
238
+ handle: drillInHandle,
239
+ label: drillInItem.label,
240
+ sourceFile: drillInItem.sourceFile,
241
+ }
242
+ : null,
243
+ };
244
+ }
245
+
142
246
  export function buildStudioLook(
143
247
  snapshot: StudioLookSnapshot,
144
248
  input: StudioLookInput = {},
145
249
  ): ToolResult<StudioLook> {
146
- const described = snapshot.elements.map(describeElement);
250
+ const scene = describeScene(snapshot);
147
251
  const needle = input.filter?.slice(0, MAX_FILTER_LENGTH).trim().toLowerCase();
148
252
  const matched = needle
149
- ? described.filter((element) => matchesFilter(element, needle))
150
- : described;
253
+ ? scene.elements.filter((element) => matchesFilter(element, needle))
254
+ : scene.elements;
151
255
 
152
256
  // Clamp rather than reject: a bad limit should not cost the agent a round trip
153
257
  // when the answer it wants is right here.
@@ -163,11 +267,19 @@ export function buildStudioLook(
163
267
  isPlaying: snapshot.isPlaying,
164
268
  history: snapshot.history,
165
269
  selection: snapshot.selection
166
- ? describeSelection(snapshot.selection, snapshot.selectionAnimationCount)
270
+ ? describeSelection(
271
+ snapshot.selection,
272
+ snapshot.selectionAnimationCount,
273
+ snapshot.compositionPath ?? "index.html",
274
+ snapshot.projectId,
275
+ )
167
276
  : null,
277
+ sceneStatus: snapshot.scene.status,
278
+ drillIn: scene.drillIn,
168
279
  // The count is of everything that MATCHED, so a truncated list is visible
169
280
  // as a truncated list rather than reading as "that is all there is".
170
281
  elementCount: matched.length,
282
+ truncated: matched.length > limit,
171
283
  elements: matched.slice(0, limit),
172
284
  });
173
285
  }
@@ -193,7 +305,8 @@ export const STUDIO_LOOK_INPUT_SCHEMA = {
193
305
  export const STUDIO_LOOK_DESCRIPTION = [
194
306
  "Read HyperFrames Studio's live state in one call: the open project and composition,",
195
307
  "the playhead and duration, what the human currently has selected (including what that",
196
- "element will and will not accept), and the timeline's elements with a handle for each.",
308
+ "element will and will not accept), and the live nested scene in DOM preorder.",
309
+ "Each scene element includes source ownership, hierarchy, and optional timeline timing.",
197
310
  "Pass a handle back to any tool that edits an element.",
198
311
  "Returns an object with `ok: true`, or `ok: false` with `kind`, `reason` and often a `hint`.",
199
312
  "`history.undoLabel` is worth checkpointing before a batch: if it later names something",
@@ -7,20 +7,37 @@ import {
7
7
  type StudioSeekResult,
8
8
  type StudioSelectResult,
9
9
  } from "./selectionTools";
10
- import { expectFailure, expectOk, previewDoc, selectionFor } from "../webmcpTestUtils";
10
+ import {
11
+ expectFailure,
12
+ expectOk,
13
+ previewDoc,
14
+ selectionFor,
15
+ selectionToolDeps,
16
+ sourceHandle,
17
+ } from "../webmcpTestUtils";
11
18
 
12
19
  function selectionDeps(overrides: Partial<SelectionToolDeps> = {}): SelectionToolDeps {
13
- return {
14
- getPreviewDocument: () => null,
15
- buildSelection: async (element) => selectionFor(element),
16
- applySelection: () => undefined,
17
- requestSeek: () => undefined,
18
- readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
19
- ...overrides,
20
- };
20
+ return selectionToolDeps(overrides);
21
21
  }
22
22
 
23
23
  describe("studioSelect", () => {
24
+ it("refuses a scoped handle from another project before selecting", async () => {
25
+ const doc = previewDoc('<h1 id="headline">Ship it</h1>');
26
+ const applySelection = vi.fn();
27
+
28
+ const result = await studioSelect(
29
+ selectionDeps({ getPreviewDocument: () => doc, applySelection }),
30
+ sourceHandle("headline", "project-b"),
31
+ );
32
+
33
+ expect(result).toMatchObject({
34
+ ok: false,
35
+ kind: "invalid",
36
+ reason: "the handle belongs to a different project",
37
+ });
38
+ expect(applySelection).not.toHaveBeenCalled();
39
+ });
40
+
24
41
  it("applies the selection a click would produce and reports it back", async () => {
25
42
  const doc = previewDoc('<h1 id="headline" data-hf-id="abc">Ship it</h1>');
26
43
  const applySelection = vi.fn();
@@ -38,6 +55,85 @@ describe("studioSelect", () => {
38
55
  expect(applySelection).toHaveBeenCalledTimes(1);
39
56
  });
40
57
 
58
+ it("returns the exact source-safe handle it resolved", async () => {
59
+ const doc = previewDoc(
60
+ '<main data-composition-id="root" data-composition-file="index.html"><h1 id="headline">Ship it</h1></main>',
61
+ );
62
+ const handle = "dom:v1:index.html:index.html:headline";
63
+
64
+ const ok = expectOk<StudioSelectResult>(
65
+ await studioSelect(selectionDeps({ getPreviewDocument: () => doc }), handle),
66
+ );
67
+
68
+ expect(ok.handle).toBe(handle);
69
+ });
70
+
71
+ it("reacquires once when the preview document reloads during selection resolution", async () => {
72
+ const firstDoc = previewDoc('<h1 data-hf-id="abc">Old preview</h1>');
73
+ const nextDoc = previewDoc('<h1 data-hf-id="abc">Current preview</h1>');
74
+ let currentDoc = firstDoc;
75
+ const buildSelection = vi.fn(async (element: HTMLElement) => {
76
+ if (element.ownerDocument === firstDoc) {
77
+ currentDoc = nextDoc;
78
+ return selectionFor(element, { boundingBox: { x: 0, y: 0, width: 0, height: 0 } });
79
+ }
80
+ return selectionFor(element, {
81
+ boundingBox: { x: 98, y: 206, width: 304, height: 50 },
82
+ });
83
+ });
84
+ const applySelection = vi.fn();
85
+
86
+ const ok = expectOk<StudioSelectResult>(
87
+ await studioSelect(
88
+ selectionDeps({
89
+ getPreviewDocument: () => currentDoc,
90
+ buildSelection,
91
+ applySelection,
92
+ }),
93
+ "hf:abc",
94
+ ),
95
+ );
96
+
97
+ expect(ok.box).toEqual({ x: 98, y: 206, width: 304, height: 50 });
98
+ expect(buildSelection).toHaveBeenCalledTimes(2);
99
+ expect(applySelection).toHaveBeenCalledWith(
100
+ expect.objectContaining({ element: nextDoc.querySelector("h1") }),
101
+ );
102
+ });
103
+
104
+ it("refuses when the preview changes again during the bounded reacquire", async () => {
105
+ const documents = [
106
+ previewDoc('<h1 data-hf-id="abc">First preview</h1>'),
107
+ previewDoc('<h1 data-hf-id="abc">Second preview</h1>'),
108
+ previewDoc('<h1 data-hf-id="abc">Third preview</h1>'),
109
+ ];
110
+ let currentIndex = 0;
111
+ const buildSelection = vi.fn(async (element: HTMLElement) => {
112
+ currentIndex += 1;
113
+ return selectionFor(element);
114
+ });
115
+ const applySelection = vi.fn();
116
+
117
+ const failure = expectFailure(
118
+ await studioSelect(
119
+ selectionDeps({
120
+ getPreviewDocument: () => documents[currentIndex] ?? documents.at(-1)!,
121
+ buildSelection,
122
+ applySelection,
123
+ }),
124
+ "hf:abc",
125
+ ),
126
+ );
127
+
128
+ expect(failure).toMatchObject({
129
+ kind: "invalid",
130
+ reason: "the target changed while it was resolving",
131
+ hint: "Call studio_look again.",
132
+ });
133
+ expect(buildSelection).toHaveBeenCalledTimes(2);
134
+ expect(applySelection).not.toHaveBeenCalled();
135
+ });
136
+
41
137
  it("distinguishes a preview that is not mounted from a handle that does not match", async () => {
42
138
  const notMounted = expectFailure(await studioSelect(selectionDeps(), "dom:headline"));
43
139
  expect(notMounted.kind).toBe("blocked");
@@ -2,21 +2,20 @@
2
2
  * `studio_select` and `studio_seek`: pointing the human and the agent at the
3
3
  * same thing.
4
4
  *
5
- * Selection is shared state, not a per-call argument. That is deliberate and it
6
- * is also forced: most of Studio's edit handlers read the ambient React
7
- * selection, and `applyDomSelection` only schedules a state update, so
8
- * selecting and committing inside ONE call would write to whatever was selected
9
- * before. Two tool calls are separated by a render, so the contract is select
10
- * first, then act, which is also how a human works: click, then type.
5
+ * Selection is shared human-visible state. Element writes do not infer their
6
+ * target from it: they resolve an explicit source-safe handle per call. Keeping
7
+ * these contracts separate means a human click cannot redirect an agent write.
11
8
  */
12
9
 
13
10
  import type { DomEditSelection } from "../../components/editor/domEditingTypes";
14
- import { mintElementHandle, patchTargetAddress, resolveElementHandle } from "../handles";
11
+ import { elementHandleMatchesProject, resolveLiveHandleSelection } from "../handles";
15
12
  import { toolFailure, toolOk, type ToolResult } from "../toolResult";
16
13
 
17
14
  export interface SelectionToolDeps {
18
15
  /** The preview iframe's document, or null before it mounts. */
19
16
  getPreviewDocument: () => Document | null;
17
+ getCompositionPath: () => string | null;
18
+ getProjectId: () => string | null;
20
19
  buildSelection: (element: HTMLElement) => Promise<DomEditSelection | null>;
21
20
  applySelection: (selection: DomEditSelection) => void;
22
21
  /** Out-of-loop seek. `requestSeek`, not `setCurrentTime`. */
@@ -25,7 +24,7 @@ export interface SelectionToolDeps {
25
24
  }
26
25
 
27
26
  export interface StudioSelectResult {
28
- handle: string | null;
27
+ handle: string;
29
28
  label: string;
30
29
  tagName: string;
31
30
  box: { x: number; y: number; width: number; height: number };
@@ -38,41 +37,56 @@ export async function studioSelect(
38
37
  if (typeof handle !== "string" || !handle.trim()) {
39
38
  return toolFailure("invalid", "handle must be a non-empty string", "Call studio_look first.");
40
39
  }
40
+ if (!elementHandleMatchesProject(handle, deps.getProjectId())) {
41
+ return toolFailure(
42
+ "invalid",
43
+ "the handle belongs to a different project",
44
+ "Call studio_look in the active project.",
45
+ );
46
+ }
41
47
 
42
48
  // Three distinct failures, deliberately not collapsed: "the preview is not up
43
49
  // yet" is a wait, "no such element" is a stale handle, and "could not build a
44
50
  // selection" is an element Studio cannot drive. The agent's next move differs
45
51
  // for each.
46
- const doc = deps.getPreviewDocument();
47
- if (!doc) {
52
+ const resolved = await resolveLiveHandleSelection(
53
+ deps.getPreviewDocument,
54
+ handle,
55
+ deps.buildSelection,
56
+ );
57
+ if (resolved.status === "preview-unavailable") {
48
58
  return toolFailure(
49
59
  "blocked",
50
60
  "the preview is not mounted yet",
51
61
  "Wait for the composition to load, then retry.",
52
62
  );
53
63
  }
54
-
55
- const element = resolveElementHandle(doc, handle);
56
- if (!element) {
64
+ if (resolved.status === "not-found") {
57
65
  return toolFailure(
58
66
  "invalid",
59
67
  `no element matches handle ${handle}`,
60
68
  "The composition may have changed. Call studio_look for current handles.",
61
69
  );
62
70
  }
63
-
64
- const selection = await deps.buildSelection(element);
65
- if (!selection) {
71
+ if (resolved.status === "unsupported") {
66
72
  return toolFailure(
67
73
  "blocked",
68
74
  `${handle} resolved to an element Studio cannot select`,
69
75
  "Try a parent or child element from studio_look.",
70
76
  );
71
77
  }
78
+ if (resolved.status === "changed") {
79
+ return toolFailure(
80
+ "invalid",
81
+ "the target changed while it was resolving",
82
+ "Call studio_look again.",
83
+ );
84
+ }
72
85
 
86
+ const { selection } = resolved;
73
87
  deps.applySelection(selection);
74
88
  return toolOk<StudioSelectResult>({
75
- handle: mintElementHandle(patchTargetAddress(selection)),
89
+ handle,
76
90
  label: selection.label,
77
91
  tagName: selection.tagName,
78
92
  box: selection.boundingBox,
@@ -132,8 +146,9 @@ export const STUDIO_SELECT_INPUT_SCHEMA = {
132
146
  export const STUDIO_SELECT_DESCRIPTION = [
133
147
  "Select an element in HyperFrames Studio, exactly as clicking it would:",
134
148
  "the human sees the same selection box and inspector.",
135
- "Takes a handle from studio_look. Most editing tools act on the CURRENT selection,",
136
- "so call this first, then the edit.",
149
+ "Call this before the first write to a target so the human sees the agent's intent.",
150
+ "Takes a handle from studio_look. Selection is visual context for the human;",
151
+ "element writes take their own explicit handle and do not require this tool first.",
137
152
  "Returns `ok: true` with the resulting selection, or `ok: false` with `kind`, `reason` and a `hint`.",
138
153
  ].join(" ");
139
154
 
@@ -0,0 +1,125 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { mintElementHandle } from "../handles";
4
+ import { previewDoc, selectionFor } from "../webmcpTestUtils";
5
+ import {
6
+ studioAddAnimation,
7
+ studioAddKeyframe,
8
+ studioDeleteAnimation,
9
+ studioUpdateAnimation,
10
+ type AnimationToolDeps,
11
+ } from "./animationTools";
12
+ import { studioTransform, type ElementBox, type TransformToolDeps } from "./transformTools";
13
+
14
+ function target() {
15
+ const doc = previewDoc('<div id="agent">Agent</div>');
16
+ const element = doc.getElementById("agent") as HTMLElement;
17
+ const handle = mintElementHandle({
18
+ projectId: "demo",
19
+ domId: "agent",
20
+ sourceFile: "index.html",
21
+ activeCompositionPath: "index.html",
22
+ });
23
+ if (!handle) throw new Error("expected handle");
24
+ const common = {
25
+ getPreviewDocument: () => doc,
26
+ getCompositionPath: () => "index.html",
27
+ getProjectId: () => "demo",
28
+ getWriteBlockedReason: () => null,
29
+ buildSelection: async (targetElement: HTMLElement) => selectionFor(targetElement),
30
+ applySelection: () => undefined,
31
+ };
32
+ return { common, element, handle };
33
+ }
34
+
35
+ function transformDeps(
36
+ common: ReturnType<typeof target>["common"],
37
+ box: ElementBox,
38
+ overrides: Partial<TransformToolDeps> = {},
39
+ ): TransformToolDeps {
40
+ return {
41
+ ...common,
42
+ readBox: () => ({ ...box }),
43
+ moveTo: async () => undefined,
44
+ resizeTo: async () => undefined,
45
+ rotateTo: async () => undefined,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function animationDeps(
51
+ common: ReturnType<typeof target>["common"],
52
+ overrides: Partial<AnimationToolDeps> = {},
53
+ ): AnimationToolDeps {
54
+ return {
55
+ ...common,
56
+ getAnimationsForSelection: async () => [{ id: "anim-1" }],
57
+ readPlayhead: () => ({ currentTime: 2, duration: 10, isPlaying: false }),
58
+ addAnimation: async () => true,
59
+ updateAnimation: async () => true,
60
+ addKeyframe: async () => undefined,
61
+ deleteAnimation: async () => true,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ describe("targeted write actor families", () => {
67
+ it("passes the live handle selection to transform and every animation actor", async () => {
68
+ const { common, element, handle } = target();
69
+ const box = { x: 0, y: 0, width: 100, height: 50 };
70
+ const moveTo = vi.fn(async (_selection, next: { x: number; y: number }) => {
71
+ Object.assign(box, next);
72
+ });
73
+ const addAnimation = vi.fn();
74
+ const updateAnimation = vi.fn(async () => true);
75
+ const addKeyframe = vi.fn(async () => undefined);
76
+ const deleteAnimation = vi.fn();
77
+
78
+ await studioTransform(transformDeps(common, box, { moveTo }), { handle, x: 20, y: 30 });
79
+ const animations = animationDeps(common, {
80
+ addAnimation,
81
+ updateAnimation,
82
+ addKeyframe,
83
+ deleteAnimation,
84
+ });
85
+ await studioAddAnimation(animations, { handle, method: "to" });
86
+ await studioUpdateAnimation(animations, { handle, animationId: "anim-1", duration: 2 });
87
+ await studioAddKeyframe(animations, {
88
+ handle,
89
+ animationId: "anim-1",
90
+ percent: 50,
91
+ properties: { opacity: 0 },
92
+ });
93
+ await studioDeleteAnimation(animations, { handle, animationId: "anim-1" });
94
+
95
+ for (const actor of [moveTo, addAnimation, updateAnimation, addKeyframe, deleteAnimation]) {
96
+ expect(actor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ element }));
97
+ }
98
+ });
99
+
100
+ it("keeps mixed durable and void transform operations at dispatched", async () => {
101
+ const { common, handle } = target();
102
+ const box = { x: 0, y: 0, width: 100, height: 50 };
103
+ const result = await studioTransform(
104
+ transformDeps(common, box, {
105
+ resizeTo: async (_selection, next) => {
106
+ Object.assign(box, next);
107
+ return {
108
+ ok: true,
109
+ persistence: {
110
+ sourceFile: "index.html",
111
+ version: '"sha256:size"',
112
+ changed: true,
113
+ },
114
+ };
115
+ },
116
+ moveTo: async (_selection, next) => {
117
+ Object.assign(box, next);
118
+ },
119
+ }),
120
+ { handle, width: 200, height: 80, x: 20, y: 30 },
121
+ );
122
+
123
+ expect(result).toMatchObject({ ok: true, stage: "dispatched" });
124
+ });
125
+ });