@hyperframes/studio 0.7.58 → 0.7.60

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 (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -25,6 +25,7 @@ interface DataAttributeCommitOptions {
25
25
  coalescePrefix: string;
26
26
  skipRefresh: boolean;
27
27
  refreshAfter?: boolean;
28
+ onSettled?: (ok: boolean) => void;
28
29
  }
29
30
 
30
31
  function resolveFullAttrName(attr: string, prefixData: boolean | undefined): string {
@@ -57,6 +58,25 @@ interface CapturedAttributeElement {
57
58
  previousValue: string | null;
58
59
  }
59
60
 
61
+ interface CapturedMultiAttributeElement {
62
+ element: HTMLElement;
63
+ previousValues: Map<string, string | null>;
64
+ }
65
+
66
+ function captureMultiAttributeElement(
67
+ doc: Document | null | undefined,
68
+ selection: DomEditSelection,
69
+ activeCompPath: string | null,
70
+ fullAttrs: string[],
71
+ ): CapturedMultiAttributeElement | null {
72
+ const el = findPreviewAttributeElement(doc, selection, activeCompPath);
73
+ if (!el) return null;
74
+ const previousValues = new Map(
75
+ fullAttrs.map((fullAttr) => [fullAttr, el.getAttribute(fullAttr)]),
76
+ );
77
+ return { element: el, previousValues };
78
+ }
79
+
60
80
  function captureAttributeElement(
61
81
  doc: Document | null | undefined,
62
82
  selection: DomEditSelection,
@@ -128,6 +148,7 @@ export function useDomEditAttributeCommits({
128
148
  onError: (error) => reportDomEditPersistFailure(domEditSelection, [op], error, showToast),
129
149
  shouldResync: () => isLatestCommit() && !!options.refreshAfter,
130
150
  resync: () => refreshDomEditSelectionFromPreview(domEditSelection),
151
+ onSettled: options.onSettled,
131
152
  });
132
153
  },
133
154
  [
@@ -140,6 +161,105 @@ export function useDomEditAttributeCommits({
140
161
  ],
141
162
  );
142
163
 
164
+ // Commits several data-* attributes on the SAME element in ONE persist call
165
+ // — needed when two attributes together describe a single logical value
166
+ // (e.g. a pinned timing range's start+duration): committing them through two
167
+ // separate sequential `commitDataAttribute` calls leaves a window where the
168
+ // second call resolves `domEditSelection` fresh from current hook state, so
169
+ // a selection change between the two awaits would misdirect it at the
170
+ // NEWLY selected element instead of the one being edited, and a failure of
171
+ // just the second call would leave the two attributes in an inconsistent
172
+ // half-applied state. Bundling them into one `PatchOperation[]` against an
173
+ // explicit, caller-supplied `selection` (not the "current" one) closes both
174
+ // gaps — matching `onCommitAnimatedProperties`'s same-shaped fix for GSAP
175
+ // property batches.
176
+ const commitDataAttributes = useCallback(
177
+ async (
178
+ selection: DomEditSelection,
179
+ attrs: Record<string, string | null>,
180
+ options: DataAttributeCommitOptions,
181
+ ) => {
182
+ const iframe = previewIframeRef.current;
183
+ const entries = Object.entries(attrs).map(([attr, value]) => ({
184
+ attr,
185
+ fullAttr: resolveFullAttrName(attr, true),
186
+ value,
187
+ }));
188
+ const commitKey = `${options.coalescePrefix}:${entries
189
+ .map((entry) => entry.attr)
190
+ .sort()
191
+ .join(",")}:${getDomEditTargetKey(selection)}`;
192
+ const isLatestCommit = bumpDomEditCommitMapVersion(
193
+ domAttributeCommitVersionRef.current,
194
+ commitKey,
195
+ );
196
+ const ops: PatchOperation[] = entries.map((entry) => ({
197
+ type: "attribute",
198
+ property: entry.attr,
199
+ value: entry.value,
200
+ }));
201
+ let captured: CapturedMultiAttributeElement | null = null;
202
+
203
+ await runDomEditCommit({
204
+ capture: () => {
205
+ captured = captureMultiAttributeElement(
206
+ iframe?.contentDocument,
207
+ selection,
208
+ activeCompPath,
209
+ entries.map((entry) => entry.fullAttr),
210
+ );
211
+ },
212
+ apply: () => {
213
+ if (!captured) return;
214
+ for (const entry of entries) {
215
+ const nextValue = entry.value === null || entry.value === "" ? null : entry.value;
216
+ setOrRemovePreviewAttribute(captured.element, entry.fullAttr, nextValue);
217
+ }
218
+ },
219
+ persist: () =>
220
+ persistDomEditOperations(selection, ops, {
221
+ label: options.label,
222
+ coalesceKey: commitKey,
223
+ skipRefresh: options.skipRefresh,
224
+ }),
225
+ shouldRevert: () => isLatestCommit(),
226
+ revert: () => {
227
+ if (!captured) return;
228
+ for (const entry of entries) {
229
+ setOrRemovePreviewAttribute(
230
+ captured.element,
231
+ entry.fullAttr,
232
+ captured.previousValues.get(entry.fullAttr) ?? null,
233
+ );
234
+ }
235
+ },
236
+ onError: (error) => reportDomEditPersistFailure(selection, ops, error, showToast),
237
+ shouldResync: () => isLatestCommit() && !!options.refreshAfter,
238
+ resync: () => refreshDomEditSelectionFromPreview(selection),
239
+ onSettled: options.onSettled,
240
+ });
241
+ },
242
+ [
243
+ activeCompPath,
244
+ persistDomEditOperations,
245
+ refreshDomEditSelectionFromPreview,
246
+ showToast,
247
+ previewIframeRef,
248
+ ],
249
+ );
250
+
251
+ const handleDomAttributesCommit = useCallback(
252
+ async (selection: DomEditSelection, attrs: Record<string, string>) => {
253
+ await commitDataAttributes(selection, attrs, {
254
+ label: "Edit timing",
255
+ coalescePrefix: "attrs",
256
+ skipRefresh: false,
257
+ refreshAfter: true,
258
+ });
259
+ },
260
+ [commitDataAttributes],
261
+ );
262
+
143
263
  const handleDomAttributeCommit = useCallback(
144
264
  async (attr: string, value: string) => {
145
265
  await commitDataAttribute(attr, value, {
@@ -153,11 +273,12 @@ export function useDomEditAttributeCommits({
153
273
  );
154
274
 
155
275
  const handleDomAttributeLiveCommit = useCallback(
156
- async (attr: string, value: string | null) => {
276
+ async (attr: string, value: string | null, onSettled?: (ok: boolean) => void) => {
157
277
  await commitDataAttribute(attr, value, {
158
278
  label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
159
279
  coalescePrefix: "attr-live",
160
280
  skipRefresh: true,
281
+ onSettled,
161
282
  });
162
283
  },
163
284
  [commitDataAttribute],
@@ -223,5 +344,6 @@ export function useDomEditAttributeCommits({
223
344
  handleDomAttributeCommit,
224
345
  handleDomAttributeLiveCommit,
225
346
  handleDomHtmlAttributeCommit,
347
+ handleDomAttributesCommit,
226
348
  };
227
349
  }
@@ -34,7 +34,7 @@ interface RenderedDomEditCommits {
34
34
 
35
35
  interface RenderDomEditCommitsOptions {
36
36
  importedFontAssets?: ImportedFontAsset[];
37
- writeProjectFile?: (path: string, content: string) => Promise<void>;
37
+ writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
38
38
  }
39
39
 
40
40
  type FetchHandler = (
@@ -1050,6 +1050,46 @@ describe("useDomEditCommits style persist handling", () => {
1050
1050
  }
1051
1051
  });
1052
1052
 
1053
+ it("uses the patched server content as the custom-font write precondition", async () => {
1054
+ const patchedContent =
1055
+ '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
1056
+ stubPatchFetch(
1057
+ { ok: true, changed: true, matched: true, content: patchedContent },
1058
+ patchedContent,
1059
+ );
1060
+ const { iframe, element } = createPreviewElement();
1061
+ const selection = createSelection(element, {
1062
+ textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1063
+ });
1064
+ const writeProjectFile = vi.fn(async () => {});
1065
+ const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
1066
+
1067
+ try {
1068
+ await act(async () => {
1069
+ await rendered.hook.commitDomTextFields(
1070
+ selection,
1071
+ [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1072
+ {
1073
+ importedFont: {
1074
+ family: "Imported",
1075
+ path: "fonts/Imported.woff2",
1076
+ url: "/api/projects/p1/preview/fonts/Imported.woff2",
1077
+ },
1078
+ },
1079
+ );
1080
+ });
1081
+
1082
+ expect(writeProjectFile).toHaveBeenCalledWith(
1083
+ "index.html",
1084
+ expect.stringContaining("@font-face"),
1085
+ patchedContent,
1086
+ );
1087
+ expect(rendered.showToast).not.toHaveBeenCalled();
1088
+ } finally {
1089
+ rendered.cleanup();
1090
+ }
1091
+ });
1092
+
1053
1093
  it("keeps a rejected patch request (HTTP error) to one toast", async () => {
1054
1094
  const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
1055
1095
  const url = requestUrl(input);
@@ -12,11 +12,7 @@ import {
12
12
  import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
13
13
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
14
14
  import type { EditHistoryKind } from "../utils/editHistory";
15
- import type {
16
- CommitDomEditPatchBatches,
17
- DomEditPatchBatch,
18
- PersistDomEditOperations,
19
- } from "./domEditCommitTypes";
15
+ import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
20
16
  import type { PatchOperation } from "../utils/sourcePatcher";
21
17
  import {
22
18
  DomEditPersistUnsafeValueError,
@@ -27,31 +23,16 @@ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
27
23
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
28
24
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
29
25
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
30
- import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
31
-
32
- // ── Helpers ──
33
-
34
- function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
35
- return fields.map((field) => field.path).join(", ");
36
- }
37
-
38
- function getErrorDetail(error: unknown): string {
39
- return error instanceof Error ? error.message : String(error);
40
- }
41
-
42
- async function readErrorResponseBody(
43
- response: Response,
44
- ): Promise<{ error?: string; fields?: string[] } | null> {
45
- const contentType = response.headers.get("content-type") ?? "";
46
- if (!contentType.includes("application/json")) return null;
47
- return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
48
- }
49
-
50
- function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
51
- if (!body?.error) return "Couldn't save edit";
52
- return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
53
- }
54
-
26
+ import {
27
+ AtomicElementPatchConvergenceError,
28
+ batchesAreInlineStyleOnly,
29
+ formatPatchRejectionMessage,
30
+ formatUnsafeFieldList,
31
+ getErrorDetail,
32
+ patchElementBatches,
33
+ readErrorResponseBody,
34
+ } from "./useDomEditCommitsHelpers";
35
+ import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
55
36
  interface RecordEditInput {
56
37
  label: string;
57
38
  kind: EditHistoryKind;
@@ -60,144 +41,12 @@ interface RecordEditInput {
60
41
  files: Record<string, { before: string; after: string }>;
61
42
  }
62
43
 
63
- /** Human-readable identifier for a batch patch target (for the unmatched warning). */
64
- function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
65
- return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
66
- }
67
-
68
- /**
69
- * Surface server-reported unmatched patches. The server atomically refuses the
70
- * whole multi-file gesture; the caller uses `durable: false` to roll back and
71
- * reload, so report the refusal without turning it into a second failure.
72
- */
73
- function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
74
- const unmatchedIds = batch.patches
75
- .filter((_, index) => matched[index] === false)
76
- .map(describeBatchPatchTarget);
77
- if (unmatchedIds.length === 0) return;
78
- console.warn(
79
- `[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
80
- `${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
81
- unmatchedIds.join(", "),
82
- );
83
- trackStudioSaveFailure({
84
- source: "dom_edit",
85
- error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
86
- filePath: batch.sourceFile,
87
- mutationType: "z-reorder-unmatched",
88
- });
89
- }
90
-
91
- interface AtomicElementPatchFile {
92
- sourceFile: string;
93
- changed: boolean;
94
- matched?: boolean[];
95
- before: string;
96
- after: string;
97
- }
98
-
99
- class AtomicElementPatchConvergenceError extends Error {
100
- constructor(message: string, options?: { cause?: unknown }) {
101
- super(message, options);
102
- this.name = "AtomicElementPatchConvergenceError";
103
- }
104
- }
105
-
106
- // Keep the atomic response contract in one guard so callers do not compose validity.
107
- // fallow-ignore-next-line complexity
108
- function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
109
- return (
110
- typeof value === "object" &&
111
- value !== null &&
112
- "sourceFile" in value &&
113
- typeof value.sourceFile === "string" &&
114
- "changed" in value &&
115
- typeof value.changed === "boolean" &&
116
- (!("matched" in value) ||
117
- (Array.isArray(value.matched) &&
118
- value.matched.every((matched) => typeof matched === "boolean"))) &&
119
- "before" in value &&
120
- typeof value.before === "string" &&
121
- "after" in value &&
122
- typeof value.after === "string" &&
123
- value.changed === (value.before !== value.after)
124
- );
125
- }
126
-
127
- // This is the single client owner for dispatching and validating the aggregate
128
- // atomic endpoint. Splitting validation from the request would weaken that wire contract.
129
- // fallow-ignore-next-line complexity
130
- async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
131
- const body = JSON.stringify({ batches });
132
- try {
133
- const response = await fetch(
134
- `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
135
- {
136
- method: "POST",
137
- headers: { "Content-Type": "application/json" },
138
- body,
139
- },
140
- );
141
- if (!response.ok) {
142
- const rejection = await readErrorResponseBody(response);
143
- throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
144
- }
145
- const result: unknown = await response.json().catch(() => null);
146
- if (
147
- typeof result !== "object" ||
148
- result === null ||
149
- !("durable" in result) ||
150
- typeof result.durable !== "boolean" ||
151
- !("files" in result) ||
152
- !Array.isArray(result.files) ||
153
- result.files.length !== batches.length ||
154
- !result.files.every(isAtomicElementPatchFile) ||
155
- (!result.durable && result.files.some((file) => file.changed))
156
- ) {
157
- throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
158
- }
159
- const files = result.files.map((file, index) => {
160
- const batch = batches[index];
161
- const matched = file.matched ?? [];
162
- if (
163
- !batch ||
164
- file.sourceFile !== batch.sourceFile ||
165
- (matched.length !== 0 && matched.length !== batch.patches.length)
166
- ) {
167
- throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
168
- }
169
- reportUnmatchedBatchPatches(batch, matched);
170
- return {
171
- ...file,
172
- matched,
173
- allMatched: matched.length === batch.patches.length && matched.every(Boolean),
174
- };
175
- });
176
- return { durable: result.durable, files };
177
- } catch (error) {
178
- throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
179
- }
180
- }
181
-
182
- /**
183
- * A batch is reload-skippable only when it is style-only: every operation is an
184
- * `inline-style` write. The z-reorder commit applies those exact styles to the
185
- * live iframe DOM synchronously, so persisting them adds nothing the preview
186
- * doesn't already show. Any other op type (attribute / text-content / …) can
187
- * have server-side semantics the live DOM hasn't mirrored — reload for those.
188
- */
189
- function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
190
- return batches.every((batch) =>
191
- batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
192
- );
193
- }
194
-
195
44
  export interface UseDomEditCommitsParams {
196
45
  activeCompPath: string | null;
197
46
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
198
47
  showToast: (message: string, tone?: "error" | "info") => void;
199
48
  queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
200
- writeProjectFile: (path: string, content: string) => Promise<void>;
49
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
201
50
  domEditSaveTimestampRef: React.MutableRefObject<number>;
202
51
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
203
52
  fileTree: string[];
@@ -222,16 +71,20 @@ export interface UseDomEditCommitsParams {
222
71
  * path, whose session is already current) so a later SDK edit doesn't
223
72
  * serialize the pre-write doc and revert the server's change. */
224
73
  forceReloadSdkSession?: () => void;
225
- /** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
74
+ /** Stage 7 Step 3c: called before the server-side patch path. */
226
75
  onTrySdkPersist?: (
227
76
  selection: DomEditSelection,
228
77
  operations: PatchOperation[],
229
78
  originalContent: string,
230
79
  targetPath: string,
231
80
  options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
232
- ) => Promise<boolean>;
233
- /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
234
- onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
81
+ ) => Promise<CutoverResult>;
82
+ /** Stage 7 §3.1: called before the server-side delete path. */
83
+ onTrySdkDelete?: (
84
+ hfId: string,
85
+ originalContent: string,
86
+ targetPath: string,
87
+ ) => Promise<CutoverResult>;
235
88
  /** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
236
89
  onReorderShadow?: (targets: string[]) => void;
237
90
  }
@@ -326,18 +179,17 @@ export function useDomEditCommits({
326
179
  // Skip the SDK path when prepareContent is set (e.g. @font-face injection
327
180
  // for a custom font): sdkCutoverPersist serializes only the patched DOM
328
181
  // and would drop the injected content. Let the server path run prepareContent.
329
- if (
330
- onTrySdkPersist &&
331
- !options?.prepareContent &&
332
- (await onTrySdkPersist(selection, operations, originalContent, targetPath, {
182
+ if (onTrySdkPersist && !options?.prepareContent) {
183
+ const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
333
184
  label: options?.label,
334
185
  coalesceKey: options?.coalesceKey,
335
186
  skipRefresh: options?.skipRefresh,
336
- }))
337
- ) {
338
- // SDK handled it — its in-memory doc is already current, so do NOT
339
- // forceReload (that would echo-reload the session we just wrote).
340
- return;
187
+ });
188
+ if (cutoverCommittedOrThrow(cutover)) {
189
+ // SDK handled it — its in-memory doc is already current, so do NOT
190
+ // forceReload (that would echo-reload the session we just wrote).
191
+ return;
192
+ }
341
193
  }
342
194
 
343
195
  // Mark the save timestamp before the file write so the SSE file-change
@@ -393,7 +245,7 @@ export function useDomEditCommits({
393
245
  const preparedContent = options.prepareContent(patchedContent, targetPath);
394
246
  if (preparedContent !== patchedContent) {
395
247
  try {
396
- await writeProjectFile(targetPath, preparedContent);
248
+ await writeProjectFile(targetPath, preparedContent, patchedContent);
397
249
  finalContent = preparedContent;
398
250
  } catch (error) {
399
251
  // The patch above already landed on disk — only the prepareContent
@@ -523,6 +375,7 @@ export function useDomEditCommits({
523
375
  handleDomAttributeCommit,
524
376
  handleDomAttributeLiveCommit,
525
377
  handleDomHtmlAttributeCommit,
378
+ handleDomAttributesCommit,
526
379
  handleDomTextCommit,
527
380
  commitDomTextFields,
528
381
  handleDomTextFieldStyleCommit,
@@ -585,6 +438,7 @@ export function useDomEditCommits({
585
438
  handleDomAttributeCommit,
586
439
  handleDomAttributeLiveCommit,
587
440
  handleDomHtmlAttributeCommit,
441
+ handleDomAttributesCommit,
588
442
  handleDomTextCommit,
589
443
  commitDomTextFields,
590
444
  handleDomTextFieldStyleCommit,
@@ -0,0 +1,158 @@
1
+ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
2
+ import type { DomEditPatchBatch } from "./domEditCommitTypes";
3
+ import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
4
+
5
+ export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
6
+ return fields.map((field) => field.path).join(", ");
7
+ }
8
+
9
+ export function getErrorDetail(error: unknown): string {
10
+ return error instanceof Error ? error.message : String(error);
11
+ }
12
+
13
+ export async function readErrorResponseBody(
14
+ response: Response,
15
+ ): Promise<{ error?: string; fields?: string[] } | null> {
16
+ const contentType = response.headers.get("content-type") ?? "";
17
+ if (!contentType.includes("application/json")) return null;
18
+ return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
19
+ }
20
+
21
+ export function formatPatchRejectionMessage(
22
+ body: { error?: string; fields?: string[] } | null,
23
+ ): string {
24
+ if (!body?.error) return "Couldn't save edit";
25
+ return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
26
+ }
27
+
28
+ /** Human-readable identifier for a batch patch target (for the unmatched warning). */
29
+ function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
30
+ return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
31
+ }
32
+
33
+ /**
34
+ * Surface server-reported unmatched patches. The server atomically refuses the
35
+ * whole multi-file gesture; the caller uses `durable: false` to roll back and
36
+ * reload, so report the refusal without turning it into a second failure.
37
+ */
38
+ function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
39
+ const unmatchedIds = batch.patches
40
+ .filter((_, index) => matched[index] === false)
41
+ .map(describeBatchPatchTarget);
42
+ if (unmatchedIds.length === 0) return;
43
+ console.warn(
44
+ `[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
45
+ `${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
46
+ unmatchedIds.join(", "),
47
+ );
48
+ trackStudioSaveFailure({
49
+ source: "dom_edit",
50
+ error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
51
+ filePath: batch.sourceFile,
52
+ mutationType: "z-reorder-unmatched",
53
+ });
54
+ }
55
+
56
+ interface AtomicElementPatchFile {
57
+ sourceFile: string;
58
+ changed: boolean;
59
+ matched?: boolean[];
60
+ before: string;
61
+ after: string;
62
+ }
63
+
64
+ export class AtomicElementPatchConvergenceError extends Error {
65
+ constructor(message: string, options?: { cause?: unknown }) {
66
+ super(message, options);
67
+ this.name = "AtomicElementPatchConvergenceError";
68
+ }
69
+ }
70
+
71
+ // Keep the atomic response contract in one guard so callers do not compose validity.
72
+ // fallow-ignore-next-line complexity
73
+ function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
74
+ return (
75
+ typeof value === "object" &&
76
+ value !== null &&
77
+ "sourceFile" in value &&
78
+ typeof value.sourceFile === "string" &&
79
+ "changed" in value &&
80
+ typeof value.changed === "boolean" &&
81
+ (!("matched" in value) ||
82
+ (Array.isArray(value.matched) &&
83
+ value.matched.every((matched) => typeof matched === "boolean"))) &&
84
+ "before" in value &&
85
+ typeof value.before === "string" &&
86
+ "after" in value &&
87
+ typeof value.after === "string" &&
88
+ value.changed === (value.before !== value.after)
89
+ );
90
+ }
91
+
92
+ // This is the single client owner for dispatching and validating the aggregate
93
+ // atomic endpoint. Splitting validation from the request would weaken that wire contract.
94
+ // fallow-ignore-next-line complexity
95
+ export async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
96
+ const body = JSON.stringify({ batches });
97
+ try {
98
+ const response = await fetch(
99
+ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
100
+ {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body,
104
+ },
105
+ );
106
+ if (!response.ok) {
107
+ const rejection = await readErrorResponseBody(response);
108
+ throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
109
+ }
110
+ const result: unknown = await response.json().catch(() => null);
111
+ if (
112
+ typeof result !== "object" ||
113
+ result === null ||
114
+ !("durable" in result) ||
115
+ typeof result.durable !== "boolean" ||
116
+ !("files" in result) ||
117
+ !Array.isArray(result.files) ||
118
+ result.files.length !== batches.length ||
119
+ !result.files.every(isAtomicElementPatchFile) ||
120
+ (!result.durable && result.files.some((file) => file.changed))
121
+ ) {
122
+ throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
123
+ }
124
+ const files = result.files.map((file, index) => {
125
+ const batch = batches[index];
126
+ const matched = file.matched ?? [];
127
+ if (
128
+ !batch ||
129
+ file.sourceFile !== batch.sourceFile ||
130
+ (matched.length !== 0 && matched.length !== batch.patches.length)
131
+ ) {
132
+ throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
133
+ }
134
+ reportUnmatchedBatchPatches(batch, matched);
135
+ return {
136
+ ...file,
137
+ matched,
138
+ allMatched: matched.length === batch.patches.length && matched.every(Boolean),
139
+ };
140
+ });
141
+ return { durable: result.durable, files };
142
+ } catch (error) {
143
+ throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
144
+ }
145
+ }
146
+
147
+ /**
148
+ * A batch is reload-skippable only when it is style-only: every operation is an
149
+ * `inline-style` write. The z-reorder commit applies those exact styles to the
150
+ * live iframe DOM synchronously, so persisting them adds nothing the preview
151
+ * doesn't already show. Any other op type (attribute / text-content / …) can
152
+ * have server-side semantics the live DOM hasn't mirrored — reload for those.
153
+ */
154
+ export function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
155
+ return batches.every((batch) =>
156
+ batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
157
+ );
158
+ }