@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
@@ -3,7 +3,11 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { computeCurrentPercentage } from "./gsapDragCommit";
6
- import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
6
+ import {
7
+ getStudioSaveErrorMessage,
8
+ isStudioSaveErrorAlreadyToasted,
9
+ trackStudioSaveFailure,
10
+ } from "../utils/studioSaveDiagnostics";
7
11
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
12
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
9
13
 
@@ -34,6 +38,7 @@ export function useGsapSelectionHandlers({
34
38
  removeAllKeyframes,
35
39
  handleDomManualEditsReset,
36
40
  selectedGsapAnimations,
41
+ showToast,
37
42
  }: {
38
43
  domEditSelection: DomEditSelection | null;
39
44
  updateGsapProperty: (
@@ -46,24 +51,24 @@ export function useGsapSelectionHandlers({
46
51
  sel: DomEditSelection,
47
52
  animId: string,
48
53
  updates: { duration?: number; ease?: string; position?: number },
49
- ) => void;
50
- deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
51
- deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
54
+ ) => Promise<void>;
55
+ deleteGsapAnimation: (sel: DomEditSelection, animId: string) => Promise<void>;
56
+ deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => Promise<void>;
52
57
  addGsapAnimation: (
53
58
  sel: DomEditSelection,
54
59
  method: "to" | "from" | "set" | "fromTo",
55
60
  time: number,
56
61
  ) => Promise<void>;
57
- addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
58
- removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
62
+ addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
63
+ removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
59
64
  updateGsapFromProperty: (
60
65
  sel: DomEditSelection,
61
66
  animId: string,
62
67
  prop: string,
63
68
  value: number | string,
64
- ) => void;
65
- addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
66
- removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
69
+ ) => Promise<void>;
70
+ addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
71
+ removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
67
72
  addKeyframe: (
68
73
  sel: DomEditSelection,
69
74
  animId: string,
@@ -104,10 +109,11 @@ export function useGsapSelectionHandlers({
104
109
  duration?: number,
105
110
  commitOverrides?: Partial<CommitMutationOptions>,
106
111
  ) => Promise<void>;
107
- removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
112
+ removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
108
113
 
109
114
  handleDomManualEditsReset: (sel: DomEditSelection) => void;
110
115
  selectedGsapAnimations: GsapAnimation[];
116
+ showToast: (message: string, tone?: "error" | "info") => void;
111
117
  }) {
112
118
  const lastSelectionRef = useRef<DomEditSelection | null>(null);
113
119
  if (domEditSelection) lastSelectionRef.current = domEditSelection;
@@ -124,8 +130,20 @@ export function useGsapSelectionHandlers({
124
130
  targetSelector: selection.selector,
125
131
  targetSourceFile: selection.sourceFile,
126
132
  });
133
+ if (!isStudioSaveErrorAlreadyToasted(error)) {
134
+ showToast(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
135
+ }
136
+ },
137
+ [showToast],
138
+ );
139
+
140
+ const observeGsapMutation = useCallback(
141
+ (mutation: Promise<void>, selection: DomEditSelection, mutationType: string, label: string) => {
142
+ void mutation.catch((error) => {
143
+ trackGsapHandlerFailure(error, selection, mutationType, label);
144
+ });
127
145
  },
128
- [],
146
+ [trackGsapHandlerFailure],
129
147
  );
130
148
 
131
149
  const handleGsapUpdateProperty = useCallback(
@@ -144,18 +162,23 @@ export function useGsapSelectionHandlers({
144
162
  ) => {
145
163
  const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
146
164
  if (!sel) return;
147
- updateGsapMeta(sel, animId, updates);
165
+ observeGsapMutation(
166
+ updateGsapMeta(sel, animId, updates),
167
+ sel,
168
+ "update-meta",
169
+ "Edit GSAP animation",
170
+ );
148
171
  },
149
- [domEditSelection, updateGsapMeta],
172
+ [domEditSelection, observeGsapMutation, updateGsapMeta],
150
173
  );
151
174
 
152
175
  const handleGsapDeleteAnimation = useCallback(
153
176
  (animId: string) => {
154
177
  const sel = domEditSelection ?? lastSelectionRef.current;
155
178
  if (!sel) return;
156
- deleteGsapAnimation(sel, animId);
179
+ observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
157
180
  },
158
- [domEditSelection, deleteGsapAnimation],
181
+ [domEditSelection, deleteGsapAnimation, observeGsapMutation],
159
182
  );
160
183
 
161
184
  const handleGsapDeleteAllForElement = useCallback(
@@ -163,9 +186,14 @@ export function useGsapSelectionHandlers({
163
186
  const sel = domEditSelection ?? lastSelectionRef.current;
164
187
  if (!sel) return;
165
188
  trackStudioEvent("keyframe", { action: "delete_all" });
166
- deleteAllForSelector(sel, targetSelector);
189
+ observeGsapMutation(
190
+ deleteAllForSelector(sel, targetSelector),
191
+ sel,
192
+ "delete-all-for-selector",
193
+ "Delete all animations for element",
194
+ );
167
195
  },
168
- [domEditSelection, deleteAllForSelector],
196
+ [domEditSelection, deleteAllForSelector, observeGsapMutation],
169
197
  );
170
198
 
171
199
  const handleGsapAddAnimation = useCallback(
@@ -186,41 +214,66 @@ export function useGsapSelectionHandlers({
186
214
  const handleGsapAddProperty = useCallback(
187
215
  (animId: string, prop: string) => {
188
216
  if (!domEditSelection) return;
189
- addGsapProperty(domEditSelection, animId, prop);
217
+ observeGsapMutation(
218
+ addGsapProperty(domEditSelection, animId, prop),
219
+ domEditSelection,
220
+ "add-property",
221
+ `Add GSAP ${prop}`,
222
+ );
190
223
  },
191
- [domEditSelection, addGsapProperty],
224
+ [domEditSelection, addGsapProperty, observeGsapMutation],
192
225
  );
193
226
 
194
227
  const handleGsapRemoveProperty = useCallback(
195
228
  (animId: string, prop: string) => {
196
229
  if (!domEditSelection) return;
197
- removeGsapProperty(domEditSelection, animId, prop);
230
+ observeGsapMutation(
231
+ removeGsapProperty(domEditSelection, animId, prop),
232
+ domEditSelection,
233
+ "remove-property",
234
+ `Remove GSAP ${prop}`,
235
+ );
198
236
  },
199
- [domEditSelection, removeGsapProperty],
237
+ [domEditSelection, observeGsapMutation, removeGsapProperty],
200
238
  );
201
239
 
202
240
  const handleGsapUpdateFromProperty = useCallback(
203
241
  (animId: string, prop: string, value: number | string) => {
204
242
  if (!domEditSelection) return;
205
- updateGsapFromProperty(domEditSelection, animId, prop, value);
243
+ observeGsapMutation(
244
+ updateGsapFromProperty(domEditSelection, animId, prop, value),
245
+ domEditSelection,
246
+ "update-from-property",
247
+ `Edit GSAP from-${prop}`,
248
+ );
206
249
  },
207
- [domEditSelection, updateGsapFromProperty],
250
+ [domEditSelection, observeGsapMutation, updateGsapFromProperty],
208
251
  );
209
252
 
210
253
  const handleGsapAddFromProperty = useCallback(
211
254
  (animId: string, prop: string) => {
212
255
  if (!domEditSelection) return;
213
- addGsapFromProperty(domEditSelection, animId, prop);
256
+ observeGsapMutation(
257
+ addGsapFromProperty(domEditSelection, animId, prop),
258
+ domEditSelection,
259
+ "add-from-property",
260
+ `Add GSAP from-${prop}`,
261
+ );
214
262
  },
215
- [domEditSelection, addGsapFromProperty],
263
+ [domEditSelection, addGsapFromProperty, observeGsapMutation],
216
264
  );
217
265
 
218
266
  const handleGsapRemoveFromProperty = useCallback(
219
267
  (animId: string, prop: string) => {
220
268
  if (!domEditSelection) return;
221
- removeGsapFromProperty(domEditSelection, animId, prop);
269
+ observeGsapMutation(
270
+ removeGsapFromProperty(domEditSelection, animId, prop),
271
+ domEditSelection,
272
+ "remove-from-property",
273
+ `Remove GSAP from-${prop}`,
274
+ );
222
275
  },
223
- [domEditSelection, removeGsapFromProperty],
276
+ [domEditSelection, observeGsapMutation, removeGsapFromProperty],
224
277
  );
225
278
 
226
279
  const handleGsapAddKeyframe = useCallback(
@@ -354,18 +407,28 @@ export function useGsapSelectionHandlers({
354
407
  const handleGsapRemoveAllKeyframes = useCallback(
355
408
  (animId: string) => {
356
409
  if (!domEditSelection) return;
357
- removeAllKeyframes(domEditSelection, animId);
410
+ observeGsapMutation(
411
+ removeAllKeyframes(domEditSelection, animId),
412
+ domEditSelection,
413
+ "remove-all-keyframes",
414
+ "Remove all keyframes",
415
+ );
358
416
  },
359
- [domEditSelection, removeAllKeyframes],
417
+ [domEditSelection, observeGsapMutation, removeAllKeyframes],
360
418
  );
361
419
 
362
420
  const handleResetSelectedElementKeyframes = useCallback((): boolean => {
363
421
  if (!domEditSelection) return false;
364
422
  const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
365
423
  if (!withKeyframes) return false;
366
- removeAllKeyframes(domEditSelection, withKeyframes.id);
424
+ observeGsapMutation(
425
+ removeAllKeyframes(domEditSelection, withKeyframes.id),
426
+ domEditSelection,
427
+ "remove-all-keyframes",
428
+ "Remove all keyframes",
429
+ );
367
430
  return true;
368
- }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
431
+ }, [domEditSelection, observeGsapMutation, removeAllKeyframes, selectedGsapAnimations]);
369
432
 
370
433
  return {
371
434
  handleGsapUpdateProperty,
@@ -0,0 +1,51 @@
1
+ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
+
3
+ const MIN_INSPECTOR_SPLIT_PERCENT = 20;
4
+ const MAX_INSPECTOR_SPLIT_PERCENT = 75;
5
+
6
+ export function useInspectorSplitResize() {
7
+ const [layersPanePercent, setLayersPanePercent] = useState(40);
8
+ const splitContainerRef = useRef<HTMLDivElement>(null);
9
+ const splitDragRef = useRef<{
10
+ startY: number;
11
+ startPercent: number;
12
+ height: number;
13
+ } | null>(null);
14
+
15
+ const handleInspectorSplitResizeStart = useCallback(
16
+ (event: ReactPointerEvent<HTMLDivElement>) => {
17
+ event.preventDefault();
18
+ event.currentTarget.setPointerCapture(event.pointerId);
19
+ const height = splitContainerRef.current?.getBoundingClientRect().height ?? 0;
20
+ splitDragRef.current = {
21
+ startY: event.clientY,
22
+ startPercent: layersPanePercent,
23
+ height,
24
+ };
25
+ },
26
+ [layersPanePercent],
27
+ );
28
+
29
+ const handleInspectorSplitResizeMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
30
+ const drag = splitDragRef.current;
31
+ if (!drag || drag.height <= 0) return;
32
+ const deltaPercent = ((event.clientY - drag.startY) / drag.height) * 100;
33
+ const next = Math.min(
34
+ MAX_INSPECTOR_SPLIT_PERCENT,
35
+ Math.max(MIN_INSPECTOR_SPLIT_PERCENT, drag.startPercent + deltaPercent),
36
+ );
37
+ setLayersPanePercent(next);
38
+ }, []);
39
+
40
+ const handleInspectorSplitResizeEnd = useCallback(() => {
41
+ splitDragRef.current = null;
42
+ }, []);
43
+
44
+ return {
45
+ layersPanePercent,
46
+ splitContainerRef,
47
+ handleInspectorSplitResizeStart,
48
+ handleInspectorSplitResizeMove,
49
+ handleInspectorSplitResizeEnd,
50
+ };
51
+ }
@@ -0,0 +1,118 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePanelLayout } from "./usePanelLayout";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.doUnmock("../components/editor/manualEditingAvailability");
13
+ vi.resetModules();
14
+ });
15
+
16
+ function renderPanelLayoutWith(hook: typeof usePanelLayout) {
17
+ const host = document.createElement("div");
18
+ document.body.append(host);
19
+ const root = createRoot(host);
20
+ let current: ReturnType<typeof usePanelLayout> | null = null;
21
+
22
+ function Harness() {
23
+ current = hook();
24
+ return null;
25
+ }
26
+
27
+ act(() => {
28
+ root.render(React.createElement(Harness));
29
+ });
30
+
31
+ return {
32
+ getState: (): ReturnType<typeof usePanelLayout> => {
33
+ if (!current) throw new Error("usePanelLayout did not render");
34
+ return current;
35
+ },
36
+ unmount: () => act(() => root.unmount()),
37
+ };
38
+ }
39
+
40
+ function renderPanelLayout() {
41
+ return renderPanelLayoutWith(usePanelLayout);
42
+ }
43
+
44
+ describe("usePanelLayout — right inspector panes", () => {
45
+ it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
46
+ const harness = renderPanelLayout();
47
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
48
+
49
+ act(() => harness.getState().toggleRightInspectorPane("layers"));
50
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
51
+
52
+ harness.unmount();
53
+ });
54
+
55
+ it("toggleRightInspectorPane refuses to turn off the last remaining pane", () => {
56
+ const harness = renderPanelLayout();
57
+ act(() => harness.getState().toggleRightInspectorPane("design"));
58
+ // Only "design" was on; toggling it off would leave both false — guarded.
59
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
60
+ harness.unmount();
61
+ });
62
+
63
+ it("setExclusiveRightInspectorPane is radio-style — selecting one turns the other off", () => {
64
+ const harness = renderPanelLayout();
65
+ act(() => harness.getState().toggleRightInspectorPane("layers"));
66
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
67
+
68
+ act(() => harness.getState().setExclusiveRightInspectorPane("layers"));
69
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
70
+
71
+ act(() => harness.getState().setExclusiveRightInspectorPane("design"));
72
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
73
+
74
+ harness.unmount();
75
+ });
76
+
77
+ it("setRightPanelTab additively opens a pane when the flat inspector is off (legacy behavior)", async () => {
78
+ vi.resetModules();
79
+ vi.doMock("../components/editor/manualEditingAvailability", async () => {
80
+ const actual = await vi.importActual<
81
+ typeof import("../components/editor/manualEditingAvailability")
82
+ >("../components/editor/manualEditingAvailability");
83
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
84
+ });
85
+ const { usePanelLayout: usePanelLayoutFlatOff } = await import("./usePanelLayout");
86
+ const harness = renderPanelLayoutWith(usePanelLayoutFlatOff);
87
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
88
+
89
+ act(() => harness.getState().setRightPanelTab("layers"));
90
+ // Legacy (split-view) behavior: additive, both panes end up open.
91
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
92
+
93
+ harness.unmount();
94
+ });
95
+
96
+ it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
97
+ vi.resetModules();
98
+ vi.doMock("../components/editor/manualEditingAvailability", async () => {
99
+ const actual = await vi.importActual<
100
+ typeof import("../components/editor/manualEditingAvailability")
101
+ >("../components/editor/manualEditingAvailability");
102
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
103
+ });
104
+ const { usePanelLayout: usePanelLayoutFlatOn } = await import("./usePanelLayout");
105
+ const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
106
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
107
+
108
+ // Element-select / block-params-close / header Inspector-button callers
109
+ // all reach setRightPanelTab directly, not through the in-panel tab
110
+ // click's own setExclusiveRightInspectorPane call — this must still
111
+ // enforce exclusivity under the flat flag, or both tabs end up
112
+ // highlighted while only one pane actually renders.
113
+ act(() => harness.getState().setRightPanelTab("layers"));
114
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
115
+
116
+ harness.unmount();
117
+ });
118
+ });
@@ -6,6 +6,7 @@ import type {
6
6
  } from "../utils/studioHelpers";
7
7
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
+ import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
9
10
 
10
11
  export interface InitialPanelLayoutState {
11
12
  rightCollapsed?: boolean | null;
@@ -80,7 +81,20 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
80
81
  const trackedSetRightPanelTab = useCallback(
81
82
  (tab: RightPanelTab) => {
82
83
  if (tab === "design" || tab === "layers") {
83
- setRightInspectorPanes((panes) => ({ ...panes, [tab]: true }));
84
+ // Flat inspector: Layers always renders full-height by itself (see
85
+ // StudioRightPanel's render gate), so this MUST land on the same
86
+ // radio-style exclusivity setExclusiveRightInspectorPane enforces for
87
+ // the direct in-panel tab click — every OTHER path that reaches here
88
+ // (element select, closing block-params, the header Inspector
89
+ // button, and this function's own callers outside an active
90
+ // inspector tab) would otherwise additively leave both panes `true`
91
+ // and reproduce the "both tabs highlight, only one renders" bug this
92
+ // still-additive branch used to cause under the flat flag.
93
+ setRightInspectorPanes(
94
+ STUDIO_FLAT_INSPECTOR_ENABLED
95
+ ? { design: tab === "design", layers: tab === "layers" }
96
+ : (panes) => ({ ...panes, [tab]: true }),
97
+ );
84
98
  }
85
99
  setRightPanelTab(tab);
86
100
  trackStudioEvent("tab_switch", { panel: "right_panel", tab });
@@ -96,6 +110,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
96
110
  });
97
111
  }, []);
98
112
 
113
+ // Radio-style variant for the flat inspector: Layers always renders full-
114
+ // height by itself there (never split-shared with Design), so leaving both
115
+ // panes independently toggleable would highlight both tabs as "active"
116
+ // while only one actually shows. Selecting one turns the other off.
117
+ const setExclusiveRightInspectorPane = useCallback((pane: RightInspectorPane) => {
118
+ setRightInspectorPanes({ design: pane === "design", layers: pane === "layers" });
119
+ }, []);
120
+
99
121
  return {
100
122
  leftWidth,
101
123
  setLeftWidth,
@@ -109,6 +131,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
109
131
  setRightPanelTab: trackedSetRightPanelTab,
110
132
  rightInspectorPanes,
111
133
  toggleRightInspectorPane,
134
+ setExclusiveRightInspectorPane,
112
135
  toggleLeftSidebar,
113
136
  handlePanelResizeStart,
114
137
  handlePanelResizeMove,
@@ -0,0 +1,60 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it } from "vitest";
5
+ import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
6
+ import { usePersistentEditHistory } from "./usePersistentEditHistory";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ async function flushAsyncEffects(): Promise<void> {
11
+ await act(async () => {
12
+ await new Promise((resolve) => setTimeout(resolve, 0));
13
+ });
14
+ }
15
+
16
+ describe("usePersistentEditHistory project ownership", () => {
17
+ it("rejects a delayed project A recorder after project B becomes active", async () => {
18
+ const storage = createMemoryEditHistoryStorage();
19
+ const now = () => 100;
20
+ const captured: { history: ReturnType<typeof usePersistentEditHistory> | null } = {
21
+ history: null,
22
+ };
23
+
24
+ function Probe({ projectId }: { projectId: string }) {
25
+ captured.history = usePersistentEditHistory({ projectId, storage, now });
26
+ return null;
27
+ }
28
+
29
+ const root = createRoot(document.createElement("div"));
30
+ await act(async () => root.render(<Probe projectId="project-a" />));
31
+ await flushAsyncEffects();
32
+ const recordProjectA = captured.history?.recordEdit;
33
+ if (!recordProjectA) throw new Error("project A history did not load");
34
+
35
+ await act(async () => root.render(<Probe projectId="project-b" />));
36
+ await expect(
37
+ recordProjectA({
38
+ label: "Delayed A edit",
39
+ kind: "manual",
40
+ files: { "index.html": { before: "A", after: "A2" } },
41
+ }),
42
+ ).rejects.toThrow("inactive project project-a");
43
+ await flushAsyncEffects();
44
+
45
+ const recordProjectB = captured.history?.recordEdit;
46
+ if (!recordProjectB) throw new Error("project B history did not load");
47
+ await act(async () => {
48
+ await recordProjectB({
49
+ label: "B edit",
50
+ kind: "manual",
51
+ files: { "index.html": { before: "B", after: "B2" } },
52
+ });
53
+ });
54
+
55
+ expect(await storage.get("project-a")).toBeNull();
56
+ expect((await storage.get("project-b"))?.undo.map((entry) => entry.label)).toEqual(["B edit"]);
57
+
58
+ await act(async () => root.unmount());
59
+ });
60
+ });
@@ -1,7 +1,11 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { createEmptyEditHistory } from "../utils/editHistory";
3
3
  import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
4
4
  import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
5
+ import {
6
+ serializeStudioFileMutation,
7
+ serializeStudioFileMutations,
8
+ } from "../utils/studioFileMutationCoordinator";
5
9
  import {
6
10
  createPersistentEditHistoryController,
7
11
  createPersistentEditHistoryStore,
@@ -213,6 +217,50 @@ describe("createPersistentEditHistoryController", () => {
213
217
  expect(store.snapshot().canRedo).toBe(true);
214
218
  });
215
219
 
220
+ it("waits for same-file mutations before checking an undo hash", async () => {
221
+ const storage = createMemoryEditHistoryStorage();
222
+ const store = createPersistentEditHistoryStore({
223
+ projectId: "project-1",
224
+ storage,
225
+ initialState: createEmptyEditHistory(),
226
+ now: () => 100,
227
+ onChange: () => {},
228
+ });
229
+ await store.recordEdit({
230
+ label: "Edit source",
231
+ kind: "source",
232
+ files: { "index.html": { before: "before", after: "after" } },
233
+ });
234
+
235
+ let disk = "after";
236
+ let release!: () => void;
237
+ const blocked = new Promise<void>((resolve) => {
238
+ release = resolve;
239
+ });
240
+ const writeFile = vi.fn(async (_path: string, content: string) => {
241
+ disk = content;
242
+ });
243
+ const priorMutation = serializeStudioFileMutation(writeFile, "index.html", async () => {
244
+ await blocked;
245
+ disk = "newer-edit";
246
+ });
247
+ const readFile = vi.fn(async () => disk);
248
+ const undo = store.undo({
249
+ readFile,
250
+ writeFile,
251
+ serialize: (paths, task) => serializeStudioFileMutations(writeFile, paths, task),
252
+ });
253
+
254
+ await Promise.resolve();
255
+ expect(readFile).not.toHaveBeenCalled();
256
+ release();
257
+ await priorMutation;
258
+ await expect(undo).resolves.toMatchObject({ ok: false, reason: "content-mismatch" });
259
+ expect(disk).toBe("newer-edit");
260
+ expect(writeFile).not.toHaveBeenCalled();
261
+ expect(store.snapshot().canUndo).toBe(true);
262
+ });
263
+
216
264
  it("returns per-file restored/previous content so the preview can soft-apply", async () => {
217
265
  const storage = createMemoryEditHistoryStorage();
218
266
  const store = createPersistentEditHistoryStore({