@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  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 +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -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,8 +1,12 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { applyInlineStyle } from "./inlineTextStyleRange";
4
- import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
5
- import { parseCssColor, toHexColor } from "./colorValue";
4
+ import {
5
+ readFirstPaintedElement,
6
+ readInlineStyle,
7
+ readInlineStyleSpread,
8
+ } from "./inlineTextStyleRead";
9
+ import { resolvePickerColor } from "./colorValue";
6
10
  import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
7
11
 
8
12
  /**
@@ -248,7 +252,11 @@ function placeOverSelection(
248
252
  placeBelow,
249
253
  styles,
250
254
  colours,
251
- pickerColour: toPickerColour(styles.color ?? colours[0], doc),
255
+ pickerColour: resolvePickerColor(
256
+ styles.color ?? colours[0],
257
+ readFirstPaintedElement(range, "color"),
258
+ DEFAULT_COLOR,
259
+ ),
252
260
  };
253
261
  }
254
262
 
@@ -257,27 +265,3 @@ function isBold(weight: string | undefined): boolean {
257
265
  if (weight === "bold" || weight === "bolder") return true;
258
266
  return Number.parseInt(weight, 10) >= 600;
259
267
  }
260
-
261
- /** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */
262
- function toPickerColour(value: string | undefined, doc: Document): string {
263
- if (!value) return DEFAULT_COLOR;
264
- const parsed = parseCssColor(value);
265
- if (parsed) return toHexColor(parsed);
266
-
267
- // Canvas delegates the full CSS colour grammar to the browser, including
268
- // named colours that the small serialisation parser intentionally omits.
269
- // DOM-only test environments can lack a canvas implementation, in which
270
- // case the picker degrades to its explicit default while the swatch remains
271
- // truthful because CSS still paints the original value.
272
- try {
273
- const context = doc.createElement("canvas").getContext("2d");
274
- if (!context) return DEFAULT_COLOR;
275
- context.fillStyle = DEFAULT_COLOR;
276
- context.fillStyle = value;
277
- const normalised =
278
- typeof context.fillStyle === "string" ? parseCssColor(context.fillStyle) : null;
279
- return normalised ? toHexColor(normalised) : DEFAULT_COLOR;
280
- } catch {
281
- return DEFAULT_COLOR;
282
- }
283
- }
@@ -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
+ });
@@ -1,3 +1,4 @@
1
+ import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
1
2
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
3
  import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
3
4
  import { usePlayerStore } from "../../player/store/playerStore";
@@ -16,6 +17,16 @@ export const SnapToolbar = memo(function SnapToolbar() {
16
17
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
17
18
  const popoverRef = useRef<HTMLDivElement>(null);
18
19
  const gridButtonRef = useRef<HTMLButtonElement>(null);
20
+ const gridSpacingAtFocus = useRef<number | null>(null);
21
+ const currentGridSpacing = useRef(prefs.gridSpacing);
22
+ currentGridSpacing.current = prefs.gridSpacing;
23
+ const settleGridSpacing = useCallback(() => {
24
+ const previous = gridSpacingAtFocus.current;
25
+ gridSpacingAtFocus.current = null;
26
+ if (previous !== null && previous !== currentGridSpacing.current)
27
+ trackPreviewFeatureUsed("grid_spacing", "field");
28
+ }, []);
29
+ useEffect(() => settleGridSpacing, [settleGridSpacing]);
19
30
 
20
31
  const updatePrefs = useCallback(
21
32
  (patch: Partial<typeof prefs>) => {
@@ -26,10 +37,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
26
37
 
27
38
  const toggleSnap = useCallback(() => {
28
39
  updatePrefs({ snapEnabled: !prefs.snapEnabled });
40
+ trackPreviewFeatureUsed("snapping", "button");
29
41
  }, [prefs.snapEnabled, updatePrefs]);
30
42
 
31
43
  const toggleGrid = useCallback(() => {
32
44
  updatePrefs({ gridVisible: !prefs.gridVisible });
45
+ trackPreviewFeatureUsed("grid", "button");
33
46
  }, [prefs.gridVisible, updatePrefs]);
34
47
 
35
48
  useEffect(() => {
@@ -41,10 +54,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
41
54
  if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
42
55
  e.preventDefault();
43
56
  updatePrefs({ snapEnabled: !prefs.snapEnabled });
57
+ if (!e.repeat) trackPreviewFeatureUsed("snapping", "keyboard");
44
58
  }
45
59
  if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
46
60
  e.preventDefault();
47
61
  updatePrefs({ gridVisible: !prefs.gridVisible });
62
+ if (!e.repeat) trackPreviewFeatureUsed("grid", "keyboard");
48
63
  }
49
64
  };
50
65
  document.addEventListener("keydown", handleKeyDown);
@@ -56,11 +71,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
56
71
  const handleClickOutside = (e: MouseEvent) => {
57
72
  const target = e.target as Node;
58
73
  if (popoverRef.current?.contains(target) || gridButtonRef.current?.contains(target)) return;
74
+ settleGridSpacing();
59
75
  setGridPopoverOpen(false);
60
76
  };
61
77
  document.addEventListener("mousedown", handleClickOutside);
62
78
  return () => document.removeEventListener("mousedown", handleClickOutside);
63
- }, [gridPopoverOpen]);
79
+ }, [gridPopoverOpen, settleGridSpacing]);
64
80
 
65
81
  return (
66
82
  <div
@@ -101,7 +117,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
101
117
  ? "bg-studio-accent/20 text-accent-ink"
102
118
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
103
119
  }`}
104
- onClick={toggle}
120
+ onClick={() => {
121
+ toggle();
122
+ trackPreviewFeatureUsed(key === "rulerVisible" ? "ruler" : "safe_margins", "button");
123
+ }}
105
124
  title={`${label} ${visible ? "on" : "off"}`}
106
125
  aria-label={`Toggle ${label.toLowerCase()}`}
107
126
  aria-pressed={visible}
@@ -136,6 +155,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
136
155
  onClick={toggleGrid}
137
156
  onContextMenu={(e) => {
138
157
  e.preventDefault();
158
+ if (gridPopoverOpen) settleGridSpacing();
139
159
  setGridPopoverOpen((v) => !v);
140
160
  }}
141
161
  title={
@@ -150,7 +170,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
150
170
  <button
151
171
  type="button"
152
172
  className="absolute -right-0.5 -bottom-0.5 rounded-sm p-0.5 text-white/50 hover:text-white/90 bg-black/50"
153
- onClick={() => setGridPopoverOpen((v) => !v)}
173
+ onClick={() => {
174
+ if (gridPopoverOpen) settleGridSpacing();
175
+ setGridPopoverOpen((v) => !v);
176
+ }}
154
177
  title="Grid options"
155
178
  aria-label="Grid options"
156
179
  aria-expanded={gridPopoverOpen}
@@ -173,9 +196,21 @@ export const SnapToolbar = memo(function SnapToolbar() {
173
196
  max={500}
174
197
  step={10}
175
198
  value={prefs.gridSpacing}
199
+ onFocus={() => {
200
+ gridSpacingAtFocus.current = prefs.gridSpacing;
201
+ }}
202
+ onBlur={settleGridSpacing}
203
+ onKeyDown={(e) => {
204
+ if (e.key === "Enter") e.currentTarget.blur();
205
+ }}
176
206
  onChange={(e) => {
177
207
  const val = Number.parseInt(e.target.value, 10);
178
- if (Number.isFinite(val) && val >= 10 && val <= 500) {
208
+ if (
209
+ Number.isFinite(val) &&
210
+ val >= 10 &&
211
+ val <= 500 &&
212
+ val !== prefs.gridSpacing
213
+ ) {
179
214
  updatePrefs({ gridSpacing: val });
180
215
  }
181
216
  }}
@@ -186,7 +221,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
186
221
  <input
187
222
  type="checkbox"
188
223
  checked={prefs.snapToGrid}
189
- onChange={() => updatePrefs({ snapToGrid: !prefs.snapToGrid })}
224
+ onChange={() => {
225
+ updatePrefs({ snapToGrid: !prefs.snapToGrid });
226
+ trackPreviewFeatureUsed("snap_to_grid", "button");
227
+ }}
190
228
  className="accent-studio-accent"
191
229
  />
192
230
  <span>Snap to grid</span>
@@ -1,6 +1,8 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
5
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
4
6
  import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
5
7
  import type { DomEditSelection } from "./domEditing";
6
8
  import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
@@ -178,7 +180,7 @@ function buildHarness(
178
180
  };
179
181
 
180
182
  const handlers = createDomEditOverlayGestureHandlers(opts);
181
- return { handlers, commits, selection };
183
+ return { handlers, commits, selection, opts };
182
184
  }
183
185
 
184
186
  type OverlayRectLike = {
@@ -287,3 +289,48 @@ describe("anchored corner resize — the release commit feeds the center-pin off
287
289
  expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
288
290
  });
289
291
  });
292
+
293
+ describe("resize usage at pointer release", () => {
294
+ it("counts one completed resize despite multiple pointer moves", async () => {
295
+ vi.mocked(trackStudioEvent).mockClear();
296
+ const h = buildHarness(async () => ({ ok: true, changed: true }));
297
+ await finishResize(h.handlers);
298
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
299
+ feature: "resize",
300
+ surface: "preview",
301
+ method: "drag",
302
+ });
303
+ });
304
+ it.each([undefined, { ok: true as const, changed: false }])(
305
+ "does not count an unchanged or unconfirmed resize",
306
+ async (result) => {
307
+ vi.mocked(trackStudioEvent).mockClear();
308
+ const h = buildHarness(async () => result);
309
+ await finishResize(h.handlers);
310
+ expect(trackStudioEvent).not.toHaveBeenCalled();
311
+ },
312
+ );
313
+ });
314
+
315
+ describe("move and rotation usage", () => {
316
+ it.each(["drag", "rotate"] as const)("counts %s once after pointer release", async (kind) => {
317
+ vi.mocked(trackStudioEvent).mockClear();
318
+ const h = buildHarness();
319
+ h.selection.capabilities.canApplyManualRotation = true;
320
+ const saved = vi.fn(async () => ({ ok: true as const, changed: true }));
321
+ h.opts.onPathOffsetCommitRef.current = saved;
322
+ h.opts.onRotationCommitRef.current = saved;
323
+ h.handlers.startGesture(kind, evt(200, 50));
324
+ h.handlers.onPointerMove(evt(150, 120));
325
+ h.handlers.onPointerMove(evt(100, 150));
326
+ expect(trackStudioEvent).not.toHaveBeenCalled();
327
+ h.handlers.onPointerUp(evt(100, 150));
328
+ await Promise.resolve();
329
+ expect(saved).toHaveBeenCalledTimes(1);
330
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
331
+ feature: kind === "drag" ? "move" : "rotate",
332
+ surface: "preview",
333
+ method: "drag",
334
+ });
335
+ });
336
+ });
@@ -10,9 +10,8 @@
10
10
  * filenames, not composition paths, not chain JSON. Every property below is
11
11
  * either a fixed identifier from our own catalogue (a preset id, an effect
12
12
  * type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
13
- * strips the query string off `url_hash` for exactly this reason — the ids in
14
- * it are the author's own. The rack sees the same class of data and must hold
15
- * the same line.
13
+ * sends only a route kind in `url_hash` for the same reason. The rack sees
14
+ * the same class of data and must hold the same line.
16
15
  *
17
16
  * The second rule is about volume: **commit, not preview.** Every control in
18
17
  * the rack has a preview path that fires continuously while a slider moves and