@bycrux/editor 0.11.2 → 1.0.1

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 (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,1367 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { render, screen, fireEvent, act } from '@testing-library/react'
3
+ import type { KeyframeProp, VisualItem } from '../../schema'
4
+ import { createPlaybackClock } from '../playback-clock'
5
+ import { hasKeyframes, trackFor, valueAt } from '../keyframeOps'
6
+ import OverlayInspector, { alignedOffset } from '../OverlayInspector'
7
+
8
+ // Mirrors keyframeOps.test.ts's `overlay()` helper: start=5 deliberately
9
+ // non-zero, so any spot that treats absolute playhead time as item-relative
10
+ // `t` produces an obviously wrong number rather than passing by accident.
11
+ function overlayItem(over: Partial<VisualItem> = {}): VisualItem {
12
+ return {
13
+ id: 'o1',
14
+ type: 'overlay',
15
+ src: 'o.jsx',
16
+ start: 5,
17
+ end: 15,
18
+ props: {},
19
+ offsetX: 10,
20
+ offsetY: -5,
21
+ scale: 1.2,
22
+ rotation: 45,
23
+ opacity: 0.5,
24
+ ...over,
25
+ } as VisualItem
26
+ }
27
+
28
+ /** The header's all-props list for a UNIFORM item — what every suite below
29
+ * that drives the header diamond uses, since they all render uniform items.
30
+ * An item carrying scaleX/scaleY gets PER_AXIS_PROPS instead; see the
31
+ * "uniform scale lock" suite. Deliberately still hard-coded rather than
32
+ * imported: it is the contract the panel is being held to. */
33
+ const ALL_PROPS: KeyframeProp[] = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity']
34
+ const PER_AXIS_PROPS: KeyframeProp[] = ['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity']
35
+
36
+ const LOCK = 'Uniform scale'
37
+
38
+ function renderInspector(
39
+ item: VisualItem | null,
40
+ playhead: number,
41
+ opts: { onSeek?: (time: number) => void } = {},
42
+ ) {
43
+ const clock = createPlaybackClock(playhead)
44
+ const onPreview = vi.fn()
45
+ const onCommit = vi.fn()
46
+ const onChange = vi.fn()
47
+ const utils = render(
48
+ <OverlayInspector
49
+ item={item}
50
+ clock={clock}
51
+ onPreview={onPreview}
52
+ onCommit={onCommit}
53
+ onChange={onChange}
54
+ onSeek={opts.onSeek}
55
+ />,
56
+ )
57
+ return { ...utils, clock, onPreview, onCommit, onChange }
58
+ }
59
+
60
+ /** The one item the header's keyframe-all "pressed" state needs: every
61
+ * transform prop animated, each with a point at item-relative t=0. */
62
+ function allKeyedItem(over: Partial<VisualItem> = {}): VisualItem {
63
+ return overlayItem({
64
+ keyframes: [
65
+ { prop: 'offsetX', points: [{ t: 0, value: 10 }, { t: 10, value: 100 }] },
66
+ { prop: 'offsetY', points: [{ t: 0, value: -5 }, { t: 10, value: 50 }] },
67
+ { prop: 'scale', points: [{ t: 0, value: 1.2 }, { t: 10, value: 2 }] },
68
+ { prop: 'rotation', points: [{ t: 0, value: 45 }, { t: 10, value: 90 }] },
69
+ { prop: 'opacity', points: [{ t: 0, value: 0.5 }, { t: 10, value: 1 }] },
70
+ ],
71
+ ...over,
72
+ })
73
+ }
74
+
75
+ const HEADER_DIAMOND = 'Keyframe all transform properties at playhead'
76
+
77
+ /**
78
+ * Fire a pointer event that actually CARRIES coordinates.
79
+ *
80
+ * jsdom ships no `PointerEvent`, so `fireEvent.pointerDown(el, { clientX })`
81
+ * falls back to a bare `Event` and silently drops clientX/clientY — the
82
+ * handler runs with `undefined` coordinates. A `MouseEvent` named
83
+ * `pointerdown` is what React's synthetic pointer event reads, and it does
84
+ * carry them. (`pointerId` is still undefined, which is why the dial's
85
+ * `setPointerCapture` call is optional — jsdom implements neither.)
86
+ */
87
+ function firePointer(el: Element, type: 'pointerdown' | 'pointermove' | 'pointerup', clientX: number, clientY: number) {
88
+ fireEvent(el, new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY }))
89
+ }
90
+
91
+ const dial = () => screen.getByRole('slider', { name: 'Rotation dial' })
92
+
93
+ /** jsdom lays nothing out, so every `getBoundingClientRect` is 0x0. Stand in
94
+ * a real 40x40 box at the origin so the dial's atan2 math is exercised. */
95
+ function stubDialRect(el: Element) {
96
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
97
+ left: 0, top: 0, right: 40, bottom: 40, width: 40, height: 40, x: 0, y: 0, toJSON: () => ({}),
98
+ } as DOMRect)
99
+ }
100
+
101
+ describe('OverlayInspector — rendering', () => {
102
+ it('renders a row per property with the current values', () => {
103
+ renderInspector(overlayItem(), 5) // localT = 0, all static
104
+
105
+ expect(screen.getByLabelText('Offset X')).toHaveValue(10)
106
+ expect(screen.getByLabelText('Offset Y')).toHaveValue(-5)
107
+ expect(screen.getByLabelText('Scale')).toHaveValue(120) // shown as a percentage now
108
+ expect(screen.getByLabelText('Rotation')).toHaveValue(45)
109
+ expect(screen.getByLabelText('Opacity')).toHaveValue(0.5)
110
+ })
111
+
112
+ it('renders the empty state when nothing is selected', () => {
113
+ renderInspector(null, 5)
114
+
115
+ expect(screen.getByText('Select an overlay to edit its properties.')).toBeInTheDocument()
116
+ expect(screen.queryByLabelText('Offset X')).toBeNull()
117
+ })
118
+
119
+ it('renders the panel for a VIDEO clip now that clips are keyframeable (SP9d)', () => {
120
+ const item = { id: 'v1', type: 'video', src: 'v.mp4', start: 0, end: 10 } as VisualItem
121
+ renderInspector(item, 5)
122
+
123
+ expect(screen.queryByText('Select an overlay to edit its properties.')).toBeNull()
124
+ expect(screen.queryByLabelText('Offset X')).not.toBeNull()
125
+ })
126
+
127
+ it("disables a clip's OPACITY keyframe diamond, with a reason, rather than hiding it", () => {
128
+ // ffmpeg's colorchannelmixer takes its alpha gain as a <double> and accepts
129
+ // no expression, so clip opacity cannot animate. A missing control reads as
130
+ // a bug; a disabled one that says why reads as the limitation it is.
131
+ const item = { id: 'v1', type: 'video', src: 'v.mp4', start: 0, end: 10 } as VisualItem
132
+ renderInspector(item, 5)
133
+
134
+ const diamond = screen.getByLabelText(/Opacity keyframe at playhead/)
135
+ expect(diamond).toBeDisabled()
136
+ expect(diamond.getAttribute('title')).toMatch(/cannot be animated/i)
137
+ })
138
+
139
+ it('leaves an overlay\'s opacity diamond fully enabled', () => {
140
+ renderInspector(overlayItem(), 5)
141
+ expect(screen.getByLabelText(/Opacity keyframe at playhead/)).not.toBeDisabled()
142
+ })
143
+
144
+ it('renders the empty state for a kind that cannot be keyframed at all', () => {
145
+ const item = { id: 'a1', type: 'audio', src: 'a.wav', start: 0, end: 10 } as unknown as VisualItem
146
+ renderInspector(item, 5)
147
+
148
+ expect(screen.getByText('Select an overlay to edit its properties.')).toBeInTheDocument()
149
+ expect(screen.queryByLabelText('Offset X')).toBeNull()
150
+ })
151
+
152
+ it('renders the transform body unconditionally (no title, no collapse toggle)', () => {
153
+ renderInspector(overlayItem(), 5)
154
+
155
+ // The pane carries no "Transform" heading (the CONTENT/TRANSFORM tab names
156
+ // it) and no collapse toggle — so there is no title text and nothing to
157
+ // click to hide the body.
158
+ expect(screen.queryByText('Transform')).toBeNull()
159
+ expect(screen.queryByRole('button', { name: 'Transform' })).toBeNull()
160
+
161
+ // The body is visible immediately, with no interaction required.
162
+ expect(screen.getByLabelText('Offset X')).toHaveValue(10)
163
+ })
164
+ })
165
+
166
+ describe('OverlayInspector — keyframe diamond', () => {
167
+ it('reflects keyframed state per property', () => {
168
+ const item = overlayItem({ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1.2 }] }] })
169
+ renderInspector(item, 5)
170
+
171
+ expect(screen.getByRole('button', { name: /Remove Scale keyframe/ })).toHaveAttribute('aria-pressed', 'true')
172
+ expect(screen.getByRole('button', { name: /Add Rotation keyframe/ })).toHaveAttribute('aria-pressed', 'false')
173
+ })
174
+
175
+ it('toggling an unkeyframed property enables keyframing without moving the overlay', () => {
176
+ const item = overlayItem() // scale: 1.2, static
177
+ const { onChange } = renderInspector(item, 5) // localT = 0
178
+
179
+ fireEvent.click(screen.getByRole('button', { name: /Add Scale keyframe/ }))
180
+
181
+ expect(onChange).toHaveBeenCalledTimes(1)
182
+ const next = onChange.mock.calls[0][0] as VisualItem
183
+ expect(hasKeyframes(next, 'scale')).toBe(true)
184
+ // The seeded keyframe must hold the SAME value the overlay already had —
185
+ // enabling keyframing must never move it.
186
+ expect(valueAt(next, 'scale', 0)).toBe(valueAt(item, 'scale', 0))
187
+ expect(valueAt(next, 'scale', 0)).toBe(1.2)
188
+ })
189
+
190
+ it('toggling a keyframed property disables keyframing and freezes the current value', () => {
191
+ const item = overlayItem({
192
+ rotation: 999, // stale static field — disableKeyframing must overwrite it from the curve
193
+ keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
194
+ })
195
+ const { onChange } = renderInspector(item, 5) // localT = 0 -> curve value 0
196
+
197
+ fireEvent.click(screen.getByRole('button', { name: /Remove Rotation keyframe/ }))
198
+
199
+ const next = onChange.mock.calls[0][0] as VisualItem
200
+ expect(hasKeyframes(next, 'rotation')).toBe(false)
201
+ expect(next.rotation).toBe(0)
202
+ })
203
+ })
204
+
205
+ describe('OverlayInspector — auto-keyframe on change', () => {
206
+ it('writes the static scalar when the property is NOT keyframed', () => {
207
+ const item = overlayItem() // offsetX static
208
+ const { onPreview } = renderInspector(item, 5)
209
+
210
+ fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '42' } })
211
+
212
+ expect(onPreview).toHaveBeenCalledTimes(1)
213
+ const next = onPreview.mock.calls[0][0] as VisualItem
214
+ expect(next.offsetX).toBe(42)
215
+ expect(hasKeyframes(next, 'offsetX')).toBe(false)
216
+ })
217
+
218
+ it('writes a keyframe at (playhead - item.start) when the property IS keyframed', () => {
219
+ const item = overlayItem({
220
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
221
+ })
222
+ // item.start = 5, playhead = 8 -> localT = 3, a point NOT already on the track.
223
+ const { onPreview } = renderInspector(item, 8)
224
+
225
+ fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '77' } })
226
+
227
+ expect(onPreview).toHaveBeenCalledTimes(1)
228
+ const next = onPreview.mock.calls[0][0] as VisualItem
229
+ const track = next.keyframes!.find(t => t.prop === 'offsetX')!
230
+ const point = track.points.find(p => p.t === 3)
231
+ expect(point).toBeDefined()
232
+ expect(point!.value).toBe(77)
233
+ // The original endpoints are untouched — this ADDS a keyframe, not replaces the track.
234
+ expect(track.points.find(p => p.t === 0)?.value).toBe(0)
235
+ expect(track.points.find(p => p.t === 10)?.value).toBe(100)
236
+ })
237
+ })
238
+
239
+ // Same writeProp rule as the suite above, pinned for a CLIP specifically —
240
+ // clips are about to start using this panel (canKeyframe already accepts
241
+ // 'video'/'image', see the "renders the panel for a VIDEO clip" test), and
242
+ // nothing previously locked the static-vs-keyframe behavior in for that kind.
243
+ describe('OverlayInspector — auto-keyframe on change (video clip)', () => {
244
+ function clipItem(over: Partial<VisualItem> = {}): VisualItem {
245
+ return { id: 'v1', type: 'video', src: 'v.mp4', start: 0, end: 10, ...over } as VisualItem
246
+ }
247
+
248
+ it('writes the static scalar when the property is NOT keyframed', () => {
249
+ const item = clipItem({ rotation: 0 })
250
+ const { onPreview } = renderInspector(item, 5) // localT = 5
251
+
252
+ fireEvent.change(screen.getByLabelText('Rotation'), { target: { value: '45' } })
253
+
254
+ expect(onPreview).toHaveBeenCalledTimes(1)
255
+ const next = onPreview.mock.calls[0][0] as VisualItem
256
+ expect(next.rotation).toBe(45)
257
+ expect(hasKeyframes(next, 'rotation')).toBe(false)
258
+ expect(next.keyframes).toBeUndefined() // no implicit keyframe track appears
259
+ })
260
+
261
+ it('writes a keyframe at the playhead when the property IS keyframed', () => {
262
+ const item = clipItem({
263
+ keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
264
+ })
265
+ const { onPreview } = renderInspector(item, 3) // localT = 3, not already on the track
266
+
267
+ fireEvent.change(screen.getByLabelText('Rotation'), { target: { value: '45' } })
268
+
269
+ expect(onPreview).toHaveBeenCalledTimes(1)
270
+ const next = onPreview.mock.calls[0][0] as VisualItem
271
+ const track = next.keyframes!.find(t => t.prop === 'rotation')!
272
+ const point = track.points.find(p => p.t === 3)
273
+ expect(point).toBeDefined()
274
+ expect(point!.value).toBe(45)
275
+ // The static scalar is NOT overwritten — the write went to the curve instead.
276
+ expect(next.rotation).toBeUndefined()
277
+ // The original endpoints are untouched — this ADDS a keyframe, not replaces the track.
278
+ expect(track.points.find(p => p.t === 0)?.value).toBe(0)
279
+ expect(track.points.find(p => p.t === 10)?.value).toBe(90)
280
+ })
281
+ })
282
+
283
+ describe('OverlayInspector — clamped localT (selecting an overlay does not move the playhead)', () => {
284
+ // item.start = 5, item.end = 15 (overlayItem's defaults) — a 10s span.
285
+ it('clamps writes to t=0 when the playhead sits BEFORE the item start', () => {
286
+ const item = overlayItem({
287
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
288
+ })
289
+ // Unclamped this would be playhead(0) - start(5) = -5.
290
+ const { onPreview } = renderInspector(item, 0)
291
+
292
+ fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '42' } })
293
+
294
+ const next = onPreview.mock.calls[0][0] as VisualItem
295
+ const track = next.keyframes!.find(t => t.prop === 'offsetX')!
296
+ expect(track.points.find(p => p.t === 0)?.value).toBe(42)
297
+ expect(track.points.some(p => p.t < 0)).toBe(false)
298
+ })
299
+
300
+ it('clamps writes to t=item.end-item.start when the playhead sits AFTER the item end', () => {
301
+ const item = overlayItem({
302
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
303
+ })
304
+ // Unclamped this would be playhead(30) - start(5) = 25.
305
+ const { onPreview } = renderInspector(item, 30)
306
+
307
+ fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '77' } })
308
+
309
+ const next = onPreview.mock.calls[0][0] as VisualItem
310
+ const track = next.keyframes!.find(t => t.prop === 'offsetX')!
311
+ expect(track.points.find(p => p.t === 10)?.value).toBe(77)
312
+ expect(track.points.some(p => p.t > 10)).toBe(false)
313
+ })
314
+
315
+ it('leaves a playhead INSIDE the span unaffected by the clamp', () => {
316
+ const item = overlayItem({
317
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
318
+ })
319
+ const { onPreview } = renderInspector(item, 8) // localT = 3, well inside [0, 10]
320
+
321
+ fireEvent.change(screen.getByLabelText('Offset X'), { target: { value: '55' } })
322
+
323
+ const next = onPreview.mock.calls[0][0] as VisualItem
324
+ const track = next.keyframes!.find(t => t.prop === 'offsetX')!
325
+ expect(track.points.find(p => p.t === 3)?.value).toBe(55)
326
+ })
327
+
328
+ it('the keyframe diamond toggle also clamps its seed time to the span', () => {
329
+ const item = overlayItem() // scale: 1.2, unkeyframed
330
+ const { onChange } = renderInspector(item, 0) // playhead before start (start=5)
331
+
332
+ fireEvent.click(screen.getByRole('button', { name: /Add Scale keyframe/ }))
333
+
334
+ const next = onChange.mock.calls[0][0] as VisualItem
335
+ const track = next.keyframes!.find(t => t.prop === 'scale')!
336
+ expect(track.points).toEqual([{ t: 0, value: 1.2 }])
337
+ })
338
+ })
339
+
340
+ describe('OverlayInspector — playhead scrubbing', () => {
341
+ it('updates a keyframed field’s displayed value as the playhead moves', () => {
342
+ const item = overlayItem({
343
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
344
+ })
345
+ const { clock } = renderInspector(item, 5) // localT = 0
346
+
347
+ expect(screen.getByLabelText('Offset X')).toHaveValue(0)
348
+
349
+ act(() => { clock.set(15) }) // localT = 10
350
+
351
+ expect(screen.getByLabelText('Offset X')).toHaveValue(100)
352
+ })
353
+ })
354
+
355
+ describe('OverlayInspector — a mid-typed value survives a playhead tick (FIX 10)', () => {
356
+ it('does not clobber a mid-typed value when the clock ticks before blur', () => {
357
+ const item = overlayItem() // offsetX: 10, static
358
+ const { clock } = renderInspector(item, 5)
359
+ const input = screen.getByLabelText('Offset X')
360
+
361
+ fireEvent.change(input, { target: { value: '4' } }) // mid-typing "4" of "42"
362
+ expect(input).toHaveValue(4)
363
+
364
+ // A tick arrives while the field is still mid-edit (unblurred) — this
365
+ // used to force the controlled input's value back to the stale prop,
366
+ // clobbering the keystroke.
367
+ act(() => { clock.set(6) })
368
+ expect(input).toHaveValue(4)
369
+
370
+ fireEvent.change(input, { target: { value: '42' } })
371
+ expect(input).toHaveValue(42)
372
+ })
373
+
374
+ it('commits on blur, closing the typing gesture', () => {
375
+ const item = overlayItem()
376
+ const { onCommit } = renderInspector(item, 5)
377
+ const input = screen.getByLabelText('Offset X')
378
+
379
+ fireEvent.change(input, { target: { value: '42' } })
380
+ expect(onCommit).not.toHaveBeenCalled()
381
+
382
+ fireEvent.blur(input)
383
+ expect(onCommit).toHaveBeenCalledTimes(1)
384
+ })
385
+
386
+ it('Enter commits it too, via the same blur path', () => {
387
+ const item = overlayItem()
388
+ const { onCommit } = renderInspector(item, 5)
389
+ const input = screen.getByLabelText('Offset X') as HTMLInputElement
390
+
391
+ input.focus()
392
+ fireEvent.change(input, { target: { value: '42' } })
393
+ fireEvent.keyDown(input, { key: 'Enter' })
394
+
395
+ expect(onCommit).toHaveBeenCalledTimes(1)
396
+ expect(document.activeElement).not.toBe(input)
397
+ })
398
+
399
+ it('after commit, the field goes back to tracking the live (now-updated) value', () => {
400
+ const item = overlayItem()
401
+ const { onCommit } = renderInspector(item, 5)
402
+ const input = screen.getByLabelText('Offset X')
403
+
404
+ fireEvent.change(input, { target: { value: '99' } })
405
+ fireEvent.blur(input)
406
+ expect(onCommit).toHaveBeenCalledTimes(1)
407
+ // draft cleared -> falls back to the (still 10, since this test's parent
408
+ // never re-supplies a changed `item`) prop-derived value, proving blur
409
+ // actually released the draft rather than freezing the typed text forever.
410
+ expect(input).toHaveValue(10)
411
+ })
412
+ })
413
+
414
+ describe('OverlayInspector — scale slider', () => {
415
+ it('reflects the sampled scale as a percentage, in sync with the slider', () => {
416
+ renderInspector(overlayItem(), 5)
417
+
418
+ const slider = screen.getByRole('slider', { name: 'Scale slider' }) as HTMLInputElement
419
+ expect(slider.value).toBe('1.2')
420
+ expect(screen.getByLabelText('Scale')).toHaveValue(120) // 1.2x => 120%
421
+ })
422
+
423
+ it('tracks a keyframed scale as the playhead moves', () => {
424
+ const item = overlayItem({
425
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
426
+ })
427
+ const { clock } = renderInspector(item, 5) // localT = 0
428
+ const slider = screen.getByRole('slider', { name: 'Scale slider' }) as HTMLInputElement
429
+
430
+ expect(slider.value).toBe('0.5')
431
+
432
+ act(() => { clock.set(15) }) // localT = 10
433
+
434
+ expect(slider.value).toBe('2')
435
+ expect(screen.getByLabelText('Scale')).toHaveValue(200) // 2x => 200%
436
+ })
437
+
438
+ it('previews the slider value on every change', () => {
439
+ const { onPreview } = renderInspector(overlayItem(), 5)
440
+ const slider = screen.getByRole('slider', { name: 'Scale slider' })
441
+
442
+ fireEvent.change(slider, { target: { value: '1.5' } })
443
+ fireEvent.change(slider, { target: { value: '1.6' } })
444
+
445
+ expect(onPreview).toHaveBeenCalledTimes(2)
446
+ expect((onPreview.mock.calls[0][0] as VisualItem).scale).toBe(1.5)
447
+ expect((onPreview.mock.calls[1][0] as VisualItem).scale).toBe(1.6)
448
+ })
449
+
450
+ it('commits ONCE on pointer up, not on every change, and not again on the trailing blur', () => {
451
+ const { onCommit } = renderInspector(overlayItem(), 5)
452
+ const slider = screen.getByRole('slider', { name: 'Scale slider' })
453
+
454
+ fireEvent.change(slider, { target: { value: '1.5' } })
455
+ fireEvent.change(slider, { target: { value: '1.6' } })
456
+ expect(onCommit).not.toHaveBeenCalled()
457
+
458
+ fireEvent.pointerUp(slider)
459
+ expect(onCommit).toHaveBeenCalledTimes(1)
460
+
461
+ // A pointerup is normally followed by a blur — that must not stack a
462
+ // second undo entry onto the same gesture.
463
+ fireEvent.blur(slider)
464
+ expect(onCommit).toHaveBeenCalledTimes(1)
465
+ })
466
+
467
+ it('commits a keyboard adjustment on key up', () => {
468
+ const { onCommit } = renderInspector(overlayItem(), 5)
469
+ const slider = screen.getByRole('slider', { name: 'Scale slider' })
470
+
471
+ fireEvent.change(slider, { target: { value: '1.3' } })
472
+ fireEvent.keyUp(slider, { key: 'ArrowRight' })
473
+
474
+ expect(onCommit).toHaveBeenCalledTimes(1)
475
+ })
476
+
477
+ it('obeys the auto-keyframe rule like every other control', () => {
478
+ const item = overlayItem({
479
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
480
+ })
481
+ const { onPreview } = renderInspector(item, 8) // localT = 3
482
+ const slider = screen.getByRole('slider', { name: 'Scale slider' })
483
+
484
+ fireEvent.change(slider, { target: { value: '1.5' } })
485
+
486
+ const next = onPreview.mock.calls[0][0] as VisualItem
487
+ expect(trackFor(next, 'scale')!.points.find(p => p.t === 3)?.value).toBe(1.5)
488
+ })
489
+ })
490
+
491
+ describe('OverlayInspector — uniform scale lock', () => {
492
+ it('renders checked and ENABLED for an item with no per-axis scale', () => {
493
+ renderInspector(overlayItem(), 5)
494
+
495
+ const lock = screen.getByRole('checkbox', { name: LOCK })
496
+ expect(lock).toHaveAttribute('aria-checked', 'true')
497
+ expect(lock).not.toHaveAttribute('aria-disabled')
498
+ expect(lock).toBeEnabled()
499
+ })
500
+
501
+ it('renders UNCHECKED for an item that carries scaleX/scaleY', () => {
502
+ renderInspector(overlayItem({ scaleX: 1.2, scaleY: 0.8 }), 5)
503
+
504
+ expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
505
+ })
506
+
507
+ it('reads unchecked off a per-axis KEYFRAME TRACK, not just the scalar', () => {
508
+ const item = overlayItem({
509
+ keyframes: [{ prop: 'scaleX', points: [{ t: 0, value: 1 }, { t: 10, value: 2 }] }],
510
+ })
511
+ renderInspector(item, 5)
512
+
513
+ expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
514
+ })
515
+
516
+ it('stays unchecked for a deliberately-unlocked item whose axes happen to be equal', () => {
517
+ // Equality is NOT the test — `scaleX === scaleY` on an item the operator
518
+ // unlocked on purpose must not silently re-lock it.
519
+ renderInspector(overlayItem({ scaleX: 1.2, scaleY: 1.2 }), 5)
520
+
521
+ expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
522
+ })
523
+
524
+ it('re-derives its state when a DIFFERENT overlay is selected', () => {
525
+ // The reason the state is derived from `item` rather than held in
526
+ // useState: this panel is reconciled in place across selection changes, so
527
+ // component state would keep showing the previous overlay's answer.
528
+ const clock = createPlaybackClock(5)
529
+ const props = { clock, onPreview: vi.fn(), onCommit: vi.fn(), onChange: vi.fn() }
530
+ const { rerender } = render(<OverlayInspector item={overlayItem({ scaleX: 2, scaleY: 0.5 })} {...props} />)
531
+ expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
532
+
533
+ rerender(<OverlayInspector item={overlayItem({ id: 'o2' })} {...props} />)
534
+
535
+ expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'true')
536
+ })
537
+
538
+ it('is operable from the keyboard', () => {
539
+ const { onChange } = renderInspector(overlayItem(), 5)
540
+
541
+ // role=checkbox on a <button> fires click for Space/Enter natively; jsdom
542
+ // does not synthesize that, so the click IS the keyboard activation here.
543
+ // What this asserts is that nothing disables the control or swallows it.
544
+ const lock = screen.getByRole('checkbox', { name: LOCK })
545
+ lock.focus()
546
+ expect(lock).toHaveFocus()
547
+ fireEvent.click(lock)
548
+
549
+ expect(onChange).toHaveBeenCalledTimes(1)
550
+ })
551
+
552
+ describe('unlocking (ON → OFF)', () => {
553
+ it('splits the Scale row into Scale X / Scale Y boxes', () => {
554
+ renderInspector(overlayItem({ scaleX: 1.2, scaleY: 0.8 }), 5)
555
+
556
+ expect(screen.getByLabelText('Scale X')).toHaveValue(120)
557
+ expect(screen.getByLabelText('Scale Y')).toHaveValue(80)
558
+ // The single uniform row — box AND slider — is gone.
559
+ expect(screen.queryByLabelText('Scale')).toBeNull()
560
+ expect(screen.queryByRole('slider', { name: 'Scale slider' })).toBeNull()
561
+ })
562
+
563
+ it('seeds BOTH axes from the current scale, so nothing jumps', () => {
564
+ const item = overlayItem({ scale: 1.2 })
565
+ const { onChange } = renderInspector(item, 5) // localT = 0
566
+
567
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
568
+
569
+ expect(onChange).toHaveBeenCalledTimes(1)
570
+ const next = onChange.mock.calls[0][0] as VisualItem
571
+ expect(next.scaleX).toBe(1.2)
572
+ expect(next.scaleY).toBe(1.2)
573
+ // The actual no-jump proof: the EFFECTIVE per-axis scale the renderer
574
+ // resolves is identical either side of the transition.
575
+ expect(valueAt(next, 'scaleX', 0)).toBe(valueAt(item, 'scaleX', 0))
576
+ expect(valueAt(next, 'scaleY', 0)).toBe(valueAt(item, 'scaleY', 0))
577
+ })
578
+
579
+ it('seeds from the ANIMATED scale at the playhead, not the static scalar', () => {
580
+ const item = overlayItem({
581
+ scale: 999, // stale scalar the track shadows — seeding from it would jump
582
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 10, value: 2 }] }],
583
+ })
584
+ const { onChange } = renderInspector(item, 10) // localT = 5, halfway => 1.5
585
+
586
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
587
+
588
+ const next = onChange.mock.calls[0][0] as VisualItem
589
+ expect(next.scaleX).toBe(1.5)
590
+ expect(next.scaleY).toBe(1.5)
591
+ expect(valueAt(next, 'scaleX', 5)).toBe(valueAt(item, 'scaleX', 5))
592
+ expect(valueAt(next, 'scaleY', 5)).toBe(valueAt(item, 'scaleY', 5))
593
+ })
594
+
595
+ it('writes the per-axis boxes back as multipliers', () => {
596
+ const { onPreview } = renderInspector(overlayItem({ scaleX: 1, scaleY: 1 }), 5)
597
+
598
+ fireEvent.change(screen.getByLabelText('Scale X'), { target: { value: '200' } })
599
+ fireEvent.change(screen.getByLabelText('Scale Y'), { target: { value: '50' } })
600
+
601
+ expect((onPreview.mock.calls[0][0] as VisualItem).scaleX).toBe(2)
602
+ expect((onPreview.mock.calls[1][0] as VisualItem).scaleY).toBe(0.5)
603
+ })
604
+
605
+ it('steps each axis independently', () => {
606
+ const { onChange } = renderInspector(overlayItem({ scaleX: 1.2, scaleY: 1.2 }), 5)
607
+
608
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Scale X' }))
609
+ expect((onChange.mock.calls[0][0] as VisualItem).scaleX).toBe(1.21)
610
+
611
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease Scale Y' }))
612
+ expect((onChange.mock.calls[1][0] as VisualItem).scaleY).toBe(1.19)
613
+ })
614
+ })
615
+
616
+ describe('locking (OFF → ON)', () => {
617
+ it('collapses to `scale` keeping the X value, and clears both axes', () => {
618
+ const item = overlayItem({ scaleX: 1.5, scaleY: 0.25 })
619
+ const { onChange } = renderInspector(item, 5)
620
+
621
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
622
+
623
+ expect(onChange).toHaveBeenCalledTimes(1)
624
+ const next = onChange.mock.calls[0][0] as VisualItem
625
+ expect(next.scale).toBe(1.5) // X wins — arbitrary but documented and stable
626
+ expect('scaleX' in next).toBe(false)
627
+ expect('scaleY' in next).toBe(false)
628
+ // And the resolver agrees: both axes fall back to the uniform value.
629
+ expect(valueAt(next, 'scaleX', 0)).toBe(1.5)
630
+ expect(valueAt(next, 'scaleY', 0)).toBe(1.5)
631
+ })
632
+
633
+ it('clears per-axis keyframe TRACKS too, not just the scalars', () => {
634
+ const item = overlayItem({
635
+ scaleX: 9,
636
+ keyframes: [
637
+ { prop: 'scaleX', points: [{ t: 0, value: 1.5 }, { t: 10, value: 3 }] },
638
+ { prop: 'scaleY', points: [{ t: 0, value: 0.25 }, { t: 10, value: 4 }] },
639
+ { prop: 'opacity', points: [{ t: 0, value: 0.5 }] },
640
+ ],
641
+ })
642
+ const { onChange } = renderInspector(item, 5) // localT = 0
643
+
644
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
645
+
646
+ const next = onChange.mock.calls[0][0] as VisualItem
647
+ expect(next.scale).toBe(1.5) // the X CURVE's value at the playhead
648
+ expect('scaleX' in next).toBe(false)
649
+ expect(trackFor(next, 'scaleX')).toBeUndefined()
650
+ expect(trackFor(next, 'scaleY')).toBeUndefined()
651
+ // An unrelated track is untouched — this clears two props, not the item.
652
+ expect(trackFor(next, 'opacity')!.points).toEqual([{ t: 0, value: 0.5 }])
653
+ })
654
+
655
+ it('drops `item.keyframes` entirely when the per-axis tracks were the only ones', () => {
656
+ const item = overlayItem({
657
+ keyframes: [
658
+ { prop: 'scaleX', points: [{ t: 0, value: 1.5 }] },
659
+ { prop: 'scaleY', points: [{ t: 0, value: 0.5 }] },
660
+ ],
661
+ })
662
+ const { onChange } = renderInspector(item, 5)
663
+
664
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
665
+
666
+ // Not a lingering `[]` — downstream treats "no keyframes" as the static
667
+ // path and the two must behave identically (withTrack's contract).
668
+ expect((onChange.mock.calls[0][0] as VisualItem).keyframes).toBeUndefined()
669
+ })
670
+
671
+ it('keyframes `scale` instead of writing the scalar when it is already animated', () => {
672
+ // The collapse is a normal panel write, so writeProp's auto-keyframe rule
673
+ // applies to it exactly as it does to typing in the Scale box.
674
+ const item = overlayItem({
675
+ scaleX: 2,
676
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 1 }] }],
677
+ })
678
+ const { onChange } = renderInspector(item, 8) // localT = 3
679
+
680
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
681
+
682
+ const next = onChange.mock.calls[0][0] as VisualItem
683
+ expect(trackFor(next, 'scale')!.points.find(p => p.t === 3)?.value).toBe(2)
684
+ expect('scaleX' in next).toBe(false)
685
+ })
686
+
687
+ it('round-trips: unlock then lock returns the original scale', () => {
688
+ const item = overlayItem({ scale: 1.2 })
689
+ const first = renderInspector(item, 5)
690
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
691
+ const unlocked = first.onChange.mock.calls[0][0] as VisualItem
692
+ first.unmount()
693
+
694
+ const second = renderInspector(unlocked, 5)
695
+ fireEvent.click(screen.getByRole('checkbox', { name: LOCK }))
696
+ const relocked = second.onChange.mock.calls[0][0] as VisualItem
697
+
698
+ expect(relocked.scale).toBe(1.2)
699
+ expect('scaleX' in relocked).toBe(false)
700
+ expect('scaleY' in relocked).toBe(false)
701
+ })
702
+ })
703
+
704
+ describe('per-axis keyframes', () => {
705
+ it('gives each axis its OWN keyframe unit', () => {
706
+ renderInspector(overlayItem({ scaleX: 1, scaleY: 1 }), 5)
707
+
708
+ expect(screen.getByRole('button', { name: 'Add Scale X keyframe at playhead' })).toBeInTheDocument()
709
+ expect(screen.getByRole('button', { name: 'Add Scale Y keyframe at playhead' })).toBeInTheDocument()
710
+ expect(screen.getByRole('button', { name: 'Previous Scale X keyframe' })).toBeInTheDocument()
711
+ expect(screen.getByRole('button', { name: 'Next Scale Y keyframe' })).toBeInTheDocument()
712
+ // The uniform row's diamond is gone with the row.
713
+ expect(screen.queryByRole('button', { name: 'Add Scale keyframe at playhead' })).toBeNull()
714
+ })
715
+
716
+ it('a diamond animates ONE axis, leaving the other static', () => {
717
+ const { onChange } = renderInspector(overlayItem({ scaleX: 1.5, scaleY: 0.5 }), 8) // localT = 3
718
+
719
+ fireEvent.click(screen.getByRole('button', { name: 'Add Scale X keyframe at playhead' }))
720
+
721
+ const next = onChange.mock.calls[0][0] as VisualItem
722
+ expect(trackFor(next, 'scaleX')!.points).toEqual([{ t: 3, value: 1.5 }])
723
+ expect(hasKeyframes(next, 'scaleY')).toBe(false)
724
+ expect(next.scaleY).toBe(0.5)
725
+ })
726
+
727
+ it('a pressed diamond turns that axis back into a static scalar', () => {
728
+ const item = overlayItem({
729
+ scaleX: 999, // stale — disableKeyframing must overwrite it, not restore it
730
+ keyframes: [{ prop: 'scaleX', points: [{ t: 0, value: 1.5 }] }],
731
+ })
732
+ const { onChange } = renderInspector(item, 5) // localT = 0
733
+
734
+ const diamond = screen.getByRole('button', { name: 'Remove Scale X keyframe at playhead' })
735
+ expect(diamond).toHaveAttribute('aria-pressed', 'true')
736
+ fireEvent.click(diamond)
737
+
738
+ const next = onChange.mock.calls[0][0] as VisualItem
739
+ expect(trackFor(next, 'scaleX')).toBeUndefined()
740
+ expect(next.scaleX).toBe(1.5) // nothing moves
741
+ })
742
+
743
+ it('the per-axis boxes keyframe (in percent) when that axis is animated', () => {
744
+ const item = overlayItem({
745
+ keyframes: [{ prop: 'scaleY', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
746
+ })
747
+ const { onPreview } = renderInspector(item, 8) // localT = 3
748
+
749
+ fireEvent.change(screen.getByLabelText('Scale Y'), { target: { value: '150' } })
750
+
751
+ const next = onPreview.mock.calls[0][0] as VisualItem
752
+ expect(trackFor(next, 'scaleY')!.points.find(p => p.t === 3)?.value).toBe(1.5)
753
+ })
754
+ })
755
+
756
+ describe('the header all-props actions follow the lock', () => {
757
+ it('keyframe-all on a UNIFORM item keys `scale` and never seeds scaleX/scaleY', () => {
758
+ // The whole point of the two-list split: a one-point scaleX track would
759
+ // shadow the `scale` track and silently freeze the uniform zoom.
760
+ const item = overlayItem({
761
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 10, value: 2 }] }],
762
+ })
763
+ const { onChange } = renderInspector(item, 8) // localT = 3
764
+
765
+ fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
766
+
767
+ const next = onChange.mock.calls[0][0] as VisualItem
768
+ expect(trackFor(next, 'scale')!.points.some(p => p.t === 3)).toBe(true)
769
+ expect(trackFor(next, 'scaleX')).toBeUndefined()
770
+ expect(trackFor(next, 'scaleY')).toBeUndefined()
771
+ // The uniform animation still animates.
772
+ expect(valueAt(next, 'scaleX', 0)).toBe(1)
773
+ expect(valueAt(next, 'scaleX', 10)).toBe(2)
774
+ })
775
+
776
+ it('keyframe-all on an UNLOCKED item keys both axes and not `scale`', () => {
777
+ const item = overlayItem({ scaleX: 1.5, scaleY: 0.5 })
778
+ const { onChange } = renderInspector(item, 8) // localT = 3
779
+
780
+ fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
781
+
782
+ const next = onChange.mock.calls[0][0] as VisualItem
783
+ for (const prop of PER_AXIS_PROPS) {
784
+ expect(trackFor(next, prop)!.points.some(p => p.t === 3)).toBe(true)
785
+ expect(valueAt(next, prop, 3)).toBe(valueAt(item, prop, 3))
786
+ }
787
+ expect(trackFor(next, 'scale')).toBeUndefined()
788
+ })
789
+
790
+ it('reset-all on an UNLOCKED item resets the axes, not the shadowed `scale`', () => {
791
+ const item = overlayItem({ scale: 1.2, scaleX: 3, scaleY: 0.25 })
792
+ const { onChange } = renderInspector(item, 5)
793
+
794
+ fireEvent.click(screen.getByRole('button', { name: 'Reset transform' }))
795
+
796
+ const next = onChange.mock.calls[0][0] as VisualItem
797
+ expect(next.scaleX).toBe(1)
798
+ expect(next.scaleY).toBe(1)
799
+ expect(next.scale).toBe(1.2) // untouched, and shadowed anyway
800
+ // Reset is not a re-lock: the item stays authored per-axis.
801
+ expect(screen.getByRole('checkbox', { name: LOCK })).toHaveAttribute('aria-checked', 'false')
802
+ })
803
+
804
+ it('the header diamond reflects both axes on an unlocked item', () => {
805
+ const keyed = (props: KeyframeProp[]) =>
806
+ overlayItem({ scaleX: 1, scaleY: 1, keyframes: props.map(prop => ({ prop, points: [{ t: 0, value: 1 }] })) })
807
+
808
+ const { unmount } = renderInspector(keyed(PER_AXIS_PROPS), 5) // localT = 0
809
+ expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'true')
810
+ unmount()
811
+
812
+ // Everything but scaleY: the header must NOT read as fully keyed.
813
+ renderInspector(keyed(PER_AXIS_PROPS.filter(p => p !== 'scaleY')), 5)
814
+ expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'false')
815
+ })
816
+ })
817
+
818
+ it('the scale box takes a percentage and writes it back as a multiplier', () => {
819
+ const { onPreview } = renderInspector(overlayItem(), 5)
820
+
821
+ fireEvent.change(screen.getByLabelText('Scale'), { target: { value: '200' } })
822
+
823
+ expect(onPreview).toHaveBeenCalledTimes(1)
824
+ expect((onPreview.mock.calls[0][0] as VisualItem).scale).toBe(2)
825
+ })
826
+
827
+ it('the scale box keyframes `scale` (in percent) when it is animated', () => {
828
+ const item = overlayItem({
829
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 2 }] }],
830
+ })
831
+ const { onPreview } = renderInspector(item, 8) // localT = 3
832
+
833
+ fireEvent.change(screen.getByLabelText('Scale'), { target: { value: '150' } })
834
+
835
+ const next = onPreview.mock.calls[0][0] as VisualItem
836
+ expect(trackFor(next, 'scale')!.points.find(p => p.t === 3)?.value).toBe(1.5)
837
+ })
838
+ })
839
+
840
+ describe('OverlayInspector — steppers', () => {
841
+ it('nudges up by the row step in one discrete change', () => {
842
+ const { onChange, onPreview, onCommit } = renderInspector(overlayItem(), 5)
843
+
844
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Offset X' }))
845
+
846
+ expect(onChange).toHaveBeenCalledTimes(1)
847
+ expect(onPreview).not.toHaveBeenCalled()
848
+ expect(onCommit).not.toHaveBeenCalled()
849
+ expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(11)
850
+ })
851
+
852
+ it('nudges down by the row step', () => {
853
+ const { onChange } = renderInspector(overlayItem(), 5)
854
+
855
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease Offset X' }))
856
+
857
+ expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(9)
858
+ })
859
+
860
+ it('rounds away float noise (1.2 + 0.01 is not 1.2100000000000002)', () => {
861
+ const { onChange } = renderInspector(overlayItem(), 5)
862
+
863
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Scale' }))
864
+
865
+ expect((onChange.mock.calls[0][0] as VisualItem).scale).toBe(1.21)
866
+ })
867
+
868
+ it('clamps at the row max', () => {
869
+ const { onChange } = renderInspector(overlayItem({ opacity: 1 }), 5)
870
+
871
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Opacity' }))
872
+
873
+ expect((onChange.mock.calls[0][0] as VisualItem).opacity).toBe(1)
874
+ })
875
+
876
+ it('clamps at the row min', () => {
877
+ const { onChange } = renderInspector(overlayItem({ scale: 0.01 }), 5)
878
+
879
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease Scale' }))
880
+
881
+ expect((onChange.mock.calls[0][0] as VisualItem).scale).toBe(0.01)
882
+ })
883
+
884
+ it('writes a keyframe at the playhead when the prop is keyframed', () => {
885
+ const item = overlayItem({
886
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
887
+ })
888
+ const { onChange } = renderInspector(item, 8) // localT = 3 -> sampled 30
889
+
890
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Offset X' }))
891
+
892
+ const next = onChange.mock.calls[0][0] as VisualItem
893
+ expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 3)?.value).toBe(31)
894
+ })
895
+ })
896
+
897
+ describe('OverlayInspector — rotation dial', () => {
898
+ it('exposes the current rotation as aria-valuenow, tracking the playhead', () => {
899
+ const item = overlayItem({
900
+ keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
901
+ })
902
+ const { clock } = renderInspector(item, 5) // localT = 0
903
+ const dial = screen.getByRole('slider', { name: 'Rotation dial' })
904
+
905
+ expect(dial).toHaveAttribute('aria-valuemin', '0')
906
+ expect(dial).toHaveAttribute('aria-valuemax', '360')
907
+ expect(dial).toHaveAttribute('aria-valuenow', '0')
908
+
909
+ act(() => { clock.set(10) }) // localT = 5 -> halfway -> 45
910
+ expect(dial).toHaveAttribute('aria-valuenow', '45')
911
+
912
+ act(() => { clock.set(15) }) // localT = 10
913
+ expect(dial).toHaveAttribute('aria-valuenow', '90')
914
+ })
915
+
916
+ it('nudges one degree per arrow key', () => {
917
+ const { onChange } = renderInspector(overlayItem(), 5) // rotation 45
918
+ const dial = screen.getByRole('slider', { name: 'Rotation dial' })
919
+
920
+ fireEvent.keyDown(dial, { key: 'ArrowRight' })
921
+ expect((onChange.mock.calls[0][0] as VisualItem).rotation).toBe(46)
922
+
923
+ fireEvent.keyDown(dial, { key: 'ArrowLeft' })
924
+ expect((onChange.mock.calls[1][0] as VisualItem).rotation).toBe(44)
925
+
926
+ fireEvent.keyDown(dial, { key: 'ArrowUp' })
927
+ expect((onChange.mock.calls[2][0] as VisualItem).rotation).toBe(46)
928
+
929
+ fireEvent.keyDown(dial, { key: 'ArrowDown' })
930
+ expect((onChange.mock.calls[3][0] as VisualItem).rotation).toBe(44)
931
+ })
932
+
933
+ it('Shift multiplies the nudge to 15 degrees', () => {
934
+ const { onChange } = renderInspector(overlayItem(), 5) // rotation 45
935
+ const dial = screen.getByRole('slider', { name: 'Rotation dial' })
936
+
937
+ fireEvent.keyDown(dial, { key: 'ArrowRight', shiftKey: true })
938
+ expect((onChange.mock.calls[0][0] as VisualItem).rotation).toBe(60)
939
+
940
+ fireEvent.keyDown(dial, { key: 'ArrowLeft', shiftKey: true })
941
+ expect((onChange.mock.calls[1][0] as VisualItem).rotation).toBe(30)
942
+ })
943
+
944
+ it('wraps into [0, 360) rather than going negative', () => {
945
+ const { onChange } = renderInspector(overlayItem({ rotation: 0 }), 5)
946
+ const dial = screen.getByRole('slider', { name: 'Rotation dial' })
947
+
948
+ fireEvent.keyDown(dial, { key: 'ArrowLeft' })
949
+
950
+ expect((onChange.mock.calls[0][0] as VisualItem).rotation).toBe(359)
951
+ })
952
+
953
+ it('ignores keys it does not handle', () => {
954
+ const { onChange } = renderInspector(overlayItem(), 5)
955
+ const dial = screen.getByRole('slider', { name: 'Rotation dial' })
956
+
957
+ fireEvent.keyDown(dial, { key: 'a' })
958
+ fireEvent.keyDown(dial, { key: 'Enter' })
959
+
960
+ expect(onChange).not.toHaveBeenCalled()
961
+ })
962
+
963
+ it('writes the static scalar when rotation is NOT keyframed', () => {
964
+ const { onChange } = renderInspector(overlayItem(), 5)
965
+
966
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Rotation dial' }), { key: 'ArrowRight' })
967
+
968
+ const next = onChange.mock.calls[0][0] as VisualItem
969
+ expect(next.rotation).toBe(46)
970
+ expect(hasKeyframes(next, 'rotation')).toBe(false)
971
+ })
972
+
973
+ it('writes a keyframe at localT when rotation IS keyframed', () => {
974
+ const item = overlayItem({
975
+ keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
976
+ })
977
+ const { onChange } = renderInspector(item, 8) // localT = 3 -> sampled 27
978
+
979
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Rotation dial' }), { key: 'ArrowRight' })
980
+
981
+ const next = onChange.mock.calls[0][0] as VisualItem
982
+ const track = trackFor(next, 'rotation')!
983
+ expect(track.points.find(p => p.t === 3)?.value).toBe(28)
984
+ expect(track.points.find(p => p.t === 0)?.value).toBe(0)
985
+ expect(track.points.find(p => p.t === 10)?.value).toBe(90)
986
+ })
987
+
988
+ it('emits nothing from a drag on a zero-size rect rather than a NaN', () => {
989
+ const { onPreview, onCommit } = renderInspector(overlayItem(), 5)
990
+ const el = dial()
991
+ // No stubDialRect here: the real jsdom rect is 0x0, exactly the case
992
+ // `angleFrom` bails on. A NaN reaching `setKeyframe` would be swallowed by
993
+ // its finite guard, but the STATIC path (`{ ...item, rotation: NaN }`) has
994
+ // no such guard, so bailing here is what actually protects the item.
995
+ firePointer(el, 'pointerdown', 30, 10)
996
+ firePointer(el, 'pointermove', 40, 20)
997
+ firePointer(el, 'pointerup', 40, 20)
998
+
999
+ expect(onPreview).not.toHaveBeenCalled()
1000
+ expect(onCommit).not.toHaveBeenCalled()
1001
+ })
1002
+
1003
+ it('maps a pointer to degrees clockwise-from-up once the rect has a size', () => {
1004
+ const { onPreview, onCommit } = renderInspector(overlayItem(), 5)
1005
+ const el = dial()
1006
+ stubDialRect(el)
1007
+
1008
+ const degrees = () => {
1009
+ const calls = onPreview.mock.calls
1010
+ return (calls[calls.length - 1][0] as VisualItem).rotation
1011
+ }
1012
+
1013
+ firePointer(el, 'pointerdown', 20, 0) // straight up -> 0
1014
+ expect(degrees()).toBe(0)
1015
+
1016
+ firePointer(el, 'pointermove', 40, 20) // right -> 90
1017
+ expect(degrees()).toBe(90)
1018
+
1019
+ firePointer(el, 'pointermove', 20, 40) // down -> 180
1020
+ expect(degrees()).toBe(180)
1021
+
1022
+ firePointer(el, 'pointermove', 0, 20) // left -> 270, not -90
1023
+ expect(degrees()).toBe(270)
1024
+
1025
+ // The drag is ONE gesture: previews throughout, a single commit at the end.
1026
+ expect(onCommit).not.toHaveBeenCalled()
1027
+ firePointer(el, 'pointerup', 0, 20)
1028
+ expect(onCommit).toHaveBeenCalledTimes(1)
1029
+ })
1030
+
1031
+ it('keyframes the dragged angle when rotation is keyframed', () => {
1032
+ const item = overlayItem({
1033
+ keyframes: [{ prop: 'rotation', points: [{ t: 0, value: 0 }, { t: 10, value: 90 }] }],
1034
+ })
1035
+ const { onPreview } = renderInspector(item, 8) // localT = 3
1036
+ const el = dial()
1037
+ stubDialRect(el)
1038
+
1039
+ firePointer(el, 'pointerdown', 40, 20) // right -> 90
1040
+
1041
+ const next = onPreview.mock.calls[0][0] as VisualItem
1042
+ expect(trackFor(next, 'rotation')!.points.find(p => p.t === 3)?.value).toBe(90)
1043
+ })
1044
+
1045
+ it('ignores a pointer move with no preceding pointer down', () => {
1046
+ const { onPreview } = renderInspector(overlayItem(), 5)
1047
+ const el = dial()
1048
+ stubDialRect(el)
1049
+
1050
+ firePointer(el, 'pointermove', 40, 20)
1051
+
1052
+ expect(onPreview).not.toHaveBeenCalled()
1053
+ })
1054
+
1055
+ it('does not spend an undo entry on a pointer up that never previewed', () => {
1056
+ const { onCommit } = renderInspector(overlayItem(), 5)
1057
+ const el = dial()
1058
+ stubDialRect(el)
1059
+
1060
+ // Pressing exactly on the centre has no direction, so nothing is emitted;
1061
+ // releasing must not then commit an edit that never happened.
1062
+ firePointer(el, 'pointerdown', 20, 20)
1063
+ firePointer(el, 'pointerup', 20, 20)
1064
+
1065
+ expect(onCommit).not.toHaveBeenCalled()
1066
+ })
1067
+ })
1068
+
1069
+ describe('alignedOffset', () => {
1070
+ it('puts a scaled-down overlay’s edge on the frame edge', () => {
1071
+ expect(alignedOffset(0.5, -1)).toBe(-25)
1072
+ expect(alignedOffset(0.5, 0)).toBe(0)
1073
+ expect(alignedOffset(0.5, 1)).toBe(25)
1074
+ })
1075
+
1076
+ it('collapses to centered at scale 1, where the overlay exactly fills the frame', () => {
1077
+ expect(alignedOffset(1, -1)).toBe(0)
1078
+ expect(alignedOffset(1, 0)).toBe(0)
1079
+ expect(alignedOffset(1, 1)).toBe(0)
1080
+ })
1081
+
1082
+ it('clamps an over-scaled overlay to centered rather than pushing it further off-frame', () => {
1083
+ expect(alignedOffset(2, -1)).toBe(0)
1084
+ expect(alignedOffset(2, 0)).toBe(0)
1085
+ expect(alignedOffset(2, 1)).toBe(0)
1086
+ })
1087
+ })
1088
+
1089
+ describe('OverlayInspector — align row', () => {
1090
+ const CASES: { label: string; prop: 'offsetX' | 'offsetY'; expected: number }[] = [
1091
+ { label: 'Align left', prop: 'offsetX', expected: -25 },
1092
+ { label: 'Align center', prop: 'offsetX', expected: 0 },
1093
+ { label: 'Align right', prop: 'offsetX', expected: 25 },
1094
+ { label: 'Align top', prop: 'offsetY', expected: -25 },
1095
+ { label: 'Align middle', prop: 'offsetY', expected: 0 },
1096
+ { label: 'Align bottom', prop: 'offsetY', expected: 25 },
1097
+ ]
1098
+
1099
+ for (const { label, prop, expected } of CASES) {
1100
+ it(`${label} writes ${expected} on ${prop} and leaves the other axis alone`, () => {
1101
+ const item = overlayItem({ scale: 0.5 }) // edge offset magnitude = 25
1102
+ const { onChange, unmount } = renderInspector(item, 5)
1103
+
1104
+ fireEvent.click(screen.getByRole('button', { name: label }))
1105
+
1106
+ expect(onChange).toHaveBeenCalledTimes(1)
1107
+ const next = onChange.mock.calls[0][0] as VisualItem
1108
+ expect(next[prop]).toBe(expected)
1109
+ const other = prop === 'offsetX' ? 'offsetY' : 'offsetX'
1110
+ expect(next[other]).toBe(item[other])
1111
+ unmount()
1112
+ })
1113
+ }
1114
+
1115
+ it('uses the SAMPLED scale mid-animation, not the stale static scalar', () => {
1116
+ const item = overlayItem({
1117
+ scale: 999, // never read: the prop is keyframed
1118
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 0.5 }, { t: 10, value: 0.5 }] }],
1119
+ })
1120
+ const { onChange } = renderInspector(item, 8) // localT = 3 -> scale 0.5
1121
+
1122
+ fireEvent.click(screen.getByRole('button', { name: 'Align right' }))
1123
+
1124
+ expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(25)
1125
+ })
1126
+
1127
+ it('keyframes the offset when the offset prop is keyframed', () => {
1128
+ const item = overlayItem({
1129
+ scale: 0.5,
1130
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
1131
+ })
1132
+ const { onChange } = renderInspector(item, 8) // localT = 3
1133
+
1134
+ fireEvent.click(screen.getByRole('button', { name: 'Align left' }))
1135
+
1136
+ const next = onChange.mock.calls[0][0] as VisualItem
1137
+ expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 3)?.value).toBe(-25)
1138
+ })
1139
+
1140
+ it('uses the X scale for horizontal alignment and the Y scale for vertical', () => {
1141
+ // A non-uniform overlay's left edge is set by its WIDTH and its top edge by
1142
+ // its HEIGHT, so one shared scale would put one of the two axes wrong.
1143
+ const item = overlayItem({ scaleX: 0.5, scaleY: 0.25 })
1144
+ const { onChange } = renderInspector(item, 5)
1145
+
1146
+ fireEvent.click(screen.getByRole('button', { name: 'Align left' }))
1147
+ expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(-25) // (1 - 0.5) / 2
1148
+
1149
+ fireEvent.click(screen.getByRole('button', { name: 'Align bottom' }))
1150
+ expect((onChange.mock.calls[1][0] as VisualItem).offsetY).toBe(37.5) // (1 - 0.25) / 2
1151
+ })
1152
+
1153
+ it('samples each axis independently mid-animation', () => {
1154
+ const item = overlayItem({
1155
+ scaleX: 999,
1156
+ scaleY: 999,
1157
+ keyframes: [
1158
+ { prop: 'scaleX', points: [{ t: 0, value: 0.5 }, { t: 10, value: 0.5 }] },
1159
+ { prop: 'scaleY', points: [{ t: 0, value: 0.2 }, { t: 10, value: 0.2 }] },
1160
+ ],
1161
+ })
1162
+ const { onChange } = renderInspector(item, 8) // localT = 3
1163
+
1164
+ fireEvent.click(screen.getByRole('button', { name: 'Align right' }))
1165
+ expect((onChange.mock.calls[0][0] as VisualItem).offsetX).toBe(25)
1166
+
1167
+ fireEvent.click(screen.getByRole('button', { name: 'Align top' }))
1168
+ expect((onChange.mock.calls[1][0] as VisualItem).offsetY).toBe(-40)
1169
+ })
1170
+
1171
+ it('is unchanged for a legacy uniform item, where both axes resolve to `scale`', () => {
1172
+ const item = overlayItem({ scale: 0.5 })
1173
+ const { onChange } = renderInspector(item, 5)
1174
+
1175
+ fireEvent.click(screen.getByRole('button', { name: 'Align top' }))
1176
+
1177
+ expect((onChange.mock.calls[0][0] as VisualItem).offsetY).toBe(-25)
1178
+ })
1179
+ })
1180
+
1181
+ describe('OverlayInspector — reset', () => {
1182
+ it('writes all five defaults in a single change', () => {
1183
+ const { onChange } = renderInspector(overlayItem(), 5)
1184
+
1185
+ fireEvent.click(screen.getByRole('button', { name: 'Reset transform' }))
1186
+
1187
+ expect(onChange).toHaveBeenCalledTimes(1)
1188
+ const next = onChange.mock.calls[0][0] as VisualItem
1189
+ expect(next.offsetX).toBe(0)
1190
+ expect(next.offsetY).toBe(0)
1191
+ expect(next.scale).toBe(1)
1192
+ expect(next.rotation).toBe(0)
1193
+ expect(next.opacity).toBe(1)
1194
+ })
1195
+
1196
+ it('drops a default-valued keyframe on a keyframed prop instead of deleting the track', () => {
1197
+ const item = overlayItem({
1198
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
1199
+ })
1200
+ const { onChange } = renderInspector(item, 8) // localT = 3
1201
+
1202
+ fireEvent.click(screen.getByRole('button', { name: 'Reset transform' }))
1203
+
1204
+ const next = onChange.mock.calls[0][0] as VisualItem
1205
+ expect(hasKeyframes(next, 'offsetX')).toBe(true)
1206
+ const track = trackFor(next, 'offsetX')!
1207
+ expect(track.points.find(p => p.t === 3)?.value).toBe(0)
1208
+ // The animation itself survives — reset is non-destructive.
1209
+ expect(track.points.find(p => p.t === 0)?.value).toBe(0)
1210
+ expect(track.points.find(p => p.t === 10)?.value).toBe(100)
1211
+ // Unkeyframed props still take the plain scalar.
1212
+ expect(next.scale).toBe(1)
1213
+ expect(hasKeyframes(next, 'scale')).toBe(false)
1214
+ })
1215
+ })
1216
+
1217
+ describe('OverlayInspector — header keyframe navigation', () => {
1218
+ function multiPropKeyed() {
1219
+ return overlayItem({
1220
+ keyframes: [
1221
+ { prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 6, value: 100 }] },
1222
+ { prop: 'opacity', points: [{ t: 2, value: 0 }, { t: 8, value: 1 }] },
1223
+ ],
1224
+ })
1225
+ }
1226
+
1227
+ it('seeks to the previous keyframe across ALL props, in absolute timeline time', () => {
1228
+ const onSeek = vi.fn()
1229
+ renderInspector(multiPropKeyed(), 10, { onSeek }) // localT = 5
1230
+
1231
+ fireEvent.click(screen.getByRole('button', { name: 'Previous keyframe' }))
1232
+
1233
+ // Largest t < 5 across both tracks is 2, and the seek is item.start + t.
1234
+ expect(onSeek).toHaveBeenCalledTimes(1)
1235
+ expect(onSeek).toHaveBeenCalledWith(7)
1236
+ })
1237
+
1238
+ it('seeks to the next keyframe across ALL props', () => {
1239
+ const onSeek = vi.fn()
1240
+ renderInspector(multiPropKeyed(), 10, { onSeek }) // localT = 5
1241
+
1242
+ fireEvent.click(screen.getByRole('button', { name: 'Next keyframe' }))
1243
+
1244
+ // Smallest t > 5 across both tracks is 6.
1245
+ expect(onSeek).toHaveBeenCalledWith(11)
1246
+ })
1247
+
1248
+ it('renders the arrows disabled when the host supplies no onSeek', () => {
1249
+ renderInspector(multiPropKeyed(), 10)
1250
+
1251
+ expect(screen.getByRole('button', { name: 'Previous keyframe' })).toBeDisabled()
1252
+ expect(screen.getByRole('button', { name: 'Next keyframe' })).toBeDisabled()
1253
+ })
1254
+
1255
+ it('disables an arrow when there is no keyframe in that direction', () => {
1256
+ const onSeek = vi.fn()
1257
+ renderInspector(multiPropKeyed(), 5, { onSeek }) // localT = 0, nothing before it
1258
+
1259
+ expect(screen.getByRole('button', { name: 'Previous keyframe' })).toBeDisabled()
1260
+ expect(screen.getByRole('button', { name: 'Next keyframe' })).toBeEnabled()
1261
+ })
1262
+
1263
+ it('gives each row its own arrows, scoped to that prop', () => {
1264
+ const onSeek = vi.fn()
1265
+ renderInspector(multiPropKeyed(), 10, { onSeek }) // localT = 5
1266
+
1267
+ // The Position pair covers offsetX (points 0, 6); largest t < 5 is 0, and
1268
+ // the seek is item.start + t = 5. opacity's 2 and 8 are not its business.
1269
+ fireEvent.click(screen.getByRole('button', { name: 'Previous Position keyframe' }))
1270
+ expect(onSeek).toHaveBeenCalledWith(5)
1271
+
1272
+ // Scale has no track at all.
1273
+ expect(screen.getByRole('button', { name: 'Previous Scale keyframe' })).toBeDisabled()
1274
+ expect(screen.getByRole('button', { name: 'Next Scale keyframe' })).toBeDisabled()
1275
+ })
1276
+ })
1277
+
1278
+ describe('OverlayInspector — keyframe all transform properties', () => {
1279
+ it('is not pressed unless EVERY prop has a point at the playhead', () => {
1280
+ const item = overlayItem({ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 10 }] }] })
1281
+ renderInspector(item, 5) // localT = 0
1282
+
1283
+ expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'false')
1284
+ })
1285
+
1286
+ it('is pressed when all five have a point at the playhead', () => {
1287
+ renderInspector(allKeyedItem(), 5) // localT = 0
1288
+
1289
+ expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'true')
1290
+ })
1291
+
1292
+ it('is not pressed when the playhead sits between the points', () => {
1293
+ renderInspector(allKeyedItem(), 8) // localT = 3, no track has a point there
1294
+
1295
+ expect(screen.getByRole('button', { name: HEADER_DIAMOND })).toHaveAttribute('aria-pressed', 'false')
1296
+ })
1297
+
1298
+ it('adds a point at the playhead to all five without moving anything', () => {
1299
+ const item = overlayItem({
1300
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 10, value: 100 }] }],
1301
+ })
1302
+ const { onChange } = renderInspector(item, 8) // localT = 3
1303
+
1304
+ fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
1305
+
1306
+ expect(onChange).toHaveBeenCalledTimes(1)
1307
+ const next = onChange.mock.calls[0][0] as VisualItem
1308
+ for (const prop of ALL_PROPS) {
1309
+ expect(hasKeyframes(next, prop)).toBe(true)
1310
+ expect(trackFor(next, prop)!.points.some(p => p.t === 3)).toBe(true)
1311
+ // Nothing on screen moves: every sampled value is exactly what it was.
1312
+ expect(valueAt(next, prop, 3)).toBe(valueAt(item, prop, 3))
1313
+ }
1314
+ // The prop that was ALREADY animated keeps its curve — enableKeyframing's
1315
+ // no-op contract is what protects it.
1316
+ expect(valueAt(next, 'offsetX', 0)).toBe(0)
1317
+ expect(valueAt(next, 'offsetX', 10)).toBe(100)
1318
+ })
1319
+
1320
+ it('removes just the point at the playhead from multi-point tracks', () => {
1321
+ const { onChange } = renderInspector(allKeyedItem(), 5) // localT = 0, all pressed
1322
+
1323
+ fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
1324
+
1325
+ expect(onChange).toHaveBeenCalledTimes(1)
1326
+ const next = onChange.mock.calls[0][0] as VisualItem
1327
+ for (const prop of ALL_PROPS) {
1328
+ expect(hasKeyframes(next, prop)).toBe(true) // still animated
1329
+ expect(trackFor(next, prop)!.points.some(p => p.t === 0)).toBe(false)
1330
+ }
1331
+ expect(trackFor(next, 'offsetX')!.points).toEqual([{ t: 10, value: 100 }])
1332
+ })
1333
+
1334
+ it('writes the static scalar for a prop whose ONLY point is at the playhead', () => {
1335
+ const item = overlayItem({
1336
+ // Stale scalars: whatever was left behind when keyframing was switched
1337
+ // on. `removeKeyframe` would drop the track and let these snap back.
1338
+ offsetX: 999,
1339
+ offsetY: 999,
1340
+ scale: 999,
1341
+ rotation: 999,
1342
+ opacity: 999,
1343
+ keyframes: [
1344
+ { prop: 'offsetX', points: [{ t: 0, value: 3 }] },
1345
+ { prop: 'offsetY', points: [{ t: 0, value: 4 }] },
1346
+ { prop: 'scale', points: [{ t: 0, value: 0.5 }] },
1347
+ { prop: 'rotation', points: [{ t: 0, value: 30 }] },
1348
+ { prop: 'opacity', points: [{ t: 0, value: 0.25 }] },
1349
+ ],
1350
+ })
1351
+ const { onChange } = renderInspector(item, 5) // localT = 0, all pressed
1352
+
1353
+ fireEvent.click(screen.getByRole('button', { name: HEADER_DIAMOND }))
1354
+
1355
+ const next = onChange.mock.calls[0][0] as VisualItem
1356
+ expect(next.keyframes).toBeUndefined()
1357
+ expect(next.offsetX).toBe(3)
1358
+ expect(next.offsetY).toBe(4)
1359
+ expect(next.scale).toBe(0.5)
1360
+ expect(next.rotation).toBe(30)
1361
+ expect(next.opacity).toBe(0.25)
1362
+ // The overlay does not move: every value survived the round trip.
1363
+ for (const prop of ALL_PROPS) {
1364
+ expect(valueAt(next, prop, 0)).toBe(valueAt(item, prop, 0))
1365
+ }
1366
+ })
1367
+ })