@hyperframes/studio 0.8.114 → 0.8.116

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 (266) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
  4. package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
  7. package/dist/chunk-3F47EFCT.js.map +1 -0
  8. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  9. package/dist/chunk-5EKWJZLM.js.map +1 -0
  10. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
  12. package/dist/index.d.ts +146 -40
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +4021 -3591
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/captions/hooks/useCaptionSync.ts +10 -6
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  23. package/src/components/dock/Dock.tsx +5 -7
  24. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  25. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  27. package/src/components/editor/DomEditOverlay.tsx +5 -1
  28. package/src/components/editor/FileTreeNodes.tsx +13 -10
  29. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  30. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  31. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  32. package/src/components/editor/SnapToolbar.tsx +43 -5
  33. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  34. package/src/components/editor/audioFxTelemetry.ts +2 -3
  35. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  36. package/src/components/editor/cropResize.ts +4 -3
  37. package/src/components/editor/domEditingDom.ts +4 -0
  38. package/src/components/editor/domEditingLayers.ts +1 -5
  39. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  40. package/src/components/editor/domGroupMoveBy.ts +95 -0
  41. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  42. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  43. package/src/components/editor/manualEditingAvailability.ts +8 -0
  44. package/src/components/editor/manualEdits.ts +1 -0
  45. package/src/components/editor/manualEditsParsing.ts +7 -0
  46. package/src/components/editor/manualOffsetDrag.ts +5 -6
  47. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  48. package/src/components/editor/marqueeCommit.ts +24 -8
  49. package/src/components/editor/motionPathCommit.test.ts +29 -1
  50. package/src/components/editor/motionPathCommit.ts +21 -9
  51. package/src/components/editor/motionPathSelection.test.ts +6 -31
  52. package/src/components/editor/probeSourceElement.test.ts +45 -0
  53. package/src/components/editor/probeSourceElement.ts +41 -16
  54. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  55. package/src/components/editor/propertyPanelFont.tsx +3 -2
  56. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  57. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  58. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  59. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  60. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  61. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  62. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  63. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  64. package/src/components/editor/propertyPanelTypes.ts +6 -3
  65. package/src/components/editor/useColorGradingController.ts +2 -1
  66. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  67. package/src/components/editor/useDomEditNudge.ts +2 -0
  68. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  69. package/src/components/editor/useDomEditZOrder.ts +12 -2
  70. package/src/components/editor/useFxCarveNodes.ts +2 -1
  71. package/src/components/editor/useFxLevelling.ts +2 -1
  72. package/src/components/feedback/projectProvenance.ts +2 -1
  73. package/src/components/nle/NLEContext.test.ts +1 -0
  74. package/src/components/nle/NLEContext.tsx +2 -1
  75. package/src/components/nle/NLEPreview.tsx +12 -10
  76. package/src/components/nle/PreviewPoster.tsx +2 -1
  77. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  78. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  79. package/src/components/renders/useFfmpegStatus.ts +2 -1
  80. package/src/components/renders/useRenderQueue.ts +5 -4
  81. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  82. package/src/components/sidebar/AssetsTab.tsx +2 -1
  83. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  84. package/src/components/ui/menuStyle.test.ts +61 -0
  85. package/src/components/ui/menuStyle.ts +10 -0
  86. package/src/contexts/DomEditContext.tsx +5 -1
  87. package/src/contexts/TimelineEditContext.tsx +1 -0
  88. package/src/domEditExports.test.tsx +9 -1
  89. package/src/hooks/domEditCommitRunner.ts +1 -1
  90. package/src/hooks/domSelectionTestHarness.ts +23 -0
  91. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  92. package/src/hooks/domStyleCommit.ts +8 -1
  93. package/src/hooks/draggedGsapPosition.ts +10 -0
  94. package/src/hooks/elementOffsetStager.ts +5 -4
  95. package/src/hooks/gsapDragCommit.test.ts +75 -164
  96. package/src/hooks/gsapDragCommit.ts +4 -125
  97. package/src/hooks/gsapDragPositionCommit.ts +36 -384
  98. package/src/hooks/gsapEditOutcome.ts +22 -2
  99. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  100. package/src/hooks/gsapGestureOutcome.ts +64 -0
  101. package/src/hooks/gsapGroupPreflight.ts +34 -0
  102. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  103. package/src/hooks/gsapMutationClient.ts +4 -3
  104. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  105. package/src/hooks/gsapParsedTween.test.ts +62 -0
  106. package/src/hooks/gsapParsedTween.ts +243 -0
  107. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  108. package/src/hooks/gsapPositionDetection.ts +52 -33
  109. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  110. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  111. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  112. package/src/hooks/gsapResizeIntercept.test.ts +96 -11
  113. package/src/hooks/gsapResizeIntercept.ts +79 -168
  114. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  115. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  116. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  117. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  118. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  119. package/src/hooks/gsapScriptCommitTypes.ts +13 -0
  120. package/src/hooks/gsapShared.ts +7 -0
  121. package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
  122. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  123. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  124. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  125. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  126. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  127. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  128. package/src/hooks/keyframeCacheAstLoad.ts +8 -6
  129. package/src/hooks/newTweenTarget.test.ts +41 -76
  130. package/src/hooks/plainRotation.ts +3 -2
  131. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  132. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  133. package/src/hooks/refreshTimelineRowText.ts +29 -0
  134. package/src/hooks/timelineTimingSync.test.ts +1 -0
  135. package/src/hooks/timelineTimingSync.ts +2 -1
  136. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  137. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  138. package/src/hooks/useAskAgentModal.ts +2 -1
  139. package/src/hooks/useBlockCatalog.ts +2 -1
  140. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  141. package/src/hooks/useCaptionDetection.ts +2 -1
  142. package/src/hooks/useCompositionContentLoader.ts +2 -1
  143. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  144. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  145. package/src/hooks/useDomEditSession.test.tsx +30 -1
  146. package/src/hooks/useDomEditSession.ts +10 -0
  147. package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
  148. package/src/hooks/useDomEditTextCommits.ts +13 -3
  149. package/src/hooks/useDomEditWiring.ts +4 -2
  150. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  151. package/src/hooks/useDomGeometryCommit.ts +8 -4
  152. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  153. package/src/hooks/useDomGeometryCommits.ts +3 -2
  154. package/src/hooks/useDomSelection.test.ts +26 -0
  155. package/src/hooks/useDomSelection.ts +10 -3
  156. package/src/hooks/useDomSelectionTypes.ts +6 -1
  157. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  158. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  159. package/src/hooks/useEditHistoryActions.ts +16 -5
  160. package/src/hooks/useElementLifecycleOps.ts +2 -1
  161. package/src/hooks/useEnableKeyframes.test.ts +34 -3
  162. package/src/hooks/useEnableKeyframes.ts +7 -4
  163. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
  164. package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
  165. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  166. package/src/hooks/useFileManager.ts +14 -10
  167. package/src/hooks/useFileTree.ts +3 -2
  168. package/src/hooks/useFrameCapture.ts +5 -1
  169. package/src/hooks/useFreezeFrame.ts +3 -2
  170. package/src/hooks/useGestureCommit.test.tsx +225 -55
  171. package/src/hooks/useGestureCommit.ts +86 -55
  172. package/src/hooks/useGroupCommits.ts +2 -1
  173. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  174. package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
  175. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  176. package/src/hooks/useGsapAwareEditing.ts +101 -195
  177. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  178. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  179. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  180. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  181. package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
  182. package/src/hooks/useGsapScriptCommits.ts +15 -10
  183. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  184. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  185. package/src/hooks/useGsapTweenCache.ts +18 -12
  186. package/src/hooks/useLintModal.ts +2 -1
  187. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  188. package/src/hooks/usePersistentEditHistory.ts +47 -8
  189. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  190. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  191. package/src/hooks/usePreviewInteraction.ts +2 -0
  192. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  193. package/src/hooks/usePreviewPersistence.ts +13 -11
  194. package/src/hooks/useProjectFileWriter.ts +20 -19
  195. package/src/hooks/useRemoveBackground.ts +2 -1
  196. package/src/hooks/useRenderClipContent.test.ts +18 -0
  197. package/src/hooks/useRenderClipContent.ts +4 -5
  198. package/src/hooks/useSdkSession.ts +2 -1
  199. package/src/hooks/useServerConnection.ts +4 -3
  200. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  201. package/src/hooks/useThumbnailStill.test.ts +100 -0
  202. package/src/hooks/useThumbnailStill.ts +37 -0
  203. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  204. package/src/hooks/useTimelineDeleteOps.ts +22 -27
  205. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  206. package/src/index.ts +4 -0
  207. package/src/player/components/AudioWaveform.tsx +3 -2
  208. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  209. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  210. package/src/player/components/ClipContextMenu.tsx +33 -31
  211. package/src/player/components/CompositionThumbnail.tsx +2 -1
  212. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  213. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  214. package/src/player/components/TextClipContent.test.ts +19 -0
  215. package/src/player/components/TextClipContent.tsx +27 -0
  216. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  217. package/src/player/components/clipMenuNormalize.ts +2 -1
  218. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  219. package/src/player/components/clipMenuToolItems.tsx +13 -9
  220. package/src/player/components/clipPeakMap.ts +2 -1
  221. package/src/player/components/timelineCallbacks.ts +1 -0
  222. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  223. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  224. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  225. package/src/player/components/timelineEditing.ts +3 -1
  226. package/src/player/components/timelineLinkSelection.ts +35 -1
  227. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  228. package/src/player/components/useTimelineClipDrag.ts +6 -7
  229. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  230. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  231. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  232. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  233. package/src/player/lib/thumbnailScheduler.ts +10 -2
  234. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  235. package/src/player/lib/timelineElementHelpers.ts +6 -15
  236. package/src/player/lib/timelineText.ts +40 -0
  237. package/src/player/sceneSwap.ts +2 -1
  238. package/src/player/store/timelineElement.ts +12 -0
  239. package/src/styles/theme.css +3 -0
  240. package/src/telemetry/client.ts +2 -1
  241. package/src/utils/blockInstaller.ts +2 -1
  242. package/src/utils/elementGsap.test.ts +9 -0
  243. package/src/utils/elementGsap.ts +7 -0
  244. package/src/utils/gsapSoftReload.ts +2 -3
  245. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  246. package/src/utils/gsapUndoRestore.ts +3 -6
  247. package/src/utils/keyframeUsage.ts +79 -0
  248. package/src/utils/previewFeatureUsage.test.ts +32 -0
  249. package/src/utils/previewFeatureUsage.ts +46 -0
  250. package/src/utils/previewPrefetch.ts +2 -1
  251. package/src/utils/razorSplitTransaction.ts +12 -8
  252. package/src/utils/softReloadTargets.ts +1 -3
  253. package/src/utils/studioApiFetch.test.ts +75 -0
  254. package/src/utils/studioApiFetch.ts +13 -0
  255. package/src/utils/studioFileHistory.ts +4 -1
  256. package/src/utils/studioHelpers.ts +7 -1
  257. package/src/utils/studioPendingEdits.test.ts +23 -6
  258. package/src/utils/studioPendingEdits.ts +32 -8
  259. package/src/utils/studioTelemetry.test.ts +107 -0
  260. package/src/utils/studioTelemetry.ts +51 -5
  261. package/src/utils/timelineCompositionInsert.ts +3 -2
  262. package/src/webmcp/StudioAgentTools.tsx +4 -3
  263. package/dist/chunk-BIUXP3PG.js.map +0 -1
  264. package/dist/chunk-K47E2ZJA.js.map +0 -1
  265. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  266. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.114",
3
+ "version": "0.8.116",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/player": "0.8.114",
64
- "@hyperframes/parsers": "0.8.114",
65
- "@hyperframes/sdk": "0.8.114",
66
- "@hyperframes/shader-transitions": "0.8.114",
67
- "@hyperframes/studio-server": "0.8.114",
68
- "@hyperframes/core": "0.8.114"
63
+ "@hyperframes/parsers": "0.8.116",
64
+ "@hyperframes/player": "0.8.116",
65
+ "@hyperframes/core": "0.8.116",
66
+ "@hyperframes/sdk": "0.8.116",
67
+ "@hyperframes/studio-server": "0.8.116",
68
+ "@hyperframes/shader-transitions": "0.8.116"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.114"
84
+ "@hyperframes/producer": "0.8.116"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -97,6 +97,7 @@
97
97
  "test:filmstrip-controls": "node tests/e2e/filmstrip-controls.mjs",
98
98
  "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
99
99
  "test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs",
100
+ "bench:timeline-bulk-actions": "node tests/e2e/timeline-bulk-actions.mjs",
100
101
  "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
101
102
  "test:watch": "vitest",
102
103
  "test:colors:browser": "node tests/e2e/native-css-colors.mjs",
package/src/App.tsx CHANGED
@@ -272,7 +272,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
272
272
  onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
273
273
  activeCompPath,
274
274
  forceReloadSdkSession: sdkHandle.forceReload,
275
- onToggleRecording: () => handleToggleRecordingRef.current(),
275
+ onToggleRecording: () => handleToggleRecordingRef.current("keyboard"),
276
276
  readOnlyPreview,
277
277
  });
278
278
  const domEditSessionResult = useDomEditSession({
@@ -363,7 +363,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
363
363
  resetErrors: resetConsoleErrors,
364
364
  } = useConsoleErrorCapture(previewIframe);
365
365
  const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
366
- const handleToggleRecordingRef = useRef<() => void>(() => {});
366
+ const handleToggleRecordingRef = useRef<(method?: "button" | "keyboard") => void>(() => {});
367
367
  const domEditSessionRef = useRef(domEditSession);
368
368
  domEditSessionRef.current = domEditSession;
369
369
  const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
@@ -5,6 +5,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
5
5
  import { trackEvent } from "../../telemetry/client";
6
6
  import type { CaptionStyle } from "../types";
7
7
  import { studioWriteHeaders } from "../../utils/studioFileVersion";
8
+ import { studioApiFetch } from "../../utils/studioApiFetch";
8
9
 
9
10
  interface CaptionOverrideEntry {
10
11
  wordId?: string;
@@ -93,11 +94,14 @@ export function useCaptionSync(projectId: string | null) {
93
94
  const seqAtSave = editSeqRef.current;
94
95
  const overrides = buildOverrides(state.model);
95
96
 
96
- fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), {
97
- method: "PUT",
98
- headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
99
- body: JSON.stringify(overrides, null, 2),
100
- })
97
+ studioApiFetch(
98
+ buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
99
+ {
100
+ method: "PUT",
101
+ headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
102
+ body: JSON.stringify(overrides, null, 2),
103
+ },
104
+ )
101
105
  .then((res) => {
102
106
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
103
107
  // A newer edit may have re-armed the debounce while this PUT was in
@@ -171,7 +175,7 @@ export function useCaptionSync(projectId: string | null) {
171
175
 
172
176
  let data: { content?: string };
173
177
  try {
174
- const res = await fetch(
178
+ const res = await studioApiFetch(
175
179
  buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
176
180
  );
177
181
  if (!res.ok) return; // no overrides file yet — normal
@@ -6,6 +6,7 @@ import { useMenuKeyboardNav } from "../player/components/menuKeyboardNav";
6
6
  import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
7
7
  import { Tooltip } from "./ui";
8
8
  import { flatActive, flatIdle } from "./timelineToolbarStyles";
9
+ import { menuClasses } from "./ui/menuStyle";
9
10
 
10
11
  function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: () => void }) {
11
12
  const menuRef = useContextMenuDismiss(onClose);
@@ -17,14 +18,14 @@ function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: (
17
18
  ref={menuRef}
18
19
  role="menu"
19
20
  aria-label="Linked clips"
20
- className="fixed z-200 min-w-[200px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
21
+ className={`${menuClasses.panel} fixed z-200 min-w-[200px]`}
21
22
  style={{ left: x, top: y }}
22
23
  >
23
24
  <button
24
25
  type="button"
25
26
  role="menuitemcheckbox"
26
27
  aria-checked={visible}
27
- className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800"
28
+ className={`${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`}
28
29
  onClick={() => {
29
30
  setVisible(!visible);
30
31
  onClose();
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { studioApiFetch } from "../utils/studioApiFetch";
2
3
 
3
4
  interface ServedProject {
4
5
  id: string;
@@ -24,8 +25,7 @@ interface ProjectUnreachableBannerProps {
24
25
  *
25
26
  * The wording is deliberately narrower than "this project is gone". We can see
26
27
  * which project this server serves; we cannot see why it does not serve this
27
- * one, and nothing here is lost. See
28
- * `docs/hyperframes/plans/sdk/2026-09-20-stale-project-id-remaining-doors-plan.md` §C.
28
+ * one, and nothing here is lost.
29
29
  */
30
30
  export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBannerProps) {
31
31
  const [served, setServed] = useState<ServedProject[] | null>(null);
@@ -35,7 +35,7 @@ export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBanner
35
35
  // A list we could not read is not evidence of anything, so a failed fetch
36
36
  // leaves `served` empty and the banner falls back to the vague wording
37
37
  // rather than claiming this Studio serves nothing.
38
- fetch("/api/projects")
38
+ studioApiFetch("/api/projects")
39
39
  .then((res) => (res.ok ? res.json() : { projects: [] }))
40
40
  .then((data: { projects?: ServedProject[] }) => {
41
41
  if (!cancelled) setServed(data.projects ?? []);
@@ -31,6 +31,7 @@ import {
31
31
  type PanelId,
32
32
  } from "./panelRegistry";
33
33
  import "./dock.css";
34
+ import { menuClasses } from "../ui/menuStyle";
34
35
 
35
36
  const PERSIST_DEBOUNCE_MS = 250;
36
37
 
@@ -288,10 +289,7 @@ function WindowMenu() {
288
289
  Window
289
290
  </button>
290
291
  {open && (
291
- <div
292
- role="menu"
293
- className="absolute right-0 top-8 z-50 w-44 rounded-md border border-neutral-800 bg-neutral-900 py-1 shadow-lg"
294
- >
292
+ <div role="menu" className={`${menuClasses.panel} absolute right-0 top-8 z-50 w-44`}>
295
293
  {panels.map((id) => (
296
294
  <button
297
295
  key={id}
@@ -299,13 +297,13 @@ function WindowMenu() {
299
297
  role="menuitemcheckbox"
300
298
  aria-checked={openPanels.has(id)}
301
299
  onClick={() => togglePanel(id)}
302
- className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-neutral-300 hover:bg-neutral-800"
300
+ className={`${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`}
303
301
  >
304
302
  <span className="w-3 text-accent-ink">{openPanels.has(id) ? "✓" : ""}</span>
305
303
  {PANEL_DEFINITIONS[id].title}
306
304
  </button>
307
305
  ))}
308
- <div className="my-1 border-t border-neutral-800" />
306
+ <div className={menuClasses.divider} />
309
307
  <button
310
308
  type="button"
311
309
  role="menuitem"
@@ -313,7 +311,7 @@ function WindowMenu() {
313
311
  resetLayout();
314
312
  setOpen(false);
315
313
  }}
316
- className="w-full px-3 py-1.5 text-left text-[11px] text-neutral-300 hover:bg-neutral-800"
314
+ className={`${menuClasses.row} ${menuClasses.rowEnabled}`}
317
315
  >
318
316
  Reset layout
319
317
  </button>
@@ -33,6 +33,7 @@ import {
33
33
  type ZOrderAction,
34
34
  type ZOrderPatch,
35
35
  } from "./canvasContextMenuZOrder";
36
+ import { menuClasses } from "../ui/menuStyle";
36
37
 
37
38
  interface CanvasContextMenuProps {
38
39
  /** Viewport x of the right-click event. */
@@ -209,7 +210,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
209
210
  return createPortal(
210
211
  <div
211
212
  ref={menuRef}
212
- className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
213
+ className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
213
214
  style={{ left: adjustedX, top: adjustedY }}
214
215
  onPointerDown={stopBubble}
215
216
  onMouseDown={stopBubble}
@@ -252,7 +253,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
252
253
  );
253
254
  })}
254
255
 
255
- {hasDivider && <div className="my-1 border-t border-neutral-700/60" />}
256
+ {hasDivider && <div className={menuClasses.divider} />}
256
257
 
257
258
  {hasDelete && (
258
259
  <button
@@ -345,3 +345,30 @@ describe("DomEditCropHandles preset bar", () => {
345
345
  expect(useCropPresetBarStore.getState().openFor).toBeNull();
346
346
  });
347
347
  });
348
+
349
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
350
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
351
+ describe("crop usage", () => {
352
+ it.each([true, false])(
353
+ "counts a crop drag only after a changed writer result (%s)",
354
+ async (changed) => {
355
+ vi.mocked(trackStudioEvent).mockClear();
356
+ const saved = vi.fn(async () => ({ ok: true, changed }));
357
+ const { root } = render(makeEl("private-target", "inset(10px)"), saved);
358
+ const handle = document.querySelector<HTMLElement>('[aria-label="Crop right"]')!;
359
+ dragCropRight(handle, 1, [
360
+ [200, 1],
361
+ [180, 1],
362
+ [160, 1],
363
+ ]);
364
+ await act(async () => {
365
+ await Promise.resolve();
366
+ });
367
+ expect(saved).toHaveBeenCalledTimes(1);
368
+ expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
369
+ changed ? [["feature_used", { feature: "crop", surface: "preview", method: "drag" }]] : [],
370
+ );
371
+ act(() => root.unmount());
372
+ },
373
+ );
374
+ });
@@ -1,3 +1,4 @@
1
+ import { trackPreviewEditResult, type PreviewMethod } from "../../utils/previewFeatureUsage";
1
2
  import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
3
  import type { DomEditSelection } from "./domEditing";
3
4
  import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry";
@@ -229,19 +230,21 @@ export function DomEditCropHandles({
229
230
  // the lift keeps it hidden while selected. A drag that ends where it started saves nothing.
230
231
  const value = buildInsetClipPathSides(gesture.insets, gesture.radius);
231
232
  if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return;
232
- commitClipPath(value);
233
+ commitClipPath(value, "drag");
233
234
  };
234
235
 
235
- const commitClipPath = (value: string) => {
236
+ const commitClipPath = (value: string, method: PreviewMethod) => {
236
237
  const commit = onStyleCommit?.("clip-path", value);
237
- void Promise.resolve(commit).catch(() => undefined);
238
+ void Promise.resolve(commit)
239
+ .then((result) => trackPreviewEditResult("crop", method, result))
240
+ .catch(() => undefined);
238
241
  };
239
242
 
240
243
  const applyPresetInsets = (insets: ClipPathInsetSides | null) => {
241
244
  const next = insets ?? { top: 0, right: 0, bottom: 0, left: 0 };
242
245
  setState((prev) => ({ ...prev, insets: next }));
243
246
  const radius = readCropFollowingResize(selection.element)?.radius ?? 0;
244
- commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius));
247
+ commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius), "button");
245
248
  };
246
249
 
247
250
  const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
@@ -1,3 +1,4 @@
1
+ import type { DomSelectionResult } from "../../hooks/useDomSelectionTypes";
1
2
  import type { RotationCommit } from "./rotationDraft";
2
3
  import { memo, useEffect, useMemo, useRef, type RefObject } from "react";
3
4
  import { type DomEditSelection } from "./domEditing";
@@ -98,7 +99,10 @@ export interface DomEditOverlayProps {
98
99
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
99
100
  recordingState?: GestureRecordingState;
100
101
  onToggleRecording?: () => void;
101
- onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
102
+ onMarqueeSelect?: (
103
+ selections: DomEditSelection[],
104
+ additive: boolean,
105
+ ) => DomSelectionResult | void;
102
106
  /**
103
107
  * Delete the selected canvas element.
104
108
  * Wire to handleDomEditElementDelete from useDomEditActionsContext —
@@ -18,11 +18,14 @@ import {
18
18
  type InlineInputState,
19
19
  DIM,
20
20
  } from "./FileTreeIcons";
21
+ import { menuClasses } from "../ui/menuStyle";
21
22
 
22
23
  export type { ContextMenuState, InlineInputState };
23
24
  export { buildTree, sortChildren, isActiveInSubtree } from "./FileTreeIcons";
24
25
 
25
26
  const SZ_ICON = 14;
27
+ const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2 active:bg-neutral-700`;
28
+ const DANGER_ROW = `${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2 active:bg-danger/25`;
26
29
 
27
30
  // ── Context Menu Component ──
28
31
 
@@ -101,14 +104,14 @@ export function ContextMenu({
101
104
  ref={menuRef}
102
105
  role="menu"
103
106
  onKeyDown={handleMenuKeyDown}
104
- className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
107
+ className={`${menuClasses.panel} fixed z-50 min-w-[160px]`}
105
108
  style={{ left: adjustedX, top: adjustedY }}
106
109
  >
107
110
  {state.targetIsFolder && (
108
111
  <>
109
112
  <button
110
113
  role="menuitem"
111
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
114
+ className={ROW}
112
115
  onClick={() => {
113
116
  onNewFile(state.targetPath);
114
117
  onClose();
@@ -119,7 +122,7 @@ export function ContextMenu({
119
122
  </button>
120
123
  <button
121
124
  role="menuitem"
122
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
125
+ className={ROW}
123
126
  onClick={() => {
124
127
  onNewFolder(state.targetPath);
125
128
  onClose();
@@ -128,14 +131,14 @@ export function ContextMenu({
128
131
  <FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
129
132
  New Folder
130
133
  </button>
131
- <div className="border-t border-neutral-700 my-1" />
134
+ <div className={menuClasses.divider} />
132
135
  </>
133
136
  )}
134
137
  {!state.targetIsFolder && (
135
138
  <>
136
139
  <button
137
140
  role="menuitem"
138
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
141
+ className={ROW}
139
142
  onClick={() => {
140
143
  onNewFile(parentPath);
141
144
  onClose();
@@ -144,12 +147,12 @@ export function ContextMenu({
144
147
  <FilePlus size={12} weight="duotone" className="text-neutral-500" />
145
148
  New File
146
149
  </button>
147
- <div className="border-t border-neutral-700 my-1" />
150
+ <div className={menuClasses.divider} />
148
151
  </>
149
152
  )}
150
153
  <button
151
154
  role="menuitem"
152
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
155
+ className={ROW}
153
156
  onClick={() => {
154
157
  onRename(state.targetPath);
155
158
  onClose();
@@ -161,7 +164,7 @@ export function ContextMenu({
161
164
  {!state.targetIsFolder && (
162
165
  <button
163
166
  role="menuitem"
164
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-hidden cursor-pointer text-left"
167
+ className={ROW}
165
168
  onClick={() => {
166
169
  onDuplicate(state.targetPath);
167
170
  onClose();
@@ -171,10 +174,10 @@ export function ContextMenu({
171
174
  Duplicate
172
175
  </button>
173
176
  )}
174
- <div className="border-t border-neutral-700 my-1" />
177
+ <div className={menuClasses.divider} />
175
178
  <button
176
179
  role="menuitem"
177
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 active:bg-danger/25 outline-hidden cursor-pointer text-left"
180
+ className={DANGER_ROW}
178
181
  onClick={() => {
179
182
  onDelete(state.targetPath);
180
183
  onClose();
@@ -1,3 +1,5 @@
1
+ import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
2
+ import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
1
3
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2
4
  import { memo, useEffect, useRef, useState, type RefObject } from "react";
3
5
  import type { DomEditSelection } from "./domEditing";
@@ -382,15 +384,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
382
384
  selection &&
383
385
  !usePlayerStore.getState().autoKeyframeEnabled
384
386
  ) {
387
+ const writes = observeGsapGesture((_sel, mutation, options) =>
388
+ commitMutation(mutation, options),
389
+ );
385
390
  void commitWholePropertyOffset(
386
391
  selection,
387
392
  anim,
388
393
  { x, y },
389
394
  d.ref.pct,
390
395
  iframeRef.current,
391
- { commitMutation: (_sel, mutation, options) => commitMutation(mutation, options) },
396
+ { commitMutation: writes.commit! },
392
397
  "Move animation path",
393
- );
398
+ ).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
394
399
  } else {
395
400
  void commitNode(d.ref, x, y, animId, commitMutation);
396
401
  }
@@ -1,6 +1,7 @@
1
1
  import { Eye, Layers } from "../../icons/SystemIcons";
2
2
  import type { DomEditSelection } from "./domEditingTypes";
3
3
  import { canHideSelections } from "../../utils/timelineInspector";
4
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
4
5
 
5
6
  function FlatEmptyState() {
6
7
  return (
@@ -71,6 +72,7 @@ function FlatMultiSelectState({
71
72
  // One predicate for both actions and for the handler's own refusal, so the
72
73
  // button and the refusal cannot disagree about what audio is.
73
74
  const hasAudio = !canHideSelections(multiSelectedElements);
75
+ const trackInput = useTrackDesignInput();
74
76
  return (
75
77
  <div className="flex flex-col gap-3 px-4 py-3">
76
78
  <div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
@@ -141,7 +143,11 @@ function FlatMultiSelectState({
141
143
  <button
142
144
  type="button"
143
145
  data-flat-multiselect-group="true"
144
- onClick={onGroupSelection}
146
+ onClick={() => {
147
+ if (!onGroupSelection) return;
148
+ onGroupSelection();
149
+ trackInput("button", "group-selection");
150
+ }}
145
151
  className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
146
152
  >
147
153
  <Layers size={13} />
@@ -150,7 +156,11 @@ function FlatMultiSelectState({
150
156
  <button
151
157
  type="button"
152
158
  data-flat-multiselect-hide-all="true"
153
- onClick={onHideAllSelected}
159
+ onClick={() => {
160
+ if (!onHideAllSelected) return;
161
+ onHideAllSelected();
162
+ trackInput("button", "hide-all");
163
+ }}
154
164
  className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
155
165
  >
156
166
  <Eye size={13} />
@@ -1,8 +1,9 @@
1
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
1
2
  // @vitest-environment happy-dom
2
3
 
3
4
  import React, { act, useRef } from "react";
4
5
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
7
  import { useAppHotkeys } from "../../hooks/useAppHotkeys";
7
8
  import { usePlayerStore } from "../../player/store/playerStore";
8
9
  import type { DomEditSelection } from "./domEditing";
@@ -182,3 +183,101 @@ describe("SnapToolbar ruler and safe-margin toggles", () => {
182
183
  act(() => root.unmount());
183
184
  });
184
185
  });
186
+
187
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
188
+ beforeEach(() => vi.clearAllMocks());
189
+ describe("preview setting usage", () => {
190
+ it.each([
191
+ ["Toggle snap", "snapping"],
192
+ ["Toggle grid", "grid"],
193
+ ["Toggle ruler", "ruler"],
194
+ ["Toggle safe margins", "safe_margins"],
195
+ ])("counts the %s button without setting values", (label, feature) => {
196
+ const { root } = renderToolbar();
197
+ const button = document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
198
+ act(() => button.click());
199
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
200
+ feature,
201
+ surface: "preview",
202
+ method: "button",
203
+ });
204
+ act(() => root.unmount());
205
+ });
206
+ it("counts keyboard grid toggling once", () => {
207
+ const { root } = renderToolbar();
208
+ act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "g", bubbles: true })));
209
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
210
+ feature: "grid",
211
+ surface: "preview",
212
+ method: "keyboard",
213
+ });
214
+ act(() => root.unmount());
215
+ });
216
+ });
217
+
218
+ describe("preview field and key gesture counting", () => {
219
+ it("counts a grid spacing edit once when the field settles", () => {
220
+ const { root } = renderToolbar();
221
+ act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
222
+ const input = document.querySelector<HTMLInputElement>('input[type="number"]')!;
223
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
224
+ act(() => input.focus());
225
+ for (const value of ["20", "200"]) {
226
+ act(() => {
227
+ setter.call(input, value);
228
+ input.dispatchEvent(new Event("input", { bubbles: true }));
229
+ });
230
+ }
231
+ expect(trackStudioEvent).not.toHaveBeenCalled();
232
+ act(() => input.blur());
233
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
234
+ feature: "grid_spacing",
235
+ surface: "preview",
236
+ method: "field",
237
+ });
238
+ act(() => root.unmount());
239
+ });
240
+ it("counts snap-to-grid through its committed checkbox", () => {
241
+ const { root } = renderToolbar();
242
+ act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
243
+ act(() => document.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
244
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
245
+ feature: "snap_to_grid",
246
+ surface: "preview",
247
+ method: "button",
248
+ });
249
+ act(() => root.unmount());
250
+ });
251
+ it.each(["s", "g"])("does not count auto-repeat as another %s key gesture", (key) => {
252
+ const { root } = renderToolbar();
253
+ act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })));
254
+ act(() =>
255
+ document.dispatchEvent(new KeyboardEvent("keydown", { key, repeat: true, bubbles: true })),
256
+ );
257
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
258
+ feature: key === "s" ? "snapping" : "grid",
259
+ surface: "preview",
260
+ method: "keyboard",
261
+ });
262
+ act(() => root.unmount());
263
+ });
264
+ });
265
+
266
+ it("counts a changed field when click-away dismisses it before blur", () => {
267
+ const { root } = renderToolbar();
268
+ act(() => document.querySelector<HTMLButtonElement>('[aria-label="Grid options"]')!.click());
269
+ const input = document.querySelector<HTMLInputElement>('input[type="number"]')!;
270
+ act(() => input.focus());
271
+ act(() => {
272
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "230");
273
+ input.dispatchEvent(new Event("input", { bubbles: true }));
274
+ });
275
+ act(() => document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })));
276
+ expect(document.querySelector('input[type="number"]')).toBeNull();
277
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
278
+ feature: "grid_spacing",
279
+ surface: "preview",
280
+ method: "field",
281
+ });
282
+ act(() => root.unmount());
283
+ });