@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -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,32 +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
- import { readProjectFileContent } from "../utils/studioFileHistory";
32
-
33
- // ── Helpers ──
34
-
35
- function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
36
- return fields.map((field) => field.path).join(", ");
37
- }
38
-
39
- function getErrorDetail(error: unknown): string {
40
- return error instanceof Error ? error.message : String(error);
41
- }
42
-
43
- async function readErrorResponseBody(
44
- response: Response,
45
- ): Promise<{ error?: string; fields?: string[] } | null> {
46
- const contentType = response.headers.get("content-type") ?? "";
47
- if (!contentType.includes("application/json")) return null;
48
- return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
49
- }
50
-
51
- function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
52
- if (!body?.error) return "Couldn't save edit";
53
- return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
54
- }
55
-
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";
56
36
  interface RecordEditInput {
57
37
  label: string;
58
38
  kind: EditHistoryKind;
@@ -61,88 +41,11 @@ interface RecordEditInput {
61
41
  files: Record<string, { before: string; after: string }>;
62
42
  }
63
43
 
64
- /** Human-readable identifier for a batch patch target (for the unmatched warning). */
65
- function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
66
- return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
67
- }
68
-
69
- /**
70
- * Surface server-reported unmatched patches. The matched subset already
71
- * persisted, so this must NOT throw (a throw would roll back applied state) —
72
- * warn and emit save-failure telemetry with a distinct reason instead.
73
- */
74
- function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
75
- const unmatchedIds = batch.patches
76
- .filter((_, index) => matched[index] === false)
77
- .map(describeBatchPatchTarget);
78
- if (unmatchedIds.length === 0) return;
79
- console.warn(
80
- `[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
81
- `${batch.sourceFile} (their z-order will revert on reload):`,
82
- unmatchedIds.join(", "),
83
- );
84
- trackStudioSaveFailure({
85
- source: "dom_edit",
86
- error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
87
- filePath: batch.sourceFile,
88
- mutationType: "z-reorder-unmatched",
89
- });
90
- }
91
-
92
- async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
93
- const before = await readProjectFileContent(projectId, batch.sourceFile);
94
- const response = await fetch(
95
- `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-elements-batch/${encodeURIComponent(batch.sourceFile)}`,
96
- {
97
- method: "POST",
98
- headers: { "Content-Type": "application/json" },
99
- body: JSON.stringify({ patches: batch.patches }),
100
- },
101
- );
102
- if (!response.ok) {
103
- const rejection = await readErrorResponseBody(response);
104
- throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
105
- }
106
- const result = (await response.json()) as {
107
- changed?: boolean;
108
- matched?: boolean[];
109
- content?: string;
110
- };
111
- if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
112
- return {
113
- sourceFile: batch.sourceFile,
114
- changed: result.changed === true,
115
- // Skip-reload safety: the persist is only provably in sync with the live
116
- // DOM when the server confirmed EVERY patch target matched. A missing /
117
- // short matched[] is treated as unknown (false) so the caller falls back
118
- // to reloading rather than silently diverging from disk.
119
- allMatched:
120
- Array.isArray(result.matched) &&
121
- result.matched.length === batch.patches.length &&
122
- result.matched.every(Boolean),
123
- before,
124
- after: typeof result.content === "string" ? result.content : before,
125
- };
126
- }
127
-
128
- /**
129
- * A batch is reload-skippable only when it is style-only: every operation is an
130
- * `inline-style` write. The z-reorder commit applies those exact styles to the
131
- * live iframe DOM synchronously, so persisting them adds nothing the preview
132
- * doesn't already show. Any other op type (attribute / text-content / …) can
133
- * have server-side semantics the live DOM hasn't mirrored — reload for those.
134
- */
135
- function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
136
- return batches.every((batch) =>
137
- batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
138
- );
139
- }
140
-
141
44
  export interface UseDomEditCommitsParams {
142
45
  activeCompPath: string | null;
143
46
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
144
47
  showToast: (message: string, tone?: "error" | "info") => void;
145
- queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
48
+ queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
146
49
  writeProjectFile: (path: string, content: string) => Promise<void>;
147
50
  domEditSaveTimestampRef: React.MutableRefObject<number>;
148
51
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
@@ -168,16 +71,20 @@ export interface UseDomEditCommitsParams {
168
71
  * path, whose session is already current) so a later SDK edit doesn't
169
72
  * serialize the pre-write doc and revert the server's change. */
170
73
  forceReloadSdkSession?: () => void;
171
- /** 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. */
172
75
  onTrySdkPersist?: (
173
76
  selection: DomEditSelection,
174
77
  operations: PatchOperation[],
175
78
  originalContent: string,
176
79
  targetPath: string,
177
80
  options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
178
- ) => Promise<boolean>;
179
- /** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
180
- 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>;
181
88
  /** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
182
89
  onReorderShadow?: (targets: string[]) => void;
183
90
  }
@@ -272,18 +179,17 @@ export function useDomEditCommits({
272
179
  // Skip the SDK path when prepareContent is set (e.g. @font-face injection
273
180
  // for a custom font): sdkCutoverPersist serializes only the patched DOM
274
181
  // and would drop the injected content. Let the server path run prepareContent.
275
- if (
276
- onTrySdkPersist &&
277
- !options?.prepareContent &&
278
- (await onTrySdkPersist(selection, operations, originalContent, targetPath, {
182
+ if (onTrySdkPersist && !options?.prepareContent) {
183
+ const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
279
184
  label: options?.label,
280
185
  coalesceKey: options?.coalesceKey,
281
186
  skipRefresh: options?.skipRefresh,
282
- }))
283
- ) {
284
- // SDK handled it — its in-memory doc is already current, so do NOT
285
- // forceReload (that would echo-reload the session we just wrote).
286
- 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
+ }
287
193
  }
288
194
 
289
195
  // Mark the save timestamp before the file write so the SSE file-change
@@ -382,48 +288,59 @@ export function useDomEditCommits({
382
288
 
383
289
  const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
384
290
  (batches, options) =>
385
- queueDomEditSave(async () => {
386
- const pid = projectIdRef.current;
387
- if (!pid) throw new Error("No active project");
388
- const unsafeFields = batches.flatMap((batch) =>
389
- batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
390
- );
391
- if (unsafeFields.length > 0) {
392
- showToast("Couldn't save edit because it contains invalid layout values", "error");
393
- throw new DomEditPersistUnsafeValueError(
394
- `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
395
- { alreadyToasted: true },
291
+ queueDomEditSave(
292
+ // One queued transaction owns validation, persistence, history, reload,
293
+ // and its durable result; splitting those phases risks partial commits.
294
+ // fallow-ignore-next-line complexity
295
+ async () => {
296
+ const pid = projectIdRef.current;
297
+ if (!pid) throw new Error("No active project");
298
+ const unsafeFields = batches.flatMap((batch) =>
299
+ batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
396
300
  );
397
- }
301
+ if (unsafeFields.length > 0) {
302
+ showToast("Couldn't save edit because it contains invalid layout values", "error");
303
+ throw new DomEditPersistUnsafeValueError(
304
+ `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
305
+ { alreadyToasted: true },
306
+ );
307
+ }
398
308
 
399
- domEditSaveTimestampRef.current = Date.now();
400
- const results = await Promise.all(batches.map((batch) => patchElementBatch(pid, batch)));
401
- const files = Object.fromEntries(
402
- results
403
- .filter((result) => result.changed)
404
- .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
405
- );
406
- if (Object.keys(files).length === 0) return;
407
- await editHistory.recordEdit({
408
- label: options.label,
409
- kind: "manual",
410
- coalesceKey: options.coalesceKey,
411
- files,
412
- });
413
- forceReloadSdkSession?.();
414
- // A z-only reorder already applied its inline styles to the live iframe
415
- // DOM (and the store) synchronously, so remounting the iframe here only
416
- // produces a visible blink. Skip the reload when the caller asked for it
417
- // AND the persist is provably in sync: style-only ops, every target
418
- // matched. Any unmatched patch means the live DOM now shows state disk
419
- // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
420
- // reload is independently suppressed by domEditSaveTimestampRef above.)
421
- const skipSafe =
422
- options.skipReload === true &&
423
- batchesAreInlineStyleOnly(batches) &&
424
- results.every((result) => result.allMatched);
425
- if (!skipSafe) reloadPreview();
426
- }).catch((error) => {
309
+ domEditSaveTimestampRef.current = Date.now();
310
+ const atomicResult = await patchElementBatches(pid, batches);
311
+ const allMatched =
312
+ atomicResult.durable && atomicResult.files.every((result) => result.allMatched);
313
+ const files = Object.fromEntries(
314
+ atomicResult.files
315
+ .filter((result) => result.changed)
316
+ .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
317
+ );
318
+ const changed = Object.keys(files).length > 0;
319
+ if (changed) {
320
+ await editHistory.recordEdit({
321
+ label: options.label,
322
+ kind: "manual",
323
+ coalesceKey: options.coalesceKey,
324
+ coalesceMs: options.coalesceMs,
325
+ files,
326
+ });
327
+ forceReloadSdkSession?.();
328
+ }
329
+ const durable = allMatched;
330
+ // A z-only reorder already applied its inline styles to the live iframe
331
+ // DOM (and the store) synchronously, so remounting the iframe here only
332
+ // produces a visible blink. Skip the reload when the caller asked for it
333
+ // AND the persist is provably in sync: style-only ops, every target
334
+ // matched. Any unmatched patch means the live DOM now shows state disk
335
+ // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
336
+ // reload is independently suppressed by domEditSaveTimestampRef above.)
337
+ const skipSafe =
338
+ options.skipReload === true && batchesAreInlineStyleOnly(batches) && durable;
339
+ if (!durable || (changed && !skipSafe)) reloadPreview();
340
+ return { durable, allMatched, changed };
341
+ },
342
+ ).catch((error) => {
343
+ if (error instanceof AtomicElementPatchConvergenceError) reloadPreview();
427
344
  const alreadyToasted =
428
345
  (error instanceof StudioSaveHttpError ||
429
346
  error instanceof DomEditPersistUnsafeValueError) &&
@@ -458,6 +375,7 @@ export function useDomEditCommits({
458
375
  handleDomAttributeCommit,
459
376
  handleDomAttributeLiveCommit,
460
377
  handleDomHtmlAttributeCommit,
378
+ handleDomAttributesCommit,
461
379
  handleDomTextCommit,
462
380
  commitDomTextFields,
463
381
  handleDomTextFieldStyleCommit,
@@ -520,6 +438,7 @@ export function useDomEditCommits({
520
438
  handleDomAttributeCommit,
521
439
  handleDomAttributeLiveCommit,
522
440
  handleDomHtmlAttributeCommit,
441
+ handleDomAttributesCommit,
523
442
  handleDomTextCommit,
524
443
  commitDomTextFields,
525
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
+ }
@@ -231,7 +231,9 @@ describe("onReorderShadow source filter", () => {
231
231
  setRightPanelTab: vi.fn(),
232
232
  showToast: vi.fn(),
233
233
  refreshPreviewDocumentVersion: vi.fn(),
234
- queueDomEditSave: vi.fn(async (save: () => Promise<void>) => save()),
234
+ queueDomEditSave: vi.fn(async <T,>(save: () => Promise<T>) => save()) as <T>(
235
+ save: () => Promise<T>,
236
+ ) => Promise<T>,
235
237
  readProjectFile,
236
238
  writeProjectFile: vi.fn(async () => {}),
237
239
  updateEditingFileContent: vi.fn(),
@@ -7,7 +7,7 @@ import type { RightPanelTab } from "../utils/studioHelpers";
7
7
  import type { PatchTarget } from "../utils/sourcePatcher";
8
8
  import type { SidebarTab } from "../components/sidebar/LeftSidebar";
9
9
  import type { Composition } from "@hyperframes/sdk";
10
- import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
10
+ import { sdkCutoverPersist, sdkDeletePersist, type PublishSdkSession } from "../utils/sdkCutover";
11
11
  import { runResolverShadow, recordResolverParity } from "../utils/sdkResolverShadow";
12
12
  import { useAskAgentModal } from "./useAskAgentModal";
13
13
  import { useDomSelection } from "./useDomSelection";
@@ -43,7 +43,7 @@ export interface UseDomEditSessionParams {
43
43
  setRightPanelTab: (tab: RightPanelTab) => void;
44
44
  showToast: (message: string, tone?: "error" | "info") => void;
45
45
  refreshPreviewDocumentVersion: () => void;
46
- queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
46
+ queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
47
47
  readProjectFile: (path: string) => Promise<string>;
48
48
  writeProjectFile: (path: string, content: string) => Promise<void>;
49
49
  updateEditingFileContent: (path: string, content: string) => void;
@@ -67,6 +67,7 @@ export interface UseDomEditSessionParams {
67
67
  selectSidebarTab?: (tab: SidebarTab) => void;
68
68
  getSidebarTab?: () => SidebarTab;
69
69
  sdkSession?: Composition | null;
70
+ publishSdkSession?: PublishSdkSession;
70
71
  forceReloadSdkSession?: () => void;
71
72
  }
72
73
 
@@ -108,10 +109,10 @@ export function useDomEditSession({
108
109
  selectSidebarTab,
109
110
  getSidebarTab,
110
111
  sdkSession,
112
+ publishSdkSession,
111
113
  forceReloadSdkSession,
112
114
  }: UseDomEditSessionParams) {
113
115
  void _setRefreshKey;
114
-
115
116
  // ── Selection ──
116
117
 
117
118
  const {
@@ -178,7 +179,6 @@ export function useDomEditSession({
178
179
  previewDocumentVersion,
179
180
  refreshDomEditSelectionFromPreview,
180
181
  });
181
-
182
182
  // ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
183
183
 
184
184
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
@@ -217,6 +217,7 @@ export function useDomEditSession({
217
217
  onFileContentChanged: updateEditingFileContent,
218
218
  showToast,
219
219
  sdkSession,
220
+ publishSdkSession,
220
221
  writeProjectFile,
221
222
  forceReloadSdkSession,
222
223
  });
@@ -229,6 +230,7 @@ export function useDomEditSession({
229
230
  handleDomAttributeCommit,
230
231
  handleDomAttributeLiveCommit,
231
232
  handleDomHtmlAttributeCommit,
233
+ handleDomAttributesCommit,
232
234
  handleDomTextCommit,
233
235
  handleDomTextFieldStyleCommit,
234
236
  handleDomAddTextField,
@@ -260,8 +262,13 @@ export function useDomEditSession({
260
262
  ? (selection, operations, originalContent, targetPath, options) => {
261
263
  // Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
262
264
  // Pass originalContent so the runtime-node filter can suppress hf-ids
263
- // absent from source (script-created nodes the SDK can't model).
264
- runResolverShadow(sdkSession, selection.hfId, operations, originalContent);
265
+ // absent from source (script-created nodes the SDK can't model), and
266
+ // the paths so cross-file edits (session models only the active comp)
267
+ // skip instead of emitting structural element_not_found noise.
268
+ runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
269
+ targetPath,
270
+ compositionPath: activeCompPath,
271
+ });
265
272
  return sdkCutoverPersist(
266
273
  selection,
267
274
  operations,
@@ -274,6 +281,8 @@ export function useDomEditSession({
274
281
  reloadPreview,
275
282
  domEditSaveTimestampRef,
276
283
  compositionPath: activeCompPath,
284
+ readProjectFile,
285
+ publishSession: publishSdkSession,
277
286
  },
278
287
  options,
279
288
  );
@@ -287,6 +296,8 @@ export function useDomEditSession({
287
296
  reloadPreview,
288
297
  domEditSaveTimestampRef,
289
298
  compositionPath: activeCompPath,
299
+ readProjectFile,
300
+ publishSession: publishSdkSession,
290
301
  })
291
302
  : undefined,
292
303
  // Resolver shadow for the z-index reorder edit: it takes the server path (no
@@ -524,6 +535,7 @@ export function useDomEditSession({
524
535
  handleDomAttributeCommit,
525
536
  handleDomAttributeLiveCommit,
526
537
  handleDomHtmlAttributeCommit,
538
+ handleDomAttributesCommit,
527
539
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
528
540
  handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
529
541
  handleDomZIndexReorderCommit,
@@ -145,15 +145,19 @@ export function useDomEditTextCommits({
145
145
  const domTextCommitVersionRef = useRef(0);
146
146
  const domStyleCommitVersionRef = useRef(new Map<string, number>());
147
147
 
148
- const { handleDomAttributeCommit, handleDomAttributeLiveCommit, handleDomHtmlAttributeCommit } =
149
- useDomEditAttributeCommits({
150
- activeCompPath,
151
- previewIframeRef,
152
- showToast,
153
- domEditSelection,
154
- refreshDomEditSelectionFromPreview,
155
- persistDomEditOperations,
156
- });
148
+ const {
149
+ handleDomAttributeCommit,
150
+ handleDomAttributeLiveCommit,
151
+ handleDomHtmlAttributeCommit,
152
+ handleDomAttributesCommit,
153
+ } = useDomEditAttributeCommits({
154
+ activeCompPath,
155
+ previewIframeRef,
156
+ showToast,
157
+ domEditSelection,
158
+ refreshDomEditSelectionFromPreview,
159
+ persistDomEditOperations,
160
+ });
157
161
 
158
162
  const handleDomStyleCommit = useCallback(
159
163
  async (property: string, value: string) => {
@@ -471,6 +475,7 @@ export function useDomEditTextCommits({
471
475
  handleDomAttributeCommit,
472
476
  handleDomAttributeLiveCommit,
473
477
  handleDomHtmlAttributeCommit,
478
+ handleDomAttributesCommit,
474
479
  handleDomTextCommit,
475
480
  commitDomTextFields,
476
481
  handleDomTextFieldStyleCommit,