@hyperframes/studio 0.8.24 → 0.8.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
  2. package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
  3. package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
  4. package/dist/assets/index-DnRfAiK2.css +1 -0
  5. package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
  6. package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
  7. package/dist/chunk-A7S5SGNA.js.map +1 -0
  8. package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6230 -5019
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/editor/TopologyLens.test.tsx +294 -0
  15. package/src/components/editor/TopologyLens.tsx +206 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  17. package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
  18. package/src/components/editor/domEditingLayers.test.ts +9 -0
  19. package/src/components/editor/domEditingLayers.ts +8 -8
  20. package/src/components/editor/topologyLensGeometry.test.ts +44 -0
  21. package/src/components/editor/topologyLensGeometry.ts +119 -0
  22. package/src/components/editor/topologyLensState.test.ts +116 -0
  23. package/src/components/editor/topologyLensState.ts +72 -0
  24. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
  25. package/src/components/nle/PreviewOverlays.tsx +24 -18
  26. package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
  27. package/src/components/sidebar/CompositionsTab.tsx +10 -3
  28. package/src/components/ui/HyperframesLoader.tsx +3 -48
  29. package/src/components/ui/HyperframesMark.tsx +53 -0
  30. package/src/contexts/DomEditContext.tsx +12 -0
  31. package/src/hooks/domEditCommitRunner.test.ts +59 -0
  32. package/src/hooks/domEditCommitRunner.ts +55 -20
  33. package/src/hooks/domEditCommitTypes.ts +8 -1
  34. package/src/hooks/domEditTextCommitPlan.ts +47 -0
  35. package/src/hooks/timelineTrackVisibility.test.ts +7 -1
  36. package/src/hooks/timelineTrackVisibility.ts +5 -5
  37. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  38. package/src/hooks/useDomEditCommits.test.tsx +243 -8
  39. package/src/hooks/useDomEditCommits.ts +69 -14
  40. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  41. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  42. package/src/hooks/useDomEditSession.ts +6 -2
  43. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  44. package/src/hooks/useDomEditTextCommits.ts +64 -86
  45. package/src/hooks/useDomEditWiring.ts +1 -1
  46. package/src/hooks/useDomSelection.ts +2 -0
  47. package/src/hooks/useDomSelectionTypes.ts +2 -0
  48. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  49. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  50. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  51. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  52. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  53. package/src/hooks/useGsapAnimationOps.ts +4 -4
  54. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  55. package/src/hooks/useGsapAwareEditing.ts +31 -10
  56. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  57. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  58. package/src/hooks/useRenderClipContent.test.ts +27 -0
  59. package/src/hooks/useRenderClipContent.ts +5 -0
  60. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  61. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  62. package/src/player/components/CompositionThumbnail.tsx +6 -0
  63. package/src/player/hooks/timelineSyncHydration.ts +10 -2
  64. package/src/player/store/playerStore.test.ts +24 -0
  65. package/src/player/store/thumbnailSlice.ts +6 -0
  66. package/src/styles/studio.css +179 -0
  67. package/src/utils/domEditSaveQueue.test.ts +5 -3
  68. package/src/utils/domEditSaveQueue.ts +6 -1
  69. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  70. package/src/webmcp/StudioAgentTools.tsx +89 -21
  71. package/src/webmcp/handles.test.ts +172 -15
  72. package/src/webmcp/handles.ts +152 -35
  73. package/src/webmcp/tools/animationTools.test.ts +241 -60
  74. package/src/webmcp/tools/animationTools.ts +191 -104
  75. package/src/webmcp/tools/contentTools.test.ts +103 -41
  76. package/src/webmcp/tools/contentTools.ts +149 -105
  77. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  78. package/src/webmcp/tools/inspectTools.ts +59 -19
  79. package/src/webmcp/tools/lookTools.test.ts +185 -38
  80. package/src/webmcp/tools/lookTools.ts +134 -21
  81. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  82. package/src/webmcp/tools/selectionTools.ts +34 -19
  83. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  84. package/src/webmcp/tools/transformTools.test.ts +140 -54
  85. package/src/webmcp/tools/transformTools.ts +215 -56
  86. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  87. package/src/webmcp/useStudioAgentTools.ts +27 -15
  88. package/src/webmcp/webmcpTestUtils.ts +37 -0
  89. package/src/webmcp/writeCoordinator.test.ts +375 -0
  90. package/src/webmcp/writeCoordinator.ts +473 -0
  91. package/dist/assets/index-yGhfxxoL.css +0 -1
  92. package/dist/chunk-6BT6DTB4.js.map +0 -1
  93. /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
@@ -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,11 +968,55 @@ 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
  }
962
982
  });
963
983
 
984
+ it("rebinds the paused preview after a saved optimistic style commit", async () => {
985
+ stubPatchFetch({
986
+ ok: true,
987
+ changed: true,
988
+ matched: true,
989
+ path: "index.html",
990
+ version: '"sha256:changed"',
991
+ });
992
+ const { iframe, element } = createPreviewElement();
993
+ const seek = vi.fn();
994
+ const forceTimelineRebind = vi.fn();
995
+ Object.defineProperty(iframe.contentWindow, "__player", {
996
+ configurable: true,
997
+ value: { seek },
998
+ });
999
+ Object.defineProperty(iframe.contentWindow, "__hfForceTimelineRebind", {
1000
+ configurable: true,
1001
+ value: forceTimelineRebind,
1002
+ });
1003
+ usePlayerStore.setState({ currentTime: 2.4 });
1004
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
1005
+
1006
+ try {
1007
+ await act(async () => {
1008
+ await rendered.hook.handleDomStyleCommit("color", "blue");
1009
+ });
1010
+
1011
+ expect(seek).toHaveBeenCalledWith(2.4);
1012
+ expect(forceTimelineRebind).toHaveBeenCalledTimes(1);
1013
+ expect(rendered.reloadPreview).not.toHaveBeenCalled();
1014
+ } finally {
1015
+ rendered.cleanup();
1016
+ usePlayerStore.getState().reset();
1017
+ }
1018
+ });
1019
+
964
1020
  it("toasts and reverts a style commit when the patch request rejects", async () => {
965
1021
  const { element, rendered, cleanup } = await commitStyleAgainst(new Error("network down"));
966
1022
 
@@ -976,22 +1032,203 @@ describe("useDomEditCommits style persist handling", () => {
976
1032
  });
977
1033
 
978
1034
  it("keeps the optimistic style and records history when the patch succeeds", async () => {
979
- const { element, rendered, cleanup } = await commitStyleAgainst({
1035
+ const { element, rendered, outcome, cleanup } = await commitStyleAgainst({
980
1036
  ok: true,
981
1037
  changed: true,
982
1038
  matched: true,
983
1039
  content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1040
+ path: "index.html",
1041
+ version: '"sha256:after"',
984
1042
  });
985
1043
 
986
1044
  try {
987
1045
  expect(rendered.showToast).not.toHaveBeenCalled();
988
1046
  expect(element.style.getPropertyValue("color")).toBe("blue");
989
1047
  expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
1048
+ expect(outcome).toEqual({
1049
+ ok: true,
1050
+ persistence: {
1051
+ sourceFile: "index.html",
1052
+ version: '"sha256:after"',
1053
+ changed: true,
1054
+ },
1055
+ });
990
1056
  } finally {
991
1057
  cleanup();
992
1058
  }
993
1059
  });
994
1060
 
1061
+ it("serializes the full read, write, and history transaction across overlapping commits", async () => {
1062
+ const firstPatch = createDeferred<Response>();
1063
+ let readCount = 0;
1064
+ let patchCount = 0;
1065
+ vi.stubGlobal(
1066
+ "fetch",
1067
+ vi.fn(async (input: Parameters<typeof fetch>[0]) => {
1068
+ const url = requestUrl(input);
1069
+ if (url.includes("/api/projects/p1/files/")) {
1070
+ readCount += 1;
1071
+ return jsonResponse({
1072
+ content:
1073
+ readCount === 1
1074
+ ? '<div data-hf-id="hf-card" style="color: red">Card</div>'
1075
+ : '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1076
+ });
1077
+ }
1078
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
1079
+ patchCount += 1;
1080
+ if (patchCount === 1) return firstPatch.promise;
1081
+ return jsonResponse({
1082
+ ok: true,
1083
+ changed: true,
1084
+ matched: true,
1085
+ content: '<div data-hf-id="hf-card" style="color: green">Card</div>',
1086
+ path: "index.html",
1087
+ version: '"sha256:green"',
1088
+ });
1089
+ }
1090
+ throw new Error(`Unexpected fetch: ${url}`);
1091
+ }),
1092
+ );
1093
+ const queue = createDomEditSaveQueue();
1094
+ const { iframe, element } = createPreviewElement();
1095
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
1096
+ queueDomEditSave: queue.enqueue,
1097
+ });
1098
+
1099
+ try {
1100
+ const first = rendered.hook.handleDomStyleCommit("color", "blue");
1101
+ await flushAsyncWork();
1102
+ const second = rendered.hook.handleDomStyleCommit("color", "green");
1103
+ await flushAsyncWork();
1104
+
1105
+ expect(readCount).toBe(1);
1106
+ expect(patchCount).toBe(1);
1107
+
1108
+ firstPatch.resolve(
1109
+ jsonResponse({
1110
+ ok: true,
1111
+ changed: true,
1112
+ matched: true,
1113
+ content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1114
+ path: "index.html",
1115
+ version: '"sha256:blue"',
1116
+ }),
1117
+ );
1118
+ await first;
1119
+ await second;
1120
+
1121
+ expect(readCount).toBe(2);
1122
+ expect(patchCount).toBe(2);
1123
+ expect(rendered.recordEdit).toHaveBeenNthCalledWith(
1124
+ 1,
1125
+ expect.objectContaining({
1126
+ files: {
1127
+ "index.html": expect.objectContaining({
1128
+ before: expect.stringContaining("color: red"),
1129
+ after: expect.stringContaining("color: blue"),
1130
+ }),
1131
+ },
1132
+ }),
1133
+ );
1134
+ expect(rendered.recordEdit).toHaveBeenNthCalledWith(
1135
+ 2,
1136
+ expect.objectContaining({
1137
+ files: {
1138
+ "index.html": expect.objectContaining({
1139
+ before: expect.stringContaining("color: blue"),
1140
+ after: expect.stringContaining("color: green"),
1141
+ }),
1142
+ },
1143
+ }),
1144
+ );
1145
+ } finally {
1146
+ queue.destroy();
1147
+ rendered.cleanup();
1148
+ }
1149
+ });
1150
+
1151
+ it("refuses queued persistence after the active project changes", async () => {
1152
+ const firstPatch = createDeferred<Response>();
1153
+ const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
1154
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]) => {
1155
+ const url = requestUrl(input);
1156
+ if (url.includes("/api/projects/p1/files/")) {
1157
+ return jsonResponse({
1158
+ content: '<div data-hf-id="hf-card" style="color: red">Card</div>',
1159
+ });
1160
+ }
1161
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
1162
+ return firstPatch.promise;
1163
+ }
1164
+ throw new Error(`Unexpected fetch: ${url}`);
1165
+ });
1166
+ vi.stubGlobal("fetch", fetchMock);
1167
+ const queue = createDomEditSaveQueue();
1168
+ const { iframe, element } = createPreviewElement();
1169
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
1170
+ queueDomEditSave: queue.enqueue,
1171
+ projectIdRef,
1172
+ });
1173
+
1174
+ try {
1175
+ const first = rendered.hook.handleDomStyleCommit("color", "blue");
1176
+ await flushAsyncWork();
1177
+ const second = rendered.hook.handleDomStyleCommit("color", "green");
1178
+ projectIdRef.current = "p2";
1179
+ firstPatch.resolve(
1180
+ jsonResponse({
1181
+ ok: true,
1182
+ changed: true,
1183
+ matched: true,
1184
+ content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
1185
+ path: "index.html",
1186
+ version: '"sha256:blue"',
1187
+ }),
1188
+ );
1189
+
1190
+ await first;
1191
+ await expect(second).resolves.toMatchObject({
1192
+ ok: false,
1193
+ reason: "persist-failed",
1194
+ });
1195
+ expect(fetchMock.mock.calls.some(([input]) => requestUrl(input).includes("/p2/"))).toBe(
1196
+ false,
1197
+ );
1198
+ expect(fetchMock).toHaveBeenCalledTimes(2);
1199
+ } finally {
1200
+ queue.destroy();
1201
+ rendered.cleanup();
1202
+ }
1203
+ });
1204
+
1205
+ it("preserves the SDK cutover version as the style commit's durable evidence", async () => {
1206
+ const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
1207
+ const { iframe, element } = createPreviewElement();
1208
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
1209
+ onTrySdkPersist: async () => ({ status: "committed", version: "sdk-version-2" }),
1210
+ });
1211
+
1212
+ try {
1213
+ let outcome: Awaited<ReturnType<typeof rendered.hook.handleDomStyleCommit>> | undefined;
1214
+ await act(async () => {
1215
+ outcome = await rendered.hook.handleDomStyleCommit("color", "blue");
1216
+ });
1217
+
1218
+ expect(outcome).toEqual({
1219
+ ok: true,
1220
+ persistence: { sourceFile: "index.html", version: "sdk-version-2", changed: true },
1221
+ });
1222
+ expect(
1223
+ fetchMock.mock.calls.some(([input]) =>
1224
+ requestUrl(input).includes("/file-mutations/patch-element/"),
1225
+ ),
1226
+ ).toBe(false);
1227
+ } finally {
1228
+ rendered.cleanup();
1229
+ }
1230
+ });
1231
+
995
1232
  it("keeps a newer style value when an older overlapping commit later fails", async () => {
996
1233
  const firstPatch = createDeferred<Response>();
997
1234
  const secondPatch = createDeferred<Response>();
@@ -1466,9 +1703,7 @@ describe("useDomEditCommits attribute persist handling", () => {
1466
1703
  // optimistic apply) and succeeds before the older one rejects. Without the
1467
1704
  // per-key version guard, the stale rejection would revert to the older
1468
1705
  // 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
- });
1706
+ const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
1472
1707
  await act(async () => {
1473
1708
  await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
1474
1709
  });
@@ -32,8 +32,9 @@ 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
+ import { reseekPreviewRuntime } from "./timelineTrackVisibility";
37
38
  interface RecordEditInput {
38
39
  label: string;
39
40
  kind: EditHistoryKind;
@@ -139,14 +140,25 @@ export function useDomEditCommits({
139
140
  const reportedUnresolvableRef = useRef(new Set<string>());
140
141
 
141
142
  // fallow-ignore-next-line complexity
142
- const persistDomEditOperations: PersistDomEditOperations = useCallback(
143
+ const performPersistDomEditOperations = useCallback(
143
144
  // fallow-ignore-next-line complexity
144
- async (selection, operations, options) => {
145
- const pid = projectIdRef.current;
146
- if (!pid) throw new Error("No active project");
145
+ async (
146
+ selection: DomEditSelection,
147
+ operations: PatchOperation[],
148
+ options: Parameters<PersistDomEditOperations>[2],
149
+ expectedProjectId: string,
150
+ ) => {
151
+ if (projectIdRef.current !== expectedProjectId) {
152
+ throw new Error("Active project changed before the edit could be saved");
153
+ }
154
+ const pid = expectedProjectId;
147
155
  if (options?.shouldSave && !options.shouldSave()) return;
148
156
 
149
157
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
158
+ const completePersistence = <T>(result: T, changed: boolean): T => {
159
+ if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current);
160
+ return result;
161
+ };
150
162
 
151
163
  const readResponse = await fetch(
152
164
  `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
@@ -160,6 +172,10 @@ export function useDomEditCommits({
160
172
  throw new Error(`Missing file contents for ${targetPath}`);
161
173
  }
162
174
 
175
+ if (projectIdRef.current !== expectedProjectId) {
176
+ throw new Error("Active project changed before the edit could be saved");
177
+ }
178
+
163
179
  if (options?.shouldSave && !options.shouldSave()) return;
164
180
 
165
181
  // Validate layout values BEFORE any persist path runs. The SDK cutover
@@ -186,10 +202,14 @@ export function useDomEditCommits({
186
202
  coalesceKey: options?.coalesceKey,
187
203
  skipRefresh: options?.skipRefresh,
188
204
  });
189
- if (cutoverCommittedOrThrow(cutover)) {
205
+ if (cutover.status === "failed") throw cutover.error;
206
+ if (cutover.status === "committed") {
190
207
  // SDK handled it — its in-memory doc is already current, so do NOT
191
208
  // forceReload (that would echo-reload the session we just wrote).
192
- return;
209
+ return completePersistence(
210
+ { sourceFile: targetPath, version: cutover.version, changed: true },
211
+ true,
212
+ );
193
213
  }
194
214
  }
195
215
 
@@ -218,6 +238,8 @@ export function useDomEditCommits({
218
238
  changed?: boolean;
219
239
  matched?: boolean;
220
240
  content?: string;
241
+ path?: string;
242
+ version?: string;
221
243
  };
222
244
 
223
245
  if (!patchData.changed) {
@@ -235,7 +257,12 @@ export function useDomEditCommits({
235
257
  throw new DomEditPersistUnresolvableError(targetPath);
236
258
  }
237
259
  warnDomEditPersistNoOp(selection, operations);
238
- return;
260
+ return completePersistence(
261
+ typeof patchData.path === "string" && typeof patchData.version === "string"
262
+ ? { sourceFile: patchData.path, version: patchData.version, changed: false }
263
+ : undefined,
264
+ false,
265
+ );
239
266
  }
240
267
 
241
268
  const patchedContent =
@@ -273,6 +300,14 @@ export function useDomEditCommits({
273
300
  if (!options?.skipRefresh) {
274
301
  reloadPreview();
275
302
  }
303
+ return completePersistence(
304
+ finalContent === patchedContent &&
305
+ typeof patchData.path === "string" &&
306
+ typeof patchData.version === "string"
307
+ ? { sourceFile: patchData.path, version: patchData.version, changed: true }
308
+ : undefined,
309
+ true,
310
+ );
276
311
  },
277
312
  [
278
313
  activeCompPath,
@@ -284,18 +319,34 @@ export function useDomEditCommits({
284
319
  showToast,
285
320
  forceReloadSdkSession,
286
321
  onTrySdkPersist,
322
+ previewIframeRef,
287
323
  ],
288
324
  );
289
325
 
326
+ const persistDomEditOperations: PersistDomEditOperations = useCallback(
327
+ (selection, operations, options) => {
328
+ const expectedProjectId = projectIdRef.current;
329
+ if (!expectedProjectId) return Promise.reject(new Error("No active project"));
330
+ return queueDomEditSave(() =>
331
+ performPersistDomEditOperations(selection, operations, options, expectedProjectId),
332
+ );
333
+ },
334
+ [performPersistDomEditOperations, projectIdRef, queueDomEditSave],
335
+ );
336
+
290
337
  const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
291
- (batches, options) =>
292
- queueDomEditSave(
338
+ (batches, options) => {
339
+ const expectedProjectId = projectIdRef.current;
340
+ if (!expectedProjectId) return Promise.reject(new Error("No active project"));
341
+ return queueDomEditSave(
293
342
  // One queued transaction owns validation, persistence, history, reload,
294
343
  // and its durable result; splitting those phases risks partial commits.
295
344
  // fallow-ignore-next-line complexity
296
345
  async () => {
297
- const pid = projectIdRef.current;
298
- if (!pid) throw new Error("No active project");
346
+ if (projectIdRef.current !== expectedProjectId) {
347
+ throw new Error("Active project changed before the edit could be saved");
348
+ }
349
+ const pid = expectedProjectId;
299
350
  const unsafeFields = batches.flatMap((batch) =>
300
351
  batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
301
352
  );
@@ -357,7 +408,8 @@ export function useDomEditCommits({
357
408
  label: options.label,
358
409
  });
359
410
  throw error;
360
- }),
411
+ });
412
+ },
361
413
  [
362
414
  domEditSaveTimestampRef,
363
415
  editHistory,
@@ -373,12 +425,14 @@ export function useDomEditCommits({
373
425
 
374
426
  const {
375
427
  handleDomStyleCommit,
428
+ handleDomStyleCommitForSelection,
376
429
  handleDomAttributeCommit,
377
430
  handleDomAttributeLiveCommit,
378
431
  handleDomAttributeQuietCommit,
379
432
  handleDomHtmlAttributeCommit,
380
433
  handleDomAttributesCommit,
381
434
  handleDomTextCommit,
435
+ handleDomTextCommitForSelection,
382
436
  handleDomRichTextCommit,
383
437
  commitDomTextFields,
384
438
  handleDomTextFieldStyleCommit,
@@ -401,7 +455,6 @@ export function useDomEditCommits({
401
455
  const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
402
456
  activeCompPath,
403
457
  persistDomEditOperations,
404
- queueDomEditSave,
405
458
  showToast,
406
459
  });
407
460
 
@@ -439,12 +492,14 @@ export function useDomEditCommits({
439
492
  return {
440
493
  resolveImportedFontAsset,
441
494
  handleDomStyleCommit,
495
+ handleDomStyleCommitForSelection,
442
496
  handleDomAttributeCommit,
443
497
  handleDomAttributeLiveCommit,
444
498
  handleDomAttributeQuietCommit,
445
499
  handleDomHtmlAttributeCommit,
446
500
  handleDomAttributesCommit,
447
501
  handleDomTextCommit,
502
+ handleDomTextCommitForSelection,
448
503
  handleDomRichTextCommit,
449
504
  commitDomTextFields,
450
505
  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
  }