@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.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
  2. package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
  3. package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
  4. package/dist/assets/index-DnRfAiK2.css +1 -0
  5. package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
  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 +6230 -5019
  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/timelineTrackVisibility.test.ts +7 -1
  36. package/src/hooks/timelineTrackVisibility.ts +5 -5
  37. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  38. package/src/hooks/useDomEditCommits.test.tsx +243 -8
  39. package/src/hooks/useDomEditCommits.ts +69 -14
  40. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  41. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  42. package/src/hooks/useDomEditSession.ts +6 -2
  43. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  44. package/src/hooks/useDomEditTextCommits.ts +64 -86
  45. package/src/hooks/useDomEditWiring.ts +1 -1
  46. package/src/hooks/useDomSelection.ts +2 -0
  47. package/src/hooks/useDomSelectionTypes.ts +2 -0
  48. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  49. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  50. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  51. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  52. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  53. package/src/hooks/useGsapAnimationOps.ts +4 -4
  54. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  55. package/src/hooks/useGsapAwareEditing.ts +31 -10
  56. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  57. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  58. package/src/hooks/useRenderClipContent.test.ts +27 -0
  59. package/src/hooks/useRenderClipContent.ts +5 -0
  60. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  61. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  62. package/src/player/components/CompositionThumbnail.tsx +6 -0
  63. package/src/player/hooks/timelineSyncHydration.ts +10 -2
  64. package/src/player/store/playerStore.test.ts +24 -0
  65. package/src/player/store/thumbnailSlice.ts +6 -0
  66. package/src/styles/studio.css +179 -0
  67. package/src/utils/domEditSaveQueue.test.ts +5 -3
  68. package/src/utils/domEditSaveQueue.ts +6 -1
  69. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  70. package/src/webmcp/StudioAgentTools.tsx +89 -21
  71. package/src/webmcp/handles.test.ts +172 -15
  72. package/src/webmcp/handles.ts +152 -35
  73. package/src/webmcp/tools/animationTools.test.ts +241 -60
  74. package/src/webmcp/tools/animationTools.ts +191 -104
  75. package/src/webmcp/tools/contentTools.test.ts +103 -41
  76. package/src/webmcp/tools/contentTools.ts +149 -105
  77. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  78. package/src/webmcp/tools/inspectTools.ts +59 -19
  79. package/src/webmcp/tools/lookTools.test.ts +185 -38
  80. package/src/webmcp/tools/lookTools.ts +134 -21
  81. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  82. package/src/webmcp/tools/selectionTools.ts +34 -19
  83. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  84. package/src/webmcp/tools/transformTools.test.ts +140 -54
  85. package/src/webmcp/tools/transformTools.ts +215 -56
  86. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  87. package/src/webmcp/useStudioAgentTools.ts +27 -15
  88. package/src/webmcp/webmcpTestUtils.ts +37 -0
  89. package/src/webmcp/writeCoordinator.test.ts +375 -0
  90. package/src/webmcp/writeCoordinator.ts +473 -0
  91. package/dist/assets/index-yGhfxxoL.css +0 -1
  92. package/dist/chunk-6BT6DTB4.js.map +0 -1
  93. /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 { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils";
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
- getCurrentSelection: () => selectionFor(element),
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 () => store.set({ width: 300, height: 120 }));
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 unchanged instead of success", async () => {
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 = expectFailure(
63
- await studioTransform(transformDeps({ readBox: store.read, moveTo }), { x: 500, y: 400 }),
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.kind).toBe("blocked");
68
- expect(result.reason).toMatch(/did not move/);
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 store = boxStore({ x: 0, y: 0, width: 100, height: 50 });
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
- const ok = expectOk<StudioTransformResult>(result);
85
- expect(ok.applied).toEqual(["resize"]);
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 store = boxStore({ x: 0, y: 0, width: 100, height: 50 });
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
- const ok = expectOk<StudioTransformResult>(result);
104
- expect(ok.applied).toEqual(["resize", "move"]);
105
- expect(ok.unchanged).toEqual({});
113
+ expect(result.applied).toEqual(["resize", "move"]);
114
+ expect(result.unchanged).toEqual({});
106
115
  });
107
116
 
108
- it("reports rotation as dispatched rather than verified", async () => {
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(transformDeps({ rotateTo }), { rotate: 15 });
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(["rotate"]);
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(/together/);
140
- expect(expectFailure(await studioTransform(deps, { width: 10 })).reason).toMatch(/together/);
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(expectFailure(await studioTransform(deps, { width: -1, height: 10 })).kind).toBe(
149
- "invalid",
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(transformDeps({ getCurrentSelection: () => null, moveTo }), {
170
- x: 1,
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("invalid");
176
- expect(result.hint).toMatch(/studio_select/);
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 { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult";
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: (selection: DomEditSelection, next: { x: number; y: number }) => Promise<void>;
39
- resizeTo: (selection: DomEditSelection, next: { width: number; height: number }) => Promise<void>;
40
- rotateTo: (selection: DomEditSelection, next: { angle: number }) => Promise<void>;
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
- export async function studioTransform(
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
- input: StudioTransformInput,
132
- ): Promise<ToolResult<StudioTransformResult>> {
133
- const request = parseRequest(input);
134
- if (isFailure(request)) return request;
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
- const blocked = guard(deps);
137
- if (blocked) return blocked;
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
- const selection = deps.getCurrentSelection();
140
- if (!selection) return toolFailure("invalid", "nothing is selected");
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
- const applied: string[] = [];
143
- const unchanged: Record<string, string> = {};
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
- // Sequential, and each one re-reads first, so a move is judged against the box
146
- // AFTER a resize in the same call rather than against the original.
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 before = deps.readBox(selection);
149
- await deps.resizeTo(selection, request.size);
150
- const after = deps.readBox(selection);
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 before = deps.readBox(selection);
157
- await deps.moveTo(selection, request.move);
158
- const after = deps.readBox(selection);
159
- if (after.x !== before.x || after.y !== before.y) applied.push("move");
160
- else unchanged.move = `the element did not move. ${NO_OP_HINT}`;
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
- // Rotation is written as the CSS `rotate` property, an individual transform
165
- // property that does NOT appear in getComputedStyle().transform. There is no
166
- // reliable box-derived signal, so this is reported as dispatched rather than
167
- // verified, and the description says so.
168
- await deps.rotateTo(selection, { angle: request.rotate });
169
- applied.push("rotate");
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
- if (applied.length === 0) {
173
- return toolFailure(
174
- "blocked",
175
- `nothing changed: ${Object.values(unchanged).join("; ")}`,
176
- NO_OP_HINT,
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 toolOk<StudioTransformResult>({ box: deps.readBox(selection), applied, unchanged });
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 the CURRENTLY SELECTED element, the way a drag would.",
197
- "Call studio_select first. Give x with y, and width with height.",
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(" ");