@hyperframes/studio 0.8.24 → 0.8.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-okdE01ky.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Bq3M0sjr.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-9WJFcXTP.js → index-Di-8Jxqc.js} +1 -1
  5. package/dist/assets/index-DnRfAiK2.css +1 -0
  6. package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
  7. package/dist/chunk-A7S5SGNA.js.map +1 -0
  8. package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6206 -5011
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/editor/TopologyLens.test.tsx +294 -0
  15. package/src/components/editor/TopologyLens.tsx +206 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  17. package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
  18. package/src/components/editor/domEditingLayers.test.ts +9 -0
  19. package/src/components/editor/domEditingLayers.ts +8 -8
  20. package/src/components/editor/topologyLensGeometry.test.ts +44 -0
  21. package/src/components/editor/topologyLensGeometry.ts +119 -0
  22. package/src/components/editor/topologyLensState.test.ts +116 -0
  23. package/src/components/editor/topologyLensState.ts +72 -0
  24. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
  25. package/src/components/nle/PreviewOverlays.tsx +24 -18
  26. package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
  27. package/src/components/sidebar/CompositionsTab.tsx +10 -3
  28. package/src/components/ui/HyperframesLoader.tsx +3 -48
  29. package/src/components/ui/HyperframesMark.tsx +53 -0
  30. package/src/contexts/DomEditContext.tsx +12 -0
  31. package/src/hooks/domEditCommitRunner.test.ts +59 -0
  32. package/src/hooks/domEditCommitRunner.ts +55 -20
  33. package/src/hooks/domEditCommitTypes.ts +8 -1
  34. package/src/hooks/domEditTextCommitPlan.ts +47 -0
  35. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  36. package/src/hooks/useDomEditCommits.test.tsx +207 -8
  37. package/src/hooks/useDomEditCommits.ts +54 -14
  38. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  40. package/src/hooks/useDomEditSession.ts +6 -2
  41. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  42. package/src/hooks/useDomEditTextCommits.ts +64 -86
  43. package/src/hooks/useDomEditWiring.ts +1 -1
  44. package/src/hooks/useDomSelection.ts +2 -0
  45. package/src/hooks/useDomSelectionTypes.ts +2 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  48. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  50. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  51. package/src/hooks/useGsapAnimationOps.ts +4 -4
  52. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  53. package/src/hooks/useGsapAwareEditing.ts +31 -10
  54. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  55. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  56. package/src/hooks/useRenderClipContent.test.ts +27 -0
  57. package/src/hooks/useRenderClipContent.ts +5 -0
  58. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  59. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  60. package/src/player/components/CompositionThumbnail.tsx +6 -0
  61. package/src/player/store/playerStore.test.ts +24 -0
  62. package/src/player/store/thumbnailSlice.ts +6 -0
  63. package/src/styles/studio.css +179 -0
  64. package/src/utils/domEditSaveQueue.test.ts +5 -3
  65. package/src/utils/domEditSaveQueue.ts +6 -1
  66. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  67. package/src/webmcp/StudioAgentTools.tsx +89 -21
  68. package/src/webmcp/handles.test.ts +172 -15
  69. package/src/webmcp/handles.ts +152 -35
  70. package/src/webmcp/tools/animationTools.test.ts +241 -60
  71. package/src/webmcp/tools/animationTools.ts +191 -104
  72. package/src/webmcp/tools/contentTools.test.ts +103 -41
  73. package/src/webmcp/tools/contentTools.ts +149 -105
  74. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  75. package/src/webmcp/tools/inspectTools.ts +59 -19
  76. package/src/webmcp/tools/lookTools.test.ts +185 -38
  77. package/src/webmcp/tools/lookTools.ts +134 -21
  78. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  79. package/src/webmcp/tools/selectionTools.ts +34 -19
  80. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  81. package/src/webmcp/tools/transformTools.test.ts +140 -54
  82. package/src/webmcp/tools/transformTools.ts +215 -56
  83. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  84. package/src/webmcp/useStudioAgentTools.ts +27 -15
  85. package/src/webmcp/webmcpTestUtils.ts +37 -0
  86. package/src/webmcp/writeCoordinator.test.ts +375 -0
  87. package/src/webmcp/writeCoordinator.ts +473 -0
  88. package/dist/assets/index-yGhfxxoL.css +0 -1
  89. package/dist/chunk-6BT6DTB4.js.map +0 -1
  90. /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
@@ -7,34 +7,61 @@ import {
7
7
  type StudioSetStyleResult,
8
8
  type StudioSetTextResult,
9
9
  } from "./contentTools";
10
- import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils";
10
+ import {
11
+ expectFailure,
12
+ expectOk,
13
+ previewElement,
14
+ selectionFor,
15
+ sourceHandle,
16
+ targetedWriteDeps,
17
+ } from "../webmcpTestUtils";
18
+
19
+ const textInput = (input: Record<string, unknown>) => ({
20
+ handle: sourceHandle("headline"),
21
+ ...input,
22
+ });
23
+
24
+ const styleInput = (styles: unknown) => ({ handle: sourceHandle("headline"), styles });
11
25
 
12
26
  function contentDeps(overrides: Partial<ContentToolDeps> = {}): ContentToolDeps {
13
27
  const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
28
+ const selection = selectionFor(element);
14
29
  return {
15
- getCurrentSelection: () => selectionFor(element),
16
- getWriteBlockedReason: () => null,
30
+ ...targetedWriteDeps(selection),
17
31
  setText: async () => ({ ok: true }),
18
32
  setStyle: async () => ({ ok: true }),
19
33
  ...overrides,
20
34
  };
21
35
  }
22
36
 
37
+ function childTextSelection() {
38
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
39
+ const selection = selectionFor(element);
40
+ selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
41
+ return selection;
42
+ }
43
+
23
44
  describe("studioSetText", () => {
24
45
  it("writes the text and reports what it now is", async () => {
25
- const setText = vi.fn(async () => ({ ok: true }) as const);
46
+ const setText = vi.fn(async (selection) => {
47
+ selection.element.textContent = "Ship it faster";
48
+ return { ok: true } as const;
49
+ });
26
50
 
27
- const result = await studioSetText(contentDeps({ setText }), { text: "Ship it faster" });
51
+ const result = await studioSetText(
52
+ contentDeps({ setText }),
53
+ textInput({ text: "Ship it faster" }),
54
+ );
28
55
 
29
56
  const ok = expectOk<StudioSetTextResult>(result);
30
57
  expect(ok.text).toBe("Ship it faster");
31
58
  expect(ok.changed).toBe(true);
32
59
  // The single field is resolved and named, rather than left undefined.
33
- expect(setText).toHaveBeenCalledWith("Ship it faster", "self");
60
+ expect(setText).toHaveBeenCalledWith(expect.anything(), "Ship it faster", "self");
34
61
  });
35
62
 
36
63
  it("reports changed:false when the text already said that", async () => {
37
- const result = await studioSetText(contentDeps(), { text: "Ship it" });
64
+ const result = await studioSetText(contentDeps(), textInput({ text: "Ship it" }));
38
65
 
39
66
  expect(expectOk<StudioSetTextResult>(result).changed).toBe(false);
40
67
  });
@@ -51,7 +78,7 @@ describe("studioSetText", () => {
51
78
  getWriteBlockedReason: () => "an external change to this file is waiting to be resolved",
52
79
  setText,
53
80
  }),
54
- { text: "Ship it faster" },
81
+ textInput({ text: "Ship it faster" }),
55
82
  ),
56
83
  );
57
84
 
@@ -66,7 +93,7 @@ describe("studioSetText", () => {
66
93
  const result = expectFailure(
67
94
  await studioSetText(
68
95
  contentDeps({ setText: async () => ({ ok: false, reason: "persist-failed" }) }),
69
- { text: "Ship it faster" },
96
+ textInput({ text: "Ship it faster" }),
70
97
  ),
71
98
  );
72
99
 
@@ -78,32 +105,38 @@ describe("studioSetText", () => {
78
105
  const result = expectFailure(
79
106
  await studioSetText(
80
107
  contentDeps({ setText: async () => ({ ok: false, reason: "not-text-editable" }) }),
81
- { text: "x" },
108
+ textInput({ text: "x" }),
82
109
  ),
83
110
  );
84
111
 
85
- expect(result.kind).toBe("blocked");
112
+ expect(result.kind).toBe("failed");
86
113
  expect(result.hint).toMatch(/studio_inspect/);
87
114
  });
88
115
 
89
116
  it("rejects a non-string text without dispatching", async () => {
90
117
  const setText = vi.fn();
91
118
 
92
- const result = expectFailure(await studioSetText(contentDeps({ setText }), { text: 42 }));
119
+ const result = expectFailure(
120
+ await studioSetText(contentDeps({ setText }), textInput({ text: 42 })),
121
+ );
93
122
 
94
123
  expect(result.kind).toBe("invalid");
95
124
  expect(setText).not.toHaveBeenCalled();
96
125
  });
97
126
 
98
- it("fails when nothing is selected", async () => {
127
+ it("reports an unsupported target when Studio cannot build its selection", async () => {
99
128
  const setText = vi.fn();
100
129
 
101
130
  const result = expectFailure(
102
- await studioSetText(contentDeps({ getCurrentSelection: () => null, setText }), { text: "x" }),
131
+ await studioSetText(
132
+ contentDeps({ buildSelection: async () => null, setText }),
133
+ textInput({ text: "x" }),
134
+ ),
103
135
  );
104
136
 
105
- expect(result.kind).toBe("invalid");
106
- expect(result.hint).toMatch(/studio_select/);
137
+ expect(result.kind).toBe("blocked");
138
+ expect(result.reason).toBe("the target resolved to an element Studio cannot edit");
139
+ expect(result.hint).toMatch(/parent or child/);
107
140
  expect(setText).not.toHaveBeenCalled();
108
141
  });
109
142
 
@@ -113,29 +146,26 @@ describe("studioSetText", () => {
113
146
  // operations, and the server rejected the empty patch with
114
147
  // "target and operations required" -- a persist failure that looked like a
115
148
  // server problem and was not.
116
- const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
117
- const selection = selectionFor(element);
118
- selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
149
+ const selection = childTextSelection();
119
150
  const setText = vi.fn(async () => ({ ok: true }) as const);
120
151
 
121
- await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
122
- text: "Shipped it",
123
- });
152
+ await studioSetText(
153
+ contentDeps({ ...targetedWriteDeps(selection), setText }),
154
+ textInput({ text: "Shipped it" }),
155
+ );
124
156
 
125
- expect(setText).toHaveBeenCalledWith("Shipped it", "child:0:h1");
157
+ expect(setText).toHaveBeenCalledWith(selection, "Shipped it", "child:0:h1");
126
158
  });
127
159
 
128
160
  it("rejects a field the element does not have, rather than writing nowhere", async () => {
129
- const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
130
- const selection = selectionFor(element);
131
- selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
161
+ const selection = childTextSelection();
132
162
  const setText = vi.fn();
133
163
 
134
164
  const result = expectFailure(
135
- await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
136
- text: "x",
137
- field: "self",
138
- }),
165
+ await studioSetText(
166
+ contentDeps({ ...targetedWriteDeps(selection), setText }),
167
+ textInput({ text: "x", field: "self" }),
168
+ ),
139
169
  );
140
170
 
141
171
  expect(result.kind).toBe("invalid");
@@ -154,7 +184,8 @@ describe("studioSetText", () => {
154
184
  const setText = vi.fn();
155
185
 
156
186
  const result = expectFailure(
157
- await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
187
+ await studioSetText(contentDeps({ ...targetedWriteDeps(selection), setText }), {
188
+ handle: sourceHandle("card"),
158
189
  text: "x",
159
190
  }),
160
191
  );
@@ -171,7 +202,8 @@ describe("studioSetText", () => {
171
202
  const setText = vi.fn();
172
203
 
173
204
  const result = expectFailure(
174
- await studioSetText(contentDeps({ getCurrentSelection: () => selection, setText }), {
205
+ await studioSetText(contentDeps({ ...targetedWriteDeps(selection), setText }), {
206
+ handle: sourceHandle("box"),
175
207
  text: "x",
176
208
  }),
177
209
  );
@@ -186,7 +218,7 @@ describe("studioSetStyle", () => {
186
218
  const setStyle = vi.fn(async () => ({ ok: true }) as const);
187
219
 
188
220
  const result = await studioSetStyle(contentDeps({ setStyle }), {
189
- styles: { color: "red", "font-size": "48px" },
221
+ ...styleInput({ color: "red", "font-size": "48px" }),
190
222
  });
191
223
 
192
224
  const ok = expectOk<StudioSetStyleResult>(result);
@@ -195,6 +227,36 @@ describe("studioSetStyle", () => {
195
227
  expect(setStyle).toHaveBeenCalledTimes(2);
196
228
  });
197
229
 
230
+ it("keeps an earlier changed property when the latest saved property is a no-op", async () => {
231
+ const setStyle = vi
232
+ .fn()
233
+ .mockResolvedValueOnce({
234
+ ok: true,
235
+ persistence: { sourceFile: "index.html", version: "v1", changed: true },
236
+ })
237
+ .mockResolvedValueOnce({
238
+ ok: true,
239
+ persistence: { sourceFile: "index.html", version: "v2", changed: false },
240
+ });
241
+
242
+ const result = expectOk<StudioSetStyleResult>(
243
+ await studioSetStyle(
244
+ contentDeps({ setStyle }),
245
+ styleInput({ color: "red", "font-size": "48px" }),
246
+ ),
247
+ );
248
+
249
+ expect(result).toMatchObject({
250
+ stage: "saved",
251
+ changed: true,
252
+ evidence: { kind: "content-version", sourceFile: "index.html", version: "v2" },
253
+ propertyReceipts: {
254
+ color: { stage: "saved", changed: true },
255
+ "font-size": { stage: "saved", changed: false },
256
+ },
257
+ });
258
+ });
259
+
198
260
  it("commits sequentially, never concurrently", async () => {
199
261
  // Two commits racing through Studio's client-side read-modify-write can
200
262
  // record undo entries that both claim the same starting content.
@@ -209,21 +271,21 @@ describe("studioSetStyle", () => {
209
271
  });
210
272
 
211
273
  await studioSetStyle(contentDeps({ setStyle }), {
212
- styles: { color: "red", "font-size": "48px", opacity: "0.5" },
274
+ ...styleInput({ color: "red", "font-size": "48px", opacity: "0.5" }),
213
275
  });
214
276
 
215
277
  expect(maxInFlight).toBe(1);
216
278
  });
217
279
 
218
280
  it("reports a partial success as partial, not whole", async () => {
219
- const setStyle = vi.fn(async (property: string) =>
281
+ const setStyle = vi.fn(async (_selection, property: string) =>
220
282
  property === "left"
221
283
  ? ({ ok: false, reason: "geometry-property" } as const)
222
284
  : ({ ok: true } as const),
223
285
  );
224
286
 
225
287
  const result = await studioSetStyle(contentDeps({ setStyle }), {
226
- styles: { color: "red", left: "10px" },
288
+ ...styleInput({ color: "red", left: "10px" }),
227
289
  });
228
290
 
229
291
  const ok = expectOk<StudioSetStyleResult>(result);
@@ -235,18 +297,18 @@ describe("studioSetStyle", () => {
235
297
  const result = expectFailure(
236
298
  await studioSetStyle(
237
299
  contentDeps({ setStyle: async () => ({ ok: false, reason: "styles-not-editable" }) }),
238
- { styles: { color: "red" } },
300
+ styleInput({ color: "red" }),
239
301
  ),
240
302
  );
241
303
 
242
- expect(result.kind).toBe("blocked");
304
+ expect(result.kind).toBe("failed");
243
305
  expect(result.reason).toMatch(/styles-not-editable/);
244
306
  });
245
307
 
246
308
  it("rejects an empty styles object rather than committing nothing", async () => {
247
309
  const setStyle = vi.fn();
248
310
 
249
- const result = expectFailure(await studioSetStyle(contentDeps({ setStyle }), { styles: {} }));
311
+ const result = expectFailure(await studioSetStyle(contentDeps({ setStyle }), styleInput({})));
250
312
 
251
313
  expect(result.kind).toBe("invalid");
252
314
  expect(setStyle).not.toHaveBeenCalled();
@@ -254,7 +316,7 @@ describe("studioSetStyle", () => {
254
316
 
255
317
  it("rejects a non-object styles value", async () => {
256
318
  for (const styles of ["color: red", 42, null, ["color"]]) {
257
- const result = expectFailure(await studioSetStyle(contentDeps(), { styles }));
319
+ const result = expectFailure(await studioSetStyle(contentDeps(), styleInput(styles)));
258
320
  expect(result.kind).toBe("invalid");
259
321
  }
260
322
  });
@@ -265,7 +327,7 @@ describe("studioSetStyle", () => {
265
327
  const result = expectFailure(
266
328
  await studioSetStyle(
267
329
  contentDeps({ getWriteBlockedReason: () => "Auto-save is paused", setStyle }),
268
- { styles: { color: "red" } },
330
+ styleInput({ color: "red" }),
269
331
  ),
270
332
  );
271
333
 
@@ -1,28 +1,36 @@
1
1
  /**
2
2
  * `studio_set_text` and `studio_set_style`: the first tools that change the file.
3
3
  *
4
- * Both operate on the CURRENT selection and take no handle. That is not an
5
- * omission. `handleDomTextCommit(value, fieldKey?)` and
6
- * `handleDomStyleCommit(property, value)` read the ambient React selection, and
7
- * `applyDomSelection` only schedules a state update, so selecting and
8
- * committing inside one call would write to whatever was selected before.
9
- * Two tool calls are separated by a render. Select first, then edit.
10
- *
11
- * Every write here is guarded before dispatch and verified after. Studio has
12
- * several paths where a failed commit resolves anyway, so "the function did not
13
- * throw" proves nothing; the outcome the handler now returns is what proves it.
4
+ * The explicit source-safe handle is resolved to one selection per invocation.
5
+ * That captured selection is passed into Studio's existing commit actor; visible
6
+ * selection is presentation only and cannot redirect the write.
14
7
  */
15
8
 
16
9
  import type { DomEditCommitOutcome } from "../../hooks/domEditCommitRunner";
17
10
  import type { DomEditSelection } from "../../components/editor/domEditingTypes";
18
- import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult";
19
-
20
- export interface ContentToolDeps {
21
- getCurrentSelection: () => DomEditSelection | null;
22
- /** Why a write would be refused right now, or null. Checked BEFORE dispatch. */
23
- getWriteBlockedReason: () => string | null;
24
- setText: (value: string, fieldKey?: string) => Promise<DomEditCommitOutcome>;
25
- setStyle: (property: string, value: string) => Promise<DomEditCommitOutcome>;
11
+ import { toolFailure, type ToolFailure } from "../toolResult";
12
+ import {
13
+ dispatched,
14
+ runTargetedWrite,
15
+ saved,
16
+ type StudioWriteAdapterSuccess,
17
+ type StudioWriteEvidence,
18
+ type StudioWriteResult,
19
+ type TargetedWriteDeps,
20
+ WRITE_RECEIPT_DESCRIPTION,
21
+ } from "../writeCoordinator";
22
+
23
+ export interface ContentToolDeps extends TargetedWriteDeps {
24
+ setText: (
25
+ selection: DomEditSelection,
26
+ value: string,
27
+ fieldKey?: string,
28
+ ) => Promise<DomEditCommitOutcome>;
29
+ setStyle: (
30
+ selection: DomEditSelection,
31
+ property: string,
32
+ value: string,
33
+ ) => Promise<DomEditCommitOutcome>;
26
34
  }
27
35
 
28
36
  /**
@@ -53,20 +61,6 @@ function fromOutcome(outcome: DomEditCommitOutcome, what: string): ToolFailure |
53
61
  return toolFailure(mapped.kind, `${what} was not applied: ${outcome.reason}`, mapped.hint);
54
62
  }
55
63
 
56
- function guardWrite(deps: ContentToolDeps): ToolFailure | null {
57
- // Both blocked states are banners in Studio's UI with no lock behind them, so
58
- // nothing else stops a programmatic write from landing on top of a conflict
59
- // the user has been asked to adjudicate.
60
- const blocked = deps.getWriteBlockedReason();
61
- if (blocked) {
62
- return toolFailure("blocked", blocked, "Resolve it in Studio, then retry.");
63
- }
64
- if (!deps.getCurrentSelection()) {
65
- return toolFailure("invalid", "nothing is selected", "Call studio_select first.");
66
- }
67
- return null;
68
- }
69
-
70
64
  export interface StudioSetTextResult {
71
65
  text: string;
72
66
  changed: boolean;
@@ -74,107 +68,147 @@ export interface StudioSetTextResult {
74
68
 
75
69
  export async function studioSetText(
76
70
  deps: ContentToolDeps,
77
- input: { text?: unknown; field?: unknown },
78
- ): Promise<ToolResult<StudioSetTextResult>> {
71
+ input: { handle?: unknown; text?: unknown; field?: unknown },
72
+ signal: AbortSignal = new AbortController().signal,
73
+ ): Promise<StudioWriteResult<StudioSetTextResult>> {
79
74
  if (typeof input.text !== "string") {
80
- return toolFailure("invalid", "text must be a string");
75
+ return preDispatchFailure("set-text", toolFailure("invalid", "text must be a string"));
81
76
  }
77
+ const text = input.text;
82
78
 
83
- const blocked = guardWrite(deps);
84
- if (blocked) return blocked;
85
-
86
- const selection = deps.getCurrentSelection();
87
- if (!selection) return toolFailure("invalid", "nothing is selected");
88
-
89
- const fields = selection.textFields;
90
79
  const requested = typeof input.field === "string" && input.field ? input.field : undefined;
91
- if (requested && !fields.some((candidate) => candidate.key === requested)) {
92
- return toolFailure(
93
- "invalid",
94
- `this element has no text field "${requested}"`,
95
- `Its fields are: ${fields.map((candidate) => candidate.key).join(", ") || "none"}.`,
96
- );
97
- }
98
-
99
- // Resolving the field is NOT optional. An element's text usually lives in a
100
- // child field keyed like `child:0:h1`, not in one called `self`, and passing
101
- // no key plans zero operations. The server then rejects the empty patch with
102
- // "target and operations required", which surfaces as a persist failure that
103
- // looks like a server problem and is not.
104
- const field = requested ?? (fields.length === 1 ? fields[0]?.key : undefined);
105
- if (!field) {
106
- if (fields.length === 0) {
80
+ let field: string | undefined;
81
+ return runTargetedWrite(deps, {
82
+ handle: input.handle,
83
+ operation: "set-text",
84
+ signal,
85
+ preflight: (selection) => {
86
+ const fields = selection.textFields;
87
+ if (requested && !fields.some((candidate) => candidate.key === requested)) {
88
+ return toolFailure(
89
+ "invalid",
90
+ `this element has no text field "${requested}"`,
91
+ `Its fields are: ${fields.map((candidate) => candidate.key).join(", ") || "none"}.`,
92
+ );
93
+ }
94
+ field = requested ?? (fields.length === 1 ? fields[0]?.key : undefined);
95
+ if (field) return null;
96
+ if (fields.length === 0) {
97
+ return toolFailure(
98
+ "blocked",
99
+ "this element has no editable text field",
100
+ "studio_inspect lists an element's textFields.",
101
+ );
102
+ }
107
103
  return toolFailure(
108
- "blocked",
109
- "this element has no editable text field",
110
- "studio_inspect lists an element's textFields.",
104
+ "invalid",
105
+ `this element has ${fields.length} text fields, so one must be named`,
106
+ `Pass field as one of: ${fields.map((candidate) => candidate.key).join(", ")}.`,
111
107
  );
112
- }
113
- return toolFailure(
114
- "invalid",
115
- `this element has ${fields.length} text fields, so one must be named`,
116
- `Pass field as one of: ${fields.map((candidate) => candidate.key).join(", ")}.`,
117
- );
118
- }
119
-
120
- const before = selection.textContent ?? null;
121
- const outcome = await deps.setText(input.text, field);
122
- const failure = fromOutcome(outcome, "the text");
123
- if (failure) return failure;
124
-
125
- return toolOk<StudioSetTextResult>({ text: input.text, changed: before !== input.text });
108
+ },
109
+ write: async (selection) => {
110
+ const before = selection.element.textContent;
111
+ const outcome = await deps.setText(selection, text, field);
112
+ if (!outcome.ok) return fromOutcome(outcome, "the text")!;
113
+ const value = { text, changed: selection.element.textContent !== before };
114
+ return outcome.persistence
115
+ ? saved(value, outcome.persistence)
116
+ : dispatched(value, value.changed);
117
+ },
118
+ });
126
119
  }
127
120
 
128
121
  export interface StudioSetStyleResult {
129
122
  applied: Record<string, string>;
130
123
  /** Properties the element refused, with the reason. Empty when all landed. */
131
124
  rejected: Record<string, string>;
125
+ partial: boolean;
126
+ propertyReceipts: Record<
127
+ string,
128
+ { stage: "dispatched" | "saved"; changed: boolean; evidence: StudioWriteEvidence }
129
+ >;
132
130
  }
133
131
 
134
132
  export async function studioSetStyle(
135
133
  deps: ContentToolDeps,
136
- input: { styles?: unknown },
137
- ): Promise<ToolResult<StudioSetStyleResult>> {
134
+ input: { handle?: unknown; styles?: unknown },
135
+ signal: AbortSignal = new AbortController().signal,
136
+ ): Promise<StudioWriteResult<StudioSetStyleResult>> {
138
137
  const styles = input.styles;
139
138
  if (typeof styles !== "object" || styles === null || Array.isArray(styles)) {
140
- return toolFailure("invalid", "styles must be an object of CSS property to value");
139
+ return preDispatchFailure(
140
+ "set-style",
141
+ toolFailure("invalid", "styles must be an object of CSS property to value"),
142
+ );
141
143
  }
142
144
  const entries = Object.entries(styles).filter(
143
145
  (entry): entry is [string, string] => typeof entry[1] === "string",
144
146
  );
145
147
  if (entries.length === 0) {
146
148
  // An empty commit would report success having done nothing.
147
- return toolFailure("invalid", "styles must contain at least one string value");
148
- }
149
-
150
- const blocked = guardWrite(deps);
151
- if (blocked) return blocked;
152
-
153
- // `handleDomStyleCommit` is one property per call, so N properties are N
154
- // commits and N undo entries. Sequential, not concurrent: two commits racing
155
- // through Studio's client-side read-modify-write can record undo entries that
156
- // both claim the same starting content.
157
- const applied: Record<string, string> = {};
158
- const rejected: Record<string, string> = {};
159
- for (const [property, value] of entries) {
160
- const outcome = await deps.setStyle(property, value);
161
- if (outcome.ok) applied[property] = value;
162
- else rejected[property] = outcome.reason;
149
+ return preDispatchFailure(
150
+ "set-style",
151
+ toolFailure("invalid", "styles must contain at least one string value"),
152
+ );
163
153
  }
164
154
 
165
- if (Object.keys(applied).length === 0) {
166
- const reasons = Object.entries(rejected)
167
- .map(([property, reason]) => `${property}: ${reason}`)
168
- .join(", ");
169
- return toolFailure("blocked", `no style was applied (${reasons})`);
170
- }
155
+ return runTargetedWrite(deps, {
156
+ handle: input.handle,
157
+ operation: "set-style",
158
+ signal,
159
+ preflight: (selection) =>
160
+ selection.capabilities.canEditStyles
161
+ ? null
162
+ : toolFailure(
163
+ "blocked",
164
+ "this element's styles are not editable",
165
+ "studio_inspect reports why, in can.reasonIfDisabled.",
166
+ ),
167
+ write: async (selection) => {
168
+ const applied: Record<string, string> = {};
169
+ const rejected: Record<string, string> = {};
170
+ const propertyReceipts: StudioSetStyleResult["propertyReceipts"] = {};
171
+ let weakest: StudioWriteAdapterSuccess<object> | null = null;
172
+ for (const [property, value] of entries) {
173
+ const outcome = await deps.setStyle(selection, property, value);
174
+ if (!outcome.ok) {
175
+ rejected[property] = outcome.reason;
176
+ continue;
177
+ }
178
+ applied[property] = value;
179
+ const receipt = outcome.persistence ? saved({}, outcome.persistence) : dispatched({}, true);
180
+ propertyReceipts[property] = {
181
+ stage: receipt.stage,
182
+ changed: receipt.changed,
183
+ evidence: receipt.evidence,
184
+ };
185
+ if (!weakest || weakest.stage !== "dispatched") weakest = receipt;
186
+ }
171
187
 
172
- return toolOk<StudioSetStyleResult>({ applied, rejected });
188
+ if (!weakest) {
189
+ const reasons = Object.entries(rejected)
190
+ .map(([property, reason]) => `${property}: ${reason}`)
191
+ .join(", ");
192
+ return toolFailure("blocked", `no style was applied (${reasons})`);
193
+ }
194
+ const value: StudioSetStyleResult = {
195
+ applied,
196
+ rejected,
197
+ partial: Object.keys(rejected).length > 0,
198
+ propertyReceipts,
199
+ };
200
+ const changed = Object.values(propertyReceipts).some((receipt) => receipt.changed);
201
+ return weakest.stage === "dispatched"
202
+ ? dispatched(value, changed)
203
+ : { ...weakest, ...value, changed };
204
+ },
205
+ });
173
206
  }
174
207
 
175
208
  export const STUDIO_SET_TEXT_INPUT_SCHEMA = {
176
209
  type: "object",
177
210
  properties: {
211
+ handle: { type: "string", description: "A source-safe element handle from studio_look." },
178
212
  text: { type: "string", description: "The new text content." },
179
213
  field: {
180
214
  type: "string",
@@ -182,36 +216,46 @@ export const STUDIO_SET_TEXT_INPUT_SCHEMA = {
182
216
  "Which text field to write, from studio_inspect. Omit for the element's own text.",
183
217
  },
184
218
  },
185
- required: ["text"],
219
+ required: ["handle", "text"],
186
220
  additionalProperties: false,
187
221
  } as const;
188
222
 
189
223
  export const STUDIO_SET_TEXT_DESCRIPTION = [
190
- "Set the text of the CURRENTLY SELECTED element. Call studio_select first.",
224
+ "Set one element's text using its source-safe handle from studio_look.",
191
225
  "This is the edit a synthetic double-click cannot reach, because Studio's canvas",
192
226
  "takes pointer capture and recognises the double press itself.",
193
227
  "Returns `ok: true` with the resulting text and whether it changed, or `ok: false`",
194
228
  "with `kind`, `reason` and usually a `hint` naming what to do instead.",
229
+ WRITE_RECEIPT_DESCRIPTION,
195
230
  ].join(" ");
196
231
 
197
232
  export const STUDIO_SET_STYLE_INPUT_SCHEMA = {
198
233
  type: "object",
199
234
  properties: {
235
+ handle: { type: "string", description: "A source-safe element handle from studio_look." },
200
236
  styles: {
201
237
  type: "object",
202
238
  description: 'CSS property to value, for example {"color": "red", "font-size": "48px"}.',
203
239
  additionalProperties: { type: "string" },
204
240
  },
205
241
  },
206
- required: ["styles"],
242
+ required: ["handle", "styles"],
207
243
  additionalProperties: false,
208
244
  } as const;
209
245
 
210
246
  export const STUDIO_SET_STYLE_DESCRIPTION = [
211
- "Set inline styles on the CURRENTLY SELECTED element. Call studio_select first.",
247
+ "Set inline styles on one element using its source-safe handle from studio_look.",
212
248
  "Each property is a separate commit, so N properties produce N undo entries.",
213
249
  "Position and size properties (left, top, width, height) are refused here on purpose;",
214
250
  "they belong to the transform tools.",
215
251
  "Returns `ok: true` with `applied` and `rejected` maps, so a partial success is visible",
216
252
  "as a partial success rather than reported as a whole one.",
253
+ WRITE_RECEIPT_DESCRIPTION,
217
254
  ].join(" ");
255
+
256
+ function preDispatchFailure<T extends object>(
257
+ operation: "set-text" | "set-style",
258
+ failure: ToolFailure,
259
+ ): StudioWriteResult<T> {
260
+ return { ...failure, stage: "refused", operation };
261
+ }