@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
|
@@ -6,7 +6,19 @@ import {
|
|
|
6
6
|
type StudioTransformResult,
|
|
7
7
|
type TransformToolDeps,
|
|
8
8
|
} from "./transformTools";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
expectFailure,
|
|
11
|
+
expectOk,
|
|
12
|
+
previewElement,
|
|
13
|
+
selectionFor,
|
|
14
|
+
sourceHandle,
|
|
15
|
+
targetedWriteDeps,
|
|
16
|
+
} from "../webmcpTestUtils";
|
|
17
|
+
|
|
18
|
+
const transformInput = (input: Record<string, unknown>) => ({
|
|
19
|
+
handle: sourceHandle("headline"),
|
|
20
|
+
...input,
|
|
21
|
+
});
|
|
10
22
|
|
|
11
23
|
/**
|
|
12
24
|
* A stand-in for the rendered box. happy-dom and jsdom report all-zero rects,
|
|
@@ -23,9 +35,9 @@ function boxStore(initial: ElementBox) {
|
|
|
23
35
|
|
|
24
36
|
function transformDeps(overrides: Partial<TransformToolDeps> = {}): TransformToolDeps {
|
|
25
37
|
const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
|
|
38
|
+
const selection = selectionFor(element);
|
|
26
39
|
return {
|
|
27
|
-
|
|
28
|
-
getWriteBlockedReason: () => null,
|
|
40
|
+
...targetedWriteDeps(selection),
|
|
29
41
|
readBox: () => ({ x: 0, y: 0, width: 100, height: 50 }),
|
|
30
42
|
moveTo: async () => undefined,
|
|
31
43
|
resizeTo: async () => undefined,
|
|
@@ -34,16 +46,31 @@ function transformDeps(overrides: Partial<TransformToolDeps> = {}): TransformToo
|
|
|
34
46
|
};
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
async function resizeAndMove(moveLands: boolean): Promise<StudioTransformResult> {
|
|
50
|
+
const store = boxStore({ x: 0, y: 0, width: 100, height: 50 });
|
|
51
|
+
const resizeTo = vi.fn(async () => {
|
|
52
|
+
store.set({ width: 200, height: 80 });
|
|
53
|
+
});
|
|
54
|
+
const moveTo = vi.fn(async () => {
|
|
55
|
+
if (moveLands) store.set({ x: 40, y: 40 });
|
|
56
|
+
});
|
|
57
|
+
const result = await studioTransform(transformDeps({ readBox: store.read, resizeTo, moveTo }), {
|
|
58
|
+
...transformInput({ x: 40, y: 40, width: 200, height: 80 }),
|
|
59
|
+
});
|
|
60
|
+
return expectOk<StudioTransformResult>(result);
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
describe("studioTransform", () => {
|
|
38
64
|
it("reports the box read back, not the box requested", async () => {
|
|
39
65
|
const store = boxStore({ x: 0, y: 0, width: 100, height: 50 });
|
|
40
66
|
// The handler lands somewhere other than asked, which is what a clamp or a
|
|
41
67
|
// layout constraint does.
|
|
42
|
-
const resizeTo = vi.fn(async () =>
|
|
68
|
+
const resizeTo = vi.fn(async () => {
|
|
69
|
+
store.set({ width: 300, height: 120 });
|
|
70
|
+
});
|
|
43
71
|
|
|
44
72
|
const result = await studioTransform(transformDeps({ readBox: store.read, resizeTo }), {
|
|
45
|
-
width: 999,
|
|
46
|
-
height: 999,
|
|
73
|
+
...transformInput({ width: 999, height: 999 }),
|
|
47
74
|
});
|
|
48
75
|
|
|
49
76
|
const ok = expectOk<StudioTransformResult>(result);
|
|
@@ -52,69 +79,122 @@ describe("studioTransform", () => {
|
|
|
52
79
|
expect(ok.applied).toContain("resize");
|
|
53
80
|
});
|
|
54
81
|
|
|
55
|
-
it("reports a silent no-op as
|
|
82
|
+
it("reports a void actor's silent no-op as dispatched and unchanged", async () => {
|
|
56
83
|
// handleGsapAwarePathOffsetCommit is `if (gsapCommitMutation) {...}` with no
|
|
57
84
|
// else branch. Without GSAP it resolves having written nothing, and echoing
|
|
58
85
|
// the request back would be a lie the agent builds on.
|
|
59
86
|
const store = boxStore({ x: 10, y: 10, width: 100, height: 50 });
|
|
60
87
|
const moveTo = vi.fn(async () => undefined);
|
|
61
88
|
|
|
62
|
-
const result =
|
|
63
|
-
await studioTransform(
|
|
89
|
+
const result = expectOk<StudioTransformResult>(
|
|
90
|
+
await studioTransform(
|
|
91
|
+
transformDeps({ readBox: store.read, moveTo }),
|
|
92
|
+
transformInput({ x: 500, y: 400 }),
|
|
93
|
+
),
|
|
64
94
|
);
|
|
65
95
|
|
|
66
96
|
expect(moveTo).toHaveBeenCalled();
|
|
67
|
-
expect(result.
|
|
68
|
-
expect(result.
|
|
69
|
-
expect(result.hint).toMatch(/GSAP/);
|
|
97
|
+
expect(result.stage).toBe("dispatched");
|
|
98
|
+
expect(result.unchanged.move).toMatch(/did not move/);
|
|
70
99
|
});
|
|
71
100
|
|
|
72
101
|
it("separates what landed from what did not, in one call", async () => {
|
|
73
|
-
const
|
|
74
|
-
const resizeTo = vi.fn(async () => store.set({ width: 200, height: 80 }));
|
|
75
|
-
const moveTo = vi.fn(async () => undefined);
|
|
76
|
-
|
|
77
|
-
const result = await studioTransform(transformDeps({ readBox: store.read, resizeTo, moveTo }), {
|
|
78
|
-
x: 40,
|
|
79
|
-
y: 40,
|
|
80
|
-
width: 200,
|
|
81
|
-
height: 80,
|
|
82
|
-
});
|
|
102
|
+
const result = await resizeAndMove(false);
|
|
83
103
|
|
|
84
|
-
|
|
85
|
-
expect(
|
|
86
|
-
expect(ok.unchanged.move).toMatch(/did not move/);
|
|
104
|
+
expect(result.applied).toEqual(["resize"]);
|
|
105
|
+
expect(result.unchanged.move).toMatch(/did not move/);
|
|
87
106
|
});
|
|
88
107
|
|
|
89
108
|
it("re-reads between operations so a later one sees the earlier result", async () => {
|
|
90
|
-
const
|
|
91
|
-
const resizeTo = vi.fn(async () => store.set({ width: 200, height: 80 }));
|
|
92
|
-
const moveTo = vi.fn(async () => store.set({ x: 40, y: 40 }));
|
|
93
|
-
|
|
94
|
-
const result = await studioTransform(transformDeps({ readBox: store.read, resizeTo, moveTo }), {
|
|
95
|
-
x: 40,
|
|
96
|
-
y: 40,
|
|
97
|
-
width: 200,
|
|
98
|
-
height: 80,
|
|
99
|
-
});
|
|
109
|
+
const result = await resizeAndMove(true);
|
|
100
110
|
|
|
101
111
|
// Move is judged against the box AFTER the resize. Comparing against the
|
|
102
112
|
// original would credit the resize's change to the move.
|
|
103
|
-
|
|
104
|
-
expect(
|
|
105
|
-
expect(ok.unchanged).toEqual({});
|
|
113
|
+
expect(result.applied).toEqual(["resize", "move"]);
|
|
114
|
+
expect(result.unchanged).toEqual({});
|
|
106
115
|
});
|
|
107
116
|
|
|
108
|
-
it("reports
|
|
117
|
+
it("reports durable partial success when a later operation fails", async () => {
|
|
118
|
+
const store = boxStore({ x: 0, y: 0, width: 100, height: 50 });
|
|
119
|
+
const result = expectOk<StudioTransformResult>(
|
|
120
|
+
await studioTransform(
|
|
121
|
+
transformDeps({
|
|
122
|
+
readBox: store.read,
|
|
123
|
+
resizeTo: async () => {
|
|
124
|
+
store.set({ width: 200, height: 80 });
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
persistence: {
|
|
128
|
+
sourceFile: "index.html",
|
|
129
|
+
version: '"sha256:resize"',
|
|
130
|
+
changed: true,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
moveTo: async () => ({ ok: false, reason: "the move actor failed" }),
|
|
135
|
+
}),
|
|
136
|
+
transformInput({ width: 200, height: 80, x: 40, y: 40 }),
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result).toMatchObject({
|
|
141
|
+
stage: "saved",
|
|
142
|
+
changed: true,
|
|
143
|
+
partial: true,
|
|
144
|
+
applied: ["resize"],
|
|
145
|
+
failed: { move: expect.stringContaining("move actor failed") },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("aggregates changed across durable operations instead of trusting the last no-op", async () => {
|
|
150
|
+
const store = boxStore({ x: 0, y: 0, width: 100, height: 50 });
|
|
151
|
+
const result = expectOk<StudioTransformResult>(
|
|
152
|
+
await studioTransform(
|
|
153
|
+
transformDeps({
|
|
154
|
+
readBox: store.read,
|
|
155
|
+
resizeTo: async () => {
|
|
156
|
+
store.set({ width: 200, height: 80 });
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
persistence: {
|
|
160
|
+
sourceFile: "index.html",
|
|
161
|
+
version: '"sha256:resize"',
|
|
162
|
+
changed: true,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
moveTo: async () => ({
|
|
167
|
+
ok: true,
|
|
168
|
+
persistence: {
|
|
169
|
+
sourceFile: "index.html",
|
|
170
|
+
version: '"sha256:noop"',
|
|
171
|
+
changed: false,
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
}),
|
|
175
|
+
transformInput({ width: 200, height: 80, x: 0, y: 0 }),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(result.changed).toBe(true);
|
|
180
|
+
expect(result.applied).toEqual(["resize"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("reports unobserved rotation as dispatched without claiming it applied", async () => {
|
|
109
184
|
// `rotate` is an individual transform property and does not appear in the
|
|
110
185
|
// computed transform, so there is no honest box-derived signal for it.
|
|
111
186
|
const rotateTo = vi.fn(async () => undefined);
|
|
112
187
|
|
|
113
|
-
const result = await studioTransform(
|
|
188
|
+
const result = await studioTransform(
|
|
189
|
+
transformDeps({ rotateTo }),
|
|
190
|
+
transformInput({ rotate: 15 }),
|
|
191
|
+
);
|
|
114
192
|
|
|
115
193
|
const ok = expectOk<StudioTransformResult>(result);
|
|
116
194
|
expect(rotateTo).toHaveBeenCalledWith(expect.anything(), { angle: 15 });
|
|
117
|
-
expect(ok.applied).toEqual([
|
|
195
|
+
expect(ok.applied).toEqual([]);
|
|
196
|
+
expect(ok.unchanged.rotation).toMatch(/dispatched but not independently observed/);
|
|
197
|
+
expect(ok.changed).toBe(false);
|
|
118
198
|
});
|
|
119
199
|
|
|
120
200
|
it("refuses to write while a conflict is waiting for the user", async () => {
|
|
@@ -123,7 +203,7 @@ describe("studioTransform", () => {
|
|
|
123
203
|
const result = expectFailure(
|
|
124
204
|
await studioTransform(
|
|
125
205
|
transformDeps({ getWriteBlockedReason: () => "Auto-save is paused", moveTo }),
|
|
126
|
-
{ x: 10, y: 10 },
|
|
206
|
+
transformInput({ x: 10, y: 10 }),
|
|
127
207
|
),
|
|
128
208
|
);
|
|
129
209
|
|
|
@@ -136,8 +216,12 @@ describe("studioTransform", () => {
|
|
|
136
216
|
const resizeTo = vi.fn();
|
|
137
217
|
const deps = transformDeps({ moveTo, resizeTo });
|
|
138
218
|
|
|
139
|
-
expect(expectFailure(await studioTransform(deps, { x: 10 })).reason).toMatch(
|
|
140
|
-
|
|
219
|
+
expect(expectFailure(await studioTransform(deps, transformInput({ x: 10 }))).reason).toMatch(
|
|
220
|
+
/together/,
|
|
221
|
+
);
|
|
222
|
+
expect(
|
|
223
|
+
expectFailure(await studioTransform(deps, transformInput({ width: 10 }))).reason,
|
|
224
|
+
).toMatch(/together/);
|
|
141
225
|
expect(moveTo).not.toHaveBeenCalled();
|
|
142
226
|
expect(resizeTo).not.toHaveBeenCalled();
|
|
143
227
|
});
|
|
@@ -145,17 +229,19 @@ describe("studioTransform", () => {
|
|
|
145
229
|
it("rejects a negative size and an empty request", async () => {
|
|
146
230
|
const deps = transformDeps();
|
|
147
231
|
|
|
148
|
-
expect(
|
|
149
|
-
|
|
232
|
+
expect(
|
|
233
|
+
expectFailure(await studioTransform(deps, transformInput({ width: -1, height: 10 }))).kind,
|
|
234
|
+
).toBe("invalid");
|
|
235
|
+
expect(expectFailure(await studioTransform(deps, transformInput({}))).reason).toMatch(
|
|
236
|
+
/at least one/,
|
|
150
237
|
);
|
|
151
|
-
expect(expectFailure(await studioTransform(deps, {})).reason).toMatch(/at least one/);
|
|
152
238
|
});
|
|
153
239
|
|
|
154
240
|
it("rejects non-finite numbers rather than passing them to a handler", async () => {
|
|
155
241
|
const moveTo = vi.fn();
|
|
156
242
|
|
|
157
243
|
const result = expectFailure(
|
|
158
|
-
await studioTransform(transformDeps({ moveTo }), { x: Number.NaN, y: 10 }),
|
|
244
|
+
await studioTransform(transformDeps({ moveTo }), transformInput({ x: Number.NaN, y: 10 })),
|
|
159
245
|
);
|
|
160
246
|
|
|
161
247
|
expect(result.kind).toBe("invalid");
|
|
@@ -166,14 +252,14 @@ describe("studioTransform", () => {
|
|
|
166
252
|
const moveTo = vi.fn();
|
|
167
253
|
|
|
168
254
|
const result = expectFailure(
|
|
169
|
-
await studioTransform(
|
|
170
|
-
|
|
171
|
-
y: 1,
|
|
172
|
-
|
|
255
|
+
await studioTransform(
|
|
256
|
+
transformDeps({ buildSelection: async () => null, moveTo }),
|
|
257
|
+
transformInput({ x: 1, y: 1 }),
|
|
258
|
+
),
|
|
173
259
|
);
|
|
174
260
|
|
|
175
|
-
expect(result.kind).toBe("
|
|
176
|
-
expect(result.hint).toMatch(/
|
|
261
|
+
expect(result.kind).toBe("blocked");
|
|
262
|
+
expect(result.hint).toMatch(/parent or child/);
|
|
177
263
|
expect(moveTo).not.toHaveBeenCalled();
|
|
178
264
|
});
|
|
179
265
|
});
|
|
@@ -21,7 +21,19 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { DomEditSelection } from "../../components/editor/domEditingTypes";
|
|
24
|
-
import
|
|
24
|
+
import type { DomEditCommitOutcome } from "../../hooks/domEditCommitRunner";
|
|
25
|
+
import type { DomEditPersistOutcome } from "../../hooks/domEditCommitTypes";
|
|
26
|
+
import { toolFailure, type ToolFailure } from "../toolResult";
|
|
27
|
+
import {
|
|
28
|
+
dispatched,
|
|
29
|
+
runTargetedWrite,
|
|
30
|
+
saved,
|
|
31
|
+
verified,
|
|
32
|
+
type StudioWriteAdapterSuccess,
|
|
33
|
+
type StudioWriteResult,
|
|
34
|
+
type TargetedWriteDeps,
|
|
35
|
+
WRITE_RECEIPT_DESCRIPTION,
|
|
36
|
+
} from "../writeCoordinator";
|
|
25
37
|
|
|
26
38
|
export interface ElementBox {
|
|
27
39
|
x: number;
|
|
@@ -30,17 +42,25 @@ export interface ElementBox {
|
|
|
30
42
|
height: number;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
export interface TransformToolDeps {
|
|
34
|
-
getCurrentSelection: () => DomEditSelection | null;
|
|
35
|
-
getWriteBlockedReason: () => string | null;
|
|
45
|
+
export interface TransformToolDeps extends TargetedWriteDeps {
|
|
36
46
|
/** The element's box as it renders right now. */
|
|
37
47
|
readBox: (selection: DomEditSelection) => ElementBox;
|
|
38
|
-
moveTo: (
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
moveTo: (
|
|
49
|
+
selection: DomEditSelection,
|
|
50
|
+
next: { x: number; y: number },
|
|
51
|
+
) => Promise<DomEditCommitOutcome | void>;
|
|
52
|
+
resizeTo: (
|
|
53
|
+
selection: DomEditSelection,
|
|
54
|
+
next: { width: number; height: number },
|
|
55
|
+
) => Promise<DomEditCommitOutcome | void>;
|
|
56
|
+
rotateTo: (
|
|
57
|
+
selection: DomEditSelection,
|
|
58
|
+
next: { angle: number },
|
|
59
|
+
) => Promise<DomEditCommitOutcome | void>;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
export interface StudioTransformInput {
|
|
63
|
+
handle?: unknown;
|
|
44
64
|
x?: unknown;
|
|
45
65
|
y?: unknown;
|
|
46
66
|
width?: unknown;
|
|
@@ -54,6 +74,9 @@ export interface StudioTransformResult {
|
|
|
54
74
|
applied: string[];
|
|
55
75
|
/** Requested operations whose effect could not be observed, with why. */
|
|
56
76
|
unchanged: Record<string, string>;
|
|
77
|
+
/** Present when earlier operations landed before a later operation failed. */
|
|
78
|
+
partial?: true;
|
|
79
|
+
failed?: Partial<Record<TransformOperation, string>>;
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
const NO_OP_HINT =
|
|
@@ -63,15 +86,6 @@ function readNumber(value: unknown): number | null {
|
|
|
63
86
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
64
87
|
}
|
|
65
88
|
|
|
66
|
-
function guard(deps: TransformToolDeps): ToolFailure | null {
|
|
67
|
-
const blocked = deps.getWriteBlockedReason();
|
|
68
|
-
if (blocked) return toolFailure("blocked", blocked, "Resolve it in Studio, then retry.");
|
|
69
|
-
if (!deps.getCurrentSelection()) {
|
|
70
|
-
return toolFailure("invalid", "nothing is selected", "Call studio_select first.");
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
89
|
interface TransformRequest {
|
|
76
90
|
move: { x: number; y: number } | null;
|
|
77
91
|
size: { width: number; height: number } | null;
|
|
@@ -126,75 +140,219 @@ function parseRequest(input: StudioTransformInput): TransformRequest | ToolFailu
|
|
|
126
140
|
};
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
|
|
143
|
+
type TransformOperation = "resize" | "move" | "rotate";
|
|
144
|
+
|
|
145
|
+
interface TransformObservation {
|
|
146
|
+
operation: TransformOperation;
|
|
147
|
+
changed: boolean;
|
|
148
|
+
unchangedReason?: string;
|
|
149
|
+
persistence?: DomEditPersistOutcome;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function transformPreflight(
|
|
153
|
+
request: TransformRequest,
|
|
154
|
+
selection: DomEditSelection,
|
|
155
|
+
): ToolFailure | null {
|
|
156
|
+
if (
|
|
157
|
+
request.size &&
|
|
158
|
+
!selection.capabilities.canResize &&
|
|
159
|
+
!selection.capabilities.canApplyManualSize
|
|
160
|
+
) {
|
|
161
|
+
return toolFailure("blocked", "this target cannot be resized");
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
request.move &&
|
|
165
|
+
!selection.capabilities.canMove &&
|
|
166
|
+
!selection.capabilities.canApplyManualOffset
|
|
167
|
+
) {
|
|
168
|
+
return toolFailure("blocked", "this target cannot be moved");
|
|
169
|
+
}
|
|
170
|
+
if (request.rotate !== null && !selection.capabilities.canApplyManualRotation) {
|
|
171
|
+
return toolFailure("blocked", "this target cannot be rotated");
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function observeResize(
|
|
130
177
|
deps: TransformToolDeps,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
178
|
+
selection: DomEditSelection,
|
|
179
|
+
size: NonNullable<TransformRequest["size"]>,
|
|
180
|
+
): Promise<TransformObservation | ToolFailure> {
|
|
181
|
+
const before = deps.readBox(selection);
|
|
182
|
+
const outcome = await deps.resizeTo(selection, size);
|
|
183
|
+
if (outcome && !outcome.ok) return outcomeFailure(outcome.reason);
|
|
184
|
+
const after = deps.readBox(selection);
|
|
185
|
+
const changed = after.width !== before.width || after.height !== before.height;
|
|
186
|
+
return {
|
|
187
|
+
operation: "resize",
|
|
188
|
+
changed,
|
|
189
|
+
...(!changed ? { unchangedReason: "the element's size did not change" } : {}),
|
|
190
|
+
...(outcome?.ok && outcome.persistence ? { persistence: outcome.persistence } : {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function observeMove(
|
|
195
|
+
deps: TransformToolDeps,
|
|
196
|
+
selection: DomEditSelection,
|
|
197
|
+
move: NonNullable<TransformRequest["move"]>,
|
|
198
|
+
): Promise<TransformObservation | ToolFailure> {
|
|
199
|
+
const before = deps.readBox(selection);
|
|
200
|
+
const outcome = await deps.moveTo(selection, move);
|
|
201
|
+
if (outcome && !outcome.ok) return outcomeFailure(outcome.reason);
|
|
202
|
+
const after = deps.readBox(selection);
|
|
203
|
+
const changed = after.x !== before.x || after.y !== before.y;
|
|
204
|
+
return {
|
|
205
|
+
operation: "move",
|
|
206
|
+
changed,
|
|
207
|
+
...(!changed ? { unchangedReason: `the element did not move. ${NO_OP_HINT}` } : {}),
|
|
208
|
+
...(outcome?.ok && outcome.persistence ? { persistence: outcome.persistence } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
135
211
|
|
|
136
|
-
|
|
137
|
-
|
|
212
|
+
async function observeRotation(
|
|
213
|
+
deps: TransformToolDeps,
|
|
214
|
+
selection: DomEditSelection,
|
|
215
|
+
rotate: number,
|
|
216
|
+
): Promise<TransformObservation | ToolFailure> {
|
|
217
|
+
const outcome = await deps.rotateTo(selection, { angle: rotate });
|
|
218
|
+
if (outcome && !outcome.ok) return outcomeFailure(outcome.reason);
|
|
219
|
+
if (!outcome || !outcome.persistence) {
|
|
220
|
+
return {
|
|
221
|
+
operation: "rotate",
|
|
222
|
+
changed: false,
|
|
223
|
+
unchangedReason: "rotation was dispatched but not independently observed",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!outcome.persistence.changed) {
|
|
227
|
+
return {
|
|
228
|
+
operation: "rotate",
|
|
229
|
+
changed: false,
|
|
230
|
+
unchangedReason: "the durable write reported no rotation change",
|
|
231
|
+
persistence: outcome.persistence,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { operation: "rotate", changed: true, persistence: outcome.persistence };
|
|
235
|
+
}
|
|
138
236
|
|
|
139
|
-
|
|
140
|
-
|
|
237
|
+
function requestMatchesBox(request: TransformRequest, box: ElementBox): boolean {
|
|
238
|
+
const sizeMatches =
|
|
239
|
+
!request.size || (box.width === request.size.width && box.height === request.size.height);
|
|
240
|
+
const moveMatches = !request.move || (box.x === request.move.x && box.y === request.move.y);
|
|
241
|
+
return sizeMatches && moveMatches;
|
|
242
|
+
}
|
|
141
243
|
|
|
142
|
-
|
|
143
|
-
|
|
244
|
+
function transformReceipt(
|
|
245
|
+
request: TransformRequest,
|
|
246
|
+
initial: ElementBox,
|
|
247
|
+
box: ElementBox,
|
|
248
|
+
observations: TransformObservation[],
|
|
249
|
+
failure?: { operation: TransformOperation; reason: string },
|
|
250
|
+
): StudioWriteAdapterSuccess<StudioTransformResult> {
|
|
251
|
+
const applied = observations.filter((item) => item.changed).map((item) => item.operation);
|
|
252
|
+
const unchanged = Object.fromEntries(
|
|
253
|
+
observations.flatMap((item) => {
|
|
254
|
+
if (!item.unchangedReason) return [];
|
|
255
|
+
const key = item.operation === "rotate" ? "rotation" : item.operation;
|
|
256
|
+
return [[key, item.unchangedReason]];
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
const value: StudioTransformResult = {
|
|
260
|
+
box,
|
|
261
|
+
applied,
|
|
262
|
+
unchanged,
|
|
263
|
+
...(failure ? { partial: true, failed: { [failure.operation]: failure.reason } } : {}),
|
|
264
|
+
};
|
|
265
|
+
const changed = observations.some((item) => item.changed) || !boxesEqual(initial, box);
|
|
266
|
+
const allDurable = observations.every((item) => item.persistence !== undefined);
|
|
267
|
+
const persistence = observations.at(-1)?.persistence;
|
|
268
|
+
if (!allDurable || !persistence) return dispatched(value, changed);
|
|
269
|
+
if (!failure && requestMatchesBox(request, box) && request.rotate === null) {
|
|
270
|
+
return { ...verified(value, persistence, { before: initial, after: box }), changed };
|
|
271
|
+
}
|
|
272
|
+
return { ...saved(value, persistence), changed };
|
|
273
|
+
}
|
|
144
274
|
|
|
145
|
-
|
|
146
|
-
|
|
275
|
+
async function writeTransform(
|
|
276
|
+
deps: TransformToolDeps,
|
|
277
|
+
selection: DomEditSelection,
|
|
278
|
+
request: TransformRequest,
|
|
279
|
+
): Promise<StudioWriteAdapterSuccess<StudioTransformResult> | ToolFailure> {
|
|
280
|
+
const initial = deps.readBox(selection);
|
|
281
|
+
const observations: TransformObservation[] = [];
|
|
147
282
|
if (request.size) {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (after.width !== before.width || after.height !== before.height) applied.push("resize");
|
|
152
|
-
else unchanged.resize = "the element's size did not change";
|
|
283
|
+
const observation = await observeResize(deps, selection, request.size);
|
|
284
|
+
if (isFailure(observation)) return observation;
|
|
285
|
+
observations.push(observation);
|
|
153
286
|
}
|
|
154
|
-
|
|
155
287
|
if (request.move) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
288
|
+
const observation = await observeMove(deps, selection, request.move);
|
|
289
|
+
if (isFailure(observation)) {
|
|
290
|
+
if (observations.length === 0) return observation;
|
|
291
|
+
return transformReceipt(request, initial, deps.readBox(selection), observations, {
|
|
292
|
+
operation: "move",
|
|
293
|
+
reason: observation.reason,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
observations.push(observation);
|
|
161
297
|
}
|
|
162
|
-
|
|
163
298
|
if (request.rotate !== null) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
299
|
+
const observation = await observeRotation(deps, selection, request.rotate);
|
|
300
|
+
if (isFailure(observation)) {
|
|
301
|
+
if (observations.length === 0) return observation;
|
|
302
|
+
return transformReceipt(request, initial, deps.readBox(selection), observations, {
|
|
303
|
+
operation: "rotate",
|
|
304
|
+
reason: observation.reason,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
observations.push(observation);
|
|
170
308
|
}
|
|
309
|
+
return transformReceipt(request, initial, deps.readBox(selection), observations);
|
|
310
|
+
}
|
|
171
311
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
312
|
+
export async function studioTransform(
|
|
313
|
+
deps: TransformToolDeps,
|
|
314
|
+
input: StudioTransformInput,
|
|
315
|
+
signal: AbortSignal = new AbortController().signal,
|
|
316
|
+
): Promise<StudioWriteResult<StudioTransformResult>> {
|
|
317
|
+
const request = parseRequest(input);
|
|
318
|
+
if (isFailure(request)) {
|
|
319
|
+
return { ...request, stage: "refused", operation: "transform" };
|
|
178
320
|
}
|
|
179
321
|
|
|
180
|
-
return
|
|
322
|
+
return runTargetedWrite(deps, {
|
|
323
|
+
handle: input.handle,
|
|
324
|
+
operation: "transform",
|
|
325
|
+
signal,
|
|
326
|
+
preflight: (selection) => transformPreflight(request, selection),
|
|
327
|
+
write: (selection) => writeTransform(deps, selection, request),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function boxesEqual(a: ElementBox, b: ElementBox): boolean {
|
|
332
|
+
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function outcomeFailure(reason: string): ToolFailure {
|
|
336
|
+
return toolFailure("failed", `the transform was not applied: ${reason}`);
|
|
181
337
|
}
|
|
182
338
|
|
|
183
339
|
export const STUDIO_TRANSFORM_INPUT_SCHEMA = {
|
|
184
340
|
type: "object",
|
|
185
341
|
properties: {
|
|
342
|
+
handle: { type: "string", description: "A source-safe element handle from studio_look." },
|
|
186
343
|
x: { type: "number", description: "New x offset in pixels. Must be paired with y." },
|
|
187
344
|
y: { type: "number", description: "New y offset in pixels. Must be paired with x." },
|
|
188
345
|
width: { type: "number", minimum: 0, description: "New width. Must be paired with height." },
|
|
189
346
|
height: { type: "number", minimum: 0, description: "New height. Must be paired with width." },
|
|
190
347
|
rotate: { type: "number", description: "Rotation in degrees." },
|
|
191
348
|
},
|
|
349
|
+
required: ["handle"],
|
|
192
350
|
additionalProperties: false,
|
|
193
351
|
} as const;
|
|
194
352
|
|
|
195
353
|
export const STUDIO_TRANSFORM_DESCRIPTION = [
|
|
196
|
-
"Move, resize or rotate
|
|
197
|
-
"
|
|
354
|
+
"Move, resize or rotate one element using its source-safe handle from studio_look.",
|
|
355
|
+
"Give x with y, and width with height.",
|
|
198
356
|
"The result's `box` is READ BACK after the write, not echoed from your request, and",
|
|
199
357
|
"`applied` lists what actually took effect. Check it.",
|
|
200
358
|
"Move and rotate are written as GSAP code, so in a composition with no GSAP timeline they",
|
|
@@ -202,4 +360,5 @@ export const STUDIO_TRANSFORM_DESCRIPTION = [
|
|
|
202
360
|
"Rotation is reported as dispatched rather than verified, because the CSS `rotate` property",
|
|
203
361
|
"does not appear in the element's computed transform.",
|
|
204
362
|
"Returns `ok: true`, or `ok: false` with `kind`, `reason` and a `hint`.",
|
|
363
|
+
WRITE_RECEIPT_DESCRIPTION,
|
|
205
364
|
].join(" ");
|