@hyperframes/studio 0.7.58 → 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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.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-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  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 +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  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 +101 -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/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. 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
  }
@@ -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,138 +41,6 @@ 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>;
@@ -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
@@ -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
+ }
@@ -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";
@@ -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,