@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
@@ -8,7 +8,9 @@ import type { DomEditSelection, DomEditTextField } from "../components/editor/do
8
8
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
9
9
  import { usePlayerStore } from "../player";
10
10
  import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
11
+ import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
11
12
  import { trackStudioEvent } from "../utils/studioTelemetry";
13
+ import type { CutoverResult } from "../utils/sdkCutover";
12
14
  import { useDomEditCommits } from "./useDomEditCommits";
13
15
 
14
16
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
@@ -22,6 +24,8 @@ interface PatchResponseBody {
22
24
  changed?: boolean;
23
25
  matched?: boolean;
24
26
  content?: string;
27
+ path?: string;
28
+ version?: string;
25
29
  }
26
30
 
27
31
  interface RenderedDomEditCommits {
@@ -35,6 +39,9 @@ interface RenderedDomEditCommits {
35
39
  interface RenderDomEditCommitsOptions {
36
40
  importedFontAssets?: ImportedFontAsset[];
37
41
  writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
42
+ onTrySdkPersist?: () => Promise<CutoverResult>;
43
+ queueDomEditSave?: <T>(save: () => Promise<T>) => Promise<T>;
44
+ projectIdRef?: MutableRefObject<string | null>;
38
45
  }
39
46
 
40
47
  type FetchHandler = (
@@ -201,7 +208,7 @@ function renderDomEditCommits(
201
208
  const showToast = makeShowToast();
202
209
  const recordEdit = vi.fn(async () => {});
203
210
  const previewIframeRef: MutableRefObject<HTMLIFrameElement | null> = { current: iframe };
204
- const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
211
+ const projectIdRef: MutableRefObject<string | null> = options.projectIdRef ?? { current: "p1" };
205
212
  const domEditSaveTimestampRef: MutableRefObject<number> = { current: 0 };
206
213
  const reloadPreview = vi.fn();
207
214
 
@@ -210,7 +217,7 @@ function renderDomEditCommits(
210
217
  activeCompPath: "index.html",
211
218
  previewIframeRef,
212
219
  showToast,
213
- queueDomEditSave: async (save) => save(),
220
+ queueDomEditSave: options.queueDomEditSave ?? (async (save) => save()),
214
221
  writeProjectFile: options.writeProjectFile ?? (async () => {}),
215
222
  domEditSaveTimestampRef,
216
223
  editHistory: { recordEdit },
@@ -224,6 +231,7 @@ function renderDomEditCommits(
224
231
  clearDomSelection: vi.fn(),
225
232
  refreshDomEditSelectionFromPreview: vi.fn(),
226
233
  buildDomSelectionFromTarget: vi.fn(async () => null),
234
+ onTrySdkPersist: options.onTrySdkPersist,
227
235
  });
228
236
  return null;
229
237
  }
@@ -772,11 +780,13 @@ async function commitStyleAgainst(response: Parameters<typeof stubPatchFetch>[0]
772
780
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
773
781
  const { iframe, element } = createPreviewElement();
774
782
  const rendered = renderDomEditCommits(createSelection(element), iframe);
783
+ let outcome: Awaited<ReturnType<typeof rendered.hook.handleDomStyleCommit>> | undefined;
775
784
  await act(async () => {
776
- await rendered.hook.handleDomStyleCommit("color", "blue");
785
+ outcome = await rendered.hook.handleDomStyleCommit("color", "blue");
777
786
  });
778
787
  return {
779
788
  element,
789
+ outcome,
780
790
  rendered,
781
791
  warnSpy,
782
792
  cleanup: () => {
@@ -944,10 +954,12 @@ describe("useDomEditCommits style persist handling", () => {
944
954
  });
945
955
 
946
956
  it("warns without a toast when the server matched the element but reported no change", async () => {
947
- const { rendered, warnSpy, cleanup } = await commitStyleAgainst({
957
+ const { rendered, outcome, warnSpy, cleanup } = await commitStyleAgainst({
948
958
  ok: true,
949
959
  changed: false,
950
960
  matched: true,
961
+ path: "index.html",
962
+ version: '"sha256:current"',
951
963
  });
952
964
 
953
965
  try {
@@ -956,6 +968,14 @@ describe("useDomEditCommits style persist handling", () => {
956
968
  "[Studio] DOM edit persist no-op",
957
969
  expect.objectContaining({ operations: "inline-style:color" }),
958
970
  );
971
+ expect(outcome).toEqual({
972
+ ok: true,
973
+ persistence: {
974
+ sourceFile: "index.html",
975
+ version: '"sha256:current"',
976
+ changed: false,
977
+ },
978
+ });
959
979
  } finally {
960
980
  cleanup();
961
981
  }
@@ -976,22 +996,203 @@ describe("useDomEditCommits style persist handling", () => {
976
996
  });
977
997
 
978
998
  it("keeps the optimistic style and records history when the patch succeeds", async () => {
979
- const { element, rendered, cleanup } = await commitStyleAgainst({
999
+ const { element, rendered, outcome, cleanup } = await commitStyleAgainst({
980
1000
  ok: true,
981
1001
  changed: true,
982
1002
  matched: true,
983
1003
  content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1004
+ path: "index.html",
1005
+ version: '"sha256:after"',
984
1006
  });
985
1007
 
986
1008
  try {
987
1009
  expect(rendered.showToast).not.toHaveBeenCalled();
988
1010
  expect(element.style.getPropertyValue("color")).toBe("blue");
989
1011
  expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
1012
+ expect(outcome).toEqual({
1013
+ ok: true,
1014
+ persistence: {
1015
+ sourceFile: "index.html",
1016
+ version: '"sha256:after"',
1017
+ changed: true,
1018
+ },
1019
+ });
990
1020
  } finally {
991
1021
  cleanup();
992
1022
  }
993
1023
  });
994
1024
 
1025
+ it("serializes the full read, write, and history transaction across overlapping commits", async () => {
1026
+ const firstPatch = createDeferred<Response>();
1027
+ let readCount = 0;
1028
+ let patchCount = 0;
1029
+ vi.stubGlobal(
1030
+ "fetch",
1031
+ vi.fn(async (input: Parameters<typeof fetch>[0]) => {
1032
+ const url = requestUrl(input);
1033
+ if (url.includes("/api/projects/p1/files/")) {
1034
+ readCount += 1;
1035
+ return jsonResponse({
1036
+ content:
1037
+ readCount === 1
1038
+ ? '<div data-hf-id="hf-card" style="color: red">Card</div>'
1039
+ : '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1040
+ });
1041
+ }
1042
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
1043
+ patchCount += 1;
1044
+ if (patchCount === 1) return firstPatch.promise;
1045
+ return jsonResponse({
1046
+ ok: true,
1047
+ changed: true,
1048
+ matched: true,
1049
+ content: '<div data-hf-id="hf-card" style="color: green">Card</div>',
1050
+ path: "index.html",
1051
+ version: '"sha256:green"',
1052
+ });
1053
+ }
1054
+ throw new Error(`Unexpected fetch: ${url}`);
1055
+ }),
1056
+ );
1057
+ const queue = createDomEditSaveQueue();
1058
+ const { iframe, element } = createPreviewElement();
1059
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
1060
+ queueDomEditSave: queue.enqueue,
1061
+ });
1062
+
1063
+ try {
1064
+ const first = rendered.hook.handleDomStyleCommit("color", "blue");
1065
+ await flushAsyncWork();
1066
+ const second = rendered.hook.handleDomStyleCommit("color", "green");
1067
+ await flushAsyncWork();
1068
+
1069
+ expect(readCount).toBe(1);
1070
+ expect(patchCount).toBe(1);
1071
+
1072
+ firstPatch.resolve(
1073
+ jsonResponse({
1074
+ ok: true,
1075
+ changed: true,
1076
+ matched: true,
1077
+ content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1078
+ path: "index.html",
1079
+ version: '"sha256:blue"',
1080
+ }),
1081
+ );
1082
+ await first;
1083
+ await second;
1084
+
1085
+ expect(readCount).toBe(2);
1086
+ expect(patchCount).toBe(2);
1087
+ expect(rendered.recordEdit).toHaveBeenNthCalledWith(
1088
+ 1,
1089
+ expect.objectContaining({
1090
+ files: {
1091
+ "index.html": expect.objectContaining({
1092
+ before: expect.stringContaining("color: red"),
1093
+ after: expect.stringContaining("color: blue"),
1094
+ }),
1095
+ },
1096
+ }),
1097
+ );
1098
+ expect(rendered.recordEdit).toHaveBeenNthCalledWith(
1099
+ 2,
1100
+ expect.objectContaining({
1101
+ files: {
1102
+ "index.html": expect.objectContaining({
1103
+ before: expect.stringContaining("color: blue"),
1104
+ after: expect.stringContaining("color: green"),
1105
+ }),
1106
+ },
1107
+ }),
1108
+ );
1109
+ } finally {
1110
+ queue.destroy();
1111
+ rendered.cleanup();
1112
+ }
1113
+ });
1114
+
1115
+ it("refuses queued persistence after the active project changes", async () => {
1116
+ const firstPatch = createDeferred<Response>();
1117
+ const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
1118
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]) => {
1119
+ const url = requestUrl(input);
1120
+ if (url.includes("/api/projects/p1/files/")) {
1121
+ return jsonResponse({
1122
+ content: '<div data-hf-id="hf-card" style="color: red">Card</div>',
1123
+ });
1124
+ }
1125
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
1126
+ return firstPatch.promise;
1127
+ }
1128
+ throw new Error(`Unexpected fetch: ${url}`);
1129
+ });
1130
+ vi.stubGlobal("fetch", fetchMock);
1131
+ const queue = createDomEditSaveQueue();
1132
+ const { iframe, element } = createPreviewElement();
1133
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
1134
+ queueDomEditSave: queue.enqueue,
1135
+ projectIdRef,
1136
+ });
1137
+
1138
+ try {
1139
+ const first = rendered.hook.handleDomStyleCommit("color", "blue");
1140
+ await flushAsyncWork();
1141
+ const second = rendered.hook.handleDomStyleCommit("color", "green");
1142
+ projectIdRef.current = "p2";
1143
+ firstPatch.resolve(
1144
+ jsonResponse({
1145
+ ok: true,
1146
+ changed: true,
1147
+ matched: true,
1148
+ content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1149
+ path: "index.html",
1150
+ version: '"sha256:blue"',
1151
+ }),
1152
+ );
1153
+
1154
+ await first;
1155
+ await expect(second).resolves.toMatchObject({
1156
+ ok: false,
1157
+ reason: "persist-failed",
1158
+ });
1159
+ expect(fetchMock.mock.calls.some(([input]) => requestUrl(input).includes("/p2/"))).toBe(
1160
+ false,
1161
+ );
1162
+ expect(fetchMock).toHaveBeenCalledTimes(2);
1163
+ } finally {
1164
+ queue.destroy();
1165
+ rendered.cleanup();
1166
+ }
1167
+ });
1168
+
1169
+ it("preserves the SDK cutover version as the style commit's durable evidence", async () => {
1170
+ const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
1171
+ const { iframe, element } = createPreviewElement();
1172
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
1173
+ onTrySdkPersist: async () => ({ status: "committed", version: "sdk-version-2" }),
1174
+ });
1175
+
1176
+ try {
1177
+ let outcome: Awaited<ReturnType<typeof rendered.hook.handleDomStyleCommit>> | undefined;
1178
+ await act(async () => {
1179
+ outcome = await rendered.hook.handleDomStyleCommit("color", "blue");
1180
+ });
1181
+
1182
+ expect(outcome).toEqual({
1183
+ ok: true,
1184
+ persistence: { sourceFile: "index.html", version: "sdk-version-2", changed: true },
1185
+ });
1186
+ expect(
1187
+ fetchMock.mock.calls.some(([input]) =>
1188
+ requestUrl(input).includes("/file-mutations/patch-element/"),
1189
+ ),
1190
+ ).toBe(false);
1191
+ } finally {
1192
+ rendered.cleanup();
1193
+ }
1194
+ });
1195
+
995
1196
  it("keeps a newer style value when an older overlapping commit later fails", async () => {
996
1197
  const firstPatch = createDeferred<Response>();
997
1198
  const secondPatch = createDeferred<Response>();
@@ -1466,9 +1667,7 @@ describe("useDomEditCommits attribute persist handling", () => {
1466
1667
  // optimistic apply) and succeeds before the older one rejects. Without the
1467
1668
  // per-key version guard, the stale rejection would revert to the older
1468
1669
  // commit's own previousValue (null) and stomp the newer commit's value.
1469
- const firstCommit = act(async () => {
1470
- await rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
1471
- });
1670
+ const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
1472
1671
  await act(async () => {
1473
1672
  await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
1474
1673
  });
@@ -32,7 +32,7 @@ import {
32
32
  patchElementBatches,
33
33
  readErrorResponseBody,
34
34
  } from "./useDomEditCommitsHelpers";
35
- import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
35
+ import type { CutoverResult } from "../utils/sdkCutover";
36
36
  import { studioWriteHeaders } from "../utils/studioFileVersion";
37
37
  interface RecordEditInput {
38
38
  label: string;
@@ -139,11 +139,18 @@ export function useDomEditCommits({
139
139
  const reportedUnresolvableRef = useRef(new Set<string>());
140
140
 
141
141
  // fallow-ignore-next-line complexity
142
- const persistDomEditOperations: PersistDomEditOperations = useCallback(
142
+ const performPersistDomEditOperations = useCallback(
143
143
  // fallow-ignore-next-line complexity
144
- async (selection, operations, options) => {
145
- const pid = projectIdRef.current;
146
- if (!pid) throw new Error("No active project");
144
+ async (
145
+ selection: DomEditSelection,
146
+ operations: PatchOperation[],
147
+ options: Parameters<PersistDomEditOperations>[2],
148
+ expectedProjectId: string,
149
+ ) => {
150
+ if (projectIdRef.current !== expectedProjectId) {
151
+ throw new Error("Active project changed before the edit could be saved");
152
+ }
153
+ const pid = expectedProjectId;
147
154
  if (options?.shouldSave && !options.shouldSave()) return;
148
155
 
149
156
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -160,6 +167,10 @@ export function useDomEditCommits({
160
167
  throw new Error(`Missing file contents for ${targetPath}`);
161
168
  }
162
169
 
170
+ if (projectIdRef.current !== expectedProjectId) {
171
+ throw new Error("Active project changed before the edit could be saved");
172
+ }
173
+
163
174
  if (options?.shouldSave && !options.shouldSave()) return;
164
175
 
165
176
  // Validate layout values BEFORE any persist path runs. The SDK cutover
@@ -186,10 +197,11 @@ export function useDomEditCommits({
186
197
  coalesceKey: options?.coalesceKey,
187
198
  skipRefresh: options?.skipRefresh,
188
199
  });
189
- if (cutoverCommittedOrThrow(cutover)) {
200
+ if (cutover.status === "failed") throw cutover.error;
201
+ if (cutover.status === "committed") {
190
202
  // SDK handled it — its in-memory doc is already current, so do NOT
191
203
  // forceReload (that would echo-reload the session we just wrote).
192
- return;
204
+ return { sourceFile: targetPath, version: cutover.version, changed: true };
193
205
  }
194
206
  }
195
207
 
@@ -218,6 +230,8 @@ export function useDomEditCommits({
218
230
  changed?: boolean;
219
231
  matched?: boolean;
220
232
  content?: string;
233
+ path?: string;
234
+ version?: string;
221
235
  };
222
236
 
223
237
  if (!patchData.changed) {
@@ -235,7 +249,9 @@ export function useDomEditCommits({
235
249
  throw new DomEditPersistUnresolvableError(targetPath);
236
250
  }
237
251
  warnDomEditPersistNoOp(selection, operations);
238
- return;
252
+ return typeof patchData.path === "string" && typeof patchData.version === "string"
253
+ ? { sourceFile: patchData.path, version: patchData.version, changed: false }
254
+ : undefined;
239
255
  }
240
256
 
241
257
  const patchedContent =
@@ -273,6 +289,11 @@ export function useDomEditCommits({
273
289
  if (!options?.skipRefresh) {
274
290
  reloadPreview();
275
291
  }
292
+ return finalContent === patchedContent &&
293
+ typeof patchData.path === "string" &&
294
+ typeof patchData.version === "string"
295
+ ? { sourceFile: patchData.path, version: patchData.version, changed: true }
296
+ : undefined;
276
297
  },
277
298
  [
278
299
  activeCompPath,
@@ -287,15 +308,30 @@ export function useDomEditCommits({
287
308
  ],
288
309
  );
289
310
 
311
+ const persistDomEditOperations: PersistDomEditOperations = useCallback(
312
+ (selection, operations, options) => {
313
+ const expectedProjectId = projectIdRef.current;
314
+ if (!expectedProjectId) return Promise.reject(new Error("No active project"));
315
+ return queueDomEditSave(() =>
316
+ performPersistDomEditOperations(selection, operations, options, expectedProjectId),
317
+ );
318
+ },
319
+ [performPersistDomEditOperations, projectIdRef, queueDomEditSave],
320
+ );
321
+
290
322
  const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
291
- (batches, options) =>
292
- queueDomEditSave(
323
+ (batches, options) => {
324
+ const expectedProjectId = projectIdRef.current;
325
+ if (!expectedProjectId) return Promise.reject(new Error("No active project"));
326
+ return queueDomEditSave(
293
327
  // One queued transaction owns validation, persistence, history, reload,
294
328
  // and its durable result; splitting those phases risks partial commits.
295
329
  // fallow-ignore-next-line complexity
296
330
  async () => {
297
- const pid = projectIdRef.current;
298
- if (!pid) throw new Error("No active project");
331
+ if (projectIdRef.current !== expectedProjectId) {
332
+ throw new Error("Active project changed before the edit could be saved");
333
+ }
334
+ const pid = expectedProjectId;
299
335
  const unsafeFields = batches.flatMap((batch) =>
300
336
  batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
301
337
  );
@@ -357,7 +393,8 @@ export function useDomEditCommits({
357
393
  label: options.label,
358
394
  });
359
395
  throw error;
360
- }),
396
+ });
397
+ },
361
398
  [
362
399
  domEditSaveTimestampRef,
363
400
  editHistory,
@@ -373,12 +410,14 @@ export function useDomEditCommits({
373
410
 
374
411
  const {
375
412
  handleDomStyleCommit,
413
+ handleDomStyleCommitForSelection,
376
414
  handleDomAttributeCommit,
377
415
  handleDomAttributeLiveCommit,
378
416
  handleDomAttributeQuietCommit,
379
417
  handleDomHtmlAttributeCommit,
380
418
  handleDomAttributesCommit,
381
419
  handleDomTextCommit,
420
+ handleDomTextCommitForSelection,
382
421
  handleDomRichTextCommit,
383
422
  commitDomTextFields,
384
423
  handleDomTextFieldStyleCommit,
@@ -401,7 +440,6 @@ export function useDomEditCommits({
401
440
  const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
402
441
  activeCompPath,
403
442
  persistDomEditOperations,
404
- queueDomEditSave,
405
443
  showToast,
406
444
  });
407
445
 
@@ -439,12 +477,14 @@ export function useDomEditCommits({
439
477
  return {
440
478
  resolveImportedFontAsset,
441
479
  handleDomStyleCommit,
480
+ handleDomStyleCommitForSelection,
442
481
  handleDomAttributeCommit,
443
482
  handleDomAttributeLiveCommit,
444
483
  handleDomAttributeQuietCommit,
445
484
  handleDomHtmlAttributeCommit,
446
485
  handleDomAttributesCommit,
447
486
  handleDomTextCommit,
487
+ handleDomTextCommitForSelection,
448
488
  handleDomRichTextCommit,
449
489
  commitDomTextFields,
450
490
  handleDomTextFieldStyleCommit,
@@ -55,14 +55,13 @@ function renderCommit(params: Parameters<typeof useDomEditPositionPatchCommit>[0
55
55
  return captured.commit;
56
56
  }
57
57
 
58
- function paramsWith(queueDomEditSave: (save: () => Promise<void>) => Promise<void>) {
58
+ function paramsWith(persistDomEditOperations: () => Promise<undefined>) {
59
59
  const showToast = vi.fn();
60
60
  return {
61
61
  showToast,
62
62
  params: {
63
63
  activeCompPath: "index.html",
64
- persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
65
- queueDomEditSave,
64
+ persistDomEditOperations,
66
65
  showToast,
67
66
  },
68
67
  };
@@ -104,7 +103,7 @@ describe("useDomEditPositionPatchCommit", () => {
104
103
  });
105
104
 
106
105
  it("resolves when the write lands", async () => {
107
- const { showToast, params } = paramsWith((save) => save());
106
+ const { showToast, params } = paramsWith(() => Promise.resolve(undefined));
108
107
  const commit = renderCommit(params);
109
108
 
110
109
  await act(async () => {
@@ -8,7 +8,6 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
8
8
  interface UseDomEditPositionPatchCommitParams {
9
9
  activeCompPath: string | null;
10
10
  persistDomEditOperations: PersistDomEditOperations;
11
- queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
12
11
  showToast: (message: string, tone?: "error" | "info") => void;
13
12
  }
14
13
 
@@ -22,39 +21,38 @@ interface PositionPatchOptions {
22
21
  export function useDomEditPositionPatchCommit({
23
22
  activeCompPath,
24
23
  persistDomEditOperations,
25
- queueDomEditSave,
26
24
  showToast,
27
25
  }: UseDomEditPositionPatchCommitParams) {
28
26
  return useCallback(
29
27
  (selection: DomEditSelection, patches: PatchOperation[], options: PositionPatchOptions) => {
30
- return queueDomEditSave(async () => {
31
- await persistDomEditOperations(selection, patches, {
32
- label: options.label,
33
- coalesceKey: options.coalesceKey,
34
- coalesceMs: options.coalesceMs,
35
- skipRefresh: options.skipRefresh ?? true,
28
+ return persistDomEditOperations(selection, patches, {
29
+ label: options.label,
30
+ coalesceKey: options.coalesceKey,
31
+ coalesceMs: options.coalesceMs,
32
+ skipRefresh: options.skipRefresh ?? true,
33
+ })
34
+ .then(() => undefined)
35
+ .catch((error) => {
36
+ // A paused save queue is not worth a toast: the paused-save banner is
37
+ // already on screen, and one toast per blocked edit is what this branch
38
+ // exists to prevent. It still has to REJECT, though. Swallowing it
39
+ // resolved the commit, which skipped the caller's revert, so the element
40
+ // stayed where the drag put it while nothing reached the file.
41
+ if (error instanceof DomEditSaveQueueOpenError) throw error;
42
+ showToast(error instanceof Error ? error.message : "Failed to save position");
43
+ trackStudioSaveFailure({
44
+ source: "dom_edit",
45
+ error,
46
+ filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
47
+ mutationType: "position",
48
+ label: options.label,
49
+ targetId: selection.id,
50
+ targetSelector: selection.selector,
51
+ targetSourceFile: selection.sourceFile,
52
+ });
53
+ throw error;
36
54
  });
37
- }).catch((error) => {
38
- // A paused save queue is not worth a toast: the paused-save banner is
39
- // already on screen, and one toast per blocked edit is what this branch
40
- // exists to prevent. It still has to REJECT, though. Swallowing it
41
- // resolved the commit, which skipped the caller's revert, so the element
42
- // stayed where the drag put it while nothing reached the file.
43
- if (error instanceof DomEditSaveQueueOpenError) throw error;
44
- showToast(error instanceof Error ? error.message : "Failed to save position");
45
- trackStudioSaveFailure({
46
- source: "dom_edit",
47
- error,
48
- filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
49
- mutationType: "position",
50
- label: options.label,
51
- targetId: selection.id,
52
- targetSelector: selection.selector,
53
- targetSourceFile: selection.sourceFile,
54
- });
55
- throw error;
56
- });
57
55
  },
58
- [activeCompPath, persistDomEditOperations, queueDomEditSave, showToast],
56
+ [activeCompPath, persistDomEditOperations, showToast],
59
57
  );
60
58
  }
@@ -227,12 +227,14 @@ export function useDomEditSession({
227
227
  const {
228
228
  resolveImportedFontAsset,
229
229
  handleDomStyleCommit,
230
+ handleDomStyleCommitForSelection,
230
231
  handleDomAttributeCommit,
231
232
  handleDomAttributeLiveCommit,
232
233
  handleDomAttributeQuietCommit,
233
234
  handleDomHtmlAttributeCommit,
234
235
  handleDomAttributesCommit,
235
236
  handleDomTextCommit,
237
+ handleDomTextCommitForSelection,
236
238
  handleDomRichTextCommit,
237
239
  handleDomTextFieldStyleCommit,
238
240
  handleDomAddTextField,
@@ -478,6 +480,7 @@ export function useDomEditSession({
478
480
  onClickToSource,
479
481
  });
480
482
  const {
483
+ getGsapAnimationsForSelection,
481
484
  handleGsapAwarePathOffsetCommit,
482
485
  handleGsapAwareGroupPathOffsetCommit,
483
486
  handleGsapAwareBoxSizeCommit,
@@ -506,7 +509,6 @@ export function useDomEditSession({
506
509
  const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
507
510
  useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
508
511
  return {
509
- // State
510
512
  domEditSelection,
511
513
  domEditGroupSelections,
512
514
  domEditHoverSelection,
@@ -515,7 +517,6 @@ export function useDomEditSession({
515
517
  agentModalAnchorPoint,
516
518
  copiedAgentPrompt,
517
519
  agentPromptSelectionContext,
518
- // Refs
519
520
  domEditSelectionRef,
520
521
  // Callbacks
521
522
  handleTimelineElementSelect,
@@ -525,6 +526,7 @@ export function useDomEditSession({
525
526
  applyDomSelection,
526
527
  clearDomSelection,
527
528
  handleDomStyleCommit,
529
+ handleDomStyleCommitForSelection,
528
530
  handleDomAttributeCommit,
529
531
  handleDomAttributeLiveCommit,
530
532
  handleDomAttributeQuietCommit,
@@ -537,10 +539,12 @@ export function useDomEditSession({
537
539
  handleDomRotationCommit: handleGsapAwareRotationCommit,
538
540
  handleDomManualEditsReset,
539
541
  handleDomTextCommit,
542
+ handleDomTextCommitForSelection,
540
543
  handleDomRichTextCommit,
541
544
  handleDomTextFieldStyleCommit,
542
545
  handleDomAddTextField,
543
546
  handleDomRemoveTextField,
547
+ getGsapAnimationsForSelection,
544
548
  handleAskAgent,
545
549
  handleAgentModalSubmit,
546
550
  handleBlockedDomMove,