@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
@@ -0,0 +1,293 @@
1
+ import type { MutableRefObject } from "react";
2
+ import { openComposition, type Composition } from "@hyperframes/sdk";
3
+ import type { EditHistoryKind } from "./editHistory";
4
+ import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
5
+ import { trackStudioEvent } from "./studioTelemetry";
6
+ import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
7
+
8
+ export type CutoverResult =
9
+ | { status: "declined"; reason: string }
10
+ | { status: "committed"; version: string }
11
+ | { status: "failed"; error: Error };
12
+
13
+ export interface SdkSessionPublication {
14
+ candidate: Composition;
15
+ expectedSession: Composition;
16
+ targetPath: string;
17
+ }
18
+
19
+ export type SdkSessionPublicationResult =
20
+ | "published"
21
+ | "rejected-active-target"
22
+ | "rejected-inactive-target";
23
+
24
+ export type PublishSdkSession = (publication: SdkSessionPublication) => SdkSessionPublicationResult;
25
+
26
+ export interface CutoverDeps {
27
+ editHistory: {
28
+ recordEdit: (entry: {
29
+ label: string;
30
+ kind: EditHistoryKind;
31
+ coalesceKey?: string;
32
+ coalesceMs?: number;
33
+ files: Record<string, { before: string; after: string }>;
34
+ }) => Promise<void>;
35
+ };
36
+ /**
37
+ * Must be bound to one project. Its identity plus path scopes the shared
38
+ * mutation queue used by every whole-file writer in that project.
39
+ */
40
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
41
+ reloadPreview: () => void;
42
+ domEditSaveTimestampRef: MutableRefObject<number>;
43
+ refresh?: (after: string) => void;
44
+ compositionPath?: string | null;
45
+ readProjectFile?: (path: string) => Promise<string>;
46
+ /**
47
+ * Takes ownership only when it returns `published`. Rejection identifies
48
+ * whether target-sensitive preview refresh is still safe. MUST NOT throw
49
+ * after installation; rejection leaves ownership with the caller.
50
+ */
51
+ publishSession?: PublishSdkSession;
52
+ /** Test seam; production clones with openComposition. */
53
+ createCandidateSession?: (serialized: string, live: Composition) => Promise<Composition>;
54
+ }
55
+
56
+ export interface CutoverOptions {
57
+ label?: string;
58
+ coalesceKey?: string;
59
+ /** Coalesce window (ms); Infinity folds across a slow round-trip. */
60
+ coalesceMs?: number;
61
+ skipRefresh?: boolean;
62
+ }
63
+
64
+ interface CandidateEdit {
65
+ live: Composition;
66
+ candidate: Composition;
67
+ serializedBefore: string;
68
+ after: string;
69
+ }
70
+
71
+ export function declinedCutover(reason: string): CutoverResult {
72
+ return { status: "declined", reason };
73
+ }
74
+
75
+ export function asCutoverError(error: unknown): Error {
76
+ return error instanceof Error ? error : new Error(String(error));
77
+ }
78
+
79
+ /** Only an explicit decline may enter the legacy mutation backend. */
80
+ export function cutoverCommittedOrThrow(result: CutoverResult): boolean {
81
+ if (result.status === "failed") throw result.error;
82
+ return result.status === "committed";
83
+ }
84
+
85
+ /** A supplied authoritative reader fails closed; only its absence uses the snapshot fallback. */
86
+ async function captureOnDiskBefore(
87
+ deps: CutoverDeps,
88
+ targetPath: string,
89
+ serializedFallback: string,
90
+ ): Promise<string> {
91
+ if (!deps.readProjectFile) return serializedFallback;
92
+ return deps.readProjectFile(targetPath);
93
+ }
94
+
95
+ function disposeCandidate(candidate: Composition | undefined, live: Composition): void {
96
+ if (candidate && candidate !== live) candidate.dispose();
97
+ }
98
+
99
+ function createCandidateSession(
100
+ serializedBefore: string,
101
+ live: Composition,
102
+ deps: CutoverDeps,
103
+ ): Promise<Composition> {
104
+ return deps.createCandidateSession
105
+ ? deps.createCandidateSession(serializedBefore, live)
106
+ : openComposition(serializedBefore, { history: false });
107
+ }
108
+
109
+ function candidatePublisherAvailable(
110
+ candidate: Composition,
111
+ live: Composition,
112
+ deps: CutoverDeps,
113
+ ): boolean {
114
+ return candidate === live || deps.publishSession !== undefined;
115
+ }
116
+
117
+ async function buildCandidateEdit(
118
+ live: Composition,
119
+ deps: CutoverDeps,
120
+ mutate: (candidate: Composition) => void,
121
+ sourceSnapshot?: string,
122
+ ): Promise<CandidateEdit | CutoverResult> {
123
+ let candidate: Composition | undefined;
124
+ try {
125
+ const serializedBefore = sourceSnapshot ?? live.serialize();
126
+ candidate = await createCandidateSession(serializedBefore, live, deps);
127
+ candidate.batch(() => mutate(candidate!));
128
+ const after = candidate.serialize();
129
+ if (after === serializedBefore) {
130
+ disposeCandidate(candidate, live);
131
+ return declinedCutover("no_change");
132
+ }
133
+ if (!candidatePublisherAvailable(candidate, live, deps)) {
134
+ disposeCandidate(candidate, live);
135
+ return { status: "failed", error: new Error("SDK candidate publisher is unavailable") };
136
+ }
137
+ return { live, candidate, serializedBefore, after };
138
+ } catch (error) {
139
+ disposeCandidate(candidate, live);
140
+ return { status: "failed", error: asCutoverError(error) };
141
+ }
142
+ }
143
+
144
+ function isCutoverResult(value: CandidateEdit | CutoverResult): value is CutoverResult {
145
+ return "status" in value;
146
+ }
147
+
148
+ async function rollbackWrite(
149
+ targetPath: string,
150
+ originalContent: string,
151
+ expectedCurrentContent: string,
152
+ deps: CutoverDeps,
153
+ cause: Error,
154
+ ): Promise<Error> {
155
+ try {
156
+ deps.domEditSaveTimestampRef.current = Date.now();
157
+ markSelfWrite(targetPath, originalContent);
158
+ await deps.writeProjectFile(targetPath, originalContent, expectedCurrentContent);
159
+ return cause;
160
+ } catch (rollbackError) {
161
+ return new AggregateError(
162
+ [cause, asCutoverError(rollbackError)],
163
+ `SDK edit failed and rollback could not restore ${targetPath}`,
164
+ );
165
+ }
166
+ }
167
+
168
+ async function writeAndRecord(
169
+ after: string,
170
+ targetPath: string,
171
+ originalContent: string,
172
+ deps: CutoverDeps,
173
+ options?: CutoverOptions,
174
+ ): Promise<Error | null> {
175
+ deps.domEditSaveTimestampRef.current = Date.now();
176
+ markSelfWrite(targetPath, after);
177
+ try {
178
+ await deps.writeProjectFile(targetPath, after, originalContent);
179
+ } catch (error) {
180
+ return asCutoverError(error);
181
+ }
182
+ try {
183
+ await deps.editHistory.recordEdit({
184
+ label: options?.label ?? "Edit layer",
185
+ kind: "manual",
186
+ ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
187
+ ...(options?.coalesceMs != null ? { coalesceMs: options.coalesceMs } : {}),
188
+ files: { [targetPath]: { before: originalContent, after } },
189
+ });
190
+ return null;
191
+ } catch (error) {
192
+ return rollbackWrite(targetPath, originalContent, after, deps, asCutoverError(error));
193
+ }
194
+ }
195
+
196
+ function refreshCommittedEdit(after: string, deps: CutoverDeps, options?: CutoverOptions): void {
197
+ try {
198
+ if (deps.refresh) deps.refresh(after);
199
+ else if (!options?.skipRefresh) deps.reloadPreview();
200
+ } catch (error) {
201
+ trackStudioEvent("sdk_cutover_refresh_failed", { error: asCutoverError(error).message });
202
+ }
203
+ }
204
+
205
+ async function commitCandidateEdit(
206
+ edit: CandidateEdit,
207
+ targetPath: string,
208
+ originalContent: string,
209
+ deps: CutoverDeps,
210
+ options?: CutoverOptions,
211
+ ): Promise<CutoverResult> {
212
+ const writeError = await writeAndRecord(edit.after, targetPath, originalContent, deps, options);
213
+ if (writeError) {
214
+ if (edit.candidate !== edit.live) edit.candidate.dispose();
215
+ return { status: "failed", error: writeError };
216
+ }
217
+ let refreshTarget = true;
218
+ try {
219
+ if (edit.candidate !== edit.live) {
220
+ const publication = deps.publishSession!({
221
+ candidate: edit.candidate,
222
+ expectedSession: edit.live,
223
+ targetPath,
224
+ });
225
+ if (publication !== "published") edit.candidate.dispose();
226
+ if (publication === "rejected-inactive-target") refreshTarget = false;
227
+ }
228
+ } catch (error) {
229
+ // Persistence and history are already committed. A publisher can throw
230
+ // after installing the candidate, so rolling back disk or disposing the
231
+ // candidate here can make all three authorities disagree (or dispose the
232
+ // now-live session). Production publishers are non-throwing; keep the
233
+ // durable commit authoritative and surface this post-commit fault.
234
+ trackStudioEvent("sdk_cutover_publish_failed", {
235
+ path: targetPath,
236
+ error: asCutoverError(error).message,
237
+ });
238
+ }
239
+ if (refreshTarget) refreshCommittedEdit(edit.after, deps, options);
240
+ return { status: "committed", version: hashContent(edit.after) };
241
+ }
242
+
243
+ export async function persistSdkCandidateMutation(
244
+ live: Composition,
245
+ targetPath: string,
246
+ originalContent: string,
247
+ deps: CutoverDeps,
248
+ mutate: (candidate: Composition) => void,
249
+ options?: CutoverOptions,
250
+ sourceSnapshot?: string,
251
+ ): Promise<CutoverResult> {
252
+ return serializeStudioFileMutation(
253
+ deps.writeProjectFile,
254
+ targetPath,
255
+ async (): Promise<CutoverResult> => {
256
+ // Re-read only after acquiring the path queue. This makes each candidate
257
+ // clone the latest committed bytes, even when React has not yet re-rendered
258
+ // and the caller still holds the preceding live-session object.
259
+ const serializedFallback = sourceSnapshot ?? originalContent;
260
+ let onDiskBefore: string;
261
+ try {
262
+ onDiskBefore = await captureOnDiskBefore(deps, targetPath, serializedFallback);
263
+ } catch (error) {
264
+ return { status: "failed", error: asCutoverError(error) };
265
+ }
266
+ // Preserve the live-session path for adapters without a reader (notably
267
+ // isolated consumers/tests). Production Studio supplies a reader so
268
+ // queued edits always clone the latest durable source.
269
+ const candidateSource = deps.readProjectFile ? onDiskBefore : sourceSnapshot;
270
+ const candidate = await buildCandidateEdit(live, deps, mutate, candidateSource);
271
+ if (isCutoverResult(candidate)) return candidate;
272
+ return commitCandidateEdit(candidate, targetPath, onDiskBefore, deps, options);
273
+ },
274
+ );
275
+ }
276
+
277
+ /** Transactional writer for non-SDK islands and throwaway composition sessions. */
278
+ export async function persistSdkSerialize(
279
+ buildAfter: (onDiskBefore: string) => string | Promise<string>,
280
+ targetPath: string,
281
+ originalContent: string,
282
+ deps: CutoverDeps,
283
+ options?: CutoverOptions,
284
+ ): Promise<void> {
285
+ await serializeStudioFileMutation(deps.writeProjectFile, targetPath, async () => {
286
+ const onDiskBefore = await captureOnDiskBefore(deps, targetPath, originalContent);
287
+ const after = await buildAfter(onDiskBefore);
288
+ if (after === onDiskBefore) return;
289
+ const error = await writeAndRecord(after, targetPath, onDiskBefore, deps, options);
290
+ if (error) throw error;
291
+ refreshCommittedEdit(after, deps, options);
292
+ });
293
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Attempt counter — the denominator for the resolver-shadow soak gate.
3
+ *
4
+ * The emit functions in sdkResolverShadow.ts only fire a PostHog event on
5
+ * divergence — parity is silent, by design, to avoid firing on every edit.
6
+ * That leaves no way to compute a rate (divergences / attempts): we can count
7
+ * failures but never attempts. This counter tracks attempts in memory and
8
+ * rolls them up into ONE low-frequency event instead of firing per-attempt,
9
+ * which would recreate the exact chattiness problem the divergence-only
10
+ * design avoids.
11
+ */
12
+
13
+ import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
14
+
15
+ const attemptCounts: Record<string, number> = {};
16
+
17
+ /**
18
+ * Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
19
+ * outcome (parity or divergence). No flag check of its own — only ever called
20
+ * from inside the shadow emit functions, after their own
21
+ * STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
22
+ */
23
+ export function recordAttempt(opLabel: string): void {
24
+ attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
25
+ ensureAttemptFlushScheduled();
26
+ }
27
+
28
+ /**
29
+ * Return the accumulated attempt counts since the last flush (or `null` if
30
+ * nothing has been recorded — no point emitting an empty rollup), and reset
31
+ * the counter to empty.
32
+ */
33
+ export function flushAttemptCounts(): Record<string, number> | null {
34
+ const keys = Object.keys(attemptCounts);
35
+ if (keys.length === 0) return null;
36
+ const snapshot: Record<string, number> = {};
37
+ for (const key of keys) {
38
+ snapshot[key] = attemptCounts[key];
39
+ delete attemptCounts[key];
40
+ }
41
+ return snapshot;
42
+ }
43
+
44
+ const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
45
+ let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
46
+ let attemptVisibilityHandler: (() => void) | null = null;
47
+
48
+ function flushAndEmitAttempts(): void {
49
+ const counts = flushAttemptCounts();
50
+ if (counts === null) return;
51
+ trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
52
+ }
53
+
54
+ // Lazily starts the rollup timer + visibilitychange listener on the FIRST
55
+ // attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
56
+ // start, so a session that never exercises the tripwire never runs a
57
+ // background timer.
58
+ function ensureAttemptFlushScheduled(): void {
59
+ if (!attemptFlushTimer) {
60
+ attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
61
+ }
62
+ if (!attemptVisibilityHandler && typeof document !== "undefined") {
63
+ attemptVisibilityHandler = () => {
64
+ if (document.visibilityState !== "hidden") return;
65
+ flushAndEmitAttempts();
66
+ // studioTelemetry.ts registers its own visibilitychange listener (on
67
+ // window, at module load) that drains its queue via sendBeacon. Listener
68
+ // execution order between that handler and this one (on document,
69
+ // registered lazily) is not something to rely on — whichever runs
70
+ // first could otherwise beacon-flush before or after this rollup lands
71
+ // in the queue. Forcing a beacon flush here makes delivery of this
72
+ // rollup event correct regardless of that order.
73
+ flushViaBeacon();
74
+ };
75
+ document.addEventListener("visibilitychange", attemptVisibilityHandler);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Test-only: clears the lazy timer/listener singleton state so tests can
81
+ * verify the "starts on first attempt" behavior in isolation, without an
82
+ * earlier test's real-timer interval (or visibilitychange listener) silently
83
+ * surviving into a later test. Does NOT touch attemptCounts — only the
84
+ * scheduling state. Not part of the public module contract; only imported
85
+ * from sdkResolverShadow.test.ts.
86
+ */
87
+ export function __resetAttemptSchedulingForTests(): void {
88
+ if (attemptFlushTimer) clearInterval(attemptFlushTimer);
89
+ attemptFlushTimer = null;
90
+ if (attemptVisibilityHandler && typeof document !== "undefined") {
91
+ document.removeEventListener("visibilitychange", attemptVisibilityHandler);
92
+ }
93
+ attemptVisibilityHandler = null;
94
+ }
@@ -432,6 +432,18 @@ describe("F. recordResolverParity", () => {
432
432
  expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
433
433
  });
434
434
 
435
+ it("skips entirely (no event, no attempt) for a cross-file op", async () => {
436
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
437
+ flushAttemptCounts();
438
+ const session = await openComposition(BASE_HTML);
439
+ await recordResolverParity(session, "hf-cross", "setTiming", undefined, {
440
+ targetPath: "compositions/other.html",
441
+ compositionPath: "index.html",
442
+ });
443
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
444
+ expect(flushAttemptCounts()).toBeNull();
445
+ });
446
+
435
447
  it("emits with sourceHfIdCount=1 when the hfId IS in source but missing from the session", async () => {
436
448
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
437
449
  const session = await openComposition(BASE_HTML);
@@ -562,6 +574,142 @@ describe("G. recordAnimationResolverParity", () => {
562
574
  recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes");
563
575
  expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
564
576
  });
577
+
578
+ // ── Stale-session disambiguation (the 0.7.48 keyframe-op class) ─────────────
579
+ // The GSAP panel computes animationIds from the CURRENT on-disk script (the
580
+ // server read path re-reads per render), but the session's parsed id space
581
+ // reflects the last session reload. An edit that shifts tween positions
582
+ // shifts every `selector-method-position` id, so a panel op landing between
583
+ // the write and the session reload targets an id the session has never seen.
584
+ // That is a sync gap, not a resolver bug — disambiguate against disk truth.
585
+
586
+ // Same tween, position moved 0→3: every id in the NEW script differs from
587
+ // the ids the OLD (session) script parses to.
588
+ const GSAP_DISK_MOVED_HTML = /* html */ `<!DOCTYPE html>
589
+ <html><body>
590
+ <div data-hf-id="hf-box" style="color: red">Hello</div>
591
+ <script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 3);</script>
592
+ </body></html>`;
593
+
594
+ it("suppresses the emit when the animationId resolves against the on-disk script (stale session)", async () => {
595
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
596
+ const session = await openComposition(GSAP_HTML); // session parsed the OLD script
597
+ const disk = await openComposition(GSAP_DISK_MOVED_HTML);
598
+ const diskId = [...disk.getAllAnimationIds()][0] ?? "";
599
+ disk.dispose();
600
+ expect(diskId).not.toBe("");
601
+ expect(session.getAllAnimationIds().has(diskId)).toBe(false); // session can't see it
602
+ await recordAnimationResolverParity(session, diskId, "removeGsapKeyframe", () =>
603
+ Promise.resolve(GSAP_DISK_MOVED_HTML),
604
+ );
605
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
606
+ });
607
+
608
+ it("emits with diskChecked when the animationId resolves in NEITHER session nor disk", async () => {
609
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
610
+ const session = await openComposition(GSAP_HTML);
611
+ await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", () =>
612
+ Promise.resolve(GSAP_DISK_MOVED_HTML),
613
+ );
614
+ const ev = lastShadow();
615
+ expect(ev?.mismatchCount).toBe(1);
616
+ expect(ev?.diskChecked).toBe(true);
617
+ expect(JSON.stringify(ev?.mismatches)).toContain("animation_not_found");
618
+ });
619
+
620
+ it("fails open with sourceReadFailed when the reader throws", async () => {
621
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
622
+ const session = await openComposition(GSAP_HTML);
623
+ await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", () =>
624
+ Promise.reject(new Error("read failed")),
625
+ );
626
+ const ev = lastShadow();
627
+ expect(ev?.mismatchCount).toBe(1);
628
+ expect(ev?.sourceReadFailed).toBe(true);
629
+ expect(ev?.diskChecked).toBeUndefined();
630
+ });
631
+
632
+ it("fails open with sourceReadFailed when the reader throws SYNCHRONOUSLY", async () => {
633
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
634
+ const session = await openComposition(GSAP_HTML);
635
+ await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", () => {
636
+ throw new Error("sync read failed");
637
+ });
638
+ const ev = lastShadow();
639
+ expect(ev?.mismatchCount).toBe(1);
640
+ expect(ev?.sourceReadFailed).toBe(true);
641
+ });
642
+
643
+ it("dispatches the disk read synchronously on a miss — before the caller's write can land", async () => {
644
+ // The tripwire is fire-and-forget and the caller's cutover persist writes
645
+ // this same file moments after it returns. The read request must be
646
+ // dispatched in the same sync prologue as the miss check, so it observes
647
+ // the PRE-write file (a post-write read would see a remove op's target
648
+ // legitimately gone and misclassify it as a genuine divergence).
649
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
650
+ const session = await openComposition(GSAP_HTML);
651
+ const read = vi.fn(() => Promise.resolve(GSAP_DISK_MOVED_HTML));
652
+ const pending = recordAnimationResolverParity(
653
+ session,
654
+ "no-such-anim",
655
+ "removeGsapKeyframe",
656
+ read,
657
+ );
658
+ expect(read).toHaveBeenCalledTimes(1); // already dispatched, no await yet
659
+ await pending;
660
+ });
661
+
662
+ it("skips entirely (no event, no attempt) for a cross-file op", async () => {
663
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
664
+ flushAttemptCounts();
665
+ const session = await openComposition(GSAP_HTML);
666
+ await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", undefined, {
667
+ targetPath: "compositions/other.html",
668
+ compositionPath: "index.html",
669
+ });
670
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
671
+ expect(flushAttemptCounts()).toBeNull();
672
+ });
673
+ });
674
+
675
+ // ─── G2. runResolverShadow cross-file guard ───────────────────────────────────
676
+ // PostHog 0.7.41: one session emitted 479 element_not_found because the user
677
+ // edited elements whose sourceFile differed from the active composition — the
678
+ // session models ONLY the active comp, so cross-file targets are structurally
679
+ // unresolvable. The cutover gates already decline via wrongCompositionFile;
680
+ // the tripwire must skip the same way (no event, no attempt — the op can't
681
+ // cut over, so it belongs in neither side of the soak rate).
682
+ describe("G2. runResolverShadow cross-file guard", () => {
683
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
684
+
685
+ it("skips entirely when targetPath differs from the session's composition", async () => {
686
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
687
+ flushAttemptCounts();
688
+ const session = await openComposition(BASE_HTML);
689
+ runResolverShadow(session, "hf-cross", ops, undefined, {
690
+ targetPath: "compositions/sample-vote-count.html",
691
+ compositionPath: "templates/document-card.html",
692
+ });
693
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
694
+ expect(flushAttemptCounts()).toBeNull();
695
+ });
696
+
697
+ it("still emits for a same-file divergence", async () => {
698
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
699
+ const session = await openComposition(BASE_HTML);
700
+ runResolverShadow(session, "hf-missing", ops, undefined, {
701
+ targetPath: "index.html",
702
+ compositionPath: "index.html",
703
+ });
704
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
705
+ });
706
+
707
+ it("runs normally when paths are not supplied (status quo)", async () => {
708
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
709
+ const session = await openComposition(BASE_HTML);
710
+ runResolverShadow(session, "hf-missing", ops);
711
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
712
+ });
565
713
  });
566
714
 
567
715
  // ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────