@hyperframes/studio 0.8.24 → 0.8.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
- package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
- package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
- package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
- package/dist/chunk-A7S5SGNA.js.map +1 -0
- package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6230 -5019
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/editor/TopologyLens.test.tsx +294 -0
- package/src/components/editor/TopologyLens.tsx +206 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
- package/src/components/editor/domEditingLayers.test.ts +9 -0
- package/src/components/editor/domEditingLayers.ts +8 -8
- package/src/components/editor/topologyLensGeometry.test.ts +44 -0
- package/src/components/editor/topologyLensGeometry.ts +119 -0
- package/src/components/editor/topologyLensState.test.ts +116 -0
- package/src/components/editor/topologyLensState.ts +72 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
- package/src/components/nle/PreviewOverlays.tsx +24 -18
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
- package/src/components/sidebar/CompositionsTab.tsx +10 -3
- package/src/components/ui/HyperframesLoader.tsx +3 -48
- package/src/components/ui/HyperframesMark.tsx +53 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/domEditCommitRunner.test.ts +59 -0
- package/src/hooks/domEditCommitRunner.ts +55 -20
- package/src/hooks/domEditCommitTypes.ts +8 -1
- package/src/hooks/domEditTextCommitPlan.ts +47 -0
- package/src/hooks/timelineTrackVisibility.test.ts +7 -1
- package/src/hooks/timelineTrackVisibility.ts +5 -5
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +243 -8
- package/src/hooks/useDomEditCommits.ts +69 -14
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
- package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
- package/src/hooks/useDomEditSession.ts +6 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
- package/src/hooks/useDomEditTextCommits.ts +64 -86
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomSelection.ts +2 -0
- package/src/hooks/useDomSelectionTypes.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
- package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
- package/src/hooks/useGsapAnimationOps.ts +4 -4
- package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
- package/src/hooks/useGsapSelectionHandlers.ts +21 -11
- package/src/hooks/useRenderClipContent.test.ts +27 -0
- package/src/hooks/useRenderClipContent.ts +5 -0
- package/src/hooks/useStudioExternalFileChanges.ts +5 -0
- package/src/player/components/CompositionThumbnail.test.ts +66 -1
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/hooks/timelineSyncHydration.ts +10 -2
- package/src/player/store/playerStore.test.ts +24 -0
- package/src/player/store/thumbnailSlice.ts +6 -0
- package/src/styles/studio.css +179 -0
- package/src/utils/domEditSaveQueue.test.ts +5 -3
- package/src/utils/domEditSaveQueue.ts +6 -1
- package/src/webmcp/StudioAgentTools.test.ts +70 -0
- package/src/webmcp/StudioAgentTools.tsx +89 -21
- package/src/webmcp/handles.test.ts +172 -15
- package/src/webmcp/handles.ts +152 -35
- package/src/webmcp/tools/animationTools.test.ts +241 -60
- package/src/webmcp/tools/animationTools.ts +191 -104
- package/src/webmcp/tools/contentTools.test.ts +103 -41
- package/src/webmcp/tools/contentTools.ts +149 -105
- package/src/webmcp/tools/inspectTools.test.ts +47 -5
- package/src/webmcp/tools/inspectTools.ts +59 -19
- package/src/webmcp/tools/lookTools.test.ts +185 -38
- package/src/webmcp/tools/lookTools.ts +134 -21
- package/src/webmcp/tools/selectionTools.test.ts +105 -9
- package/src/webmcp/tools/selectionTools.ts +34 -19
- package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
- package/src/webmcp/tools/transformTools.test.ts +140 -54
- package/src/webmcp/tools/transformTools.ts +215 -56
- package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
- package/src/webmcp/useStudioAgentTools.ts +27 -15
- package/src/webmcp/webmcpTestUtils.ts +37 -0
- package/src/webmcp/writeCoordinator.test.ts +375 -0
- package/src/webmcp/writeCoordinator.ts +473 -0
- package/dist/assets/index-yGhfxxoL.css +0 -1
- package/dist/chunk-6BT6DTB4.js.map +0 -1
- /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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
159
|
+
element.label.toLowerCase().includes(needle) ||
|
|
137
160
|
element.tag.toLowerCase().includes(needle) ||
|
|
138
|
-
|
|
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
|
|
250
|
+
const scene = describeScene(snapshot);
|
|
147
251
|
const needle = input.filter?.slice(0, MAX_FILTER_LENGTH).trim().toLowerCase();
|
|
148
252
|
const matched = needle
|
|
149
|
-
?
|
|
150
|
-
:
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
47
|
-
|
|
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
|
|
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
|
-
"
|
|
136
|
-
"
|
|
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
|
+
});
|