@hyperframes/studio 0.8.23 → 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-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-DW-H7Y2b.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
@@ -124,6 +124,44 @@ afterEach(() => {
124
124
  });
125
125
 
126
126
  describe("useDomEditTextCommits", () => {
127
+ it("keeps concurrent text commit ownership isolated by target", async () => {
128
+ const { iframe, element: firstElement } = previewElement(
129
+ "<div id='first'>First</div><div id='second'>Second</div>",
130
+ "first",
131
+ );
132
+ const secondElement = iframe.contentDocument?.getElementById("second") as HTMLElement;
133
+ const firstSelection = selectionFor(firstElement);
134
+ const secondSelection = selectionFor(secondElement);
135
+ const firstPersist = createDeferred<void>();
136
+ const persistDomEditOperations = vi
137
+ .fn()
138
+ .mockImplementationOnce(() => firstPersist.promise)
139
+ .mockResolvedValueOnce(undefined);
140
+ vi.spyOn(console, "warn").mockImplementation(() => {});
141
+ const hook = renderTextCommitHook(
142
+ commitParams({
143
+ previewIframeRef: { current: iframe },
144
+ domEditSelection: firstSelection,
145
+ persistDomEditOperations,
146
+ }),
147
+ );
148
+
149
+ let firstCommit: Promise<unknown> | undefined;
150
+ act(() => {
151
+ firstCommit = hook.handleDomTextCommitForSelection(firstSelection, "Pending first", "self");
152
+ });
153
+ await act(async () => {
154
+ await hook.handleDomTextCommitForSelection(secondSelection, "Saved second", "self");
155
+ });
156
+ firstPersist.reject(new Error("first target failed"));
157
+ await act(async () => {
158
+ await firstCommit;
159
+ });
160
+
161
+ expect(firstElement.textContent).toBe("First");
162
+ expect(secondElement.textContent).toBe("Saved second");
163
+ });
164
+
127
165
  it("does not let a stale failed fields commit revert newer text", async () => {
128
166
  const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
129
167
  vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -183,8 +221,17 @@ describe("useDomEditTextCommits", () => {
183
221
  it("reports a successful style commit", async () => {
184
222
  const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
185
223
  const selection = selectionFor(element);
224
+ const persistence = {
225
+ sourceFile: "index.html",
226
+ version: '"sha256:after"',
227
+ changed: true,
228
+ } as const;
186
229
  const hook = renderTextCommitHook(
187
- commitParams({ previewIframeRef: { current: iframe }, domEditSelection: selection }),
230
+ commitParams({
231
+ previewIframeRef: { current: iframe },
232
+ domEditSelection: selection,
233
+ persistDomEditOperations: vi.fn().mockResolvedValue(persistence),
234
+ }),
188
235
  );
189
236
 
190
237
  let outcome: unknown;
@@ -192,7 +239,7 @@ describe("useDomEditTextCommits", () => {
192
239
  outcome = await hook.handleDomStyleCommit("color", "red");
193
240
  });
194
241
 
195
- expect(outcome).toEqual({ ok: true });
242
+ expect(outcome).toEqual({ ok: true, persistence });
196
243
  });
197
244
 
198
245
  it("declines a style commit with no selection, without reaching the writer", async () => {
@@ -286,4 +333,41 @@ describe("useDomEditTextCommits", () => {
286
333
  expect(outcome).toEqual({ ok: false, reason: "no-selection" });
287
334
  expect(persistDomEditOperations).not.toHaveBeenCalled();
288
335
  });
336
+
337
+ it("preserves text persistence evidence for an explicit selection", async () => {
338
+ const { iframe, element: ambientElement } = previewElement(
339
+ "<div id='ambient'>Ambient</div><div id='agent'>Agent</div>",
340
+ "ambient",
341
+ );
342
+ const agentElement = iframe.contentDocument?.getElementById("agent") as HTMLElement;
343
+ const ambientSelection = selectionFor(ambientElement);
344
+ const agentSelection = selectionFor(agentElement);
345
+ const persistence = {
346
+ sourceFile: "index.html",
347
+ version: '"sha256:text"',
348
+ changed: true,
349
+ } as const;
350
+ const persistDomEditOperations = vi.fn().mockResolvedValue(persistence);
351
+ const hook = renderTextCommitHook(
352
+ commitParams({
353
+ previewIframeRef: { current: iframe },
354
+ domEditSelection: ambientSelection,
355
+ persistDomEditOperations,
356
+ }),
357
+ );
358
+
359
+ let outcome: unknown;
360
+ await act(async () => {
361
+ outcome = await hook.handleDomTextCommitForSelection(agentSelection, "Edited", "self");
362
+ });
363
+
364
+ expect(outcome).toEqual({ ok: true, persistence });
365
+ expect(persistDomEditOperations).toHaveBeenCalledWith(
366
+ agentSelection,
367
+ expect.any(Array),
368
+ expect.any(Object),
369
+ );
370
+ expect(ambientElement.textContent).toBe("Ambient");
371
+ expect(agentElement.textContent).toBe("Edited");
372
+ });
289
373
  });
@@ -13,11 +13,9 @@ import {
13
13
  import {
14
14
  buildDomEditRichTextPatchOperation,
15
15
  buildDomEditStylePatchOperation,
16
- buildDomEditTextPatchOperation,
17
16
  findElementForSelection,
18
17
  getDomEditTargetKey,
19
18
  isTextEditableSelection,
20
- serializeDomEditTextFields,
21
19
  buildDefaultDomEditTextField,
22
20
  type DomEditTextField,
23
21
  type DomEditSelection,
@@ -25,11 +23,10 @@ import {
25
23
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
26
24
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
27
25
  import { canEditElementTextInline } from "../components/editor/domEditInlineText";
28
- import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps";
26
+ import { buildNextDomTextFields, planDomTextCommit } from "./domEditTextCommitPlan";
29
27
  import { reportDomEditPersistFailure } from "./domEditPersistFailure";
30
28
  import {
31
29
  bumpDomEditCommitMapVersion,
32
- bumpDomEditCommitVersion,
33
30
  domEditCommitDeclined,
34
31
  runDomEditCommit,
35
32
  runReportedDomEditCommit,
@@ -58,13 +55,6 @@ export interface UseDomEditTextCommitsParams {
58
55
  resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
59
56
  }
60
57
 
61
- interface DomTextCommitPlan {
62
- usesSerializedTextFields: boolean;
63
- nextContent: string;
64
- childOperations: PatchOperation[] | null;
65
- operations: PatchOperation[];
66
- }
67
-
68
58
  function canCommitInlineTextSelection(selection: DomEditSelection, element: HTMLElement): boolean {
69
59
  if (selection.isCompositionHost || selection.isInsideLockedComposition) return false;
70
60
  return canEditElementTextInline(element);
@@ -97,51 +87,6 @@ function buildDomStyleCommitOperations(
97
87
  return operations;
98
88
  }
99
89
 
100
- function buildNextDomTextFields(
101
- textFields: DomEditTextField[],
102
- value: string,
103
- fieldKey?: string,
104
- ): DomEditTextField[] {
105
- if (textFields.length === 0) return [];
106
- return textFields.map((field) => (field.key === fieldKey ? { ...field, value } : field));
107
- }
108
-
109
- function planDomTextCommit(
110
- originalTextFields: DomEditTextField[],
111
- nextTextFields: DomEditTextField[],
112
- plainTextContent: string,
113
- ): DomTextCommitPlan {
114
- const usesSerializedTextFields =
115
- nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child");
116
- const nextContent = usesSerializedTextFields
117
- ? serializeDomEditTextFields(nextTextFields)
118
- : plainTextContent;
119
- const childOperations = usesSerializedTextFields
120
- ? buildTextFieldChildOperations(originalTextFields, nextTextFields)
121
- : null;
122
- // Per-child operations when the layers still line up one-for-one, and the
123
- // element's whole markup when they do not.
124
- //
125
- // `buildTextFieldChildOperations` can only address children that already
126
- // exist, so it returns null for any change in how many there are — which is
127
- // every delete and every add. That used to end here with "Couldn't save this
128
- // text structure change": the panel offered a remove button and an Add text
129
- // field row, and neither could ever save. A structure change has one honest
130
- // operation, which is to write the structure.
131
- const operations =
132
- childOperations ??
133
- (usesSerializedTextFields
134
- ? [buildDomEditRichTextPatchOperation(nextContent)]
135
- : [buildDomEditTextPatchOperation(nextContent)]);
136
-
137
- return {
138
- usesSerializedTextFields,
139
- nextContent,
140
- childOperations,
141
- operations,
142
- };
143
- }
144
-
145
90
  async function resyncDomTextSelectionFromPreview(
146
91
  doc: Document | null | undefined,
147
92
  selection: DomEditSelection,
@@ -170,8 +115,8 @@ export function useDomEditTextCommits({
170
115
  persistDomEditOperations,
171
116
  resolveImportedFontAsset,
172
117
  }: UseDomEditTextCommitsParams) {
173
- const domTextCommitVersionRef = useRef(0);
174
- const domStyleCommitVersionRef = useRef(new Map<string, number>());
118
+ const domTextCommitVersionRef = useRef(new Map<string, symbol>());
119
+ const domStyleCommitVersionRef = useRef(new Map<string, symbol>());
175
120
 
176
121
  const {
177
122
  handleDomAttributeCommit,
@@ -188,15 +133,18 @@ export function useDomEditTextCommits({
188
133
  persistDomEditOperations,
189
134
  });
190
135
 
191
- const handleDomStyleCommit = useCallback(
192
- async (property: string, value: string): Promise<DomEditCommitOutcome> => {
193
- if (!domEditSelection) return domEditCommitDeclined("no-selection");
136
+ const handleDomStyleCommitForSelection = useCallback(
137
+ async (
138
+ selection: DomEditSelection,
139
+ property: string,
140
+ value: string,
141
+ ): Promise<DomEditCommitOutcome> => {
194
142
  if (isManualGeometryStyleProperty(property))
195
143
  return domEditCommitDeclined("geometry-property");
196
- if (!domEditSelection.capabilities.canEditStyles) {
144
+ if (!selection.capabilities.canEditStyles) {
197
145
  return domEditCommitDeclined("styles-not-editable");
198
146
  }
199
- const styleCommitKey = `${getDomEditTargetKey(domEditSelection)}:${property}`;
147
+ const styleCommitKey = `${getDomEditTargetKey(selection)}:${property}`;
200
148
  const isLatestStyleCommit = bumpDomEditCommitMapVersion(
201
149
  domStyleCommitVersionRef.current,
202
150
  styleCommitKey,
@@ -219,7 +167,7 @@ export function useDomEditTextCommits({
219
167
  return runReportedDomEditCommit({
220
168
  capture: () => {
221
169
  if (!doc) return;
222
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
170
+ const el = findElementForSelection(doc, selection, activeCompPath);
223
171
  if (!el) return;
224
172
  editedElement = el;
225
173
  previousInlineValue = el.style.getPropertyValue(property);
@@ -238,7 +186,7 @@ export function useDomEditTextCommits({
238
186
  }
239
187
  },
240
188
  persist: () =>
241
- persistDomEditOperations(domEditSelection, operations, {
189
+ persistDomEditOperations(selection, operations, {
242
190
  label: "Edit layer style",
243
191
  skipRefresh,
244
192
  prepareContent: importedFont
@@ -255,15 +203,14 @@ export function useDomEditTextCommits({
255
203
  editedElement.style.setProperty(property, previousInlineValue);
256
204
  }
257
205
  },
258
- onError: (error) =>
259
- reportDomEditPersistFailure(domEditSelection, operations, error, showToast),
206
+ onError: (error) => reportDomEditPersistFailure(selection, operations, error, showToast),
260
207
  shouldResync: isLatestStyleCommit,
261
- resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
208
+ resync: () => refreshDomEditSelectionFromPreview(selection),
209
+ onFinally: isLatestStyleCommit.release,
262
210
  });
263
211
  },
264
212
  [
265
213
  activeCompPath,
266
- domEditSelection,
267
214
  persistDomEditOperations,
268
215
  refreshDomEditSelectionFromPreview,
269
216
  resolveImportedFontAsset,
@@ -272,15 +219,29 @@ export function useDomEditTextCommits({
272
219
  ],
273
220
  );
274
221
 
275
- const handleDomTextCommit = useCallback(
276
- async (value: string, fieldKey?: string): Promise<DomEditCommitOutcome> => {
277
- if (!domEditSelection) return domEditCommitDeclined("no-selection");
278
- if (!isTextEditableSelection(domEditSelection)) {
222
+ const handleDomStyleCommit = useCallback(
223
+ (property: string, value: string): Promise<DomEditCommitOutcome> =>
224
+ domEditSelection
225
+ ? handleDomStyleCommitForSelection(domEditSelection, property, value)
226
+ : Promise.resolve(domEditCommitDeclined("no-selection")),
227
+ [domEditSelection, handleDomStyleCommitForSelection],
228
+ );
229
+
230
+ const handleDomTextCommitForSelection = useCallback(
231
+ async (
232
+ selection: DomEditSelection,
233
+ value: string,
234
+ fieldKey?: string,
235
+ ): Promise<DomEditCommitOutcome> => {
236
+ if (!isTextEditableSelection(selection)) {
279
237
  return domEditCommitDeclined("not-text-editable");
280
238
  }
281
- const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
282
- const nextTextFields = buildNextDomTextFields(domEditSelection.textFields, value, fieldKey);
283
- const textCommit = planDomTextCommit(domEditSelection.textFields, nextTextFields, value);
239
+ const isLatestTextCommit = bumpDomEditCommitMapVersion(
240
+ domTextCommitVersionRef.current,
241
+ getDomEditTargetKey(selection),
242
+ );
243
+ const nextTextFields = buildNextDomTextFields(selection.textFields, value, fieldKey);
244
+ const textCommit = planDomTextCommit(selection.textFields, nextTextFields, value);
284
245
  const iframe = previewIframeRef.current;
285
246
  const doc = iframe?.contentDocument;
286
247
  let editedElement: HTMLElement | null = null;
@@ -289,7 +250,7 @@ export function useDomEditTextCommits({
289
250
  return runReportedDomEditCommit({
290
251
  capture: () => {
291
252
  if (!doc) return;
292
- const el = findElementForSelection(doc, domEditSelection, activeCompPath);
253
+ const el = findElementForSelection(doc, selection, activeCompPath);
293
254
  if (!el) return;
294
255
  editedElement = el;
295
256
  previousInnerHtml = el.innerHTML;
@@ -302,42 +263,49 @@ export function useDomEditTextCommits({
302
263
  editedElement.textContent = value;
303
264
  }
304
265
  },
305
- persist: async () => {
306
- await persistDomEditOperations(domEditSelection, textCommit.operations, {
266
+ persist: () =>
267
+ persistDomEditOperations(selection, textCommit.operations, {
307
268
  label: "Edit text",
308
269
  skipRefresh: true,
309
270
  shouldSave: isLatestTextCommit,
310
- });
311
- },
271
+ }),
312
272
  shouldRevert: () => isLatestTextCommit(),
313
273
  revert: () => {
314
274
  if (!editedElement || previousInnerHtml === null) return;
315
275
  editedElement.innerHTML = previousInnerHtml;
316
276
  },
317
277
  onError: (error) =>
318
- reportDomEditPersistFailure(domEditSelection, textCommit.operations, error, showToast),
278
+ reportDomEditPersistFailure(selection, textCommit.operations, error, showToast),
319
279
  shouldResync: isLatestTextCommit,
320
280
  resync: () =>
321
281
  resyncDomTextSelectionFromPreview(
322
282
  doc,
323
- domEditSelection,
283
+ selection,
324
284
  activeCompPath,
325
285
  buildDomSelectionFromTarget,
326
286
  applyDomSelection,
327
287
  ),
288
+ onFinally: isLatestTextCommit.release,
328
289
  });
329
290
  },
330
291
  [
331
292
  activeCompPath,
332
293
  applyDomSelection,
333
294
  buildDomSelectionFromTarget,
334
- domEditSelection,
335
295
  persistDomEditOperations,
336
296
  previewIframeRef,
337
297
  showToast,
338
298
  ],
339
299
  );
340
300
 
301
+ const handleDomTextCommit = useCallback(
302
+ (value: string, fieldKey?: string): Promise<DomEditCommitOutcome> =>
303
+ domEditSelection
304
+ ? handleDomTextCommitForSelection(domEditSelection, value, fieldKey)
305
+ : Promise.resolve(domEditCommitDeclined("no-selection")),
306
+ [domEditSelection, handleDomTextCommitForSelection],
307
+ );
308
+
341
309
  /**
342
310
  * Persist an element's own markup, for a text edit that styled part of it.
343
311
  *
@@ -366,7 +334,10 @@ export function useDomEditTextCommits({
366
334
  // A preview reload replaces the document. Never resolve this commit onto
367
335
  // the replacement node: it did not own the edit or its rollback snapshot.
368
336
  if (!ownsCurrentPreviewElement(domEditSelection, element, doc)) return;
369
- const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
337
+ const isLatestTextCommit = bumpDomEditCommitMapVersion(
338
+ domTextCommitVersionRef.current,
339
+ getDomEditTargetKey(domEditSelection),
340
+ );
370
341
  const operations = [buildDomEditRichTextPatchOperation(html)];
371
342
  let appliedHtml = "";
372
343
 
@@ -404,6 +375,7 @@ export function useDomEditTextCommits({
404
375
  buildDomSelectionFromTarget,
405
376
  applyDomSelection,
406
377
  ),
378
+ onFinally: isLatestTextCommit.release,
407
379
  });
408
380
  },
409
381
  [
@@ -423,7 +395,10 @@ export function useDomEditTextCommits({
423
395
  nextTextFields: DomEditTextField[],
424
396
  options?: { importedFont?: ImportedFontAsset | null },
425
397
  ) => {
426
- const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
398
+ const isLatestTextCommit = bumpDomEditCommitMapVersion(
399
+ domTextCommitVersionRef.current,
400
+ getDomEditTargetKey(selection),
401
+ );
427
402
  const textCommit = planDomTextCommit(
428
403
  selection.textFields,
429
404
  nextTextFields,
@@ -476,6 +451,7 @@ export function useDomEditTextCommits({
476
451
  buildDomSelectionFromTarget,
477
452
  applyDomSelection,
478
453
  ),
454
+ onFinally: isLatestTextCommit.release,
479
455
  });
480
456
  },
481
457
  [
@@ -579,12 +555,14 @@ export function useDomEditTextCommits({
579
555
 
580
556
  return {
581
557
  handleDomStyleCommit,
558
+ handleDomStyleCommitForSelection,
582
559
  handleDomAttributeCommit,
583
560
  handleDomAttributeLiveCommit,
584
561
  handleDomAttributeQuietCommit,
585
562
  handleDomHtmlAttributeCommit,
586
563
  handleDomAttributesCommit,
587
564
  handleDomTextCommit,
565
+ handleDomTextCommitForSelection,
588
566
  handleDomRichTextCommit,
589
567
  commitDomTextFields,
590
568
  handleDomTextFieldStyleCommit,
@@ -223,7 +223,7 @@ export function useDomEditWiring({
223
223
  // ── Telemetry & fallback ──
224
224
 
225
225
  const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
226
- const makeFetchFallback = useGsapAnimationFetchFallback(projectId, gsapSourceFile);
226
+ const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
227
227
 
228
228
  // ── GSAP selection handlers ──
229
229
 
@@ -210,6 +210,7 @@ export function useDomSelection({
210
210
  options?: {
211
211
  preferClipAncestor?: boolean;
212
212
  skipSourceProbe?: boolean;
213
+ exactTarget?: boolean;
213
214
  // Override the drill-in scope (used by canvas double-click to resolve the
214
215
  // child inside a group before the activeGroupElement state has re-rendered).
215
216
  activeGroupElement?: HTMLElement | null;
@@ -220,6 +221,7 @@ export function useDomSelection({
220
221
  isMasterView,
221
222
  preferClipAncestor: options?.preferClipAncestor,
222
223
  skipSourceProbe: options?.skipSourceProbe,
224
+ exactTarget: options?.exactTarget,
223
225
  activeGroupElement:
224
226
  options && "activeGroupElement" in options
225
227
  ? options.activeGroupElement
@@ -16,6 +16,8 @@ export interface ResolveDomSelectionOptions {
16
16
  preferClipAncestor?: boolean;
17
17
  skipSourceProbe?: boolean;
18
18
  activeGroupElement?: HTMLElement | null;
19
+ /** Resolve this node itself instead of applying human group-capture behavior. */
20
+ exactTarget?: boolean;
19
21
  }
20
22
 
21
23
  export interface UseDomSelectionParams {
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
6
6
  import { markStudioWriteToken, resetStudioWriteTokens } from "../utils/studioFileVersion";
7
+ import { markSelfWrite, resetSelfWriteRegistry } from "./sdkSelfWriteRegistry";
7
8
  import {
8
9
  useExternalFileChangeCoordinator,
9
10
  type ExternalFileChangeCoordinatorHandle,
@@ -29,6 +30,7 @@ async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
29
30
  discardPendingChanges: vi.fn(),
30
31
  overwriteConflict: vi.fn(async () => undefined),
31
32
  readProjectFile: vi.fn(async () => "external"),
33
+ onAcceptedPersistedFileChange: vi.fn(),
32
34
  };
33
35
  const options = { ...defaults, ...overrides };
34
36
  const root = createRoot(document.createElement("div"));
@@ -45,6 +47,7 @@ describe("external file change coordinator", () => {
45
47
  beforeEach(() => {
46
48
  handler = null;
47
49
  resetStudioWriteTokens();
50
+ resetSelfWriteRegistry();
48
51
  vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", {
49
52
  on: (_event: string, next: HotHandler) => {
50
53
  handler = next;
@@ -67,23 +70,61 @@ describe("external file change coordinator", () => {
67
70
  order.push("drain");
68
71
  return { status: "clean" };
69
72
  },
73
+ onAcceptedPersistedFileChange: () => order.push("thumbnail"),
70
74
  reloadPreview: () => order.push("preview"),
71
75
  reloadSdkSession: () => order.push("sdk"),
72
76
  });
73
77
  await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
74
- expect(order).toEqual(["drain", "preview", "sdk"]);
78
+ expect(order).toEqual(["drain", "thumbnail", "preview", "sdk"]);
75
79
  expect(captured.handle?.blocked).toBeNull();
76
80
  });
77
81
 
78
- it("suppresses an exact Studio write receipt", async () => {
82
+ it("refreshes thumbnails once but suppresses Preview reload for an exact Studio write", async () => {
79
83
  const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
80
84
  const reloadPreview = vi.fn();
81
85
  const reloadSdkSession = vi.fn();
82
- await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
86
+ const onAcceptedPersistedFileChange = vi.fn();
87
+ await mountCoordinator({
88
+ drainPendingChanges,
89
+ reloadPreview,
90
+ reloadSdkSession,
91
+ onAcceptedPersistedFileChange,
92
+ });
83
93
  markStudioWriteToken("studio-write-1");
94
+ const payload = {
95
+ path: "index.html",
96
+ content: "studio",
97
+ version: "v2",
98
+ writeToken: "studio-write-1",
99
+ };
100
+ await act(async () => handler?.(payload));
101
+ await act(async () => handler?.(payload));
102
+ expect(drainPendingChanges).not.toHaveBeenCalled();
103
+ expect(reloadPreview).not.toHaveBeenCalled();
104
+ expect(reloadSdkSession).not.toHaveBeenCalled();
105
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
106
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
107
+ });
108
+
109
+ it("accepts a matching content echo without a write token and suppresses every reload", async () => {
110
+ const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
111
+ const reloadPreview = vi.fn();
112
+ const reloadSdkSession = vi.fn();
113
+ const onAcceptedPersistedFileChange = vi.fn();
114
+ await mountCoordinator({
115
+ drainPendingChanges,
116
+ reloadPreview,
117
+ reloadSdkSession,
118
+ onAcceptedPersistedFileChange,
119
+ });
120
+ markSelfWrite("index.html", "studio content");
121
+
84
122
  await act(async () =>
85
- handler?.({ path: "index.html", content: "studio", writeToken: "studio-write-1" }),
123
+ handler?.({ path: "index.html", content: "studio content", version: "v2" }),
86
124
  );
125
+
126
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
127
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
87
128
  expect(drainPendingChanges).not.toHaveBeenCalled();
88
129
  expect(reloadPreview).not.toHaveBeenCalled();
89
130
  expect(reloadSdkSession).not.toHaveBeenCalled();
@@ -115,15 +156,23 @@ describe("external file change coordinator", () => {
115
156
  attemptedContent: "studio",
116
157
  });
117
158
  const persistConflictSnapshot = vi.fn(async () => undefined);
159
+ const onAcceptedPersistedFileChange = vi.fn();
118
160
  const { captured, options } = await mountCoordinator({
119
161
  drainPendingChanges: async () => ({ status: "conflict", error: conflict }),
120
162
  persistConflictSnapshot,
163
+ onAcceptedPersistedFileChange,
121
164
  });
122
165
  await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
123
166
  expect(persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
124
167
  expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
125
168
  expect(options.reloadPreview).not.toHaveBeenCalled();
126
169
  expect(options.reloadSdkSession).not.toHaveBeenCalled();
170
+ expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
171
+
172
+ await act(async () => captured.handle?.useExternalFile());
173
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
174
+ expect(options.reloadPreview).toHaveBeenCalledOnce();
175
+ expect(options.reloadSdkSession).toHaveBeenCalledOnce();
127
176
  });
128
177
 
129
178
  it("ignores stale drain completion after a newer generation", async () => {
@@ -165,6 +214,7 @@ describe("external file change coordinator", () => {
165
214
  const failure = new Error("network unavailable");
166
215
  const persistFailureSnapshot = vi.fn(async () => undefined);
167
216
  const deleteConflictSnapshot = vi.fn(async () => undefined);
217
+ const onAcceptedPersistedFileChange = vi.fn();
168
218
  const { captured } = await mountCoordinator({
169
219
  drainPendingChanges: vi
170
220
  .fn()
@@ -173,6 +223,7 @@ describe("external file change coordinator", () => {
173
223
  getPendingCandidate: () => ({ path: "index.html", content: "final local candidate" }),
174
224
  persistFailureSnapshot,
175
225
  deleteConflictSnapshot,
226
+ onAcceptedPersistedFileChange,
176
227
  });
177
228
  await act(async () => handler?.({ path: "index.html" }));
178
229
  expect(captured.handle?.blocked).toMatchObject({
@@ -188,15 +239,19 @@ describe("external file change coordinator", () => {
188
239
  null,
189
240
  failure,
190
241
  );
242
+ expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
191
243
  await act(async () => captured.handle?.retry());
192
244
  expect(deleteConflictSnapshot).toHaveBeenCalledWith("project-a", "index.html");
245
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
193
246
  });
194
247
 
195
248
  it("restores and overwrites from a durable failed draft", async () => {
196
249
  const overwriteConflict = vi.fn(async () => undefined);
250
+ const onAcceptedPersistedFileChange = vi.fn();
197
251
  const { captured } = await mountCoordinator({
198
252
  recoveryFilePath: "index.html",
199
253
  overwriteConflict,
254
+ onAcceptedPersistedFileChange,
200
255
  loadConflictSnapshot: vi.fn(async () => ({
201
256
  kind: "failed" as const,
202
257
  projectId: "project-a",
@@ -217,5 +272,17 @@ describe("external file change coordinator", () => {
217
272
  expect(overwriteConflict).toHaveBeenCalledWith(
218
273
  expect.objectContaining({ attemptedContent: "recover me", currentVersion: "v2" }),
219
274
  );
275
+ expect(onAcceptedPersistedFileChange).not.toHaveBeenCalled();
276
+
277
+ markStudioWriteToken("keep-studio-write");
278
+ await act(async () =>
279
+ handler?.({
280
+ path: "index.html",
281
+ content: "recover me",
282
+ version: "v3",
283
+ writeToken: "keep-studio-write",
284
+ }),
285
+ );
286
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
220
287
  });
221
288
  });