@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,2198 @@
1
+ /**
2
+ * SP5 T4 — the canvas painter. Every draw function is pure, so the tests drive
3
+ * them with a context stub that records the calls and asserts on the list: the
4
+ * rectangle a clip lands in IS the observable behaviour of a painter.
5
+ *
6
+ * The last block is the acceptance probe for the virtualization claim: with a
7
+ * long project and a small window, the number of draw calls must be bounded by
8
+ * what's visible, not by how many items exist.
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import type { AudioTrack, CaptionSegment, VisualItem } from '../../../../schema'
12
+ import type { Project } from '../../../../types'
13
+ import {
14
+ AUDIO_HANDLE_WIDTH_PX,
15
+ AUDIO_ITEM_INSET_PX,
16
+ AUDIO_ITEM_RADIUS_PX,
17
+ CAPTION_PALETTE,
18
+ CAPTION_RAIL_ACCENT,
19
+ DARK_TIMELINE_PALETTE,
20
+ LABEL_SHADOW_COLOR,
21
+ LIGHT_CAPTION_PALETTE,
22
+ LIGHT_CAPTION_RAIL_ACCENT,
23
+ LIGHT_LABEL_SHADOW_COLOR,
24
+ LIGHT_TIMELINE_COLORS,
25
+ LIGHT_TIMELINE_PALETTE,
26
+ LIGHT_TRACK_PALETTE,
27
+ timelinePalette,
28
+ drawCursorLine,
29
+ drawMarquee,
30
+ drawRuler,
31
+ type TimelinePalette,
32
+ CLIP_HANDLE_WIDTH_PX,
33
+ FADE_GRIP_SIZE_PX,
34
+ CURSOR_WIDTH_PX,
35
+ LABEL_PAD_PX,
36
+ LABEL_TOP_OFFSET_PX,
37
+ CLIP_GUTTER_PX,
38
+ CLIP_SELECTED_BORDER_PX,
39
+ MIN_LABEL_WIDTH_PX,
40
+ PLAYHEAD_WIDTH_PX,
41
+ ROW_RADIUS_PX,
42
+ TIMELINE_COLORS,
43
+ TRACK_PALETTE,
44
+ clampRectToSurface,
45
+ drawRowBackground,
46
+ computeTimelineLayout,
47
+ overlapBands,
48
+ drawAudioItem,
49
+ drawCaptionBlock,
50
+ drawClipRect,
51
+ drawTrimHandle,
52
+ FADE_ENVELOPE_SEGMENTS,
53
+ RULER_HEIGHT_PX,
54
+ SNAP_GUIDE_CAP_HALF_WIDTH_PX,
55
+ SNAP_GUIDE_WEAK_WIDTH_PX,
56
+ SNAP_GUIDE_WIDTH_PX,
57
+ OVERLAP_EDGE_WIDTH_PX,
58
+ OVERLAP_HATCH_SHADOW_WIDTH_PX,
59
+ OVERLAP_HATCH_WIDTH_PX,
60
+ drawItemHandles,
61
+ drawKeyframeStrip,
62
+ drawOverlapBand,
63
+ drawPlayhead,
64
+ drawSnapGuide,
65
+ drawTimelineContent,
66
+ drawTimelineOverlay,
67
+ type DrawContext,
68
+ type TimelineScene,
69
+ } from '../draw'
70
+ import { KEYFRAME_DIAMOND_SIZE_PX, KEYFRAME_STRIP_BOTTOM_PAD_PX, keyframeDiamondX } from '../keyframe-strip'
71
+ import { LIGHT_WAVEFORM_COLORS, WAVEFORM_COLORS } from '../waveforms'
72
+ import {
73
+ AUDIO_LANE_HEIGHT_PX,
74
+ BASE_VISUAL_ROW_RENDER_HEIGHT_PX,
75
+ CAPTION_ROW_HEIGHT_PX,
76
+ ROW_GAP_PX,
77
+ VISUAL_ROW_RENDER_HEIGHT_PX,
78
+ } from '../../timeline-model'
79
+ import type { Viewport } from '../viewport'
80
+
81
+ // ── Recording context ────────────────────────────────────────────────────
82
+
83
+ interface RecordedCall { method: string; args: unknown[] }
84
+
85
+ interface Recorder {
86
+ ctx: DrawContext
87
+ calls: RecordedCall[]
88
+ of: (method: string) => RecordedCall[]
89
+ count: (method: string) => number
90
+ }
91
+
92
+ function recordingContext(): Recorder {
93
+ const calls: RecordedCall[] = []
94
+ const props: Record<string, unknown> = {}
95
+
96
+ const proxy = new Proxy({}, {
97
+ get(_target, prop: string) {
98
+ if (prop in props) return props[prop]
99
+ if (prop === 'createLinearGradient') {
100
+ return (...args: unknown[]) => {
101
+ calls.push({ method: 'createLinearGradient', args })
102
+ return {
103
+ addColorStop: (...stop: unknown[]) => calls.push({ method: 'addColorStop', args: stop }),
104
+ } as unknown as CanvasGradient
105
+ }
106
+ }
107
+ return (...args: unknown[]) => { calls.push({ method: prop, args }) }
108
+ },
109
+ set(_target, prop: string, value: unknown) {
110
+ props[prop] = value
111
+ calls.push({ method: `set:${prop}`, args: [value] })
112
+ return true
113
+ },
114
+ }) as unknown as DrawContext
115
+
116
+ return {
117
+ ctx: proxy,
118
+ calls,
119
+ of: (method: string) => calls.filter(c => c.method === method),
120
+ count: (method: string) => calls.filter(c => c.method === method).length,
121
+ }
122
+ }
123
+
124
+ // ── Fixtures ─────────────────────────────────────────────────────────────
125
+
126
+ function clip(over: Partial<VisualItem> = {}): VisualItem {
127
+ return { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 2, ...over }
128
+ }
129
+
130
+ function audio(over: Partial<AudioTrack> = {}): AudioTrack {
131
+ return { id: 'a0', src: '/media/voice.mp3', start: 0, end: 2, ...over }
132
+ }
133
+
134
+ function captionSegment(over: Partial<CaptionSegment> = {}): CaptionSegment {
135
+ return { id: 'seg0', text: 'hello world', start: 0, end: 2, ...over }
136
+ }
137
+
138
+ function project(over: Partial<Project> = {}): Project {
139
+ return { id: 'p1', ...over } as unknown as Project
140
+ }
141
+
142
+ function viewport(over: Partial<Viewport> = {}): Viewport {
143
+ return { pxPerSecond: 10, scrollSeconds: 0, widthPx: 1000, ...over }
144
+ }
145
+
146
+ function scene(over: Partial<TimelineScene> = {}): TimelineScene {
147
+ const p = over.project ?? project()
148
+ return {
149
+ project: p,
150
+ viewport: viewport(),
151
+ layout: computeTimelineLayout(p),
152
+ selectedIds: [],
153
+ surfaceWidth: 1000,
154
+ surfaceHeight: 200,
155
+ ...over,
156
+ }
157
+ }
158
+
159
+ /** What the ruler alone draws at this viewport — an empty project draws the
160
+ * ruler and nothing else. Content-volume assertions subtract this so they keep
161
+ * measuring the thing they are about (items, rows) rather than the fixed cost
162
+ * of the chrome above them. */
163
+ function rulerBaseline(vp: Viewport) {
164
+ const empty = project()
165
+ const r = recordingContext()
166
+ drawTimelineContent(r.ctx, scene({ project: empty, layout: computeTimelineLayout(empty), viewport: vp }))
167
+ return { calls: r.calls.length, fillRect: r.count('fillRect'), fillText: r.count('fillText') }
168
+ }
169
+
170
+ // ── Layout ───────────────────────────────────────────────────────────────
171
+
172
+ describe('computeTimelineLayout', () => {
173
+ it('stacks visual tracks reversed, base track last and taller, audio lanes below', () => {
174
+ const p = project({
175
+ // ov1/ov2 are overlay-typed (not video), so they stay the short row —
176
+ // isolates "base track is tall" from the separate "a VIDEO track is
177
+ // tall" rule below.
178
+ tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })], [clip({ id: 'ov2', type: 'overlay' })]],
179
+ audio: { tracks: [audio({ id: 'm', lane: 0 }), audio({ id: 'v', lane: 1 })] },
180
+ } as unknown as Partial<Project>)
181
+
182
+ const layout = computeTimelineLayout(p)
183
+
184
+ // Draw order top→bottom mirrors the DOM's `[...allTracks].reverse()`.
185
+ expect(layout.rows.map(r => r.trackIdx)).toEqual([2, 1, 0])
186
+ // Rows start below the ruler strip, which owns the top of the surface.
187
+ const contentTop = RULER_HEIGHT_PX + ROW_GAP_PX
188
+ expect(layout.ruler).toEqual({ y: 0, height: RULER_HEIGHT_PX })
189
+ expect(layout.rows[0]).toMatchObject({ y: contentTop, height: VISUAL_ROW_RENDER_HEIGHT_PX })
190
+ expect(layout.rows[1]).toMatchObject({ y: contentTop + VISUAL_ROW_RENDER_HEIGHT_PX + ROW_GAP_PX })
191
+ // The base video track is the tall one (120px vs. 40px for the rest).
192
+ expect(layout.rows[2].height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
193
+
194
+ expect(layout.lanes.map(l => l.laneIndex)).toEqual([0, 1])
195
+ expect(layout.lanes[0].y).toBe(layout.rows[2].y + BASE_VISUAL_ROW_RENDER_HEIGHT_PX + ROW_GAP_PX)
196
+ expect(layout.lanes[1].y).toBe(layout.lanes[0].y + AUDIO_LANE_HEIGHT_PX + ROW_GAP_PX)
197
+ expect(layout.height).toBe(layout.lanes[1].y + AUDIO_LANE_HEIGHT_PX)
198
+ })
199
+
200
+ it('gives every VIDEO-kind track the tall base row height, not just trackIdx 0 — an overlay track stays short', () => {
201
+ // A second video track carries the same filmstrip+waveform content as
202
+ // the base track and needs the same room; an overlay/image track has
203
+ // neither and keeps the short row.
204
+ const p = project({
205
+ tracks: [
206
+ [clip({ id: 'base', type: 'video' })],
207
+ [clip({ id: 'video2', type: 'video' })],
208
+ [clip({ id: 'ov', type: 'overlay' })],
209
+ ],
210
+ } as unknown as Partial<Project>)
211
+
212
+ const layout = computeTimelineLayout(p)
213
+ expect(layout.rows.find(r => r.trackIdx === 0)!.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
214
+ expect(layout.rows.find(r => r.trackIdx === 1)!.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
215
+ expect(layout.rows.find(r => r.trackIdx === 2)!.height).toBe(VISUAL_ROW_RENDER_HEIGHT_PX)
216
+ })
217
+
218
+ it('keeps the base track (trackIdx 0) tall even when it has no items yet', () => {
219
+ // trackIdx 0 is always tall regardless of content, so a blank base row
220
+ // doesn't jump size the moment footage lands on it.
221
+ const p = project({ tracks: [[]] } as unknown as Partial<Project>)
222
+ const layout = computeTimelineLayout(p)
223
+ expect(layout.rows.find(r => r.trackIdx === 0)!.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
224
+ })
225
+
226
+ it('reorders a scrambled-input project so the video track is trackIdx 0, even when it was written second on disk (Part B)', () => {
227
+ // Raw/on-disk order: overlay first, video second — `allTracks` here
228
+ // comes from `trackItems`, which funnels through `normalizeTracks`
229
+ // (now order-canonicalizing too), so `computeTimelineLayout` never sees
230
+ // the raw order — the video track lands at trackIdx 0 regardless of
231
+ // where it sat in the stored array.
232
+ const p = project({
233
+ tracks: [
234
+ [{ id: 'ov', type: 'overlay', start: 0, end: 1 }],
235
+ [{ id: 'v0', type: 'video', src: 'v0.mp4', start: 0, end: 1 }],
236
+ ],
237
+ } as unknown as Partial<Project>)
238
+
239
+ const layout = computeTimelineLayout(p)
240
+ const baseRow = layout.rows.find(r => r.trackIdx === 0)!
241
+ expect(baseRow.items.map(i => i.id)).toEqual(['v0'])
242
+ expect(baseRow.height).toBe(BASE_VISUAL_ROW_RENDER_HEIGHT_PX)
243
+ const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
244
+ expect(overlayRow.items.map(i => i.id)).toEqual(['ov'])
245
+ })
246
+
247
+ it('is empty for an empty project, but still reserves the ruler', () => {
248
+ const layout = computeTimelineLayout(project())
249
+ expect(layout).toEqual({
250
+ ruler: { y: 0, height: RULER_HEIGHT_PX },
251
+ rows: [],
252
+ lanes: [],
253
+ height: RULER_HEIGHT_PX,
254
+ })
255
+ })
256
+
257
+ // ── Captions in the canvas layout (Phase 1: one band. Phase 3: N bands) ──
258
+
259
+ describe('the caption band(s)', () => {
260
+ it('sits strictly between the last overlay row and the base video row', () => {
261
+ const p = project({
262
+ // Overlay-typed, not `clip()`'s default video — this test is
263
+ // specifically about an OVERLAY row above the base, per its own name.
264
+ tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })]],
265
+ captions: { style: 'clean', segments: [captionSegment()] },
266
+ } as unknown as Partial<Project>)
267
+
268
+ const layout = computeTimelineLayout(p)
269
+ const overlayRow = layout.rows.find(r => r.trackIdx === 1)!
270
+ const baseRow = layout.rows.find(r => r.trackIdx === 0)!
271
+
272
+ expect(layout.captions).toHaveLength(1)
273
+ expect(layout.captions![0].lane).toBe(0)
274
+ expect(layout.captions![0].height).toBe(CAPTION_ROW_HEIGHT_PX)
275
+ expect(layout.captions![0].y).toBeGreaterThan(overlayRow.y + overlayRow.height)
276
+ expect(layout.captions![0].y).toBeLessThan(baseRow.y)
277
+ expect(layout.captions![0].segments).toEqual([captionSegment()])
278
+ })
279
+
280
+ it('is absent — not an empty array — with no captions, and every other row is unchanged', () => {
281
+ const tracks = [[clip({ id: 'base' })], [clip({ id: 'ov1' })]]
282
+ const noCaptionsAtAll = project({ tracks } as unknown as Partial<Project>)
283
+ const emptySegments = project({
284
+ tracks,
285
+ captions: { style: 'clean', segments: [] },
286
+ } as unknown as Partial<Project>)
287
+
288
+ const baseline = computeTimelineLayout(noCaptionsAtAll)
289
+ for (const p of [noCaptionsAtAll, emptySegments]) {
290
+ const layout = computeTimelineLayout(p)
291
+ expect(layout.captions).toBeUndefined()
292
+ expect(layout.rows).toEqual(baseline.rows)
293
+ expect(layout.lanes).toEqual(baseline.lanes)
294
+ expect(layout.height).toBe(baseline.height)
295
+ }
296
+ })
297
+
298
+ it('still appears, above where the video row would be, on a project with captions but no visual tracks', () => {
299
+ const p = project({
300
+ captions: { style: 'clean', segments: [captionSegment()] },
301
+ } as unknown as Partial<Project>)
302
+ const layout = computeTimelineLayout(p)
303
+
304
+ expect(layout.rows).toEqual([])
305
+ expect(layout.captions).toHaveLength(1)
306
+ expect(layout.captions![0].y).toBe(RULER_HEIGHT_PX + ROW_GAP_PX)
307
+ expect(layout.height).toBe(layout.captions![0].y + CAPTION_ROW_HEIGHT_PX)
308
+ })
309
+
310
+ // ── Regression guard (Phase 3) ──
311
+ // Multi-lane captions must not move a single-lane project's band by even
312
+ // one pixel: the exact geometry a lane-0-only project got in Phase 1 is
313
+ // reproduced here, numeral for numeral, rather than compared loosely.
314
+ it('keeps a lane-0-only layout — band y, band height, and total surface height — byte-for-byte identical to before N-lane captions', () => {
315
+ const p = project({
316
+ // Overlay-typed, not `clip()`'s default video: this is the SINGLE
317
+ // -video-track case (only `base` is video) the byte-identical
318
+ // guarantee is about — the caption band still sits directly above
319
+ // the base row, exactly where it always has.
320
+ tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })]],
321
+ captions: { style: 'clean', segments: [captionSegment()] },
322
+ } as unknown as Partial<Project>)
323
+
324
+ const layout = computeTimelineLayout(p)
325
+ // Re-derived from the layout's own rows rather than pinned to a height
326
+ // constant, so this regression guard survives future height changes.
327
+ const upperRow = layout.rows.find(r => r.trackIdx === 1)!
328
+ const baseRow = layout.rows.find(r => r.trackIdx === 0)!
329
+ const band = layout.captions![0]
330
+
331
+ expect(layout.captions).toHaveLength(1)
332
+ expect(band).toMatchObject({ lane: 0, height: CAPTION_ROW_HEIGHT_PX })
333
+ expect(band.y).toBe(upperRow.y + upperRow.height + ROW_GAP_PX)
334
+ expect(baseRow.y).toBe(band.y + band.height + ROW_GAP_PX)
335
+ expect(layout.height).toBe(baseRow.y + baseRow.height)
336
+ })
337
+
338
+ it('emits N bands in descending lane order, lane 0 landing exactly where the single band sits today', () => {
339
+ const p = project({
340
+ // Overlay-typed, not `clip()`'s default video — single-video-track
341
+ // case, so lane 0 lands directly above `base` with nothing between.
342
+ tracks: [[clip({ id: 'base' })], [clip({ id: 'ov1', type: 'overlay' })]],
343
+ captions: {
344
+ style: 'clean',
345
+ segments: [
346
+ captionSegment({ id: 's0', lane: 0 }),
347
+ captionSegment({ id: 's1', lane: 1, start: 2, end: 4 }),
348
+ captionSegment({ id: 's2', lane: 2, start: 4, end: 6 }),
349
+ ],
350
+ },
351
+ } as unknown as Partial<Project>)
352
+
353
+ const layout = computeTimelineLayout(p)
354
+ const baseRow = layout.rows.find(r => r.trackIdx === 0)!
355
+
356
+ expect(layout.captions).toHaveLength(3)
357
+ // Index 0 is the HIGHEST lane; the last entry is lane 0.
358
+ expect(layout.captions!.map(c => c.lane)).toEqual([2, 1, 0])
359
+
360
+ const [lane2, lane1, lane0] = layout.captions!
361
+ // Lane 0 sits immediately above the base row — the exact position a
362
+ // lane-0-only project's single band has always occupied.
363
+ expect(lane0.y + lane0.height + ROW_GAP_PX).toBe(baseRow.y)
364
+ // Higher lanes stack upward (smaller y) toward the overlays.
365
+ expect(lane1.y).toBeLessThan(lane0.y)
366
+ expect(lane2.y).toBeLessThan(lane1.y)
367
+ })
368
+
369
+ // ── Part B: track grouping — the caption band sits above the WHOLE
370
+ // video block, not squeezed between two video tracks ──────────────────
371
+
372
+ it('with two video tracks and one overlay track, stacks overlay(top) → captions → video block (topmost video, then base)', () => {
373
+ const p = project({
374
+ tracks: [
375
+ [clip({ id: 'base' })], // video, base
376
+ [clip({ id: 'video2', type: 'video' })], // video, second track
377
+ [clip({ id: 'ov1', type: 'overlay' })], // overlay
378
+ ],
379
+ captions: { style: 'clean', segments: [captionSegment()] },
380
+ } as unknown as Partial<Project>)
381
+
382
+ const layout = computeTimelineLayout(p)
383
+ // Already in canonical order in this fixture (video tracks precede the
384
+ // overlay track), so trackIdx assignment is unchanged from array order
385
+ // — but the DRAW order (top of screen first) is still the reverse.
386
+ expect(layout.rows.map(r => r.trackIdx)).toEqual([2, 1, 0])
387
+ const overlayRow = layout.rows.find(r => r.trackIdx === 2)!
388
+ const video2Row = layout.rows.find(r => r.trackIdx === 1)!
389
+ const baseRow = layout.rows.find(r => r.trackIdx === 0)!
390
+
391
+ expect(layout.captions).toHaveLength(1)
392
+ const band = layout.captions![0]
393
+ // Captions sit directly above the TOPMOST video track (video2) — NOT
394
+ // between video2 and base — and directly below the overlay row.
395
+ expect(band.y).toBe(overlayRow.y + overlayRow.height + ROW_GAP_PX)
396
+ expect(video2Row.y).toBe(band.y + band.height + ROW_GAP_PX)
397
+ expect(video2Row.y + video2Row.height + ROW_GAP_PX).toBe(baseRow.y)
398
+ })
399
+
400
+ it('a hole lane still gets its own band, empty of segments, rather than being skipped', () => {
401
+ const p = project({
402
+ tracks: [[clip({ id: 'base' })]],
403
+ captions: {
404
+ style: 'clean',
405
+ // Lanes 0 and 2 occupied; lane 1 is a hole.
406
+ segments: [
407
+ captionSegment({ id: 's0', lane: 0 }),
408
+ captionSegment({ id: 's2', lane: 2, start: 4, end: 6 }),
409
+ ],
410
+ },
411
+ } as unknown as Partial<Project>)
412
+
413
+ const layout = computeTimelineLayout(p)
414
+ expect(layout.captions).toHaveLength(3)
415
+ expect(layout.captions!.map(c => c.lane)).toEqual([2, 1, 0])
416
+ const holeBand = layout.captions!.find(c => c.lane === 1)!
417
+ expect(holeBand.segments).toEqual([])
418
+ expect(holeBand.height).toBe(CAPTION_ROW_HEIGHT_PX)
419
+ })
420
+ })
421
+ })
422
+
423
+ describe('clampRectToSurface', () => {
424
+ it('trims a clip that runs far past both edges', () => {
425
+ expect(clampRectToSurface({ x: -50_000, y: 0, width: 100_000, height: 40 }, 1000))
426
+ .toEqual({ x: -1, y: 0, width: 1002, height: 40 })
427
+ })
428
+ })
429
+
430
+ // ── Element painters ─────────────────────────────────────────────────────
431
+
432
+ describe('drawClipRect', () => {
433
+ it('insets the body from the clip\'s span, leaving a gutter between touching clips', () => {
434
+ const r = recordingContext()
435
+ drawClipRect(r.ctx, {
436
+ rect: { x: 120, y: 40, width: 200, height: 40 },
437
+ palette: TRACK_PALETTE[1],
438
+ selected: false,
439
+ label: '▪ video',
440
+ })
441
+
442
+ // Inset by CLIP_GUTTER_PX per side — two touching clips therefore show
443
+ // twice that as dark row background, which is what makes a cut visible
444
+ // once filmstrip frames run edge to edge.
445
+ const fills = r.of('fillRect')
446
+ expect(fills[0].args).toEqual([120 + CLIP_GUTTER_PX, 40, 200 - CLIP_GUTTER_PX * 2, 40])
447
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TRACK_PALETTE[1].fill)).toBe(true)
448
+ // Top-left, not vertically centred: the centre of a clip is now the seam
449
+ // between its frames band and its waveform band.
450
+ expect(r.of('fillText')[0].args).toEqual(['▪ video', 120 + CLIP_GUTTER_PX + 6, 40 + LABEL_TOP_OFFSET_PX])
451
+ })
452
+
453
+ it('outlines every clip, and a selected one in thick white', () => {
454
+ const plain = recordingContext()
455
+ drawClipRect(plain.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'x' })
456
+ expect(plain.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TRACK_PALETTE[0].border)).toBe(true)
457
+ expect(plain.calls.some(c => c.method === 'set:lineWidth' && c.args[0] === 1)).toBe(true)
458
+
459
+ const picked = recordingContext()
460
+ drawClipRect(picked.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, palette: TRACK_PALETTE[0], selected: true, label: 'x' })
461
+ // White, not the track's own hue — the only treatment that reads over an
462
+ // arbitrary video frame.
463
+ expect(picked.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.clipSelectedOutline)).toBe(true)
464
+ expect(picked.calls.some(c => c.method === 'set:lineWidth' && c.args[0] === CLIP_SELECTED_BORDER_PX)).toBe(true)
465
+ expect(picked.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TRACK_PALETTE[0].fillSelected)).toBe(true)
466
+ })
467
+
468
+ it('clips its content to the rounded body, so frames stop at the corners', () => {
469
+ const r = recordingContext()
470
+ drawClipRect(r.ctx, {
471
+ rect: { x: 0, y: 0, width: 100, height: 40 },
472
+ palette: TRACK_PALETTE[0], selected: false, label: 'x',
473
+ drawContent: (c, body) => c.fillRect(body.x, body.y, body.width, body.height),
474
+ })
475
+ // A clip path is established before the content paints.
476
+ const clipAt = r.calls.findIndex(c => c.method === 'clip')
477
+ const contentAt = r.calls.findIndex(c => c.method === 'fillRect' && (c.args as number[])[2] === 100 - CLIP_GUTTER_PX * 2)
478
+ expect(clipAt).toBeGreaterThanOrEqual(0)
479
+ expect(clipAt).toBeLessThan(contentAt)
480
+ })
481
+
482
+ it('hands drawContent the inset body, not the full span', () => {
483
+ let handed: { x: number; width: number } | null = null
484
+ const r = recordingContext()
485
+ drawClipRect(r.ctx, {
486
+ rect: { x: 120, y: 40, width: 200, height: 40 },
487
+ palette: TRACK_PALETTE[0], selected: false, label: 'x',
488
+ drawContent: (_c, body) => { handed = { x: body.x, width: body.width } },
489
+ })
490
+ expect(handed).toEqual({ x: 120 + CLIP_GUTTER_PX, width: 200 - CLIP_GUTTER_PX * 2 })
491
+ })
492
+
493
+ it('skips the label on a clip too narrow to read it', () => {
494
+ const r = recordingContext()
495
+ drawClipRect(r.ctx, {
496
+ rect: { x: 0, y: 0, width: MIN_LABEL_WIDTH_PX - 1, height: 40 },
497
+ palette: TRACK_PALETTE[0], selected: false, label: '▪ video',
498
+ })
499
+ expect(r.count('fillText')).toBe(0)
500
+ })
501
+
502
+ it('draws nothing for a zero-width rect', () => {
503
+ const r = recordingContext()
504
+ drawClipRect(r.ctx, { rect: { x: 10, y: 0, width: 0, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'x' })
505
+ expect(r.calls).toHaveLength(0)
506
+ })
507
+
508
+ // ── Trim handles: the affordance selection is FOR ──
509
+ //
510
+ // The handles themselves are the row painter's job now (a clip stacked on
511
+ // top of this one would bury them), so `drawClipRect` must NOT draw them —
512
+ // only leave room for them.
513
+
514
+ it('leaves the handles to the row painter', () => {
515
+ const r = recordingContext()
516
+ drawClipRect(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 40 }, palette: TRACK_PALETTE[0], selected: true, label: '' })
517
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFill)).toBe(false)
518
+ })
519
+
520
+ it('indents a selected clip\'s label past its in handle', () => {
521
+ const free = recordingContext()
522
+ drawClipRect(free.ctx, { rect: { x: 0, y: 0, width: 200, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'title' })
523
+ const picked = recordingContext()
524
+ drawClipRect(picked.ctx, { rect: { x: 0, y: 0, width: 200, height: 40 }, palette: TRACK_PALETTE[0], selected: true, label: 'title' })
525
+ // Without the indent the pill would sit on top of the first glyph.
526
+ expect(Number(picked.of('fillText')[0].args[1]) - Number(free.of('fillText')[0].args[1]))
527
+ .toBe(CLIP_HANDLE_WIDTH_PX)
528
+ })
529
+
530
+ it('dims a row unrelated to the primary selection', () => {
531
+ const r = recordingContext()
532
+ drawClipRect(r.ctx, { rect: { x: 0, y: 0, width: 80, height: 40 }, palette: TRACK_PALETTE[0], selected: false, label: 'x', dimmed: true })
533
+ expect(r.calls.some(c => c.method === 'set:globalAlpha' && c.args[0] === 0.3)).toBe(true)
534
+ })
535
+ })
536
+
537
+ describe('drawTrimHandle', () => {
538
+ // 2 ticks of 1.5px with a 2.5px gap; a 40px-tall row gives the capped 16px
539
+ // tick height, centred, so the grip sits at y=12.
540
+ const GRIP_SPAN = 5.5
541
+
542
+ it('pins the in handle to the left edge and the out handle to the right', () => {
543
+ const left = recordingContext()
544
+ drawTrimHandle(left.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'in', width: 10 })
545
+ expect(left.of('fillRect').map(c => c.args)).toEqual([
546
+ [(10 - GRIP_SPAN) / 2, 12, 1.5, 16],
547
+ [(10 - GRIP_SPAN) / 2 + 4, 12, 1.5, 16],
548
+ ])
549
+
550
+ const right = recordingContext()
551
+ drawTrimHandle(right.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'out', width: 10 })
552
+ // 100 - 10 = 90 is the pill's left edge; the grip is centred in it.
553
+ expect(right.of('fillRect')[0].args[0]).toBe(90 + (10 - GRIP_SPAN) / 2)
554
+ })
555
+
556
+ it('draws the pill in the handle white, and its grip dark', () => {
557
+ const r = recordingContext()
558
+ drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'in', width: 10 })
559
+ expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
560
+ TIMELINE_COLORS.handleFill,
561
+ TIMELINE_COLORS.handleGrip,
562
+ ])
563
+ })
564
+
565
+ it('goes opaque under the pointer, so the grabbable edge is unambiguous', () => {
566
+ const r = recordingContext()
567
+ drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 100, height: 40 }, edge: 'in', width: 10, hovered: true })
568
+ expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
569
+ TIMELINE_COLORS.handleFillHovered,
570
+ TIMELINE_COLORS.handleGripHovered,
571
+ ])
572
+ })
573
+
574
+ it('halves itself on a short clip, so two handles can never meet', () => {
575
+ const r = recordingContext()
576
+ // 12px of clip, 10px of nominal handle: each takes 6 and the grip, which
577
+ // no longer fits with a margin, drops out rather than filling the pill.
578
+ drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 12, height: 40 }, edge: 'out', width: 10 })
579
+ expect(r.count('fill')).toBe(1)
580
+ expect(r.count('fillRect')).toBe(0)
581
+ // The pill starts halfway across, not 10px from the right edge.
582
+ expect(r.of('moveTo')[0].args[0]).toBeGreaterThanOrEqual(6)
583
+ })
584
+
585
+ it('draws nothing at all once the pill is thinner than its own outline', () => {
586
+ const r = recordingContext()
587
+ drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 5, height: 40 }, edge: 'in', width: 10 })
588
+ expect(r.calls).toHaveLength(0)
589
+ })
590
+
591
+ it('draws nothing for an empty rect', () => {
592
+ const r = recordingContext()
593
+ drawTrimHandle(r.ctx, { rect: { x: 0, y: 0, width: 0, height: 0 }, edge: 'in', width: 10 })
594
+ expect(r.calls).toHaveLength(0)
595
+ })
596
+ })
597
+
598
+ describe('drawKeyframeStrip', () => {
599
+ // 2s–4s overlay, keyframed on offsetX (t=0, t=1) and opacity (t=0 only —
600
+ // shares the FIRST diamond with offsetX). Union of times is {0, 1}: one
601
+ // diamond both props share, one offsetX draws alone.
602
+ function keyframedOverlay(): VisualItem {
603
+ return clip({
604
+ id: 'o0', type: 'overlay', src: undefined, start: 2, end: 4,
605
+ keyframes: [
606
+ { prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] },
607
+ { prop: 'opacity', points: [{ t: 0, value: 1 }] },
608
+ ],
609
+ } as unknown as Partial<VisualItem>)
610
+ }
611
+
612
+ // A REALISTIC body, not the clip's raw rect: the real caller (the row loop
613
+ // below) always hands `drawKeyframeStrip` `clipBodyRect(rect)`, which insets
614
+ // by `CLIP_GUTTER_PX` per side. `rect.x` for this item (start=2 @ 10px/s) is
615
+ // 20 and `rect.width` is 20, so `body` is `{ x: 22, width: 16 }` — using
616
+ // `{ x: 20, width: 20 }` here (rect verbatim) used to mask the endpoint-
617
+ // diamond clipping bug this file now guards against, because it made
618
+ // `body.x` coincide with `rect.x` instead of sitting `CLIP_GUTTER_PX` inside
619
+ // it.
620
+ const BODY = { x: 20 + CLIP_GUTTER_PX, y: 0, width: 20 - CLIP_GUTTER_PX * 2, height: 40 }
621
+ const vp = viewport({ pxPerSecond: 10 })
622
+
623
+ it('draws one diamond per DISTINCT time in the union, not one per prop', () => {
624
+ const r = recordingContext()
625
+ drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
626
+ // Each diamond is one closed 4-point path: moveTo + 3 lineTo + closePath.
627
+ expect(r.count('moveTo')).toBe(2)
628
+ expect(r.count('closePath')).toBe(2)
629
+ expect(r.count('fill')).toBe(2)
630
+ })
631
+
632
+ it('positions each diamond at item.start + t, run through the SAME timeToX everything else uses', () => {
633
+ const r = recordingContext()
634
+ drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
635
+ const y = BODY.y + BODY.height - KEYFRAME_DIAMOND_SIZE_PX / 2 - KEYFRAME_STRIP_BOTTOM_PAD_PX
636
+ const half = KEYFRAME_DIAMOND_SIZE_PX / 2
637
+ // t=0 -> (2+0)*10=20; t=1 -> (2+1)*10=30.
638
+ expect(r.of('moveTo').map(c => c.args)).toEqual([
639
+ [20, y - half],
640
+ [30, y - half],
641
+ ])
642
+ })
643
+
644
+ it('fills amber and strokes for definition', () => {
645
+ const r = recordingContext()
646
+ drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
647
+ expect(r.of('set:fillStyle').every(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(true)
648
+ expect(r.of('set:strokeStyle').every(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondStroke)).toBe(true)
649
+ })
650
+
651
+ it('paints the diamond matching selectedT with a different fillStyle than the rest', () => {
652
+ const r = recordingContext()
653
+ // Union of times is {0, 1} (ascending) — selecting t=1 must mark only the
654
+ // SECOND diamond, leaving the first one drawn with the ordinary fill.
655
+ drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp, 1)
656
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
657
+ expect(fills).toEqual([TIMELINE_COLORS.keyframeDiamondFill, TIMELINE_COLORS.keyframeDiamondSelectedFill])
658
+ })
659
+
660
+ it('paints every diamond identically when selectedT is null (or omitted)', () => {
661
+ const r = recordingContext()
662
+ drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp, null)
663
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
664
+ expect(fills[0]).toBe(fills[1])
665
+ expect(new Set(fills).size).toBe(1)
666
+ })
667
+
668
+ it('clips to a region derived from the clip body, widened past its edges (not the raw body rect)', () => {
669
+ const r = recordingContext()
670
+ drawKeyframeStrip(r.ctx, keyframedOverlay(), BODY, vp)
671
+ // The widened region equals the clip's TRUE (un-gutter-inset) rect, grown
672
+ // by half a diamond on each side: [rect.x - half, rect.x + rect.width +
673
+ // half]. rect.x=20, rect.width=20, half=4 -> [16, 44] -> x=16, width=28.
674
+ const half = KEYFRAME_DIAMOND_SIZE_PX / 2
675
+ const rectX = BODY.x - CLIP_GUTTER_PX
676
+ const rectWidth = BODY.width + CLIP_GUTTER_PX * 2
677
+ expect(r.of('rect')[0].args).toEqual([rectX - half, BODY.y, rectWidth + half * 2, BODY.height])
678
+ expect(r.count('clip')).toBe(1)
679
+ })
680
+
681
+ it('an ENDPOINT diamond (t=0, at the clip\'s own true left edge) survives WHOLE — the gutter inset no longer slices it to a sliver', () => {
682
+ const r = recordingContext()
683
+ const item = keyframedOverlay()
684
+ drawKeyframeStrip(r.ctx, item, BODY, vp)
685
+ const [regionX, , regionWidth] = r.of('rect')[0].args as number[]
686
+ const half = KEYFRAME_DIAMOND_SIZE_PX / 2
687
+ const cx = keyframeDiamondX(item, 0, vp)
688
+ // The whole diamond — both edges, not just its centre — must fall inside
689
+ // the clip region drawKeyframeStrip actually used.
690
+ expect(regionX).toBeLessThanOrEqual(cx - half)
691
+ expect(regionX + regionWidth).toBeGreaterThanOrEqual(cx + half)
692
+ })
693
+
694
+ it('draws nothing for an unkeyframed item', () => {
695
+ const r = recordingContext()
696
+ drawKeyframeStrip(r.ctx, clip({ id: 'o1', type: 'overlay', src: undefined, start: 2, end: 4 }), BODY, vp)
697
+ expect(r.calls).toHaveLength(0)
698
+ })
699
+
700
+ it('draws nothing into a zero-width body', () => {
701
+ const r = recordingContext()
702
+ drawKeyframeStrip(r.ctx, keyframedOverlay(), { ...BODY, width: 0 }, vp)
703
+ expect(r.calls).toHaveLength(0)
704
+ })
705
+ })
706
+
707
+ /**
708
+ * The i-th fade envelope's curve `lineTo` calls (0-based: fade-in is drawn
709
+ * before fade-out when both are set — see `drawAudioItem`'s own call order).
710
+ * Each curve is exactly `FADE_ENVELOPE_SEGMENTS` `lineTo` calls (one
711
+ * `moveTo` plus `FADE_ENVELOPE_SEGMENTS` `lineTo`s), and nothing else on a
712
+ * bar calls `lineTo` before them — the bar body is `arcTo`, the tint band is
713
+ * `fillRect`, and the fade grips (which DO use `lineTo`) are always drawn
714
+ * after every envelope — so a fixed-size slice from the FRONT of `lineTo`
715
+ * calls is exact regardless of what (if anything) is drawn afterward.
716
+ */
717
+ function envelopeCurveLineTos(r: Recorder, index = 0): number[][] {
718
+ const lines = r.of('lineTo').map(c => c.args as number[])
719
+ return lines.slice(index * FADE_ENVELOPE_SEGMENTS, (index + 1) * FADE_ENVELOPE_SEGMENTS)
720
+ }
721
+
722
+ describe('drawAudioItem', () => {
723
+ it('fills emerald, labels the bar, and rings it when selected', () => {
724
+ const r = recordingContext()
725
+ drawAudioItem(r.ctx, { rect: { x: 10, y: 4, width: 300, height: 32 }, selected: true, muted: false, label: 'Voiceover' })
726
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.audioFill)).toBe(true)
727
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.audioRing)).toBe(true)
728
+ // Indented past the in-handle: a selected bar grows a 6px pill on each
729
+ // end, and a label starting under one would lose its first glyph.
730
+ expect(r.of('fillText')[0].args).toEqual(['Voiceover', 16 + AUDIO_HANDLE_WIDTH_PX, 20])
731
+ })
732
+
733
+ it('leaves its handles to the lane painter too', () => {
734
+ const r = recordingContext()
735
+ drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: 300, height: 32 }, selected: true, muted: false, label: '' })
736
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFill)).toBe(false)
737
+ })
738
+
739
+ it('uses the muted fill and drops the border', () => {
740
+ const r = recordingContext()
741
+ drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: false, muted: true, label: 'Music' })
742
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.audioMutedFill)).toBe(true)
743
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.audioBorder)).toBe(false)
744
+ })
745
+
746
+ it('tints the WHOLE fade-width band, full height, both sides of the curve — clamped to the bar', () => {
747
+ const r = recordingContext()
748
+ drawAudioItem(r.ctx, {
749
+ rect: { x: 100, y: 0, width: 200, height: 32 },
750
+ selected: false, muted: false, label: 'Music',
751
+ fadeInPx: 40, fadeOutPx: 5000, // fade-out longer than the bar
752
+ })
753
+ expect(r.count('quadraticCurveTo')).toBe(0)
754
+ // The tint is a plain full-height rect — NOT a curve-bounded wedge —
755
+ // spanning the fade's own width. Fade-in: [spanX, spanX+w] = [100, 140].
756
+ const fills = r.of('fillRect').map(c => c.args as number[])
757
+ expect(fills).toContainEqual([100, 0, 40, 32])
758
+ // Fade-out, clamped to the bar's own 200px width, not the requested
759
+ // 5000: the fade eats the whole bar, so its band is the full [100, 300).
760
+ expect(fills).toContainEqual([100, 0, 200, 32])
761
+ const dimFills = r.calls.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeEnvelopeDim)
762
+ expect(dimFills).toHaveLength(2)
763
+
764
+ // The curve itself still traces the fade's own full-volume corner.
765
+ const fadeInCurve = envelopeCurveLineTos(r, 0)
766
+ expect(fadeInCurve[fadeInCurve.length - 1]).toEqual([140, 0])
767
+ const fadeOutCurve = envelopeCurveLineTos(r, 1)
768
+ expect(fadeOutCurve[fadeOutCurve.length - 1]).toEqual([100, 0])
769
+ })
770
+
771
+ it('samples the exp curve by default: y is monotonic and the endpoints are exact', () => {
772
+ const r = recordingContext()
773
+ drawAudioItem(r.ctx, {
774
+ rect: { x: 0, y: 0, width: 200, height: 32 },
775
+ selected: false, muted: false, label: 'Music',
776
+ fadeInPx: 100,
777
+ })
778
+ // moveTo[0] is the bar body's OWN rounded-rect path (`roundRectPath`,
779
+ // unrelated to the envelope); moveTo[1] is the curve's own start — the
780
+ // silent corner.
781
+ const moves = r.of('moveTo').map(c => c.args as number[])
782
+ expect(moves[1]).toEqual([0, 32])
783
+ const curve = envelopeCurveLineTos(r)
784
+ expect(curve).toHaveLength(FADE_ENVELOPE_SEGMENTS)
785
+ // Monotonically non-decreasing gain (y decreasing towards 0/top) as x
786
+ // increases from silent to full — every curve in fade-curve.ts is
787
+ // monotonic over [0,1].
788
+ for (let i = 1; i < curve.length; i++) {
789
+ expect(curve[i][1]).toBeLessThanOrEqual(curve[i - 1][1] + 1e-9)
790
+ }
791
+ // Last sample lands exactly at the full-volume corner.
792
+ expect(curve[curve.length - 1]).toEqual([100, 0])
793
+ })
794
+
795
+ it('anchors the fade to the clip′s TRUE span, not the clamped rect, so it scrolls with the clip', () => {
796
+ // A clip scrolled off the left edge: the lane painter clamps `rect` to the
797
+ // visible surface (x:0) for the bar fill and waveform, but hands the fade
798
+ // its TRUE unclamped span via fadeSpanX/fadeSpanWidth. The envelope and
799
+ // grips must anchor to the true span — otherwise they stay pinned to the
800
+ // viewport edge while the clip scrolls under them (the bug this fixes).
801
+ // The envelope is still clipped to the visible bar; only its anchor moves.
802
+ const r = recordingContext()
803
+ drawAudioItem(r.ctx, {
804
+ rect: { x: 0, y: 0, width: 200, height: 32 }, // clamped visible portion
805
+ fadeSpanX: -100, fadeSpanWidth: 300, // TRUE span: left edge off-screen at -100, 300px wide
806
+ selected: false, muted: false, label: 'Music',
807
+ fadeInPx: 40, fadeOutPx: 30,
808
+ })
809
+ // Fade-in full-volume x = fadeSpanX + fadeInPx = -60 — NOT rect.x + 40 =
810
+ // 40, where the old clamped-rect anchor wrongly pinned it on screen.
811
+ const fadeInCurve = envelopeCurveLineTos(r, 0)
812
+ expect(fadeInCurve[fadeInCurve.length - 1]).toEqual([-60, 0])
813
+ // Fade-out: silent at the TRUE right edge (fadeSpanX + fadeSpanWidth =
814
+ // 200), full-volume 30px in from there (170).
815
+ const fadeOutCurve = envelopeCurveLineTos(r, 1)
816
+ expect(fadeOutCurve[fadeOutCurve.length - 1]).toEqual([170, 0])
817
+ // The tint bands anchor to the same true span.
818
+ const fills = r.of('fillRect').map(c => c.args as number[])
819
+ expect(fills).toContainEqual([-100, 0, 40, 32]) // fade-in band: [-100, -60)
820
+ expect(fills).toContainEqual([170, 0, 30, 32]) // fade-out band: [170, 200)
821
+ // Both grips ride the same true-span anchors.
822
+ const half = FADE_GRIP_SIZE_PX / 2
823
+ const [inGrip, outGrip] = r.of('moveTo').map(c => c.args as number[]).slice(-2)
824
+ expect(inGrip).toEqual([-60 - half, 0])
825
+ expect(outGrip).toEqual([170 - half, 0])
826
+ })
827
+
828
+ it('draws the requested curve shape — linear is a straight ramp, not exp′s ease', () => {
829
+ const linear = recordingContext()
830
+ drawAudioItem(linear.ctx, {
831
+ rect: { x: 0, y: 0, width: 200, height: 32 },
832
+ selected: false, muted: false, label: 'x',
833
+ fadeInPx: 100, fadeInCurve: 'linear',
834
+ })
835
+ const exp = recordingContext()
836
+ drawAudioItem(exp.ctx, {
837
+ rect: { x: 0, y: 0, width: 200, height: 32 },
838
+ selected: false, muted: false, label: 'x',
839
+ fadeInPx: 100, fadeInCurve: 'exp',
840
+ })
841
+ // Midpoint (p=0.5) of the curve: linear sits at half the drop, exp (t²)
842
+ // has barely started dropping — the two curves must disagree.
843
+ const midOf = (r: Recorder) => {
844
+ const curve = envelopeCurveLineTos(r)
845
+ return curve[Math.floor(curve.length / 2) - 1]
846
+ }
847
+ expect(midOf(linear)[1]).not.toBeCloseTo(midOf(exp)[1], 1)
848
+ })
849
+
850
+ it('defaults an unset curve to exp', () => {
851
+ const withDefault = recordingContext()
852
+ drawAudioItem(withDefault.ctx, {
853
+ rect: { x: 0, y: 0, width: 200, height: 32 },
854
+ selected: false, muted: false, label: 'x',
855
+ fadeInPx: 100,
856
+ })
857
+ const explicit = recordingContext()
858
+ drawAudioItem(explicit.ctx, {
859
+ rect: { x: 0, y: 0, width: 200, height: 32 },
860
+ selected: false, muted: false, label: 'x',
861
+ fadeInPx: 100, fadeInCurve: 'exp',
862
+ })
863
+ expect(withDefault.calls).toEqual(explicit.calls)
864
+ })
865
+ })
866
+
867
+ describe('drawAudioItem fade grips', () => {
868
+ it('draws both grips at the bar′s own corners when no fade is set, in the SUBTLE style', () => {
869
+ const r = recordingContext()
870
+ drawAudioItem(r.ctx, { rect: { x: 100, y: 0, width: 200, height: 32 }, selected: false, muted: false, label: 'x' })
871
+ // No fade → no envelope curves → the fill/border path's own moveTo (0)
872
+ // is immediately followed by the two grips (1, 2).
873
+ const gripMoves = r.of('moveTo').map(c => c.args as number[])
874
+ const half = FADE_GRIP_SIZE_PX / 2
875
+ expect(gripMoves[1]).toEqual([100 - half, 0]) // in-grip at the LEFT corner
876
+ expect(gripMoves[2]).toEqual([300 - half, 0]) // out-grip at the RIGHT corner
877
+ const gripFills = r.calls.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripSubtle)
878
+ expect(gripFills).toHaveLength(2)
879
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripActive)).toBe(false)
880
+ })
881
+
882
+ it('moves a grip to the fade′s INNER edge once a fade is set, not the corner', () => {
883
+ const r = recordingContext()
884
+ drawAudioItem(r.ctx, {
885
+ rect: { x: 100, y: 0, width: 200, height: 32 }, selected: false, muted: false, label: 'x',
886
+ fadeInPx: 40, fadeOutPx: 30,
887
+ })
888
+ // Envelope curves add their own moveTo calls ahead of the grips'; pick
889
+ // the grips off the END rather than counting in, so this stays correct
890
+ // regardless of how many moveTo calls `drawFadeEnvelope` itself makes.
891
+ const half = FADE_GRIP_SIZE_PX / 2
892
+ const [inGrip, outGrip] = r.of('moveTo').map(c => c.args as number[]).slice(-2)
893
+ expect(inGrip).toEqual([140 - half, 0]) // rect.x + fadeInPx
894
+ expect(outGrip).toEqual([270 - half, 0]) // rect.x + rect.width - fadeOutPx
895
+ })
896
+
897
+ it('brightens BOTH grips when the bar is selected', () => {
898
+ const r = recordingContext()
899
+ drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, muted: false, label: 'x' })
900
+ const gripFills = r.calls.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripActive)
901
+ expect(gripFills).toHaveLength(2)
902
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.fadeGripSubtle)).toBe(false)
903
+ })
904
+
905
+ it('brightens only the HOVERED grip, leaving the other side subtle', () => {
906
+ const r = recordingContext()
907
+ drawAudioItem(r.ctx, {
908
+ rect: { x: 0, y: 0, width: 200, height: 32 }, selected: false, muted: false, label: 'x',
909
+ hoveredFadeSide: 'in',
910
+ })
911
+ const gripStyles = r.calls
912
+ .filter(c => c.method === 'set:fillStyle' && (c.args[0] === TIMELINE_COLORS.fadeGripActive || c.args[0] === TIMELINE_COLORS.fadeGripSubtle))
913
+ .map(c => c.args[0])
914
+ // In-grip drawn first, out-grip second (see `drawAudioItem`'s own order).
915
+ expect(gripStyles).toEqual([TIMELINE_COLORS.fadeGripActive, TIMELINE_COLORS.fadeGripSubtle])
916
+ })
917
+
918
+ it('skips both grips on a bar too narrow for them', () => {
919
+ const r = recordingContext()
920
+ drawAudioItem(r.ctx, { rect: { x: 0, y: 0, width: FADE_GRIP_SIZE_PX * 2 - 1, height: 32 }, selected: false, muted: false, label: '' })
921
+ expect(r.calls.some(c =>
922
+ c.method === 'set:fillStyle' && (c.args[0] === TIMELINE_COLORS.fadeGripActive || c.args[0] === TIMELINE_COLORS.fadeGripSubtle),
923
+ )).toBe(false)
924
+ })
925
+ })
926
+
927
+ describe('drawCaptionBlock', () => {
928
+ it('fills and borders in the unselected caption color, and centres the label', () => {
929
+ const r = recordingContext()
930
+ drawCaptionBlock(r.ctx, { rect: { x: 10, y: 4, width: 200, height: 32 }, selected: false, label: 'hello' })
931
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === CAPTION_PALETTE.fill)).toBe(true)
932
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === CAPTION_PALETTE.border)).toBe(true)
933
+ // Vertically centred (no frames/waveform split to clear, unlike a clip),
934
+ // clipped and unindented since nothing is selected.
935
+ expect(r.of('fillText')[0].args).toEqual(['hello', 10 + LABEL_PAD_PX, 4 + 16])
936
+ })
937
+
938
+ it('swaps fill AND border-for-ring on selection, mirroring the retired DOM row', () => {
939
+ const r = recordingContext()
940
+ drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, label: 'x' })
941
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === CAPTION_PALETTE.fillSelected)).toBe(true)
942
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === CAPTION_PALETTE.ring)).toBe(true)
943
+ // Mutually exclusive with the unselected border — the DOM classes were
944
+ // never both at once.
945
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === CAPTION_PALETTE.border)).toBe(false)
946
+ })
947
+
948
+ it('indents a selected block\'s label past its handle', () => {
949
+ const free = recordingContext()
950
+ drawCaptionBlock(free.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: false, label: 'title' })
951
+ const picked = recordingContext()
952
+ drawCaptionBlock(picked.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, label: 'title' })
953
+ expect(Number(picked.of('fillText')[0].args[1]) - Number(free.of('fillText')[0].args[1]))
954
+ .toBe(AUDIO_HANDLE_WIDTH_PX)
955
+ })
956
+
957
+ it('leaves the handles to the row painter, like drawAudioItem', () => {
958
+ const r = recordingContext()
959
+ drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 200, height: 32 }, selected: true, label: '' })
960
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFill)).toBe(false)
961
+ })
962
+
963
+ it('skips the label on a block too narrow to read it', () => {
964
+ const r = recordingContext()
965
+ drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: MIN_LABEL_WIDTH_PX - 1, height: 32 }, selected: false, label: 'hello' })
966
+ expect(r.count('fillText')).toBe(0)
967
+ })
968
+
969
+ it('draws nothing for a zero-width rect', () => {
970
+ const r = recordingContext()
971
+ drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 0, height: 32 }, selected: false, label: 'x' })
972
+ expect(r.calls).toHaveLength(0)
973
+ })
974
+ })
975
+
976
+ describe('drawPlayhead', () => {
977
+ it('centres the playhead on its time', () => {
978
+ const r = recordingContext()
979
+ drawPlayhead(r.ctx, 400, 0, 160)
980
+ expect(r.of('fillRect')[0].args).toEqual([400 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
981
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(true)
982
+ })
983
+ })
984
+
985
+ // ── Scene composition ────────────────────────────────────────────────────
986
+
987
+ describe('drawSnapGuide', () => {
988
+ it('centres the line on its x and points both caps inward', () => {
989
+ const r = recordingContext()
990
+ drawSnapGuide(r.ctx, 300, 0, 120)
991
+ expect(r.of('fillRect')[0].args).toEqual([300 - SNAP_GUIDE_WIDTH_PX / 2, 0, SNAP_GUIDE_WIDTH_PX, 120])
992
+ // Each cap's apex sits INSIDE the span — an arrowhead aimed at the
993
+ // boundary, not away from it.
994
+ const apexes = r.of('lineTo').filter((_, i) => i % 2 === 1).map(c => c.args)
995
+ expect(apexes).toEqual([[300, 7], [300, 113]])
996
+ })
997
+ })
998
+
999
+ describe('drawItemHandles', () => {
1000
+ it('draws one handle on each end', () => {
1001
+ const r = recordingContext()
1002
+ drawItemHandles(r.ctx, { x: 0, y: 0, width: 200, height: 40 }, CLIP_HANDLE_WIDTH_PX)
1003
+ expect(r.of('set:fillStyle').filter(c => c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(2)
1004
+ })
1005
+
1006
+ it('lights only the handle the pointer is on', () => {
1007
+ const r = recordingContext()
1008
+ drawItemHandles(r.ctx, { x: 0, y: 0, width: 200, height: 40 }, CLIP_HANDLE_WIDTH_PX, 'out')
1009
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
1010
+ expect(fills.filter(f => f === TIMELINE_COLORS.handleFill)).toHaveLength(1)
1011
+ expect(fills.filter(f => f === TIMELINE_COLORS.handleFillHovered)).toHaveLength(1)
1012
+ })
1013
+ })
1014
+
1015
+ describe('overlapBands', () => {
1016
+ it('returns the intersection of each overlapping consecutive pair', () => {
1017
+ expect(overlapBands([{ start: 0, end: 5 }, { start: 4, end: 9 }])).toEqual([{ start: 4, end: 5 }])
1018
+ })
1019
+
1020
+ it('sorts by start first, so array order cannot hide an overlap', () => {
1021
+ expect(overlapBands([{ start: 4, end: 9 }, { start: 0, end: 5 }])).toEqual([{ start: 4, end: 5 }])
1022
+ })
1023
+
1024
+ it('clamps to the shorter item when one is contained in the other', () => {
1025
+ expect(overlapBands([{ start: 0, end: 9 }, { start: 2, end: 4 }])).toEqual([{ start: 2, end: 4 }])
1026
+ })
1027
+
1028
+ it('ignores items that only touch, and gaps', () => {
1029
+ expect(overlapBands([{ start: 0, end: 5 }, { start: 5, end: 9 }])).toEqual([])
1030
+ expect(overlapBands([{ start: 0, end: 4 }, { start: 5, end: 9 }])).toEqual([])
1031
+ })
1032
+
1033
+ it('finds every overlap in a run, not just the first', () => {
1034
+ expect(overlapBands([{ start: 0, end: 3 }, { start: 2, end: 6 }, { start: 5, end: 8 }]))
1035
+ .toEqual([{ start: 2, end: 3 }, { start: 5, end: 6 }])
1036
+ })
1037
+
1038
+ it('has nothing to say about zero or one item', () => {
1039
+ expect(overlapBands([])).toEqual([])
1040
+ expect(overlapBands([{ start: 0, end: 5 }])).toEqual([])
1041
+ })
1042
+ })
1043
+
1044
+ describe('drawOverlapBand', () => {
1045
+ it('does not share a literal with the muted-audio fill', () => {
1046
+ // Both are near-transparent white. If they ever converge, a muted bar and
1047
+ // an overlap band become indistinguishable by colour, and every test that
1048
+ // keys on one of them starts matching the other.
1049
+ expect(TIMELINE_COLORS.overlapFill).not.toBe(TIMELINE_COLORS.audioMutedFill)
1050
+ })
1051
+
1052
+ it('washes, hatches and rules both edges', () => {
1053
+ const r = recordingContext()
1054
+ drawOverlapBand(r.ctx, { x: 100, y: 0, width: 60, height: 40 })
1055
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
1056
+ expect(fills).toEqual([TIMELINE_COLORS.overlapFill, TIMELINE_COLORS.overlapEdge])
1057
+ // The wash, then a hard rule down each side.
1058
+ const rects = r.of('fillRect').map(c => c.args)
1059
+ expect(rects[0]).toEqual([100, 0, 60, 40])
1060
+ expect(rects[1]).toEqual([100, 0, OVERLAP_EDGE_WIDTH_PX, 40])
1061
+ expect(rects[2]).toEqual([100 + 60 - OVERLAP_EDGE_WIDTH_PX, 0, OVERLAP_EDGE_WIDTH_PX, 40])
1062
+ // The hatch is stroked twice off one path: a wide dark pass, then amber
1063
+ // inside it, so it reads over a bright frame and a dark one alike.
1064
+ expect(r.of('set:strokeStyle').map(c => c.args[0])).toEqual([
1065
+ TIMELINE_COLORS.overlapHatchShadow,
1066
+ TIMELINE_COLORS.overlapHatch,
1067
+ ])
1068
+ expect(r.of('set:lineWidth').map(c => c.args[0])).toEqual([
1069
+ OVERLAP_HATCH_SHADOW_WIDTH_PX,
1070
+ OVERLAP_HATCH_WIDTH_PX,
1071
+ ])
1072
+ expect(r.count('stroke')).toBe(2)
1073
+ expect(r.count('beginPath')).toBe(2) // the clip rect, then the one hatch path
1074
+ })
1075
+
1076
+ it('clips the hatching so the diagonals stop at the band', () => {
1077
+ const r = recordingContext()
1078
+ drawOverlapBand(r.ctx, { x: 100, y: 0, width: 60, height: 40 })
1079
+ const clipAt = r.calls.findIndex(c => c.method === 'clip')
1080
+ const strokeAt = r.calls.findIndex(c => c.method === 'stroke')
1081
+ expect(clipAt).toBeGreaterThanOrEqual(0)
1082
+ expect(clipAt).toBeLessThan(strokeAt)
1083
+ // …and the edges are painted after the clip is released, so they stay full
1084
+ // weight right up to the band's boundary.
1085
+ expect(r.calls.findIndex(c => c.method === 'restore')).toBeLessThan(
1086
+ r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).indexOf(TIMELINE_COLORS.overlapEdge),
1087
+ )
1088
+ })
1089
+
1090
+ it('leans its hatching one way, evenly spaced', () => {
1091
+ const r = recordingContext()
1092
+ drawOverlapBand(r.ctx, { x: 0, y: 0, width: 18, height: 10 })
1093
+ // Bottom-left to top-right, every OVERLAP_HATCH_SPACING_PX across a span
1094
+ // of width + height.
1095
+ expect(r.of('moveTo').map(c => c.args)).toEqual([[0, 10], [9, 10], [18, 10], [27, 10]])
1096
+ expect(r.of('lineTo').map(c => c.args)).toEqual([[-10, 0], [-1, 0], [8, 0], [17, 0]])
1097
+ })
1098
+
1099
+ it('never lets the two edge rules cross on a hairline overlap', () => {
1100
+ const r = recordingContext()
1101
+ drawOverlapBand(r.ctx, { x: 100, y: 0, width: 3, height: 40 })
1102
+ const rects = r.of('fillRect').map(c => c.args as number[])
1103
+ expect(rects[1][2]).toBe(1.5)
1104
+ expect(rects[2][0]).toBe(101.5)
1105
+ })
1106
+
1107
+ it('draws nothing for an empty band', () => {
1108
+ const r = recordingContext()
1109
+ drawOverlapBand(r.ctx, { x: 10, y: 0, width: 0, height: 40 })
1110
+ expect(r.calls).toHaveLength(0)
1111
+ })
1112
+ })
1113
+
1114
+ describe('drawSnapGuide — weak', () => {
1115
+ it('draws a capless hairline for a cross-track boundary', () => {
1116
+ const r = recordingContext()
1117
+ drawSnapGuide(r.ctx, 300, 0, 120, 'weak')
1118
+ expect(r.of('fillRect')[0].args).toEqual([300 - SNAP_GUIDE_WEAK_WIDTH_PX / 2, 0, SNAP_GUIDE_WEAK_WIDTH_PX, 120])
1119
+ expect(r.of('set:fillStyle')[0].args[0]).toBe(TIMELINE_COLORS.snapGuideWeak)
1120
+ // No arrowheads: the caps are what make the strong guide shout, and this
1121
+ // one is meant to be barely there.
1122
+ expect(r.count('closePath')).toBe(0)
1123
+ })
1124
+
1125
+ it('is thinner than the strong guide it sits alongside', () => {
1126
+ expect(SNAP_GUIDE_WEAK_WIDTH_PX).toBeLessThan(SNAP_GUIDE_WIDTH_PX)
1127
+ })
1128
+ })
1129
+
1130
+ describe('drawRowBackground — panel + divider', () => {
1131
+ it('fills the row and strokes a rowDivider outline as the track divider', () => {
1132
+ const r = recordingContext()
1133
+ drawRowBackground(r.ctx, { x: 0, y: 10, width: 200, height: 40 })
1134
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(true)
1135
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.rowDivider)).toBe(true)
1136
+ const strokes = r.of('strokeRect')
1137
+ expect(strokes).toHaveLength(1)
1138
+ expect(strokes[0].args).toEqual([0.5, 10.5, 199, 39])
1139
+ })
1140
+
1141
+ it('paints the alternate shade when one is passed, so adjacent lanes differ', () => {
1142
+ const r = recordingContext()
1143
+ drawRowBackground(r.ctx, { x: 0, y: 0, width: 100, height: 20 }, TIMELINE_COLORS.rowBackgroundAlt)
1144
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackgroundAlt)).toBe(true)
1145
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(false)
1146
+ })
1147
+ })
1148
+
1149
+ describe('drawTimelineContent', () => {
1150
+ it('places clips from the viewport, not from a percentage of the project', () => {
1151
+ const p = project({ tracks: [[clip({ id: 'c0', start: 10, end: 14 })]] } as unknown as Partial<Project>)
1152
+ const r = recordingContext()
1153
+ drawTimelineContent(r.ctx, scene({
1154
+ project: p,
1155
+ layout: computeTimelineLayout(p),
1156
+ viewport: viewport({ pxPerSecond: 25, scrollSeconds: 8 }),
1157
+ }))
1158
+
1159
+ // (10 - 8) * 25 = 50px from the left, 4s * 25 = 100px wide — then inset by
1160
+ // the gutter each side, which is paint only and does not move the clip in time.
1161
+ // Located by its width rather than by call order: the ruler and the row
1162
+ // background both fillRect before the clip does.
1163
+ const rowY = computeTimelineLayout(p).rows[0].y
1164
+ const clipFill = r.of('fillRect').find(c => c.args[2] === 100 - CLIP_GUTTER_PX * 2)
1165
+ expect(clipFill?.args).toEqual([50 + CLIP_GUTTER_PX, rowY, 100 - CLIP_GUTTER_PX * 2, BASE_VISUAL_ROW_RENDER_HEIGHT_PX])
1166
+ })
1167
+
1168
+ it('insets audio bars inside their lane, left/right AND top/bottom', () => {
1169
+ const p = project({ audio: { tracks: [audio({ start: 0, end: 4 })] } } as unknown as Partial<Project>)
1170
+ const r = recordingContext()
1171
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 20 }) }))
1172
+
1173
+ // Audio bars are rounded, so their box is a path, not a fillRect: the lane
1174
+ // background's path comes first, the bar's second, inset top and bottom
1175
+ // by AUDIO_ITEM_INSET_PX, AND left/right by CLIP_GUTTER_PX — the same
1176
+ // horizontal gutter a video clip gets, so two touching audio bars show
1177
+ // the same uniform gap two touching video clips do.
1178
+ const barHeight = AUDIO_LANE_HEIGHT_PX - AUDIO_ITEM_INSET_PX * 2
1179
+ // Lane y, not 0: the ruler owns the top of the surface and everything below
1180
+ // it is offset by that strip.
1181
+ const barTop = computeTimelineLayout(p).lanes[0].y + AUDIO_ITEM_INSET_PX
1182
+ // 4s @ 20px/s = 80px span, inset by CLIP_GUTTER_PX on each side.
1183
+ const barLeft = CLIP_GUTTER_PX
1184
+ const barRight = 80 - CLIP_GUTTER_PX
1185
+ expect(r.of('moveTo')[1].args).toEqual([barLeft + 4, barTop])
1186
+ expect(r.of('arcTo').some(c =>
1187
+ JSON.stringify(c.args) === JSON.stringify([barRight, barTop, barRight, barTop + barHeight, 4]),
1188
+ )).toBe(true)
1189
+ // By content, not by index — the ruler's own tick labels are fillText too.
1190
+ const barLabel = r.of('fillText').find(c => c.args[0] === 'voice.mp3')
1191
+ expect(barLabel?.args).toEqual(['voice.mp3', barLeft + 6, barTop + barHeight / 2])
1192
+ })
1193
+
1194
+ it('gives two butted audio bars the same uniform CLIP_GUTTER_PX gap two butted video clips get, not a seamless touch', () => {
1195
+ const p = project({
1196
+ audio: {
1197
+ tracks: [
1198
+ audio({ id: 'a', start: 0, end: 5, lane: 0 }),
1199
+ audio({ id: 'b', start: 5, end: 9, lane: 0 }), // butted to `a` — no gap in time
1200
+ ],
1201
+ },
1202
+ } as unknown as Partial<Project>)
1203
+ const r = recordingContext()
1204
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
1205
+
1206
+ // Path order: the lane background's own roundRectPath moves first (1);
1207
+ // then each bar's OWN fill path (1 moveTo, unselected/unmuted — fill and
1208
+ // border share the one path), followed by its two fade-grip triangles
1209
+ // (1 moveTo apiece, drawn on every bar regardless of selection — see
1210
+ // `drawFadeGrip`). So track `a` is index 1, and track `b`'s fill-path
1211
+ // moveTo is index 1 + (1 fill + 2 grips) = 4, not a plain "next index".
1212
+ const moveTos = r.of('moveTo').map(c => c.args as number[])
1213
+ const aMoveTo = moveTos[1]
1214
+ const bMoveTo = moveTos[4]
1215
+ const aLeft = aMoveTo[0] - AUDIO_ITEM_RADIUS_PX
1216
+ const bLeft = bMoveTo[0] - AUDIO_ITEM_RADIUS_PX
1217
+ // `a` spans [0,5) → raw x 0; `b` starts immediately after at raw x 50
1218
+ // (5s × 10px/s). Each bar's drawn left edge sits CLIP_GUTTER_PX in from
1219
+ // its raw span start — the identical inset a video clip's `clipBodyRect`
1220
+ // applies — so the two bars show a uniform 2×CLIP_GUTTER_PX gap rather
1221
+ // than touching.
1222
+ expect(aLeft).toBe(CLIP_GUTTER_PX)
1223
+ expect(bLeft).toBe(50 + CLIP_GUTTER_PX)
1224
+ })
1225
+
1226
+ it('marks the overlap between two audio bars as a crossfade band', () => {
1227
+ const p = project({
1228
+ audio: { tracks: [audio({ id: 'a', start: 0, end: 5, lane: 0 }), audio({ id: 'b', start: 4, end: 9, lane: 0 })] },
1229
+ } as unknown as Partial<Project>)
1230
+ const r = recordingContext()
1231
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
1232
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(true)
1233
+ })
1234
+
1235
+ it('leaves a muted bar out of the pairing — it is crossfading with nothing', () => {
1236
+ const p = project({
1237
+ audio: { tracks: [audio({ id: 'a', start: 0, end: 5, lane: 0 }), audio({ id: 'b', start: 4, end: 9, lane: 0, muted: true })] },
1238
+ } as unknown as Partial<Project>)
1239
+ const r = recordingContext()
1240
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
1241
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(false)
1242
+ })
1243
+
1244
+ // ── Overlaps on a VISUAL row: previously not marked at all ──
1245
+
1246
+ it('marks two overlapping clips on a video track', () => {
1247
+ // The bug this fixes: within a track the later clip simply wins, so this
1248
+ // span is one second of clip `a` that will never be seen — and the only
1249
+ // sign of it was the clip band's wash landing twice.
1250
+ const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 4, end: 9 })]] } as unknown as Partial<Project>)
1251
+ const r = recordingContext()
1252
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
1253
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(true)
1254
+ })
1255
+
1256
+ it('draws no band for clips that merely touch', () => {
1257
+ // Butted-up clips are the normal case and must stay unmarked, or every
1258
+ // cut on the timeline would be hatched.
1259
+ const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 5, end: 9 })]] } as unknown as Partial<Project>)
1260
+ const r = recordingContext()
1261
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
1262
+ expect(r.calls.some(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)).toBe(false)
1263
+ })
1264
+
1265
+ it('draws the band over the clips it spans, not under them', () => {
1266
+ const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 4, end: 9 })]] } as unknown as Partial<Project>)
1267
+ const r = recordingContext()
1268
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }) }))
1269
+ const lastClipFill = r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).lastIndexOf(TRACK_PALETTE[0].fill)
1270
+ const bandAt = r.calls.findIndex(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)
1271
+ expect(bandAt).toBeGreaterThan(lastClipFill)
1272
+ })
1273
+
1274
+ it('draws a selected clip\'s handles after every clip in the row', () => {
1275
+ // The bug: clip `b` overlaps the end of the selected clip `a`, so painting
1276
+ // handles in stacking order buried a's out handle under b. You could see
1277
+ // the clip was selected and had no way to see — or reach — its trailing
1278
+ // edge.
1279
+ const p = project({ tracks: [[clip({ id: 'a', start: 0, end: 5 }), clip({ id: 'b', start: 4, end: 9 })]] } as unknown as Partial<Project>)
1280
+ const r = recordingContext()
1281
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['a'], viewport: viewport({ pxPerSecond: 30 }) }))
1282
+
1283
+ const handleAt = r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).lastIndexOf(TIMELINE_COLORS.handleFill)
1284
+ const lastClipFill = r.calls.map(c => c.method === 'set:fillStyle' ? c.args[0] : null).lastIndexOf(TRACK_PALETTE[0].fill)
1285
+ expect(handleAt).toBeGreaterThan(lastClipFill)
1286
+ // …and over the overlap hatching as well, since the handle is the control.
1287
+ const bandAt = r.calls.findIndex(c => c.method === 'set:strokeStyle' && c.args[0] === TIMELINE_COLORS.overlapHatch)
1288
+ expect(handleAt).toBeGreaterThan(bandAt)
1289
+ })
1290
+
1291
+ it('never draws the playhead — that is the overlay layer', () => {
1292
+ const p = project({ tracks: [[clip({ start: 0, end: 5 })]] } as unknown as Partial<Project>)
1293
+ const r = recordingContext()
1294
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
1295
+ expect(r.calls.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(false)
1296
+ })
1297
+
1298
+ it('clears before it paints', () => {
1299
+ const r = recordingContext()
1300
+ drawTimelineContent(r.ctx, scene())
1301
+ expect(r.calls[0]).toEqual({ method: 'clearRect', args: [0, 0, 1000, 200] })
1302
+ })
1303
+
1304
+ // ── Captions (Phase 1: display only) ──
1305
+
1306
+ describe('the caption band', () => {
1307
+ function projectWithCaptions(segments: CaptionSegment[]): Project {
1308
+ return project({
1309
+ tracks: [[clip({ id: 'base', start: 0, end: 20 })]],
1310
+ captions: { style: 'clean', segments },
1311
+ } as unknown as Partial<Project>)
1312
+ }
1313
+
1314
+ it('draws one block per segment', () => {
1315
+ const p = projectWithCaptions([
1316
+ captionSegment({ id: 's0', start: 0, end: 2, text: 'a' }),
1317
+ captionSegment({ id: 's1', start: 2, end: 4, text: 'b' }),
1318
+ captionSegment({ id: 's2', start: 4, end: 6, text: 'c' }),
1319
+ ])
1320
+ const r = recordingContext()
1321
+ const stats = drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
1322
+ expect(stats.captionItemsDrawn).toBe(3)
1323
+ expect(r.of('set:fillStyle').filter(c => c.args[0] === CAPTION_PALETTE.fill)).toHaveLength(3)
1324
+ })
1325
+
1326
+ it('highlights the block in scene.selectedIds and gives it handles; the others get neither', () => {
1327
+ const p = projectWithCaptions([
1328
+ captionSegment({ id: 's0', start: 0, end: 2, text: 'a' }),
1329
+ captionSegment({ id: 's1', start: 2, end: 4, text: 'b' }),
1330
+ ])
1331
+ const r = recordingContext()
1332
+ // Caption ids live in the SAME unified selectedIds array as clips/audio —
1333
+ // no separate `selectedCaptionId` scene field.
1334
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['s1'] }))
1335
+
1336
+ expect(r.of('set:fillStyle').filter(c => c.args[0] === CAPTION_PALETTE.fillSelected)).toHaveLength(1)
1337
+ expect(r.of('set:fillStyle').filter(c => c.args[0] === CAPTION_PALETTE.fill)).toHaveLength(1)
1338
+ expect(r.of('set:strokeStyle').filter(c => c.args[0] === CAPTION_PALETTE.ring)).toHaveLength(1)
1339
+ // Exactly one block's worth of handles (2 pills: in + out).
1340
+ expect(r.of('set:fillStyle').filter(c => c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(2)
1341
+ })
1342
+
1343
+ it('honors hoveredHandle for the selected caption', () => {
1344
+ const p = projectWithCaptions([captionSegment({ id: 's0', start: 0, end: 2, text: 'a' })])
1345
+ const r = recordingContext()
1346
+ drawTimelineContent(r.ctx, scene({
1347
+ project: p,
1348
+ layout: computeTimelineLayout(p),
1349
+ selectedIds: ['s0'],
1350
+ hoveredHandle: { itemId: 's0', edge: 'out' },
1351
+ }))
1352
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.handleFillHovered)).toBe(true)
1353
+ })
1354
+
1355
+ it('culls segments outside the visible range', () => {
1356
+ const p = projectWithCaptions([
1357
+ captionSegment({ id: 's0', start: 0, end: 2, text: 'a' }),
1358
+ captionSegment({ id: 's1', start: 500, end: 502, text: 'far away' }),
1359
+ ])
1360
+ const r = recordingContext()
1361
+ const stats = drawTimelineContent(r.ctx, scene({
1362
+ project: p, layout: computeTimelineLayout(p), viewport: viewport({ pxPerSecond: 10 }),
1363
+ }))
1364
+ expect(stats.captionItemsDrawn).toBe(1)
1365
+ expect(stats.itemsCulled).toBeGreaterThanOrEqual(1)
1366
+ })
1367
+
1368
+ it('draws an id-less segment (before backfillCaptionIds runs) but never selects it', () => {
1369
+ const p = projectWithCaptions([{ text: 'no id yet', start: 0, end: 2 } as CaptionSegment])
1370
+ const r = recordingContext()
1371
+ const stats = drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
1372
+ expect(stats.captionItemsDrawn).toBe(1)
1373
+ expect(r.of('set:fillStyle').some(c => c.args[0] === CAPTION_PALETTE.fillSelected)).toBe(false)
1374
+ })
1375
+
1376
+ it('is a no-op on a project with no captions', () => {
1377
+ const p = project({ tracks: [[clip({ id: 'base' })]] } as unknown as Partial<Project>)
1378
+ const r = recordingContext()
1379
+ const stats = drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
1380
+ expect(stats.captionItemsDrawn).toBe(0)
1381
+ expect(r.of('set:fillStyle').some(c => c.args[0] === CAPTION_PALETTE.fill)).toBe(false)
1382
+ })
1383
+
1384
+ // ── N bands (Phase 3) ──
1385
+
1386
+ it('paints every band\'s background, including a hole lane that draws no blocks', () => {
1387
+ const p = projectWithCaptions([
1388
+ captionSegment({ id: 's0', lane: 0, start: 0, end: 2, text: 'a' }),
1389
+ captionSegment({ id: 's2', lane: 2, start: 4, end: 6, text: 'c' }),
1390
+ // Lane 1 is a hole — no segment sits there, but it still gets a band.
1391
+ ])
1392
+ const layout = computeTimelineLayout(p)
1393
+ expect(layout.captions).toHaveLength(3)
1394
+ const holeBand = layout.captions!.find(c => c.lane === 1)!
1395
+ expect(holeBand.segments).toEqual([])
1396
+
1397
+ const r = recordingContext()
1398
+ const stats = drawTimelineContent(r.ctx, scene({ project: p, layout, viewport: viewport({ pxPerSecond: 10 }) }))
1399
+
1400
+ // Two real segments drawn; the hole lane draws none.
1401
+ expect(stats.captionItemsDrawn).toBe(2)
1402
+ // Its background is still painted. `drawRowBackground`'s `roundRectPath`
1403
+ // opens with `moveTo(x + radius, y)`, so finding that call at the hole
1404
+ // band's own y proves the fill pass ran for it despite the empty
1405
+ // segment list — this is what stops the timeline jumping by a whole
1406
+ // row height mid-drag when a caption moves out of a lane that was
1407
+ // otherwise alone.
1408
+ expect(r.of('moveTo').some(c => c.args[0] === ROW_RADIUS_PX && c.args[1] === holeBand.y)).toBe(true)
1409
+ })
1410
+ })
1411
+
1412
+ describe('the keyframe strip (SP9b T3.3)', () => {
1413
+ function keyframedOverlay(over: Partial<VisualItem> = {}): VisualItem {
1414
+ return clip({
1415
+ id: 'o0', type: 'overlay', src: undefined, start: 2, end: 4,
1416
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0.5, value: 0 }] }],
1417
+ ...over,
1418
+ } as unknown as Partial<VisualItem>)
1419
+ }
1420
+
1421
+ it('draws diamonds for a selected, keyframed overlay', () => {
1422
+ const p = project({ tracks: [[keyframedOverlay()]] } as unknown as Partial<Project>)
1423
+ const r = recordingContext()
1424
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
1425
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(true)
1426
+ })
1427
+
1428
+ it('draws nothing when the overlay is not selected', () => {
1429
+ const p = project({ tracks: [[keyframedOverlay()]] } as unknown as Partial<Project>)
1430
+ const r = recordingContext()
1431
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: [] }))
1432
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(false)
1433
+ })
1434
+
1435
+ it('draws nothing for a selected overlay with no keyframes', () => {
1436
+ const p = project({ tracks: [[clip({ id: 'o0', type: 'overlay', src: undefined, start: 2, end: 4 })]] } as unknown as Partial<Project>)
1437
+ const r = recordingContext()
1438
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
1439
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(false)
1440
+ })
1441
+
1442
+ it('DRAWS the strip for a selected, keyframed video clip (SP9d)', () => {
1443
+ // Clips became keyframeable when the renderer learned to compile curves
1444
+ // into ffmpeg expressions, so the strip has to show up for them too — a
1445
+ // keyframe you cannot see is a keyframe you cannot drag or delete.
1446
+ const p = project({ tracks: [[keyframedOverlay({ type: 'video' })]] } as unknown as Partial<Project>)
1447
+ const r = recordingContext()
1448
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
1449
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(true)
1450
+ })
1451
+
1452
+ it('still draws nothing for a selected, keyframed item of a NON-visual kind', () => {
1453
+ // The gate must stay a gate — `canKeyframe`, not `isKeyframed` alone.
1454
+ const p = project({ tracks: [[keyframedOverlay({ type: 'audio' } as unknown as Partial<VisualItem>)]] } as unknown as Partial<Project>)
1455
+ const r = recordingContext()
1456
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p), selectedIds: ['o0'] }))
1457
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.keyframeDiamondFill)).toBe(false)
1458
+ })
1459
+
1460
+ it('paints the diamond matching scene.selectedKeyframe differently from the other diamond', () => {
1461
+ const p = project({ tracks: [[keyframedOverlay({
1462
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] }],
1463
+ })]] } as unknown as Partial<Project>)
1464
+ const r = recordingContext()
1465
+ drawTimelineContent(r.ctx, scene({
1466
+ project: p,
1467
+ layout: computeTimelineLayout(p),
1468
+ selectedIds: ['o0'],
1469
+ selectedKeyframe: { itemId: 'o0', t: 1 },
1470
+ }))
1471
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
1472
+ expect(fills).toContain(TIMELINE_COLORS.keyframeDiamondSelectedFill)
1473
+ expect(fills).toContain(TIMELINE_COLORS.keyframeDiamondFill)
1474
+ })
1475
+
1476
+ it('paints both diamonds identically when selectedKeyframe is null', () => {
1477
+ const p = project({ tracks: [[keyframedOverlay({
1478
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] }],
1479
+ })]] } as unknown as Partial<Project>)
1480
+ const r = recordingContext()
1481
+ drawTimelineContent(r.ctx, scene({
1482
+ project: p,
1483
+ layout: computeTimelineLayout(p),
1484
+ selectedIds: ['o0'],
1485
+ selectedKeyframe: null,
1486
+ }))
1487
+ const diamondFills = r.of('set:fillStyle')
1488
+ .map(c => c.args[0])
1489
+ .filter(fill => fill === TIMELINE_COLORS.keyframeDiamondFill || fill === TIMELINE_COLORS.keyframeDiamondSelectedFill)
1490
+ expect(diamondFills).toHaveLength(2)
1491
+ expect(new Set(diamondFills).size).toBe(1)
1492
+ })
1493
+
1494
+ it('paints nothing selected when selectedKeyframe.itemId names a different item', () => {
1495
+ const p = project({ tracks: [[keyframedOverlay({
1496
+ keyframes: [{ prop: 'offsetX', points: [{ t: 0, value: 0 }, { t: 1, value: 10 }] }],
1497
+ })]] } as unknown as Partial<Project>)
1498
+ const r = recordingContext()
1499
+ drawTimelineContent(r.ctx, scene({
1500
+ project: p,
1501
+ layout: computeTimelineLayout(p),
1502
+ selectedIds: ['o0'],
1503
+ selectedKeyframe: { itemId: 'some-other-item', t: 1 },
1504
+ }))
1505
+ const diamondFills = r.of('set:fillStyle')
1506
+ .map(c => c.args[0])
1507
+ .filter(fill => fill === TIMELINE_COLORS.keyframeDiamondFill || fill === TIMELINE_COLORS.keyframeDiamondSelectedFill)
1508
+ expect(diamondFills).toHaveLength(2)
1509
+ expect(new Set(diamondFills).size).toBe(1)
1510
+ })
1511
+ })
1512
+ })
1513
+
1514
+ describe('drawTimelineOverlay', () => {
1515
+ it('redraws only the playhead, at the viewport position', () => {
1516
+ const r = recordingContext()
1517
+ drawTimelineOverlay(r.ctx, {
1518
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 2 }),
1519
+ currentTime: 5,
1520
+ surfaceWidth: 1000,
1521
+ surfaceHeight: 160,
1522
+ })
1523
+ expect(r.count('clearRect')).toBe(1)
1524
+ expect(r.of('fillRect')[0].args).toEqual([120 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
1525
+ })
1526
+
1527
+ it('clears but draws nothing when the playhead is off-screen', () => {
1528
+ const r = recordingContext()
1529
+ drawTimelineOverlay(r.ctx, {
1530
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 200 }),
1531
+ currentTime: 5,
1532
+ surfaceWidth: 1000,
1533
+ surfaceHeight: 160,
1534
+ })
1535
+ expect(r.count('clearRect')).toBe(1)
1536
+ expect(r.count('fillRect')).toBe(0)
1537
+ })
1538
+
1539
+ // ── The preview-axis cursor, on the same layer as the playhead ──
1540
+
1541
+ it('draws nothing extra when the axis is off (no cursor time)', () => {
1542
+ const r = recordingContext()
1543
+ drawTimelineOverlay(r.ctx, {
1544
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1545
+ currentTime: 5,
1546
+ cursorTime: null,
1547
+ surfaceWidth: 1000,
1548
+ surfaceHeight: 160,
1549
+ })
1550
+ expect(r.count('fillRect')).toBe(1)
1551
+ })
1552
+
1553
+ it('draws the cursor in yellow, before the playhead', () => {
1554
+ const r = recordingContext()
1555
+ drawTimelineOverlay(r.ctx, {
1556
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1557
+ currentTime: 5,
1558
+ cursorTime: 2,
1559
+ surfaceWidth: 1000,
1560
+ surfaceHeight: 160,
1561
+ })
1562
+ const rects = r.of('fillRect')
1563
+ expect(rects.length).toBe(2)
1564
+ // Cursor first (2s × 40px/s = 80), so the red playhead wins any overlap.
1565
+ expect(rects[0].args).toEqual([80 - CURSOR_WIDTH_PX / 2, 0, CURSOR_WIDTH_PX, 160])
1566
+ expect(rects[1].args).toEqual([200 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
1567
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
1568
+ expect(fills).toEqual([TIMELINE_COLORS.cursor, TIMELINE_COLORS.playhead])
1569
+ })
1570
+
1571
+ it('culls the cursor when it scrolls off-screen, keeping the playhead', () => {
1572
+ const r = recordingContext()
1573
+ drawTimelineOverlay(r.ctx, {
1574
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1575
+ currentTime: 5,
1576
+ cursorTime: 400,
1577
+ surfaceWidth: 1000,
1578
+ surfaceHeight: 160,
1579
+ })
1580
+ expect(r.count('fillRect')).toBe(1)
1581
+ expect(r.of('fillRect')[0].args).toEqual([200 - PLAYHEAD_WIDTH_PX / 2, 0, PLAYHEAD_WIDTH_PX, 160])
1582
+ })
1583
+
1584
+ it('draws the cursor independently of the playhead — the two diverge by design', () => {
1585
+ // This is the whole feature: the pointer is at 3s, playback is still at 0.
1586
+ const r = recordingContext()
1587
+ drawTimelineOverlay(r.ctx, {
1588
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1589
+ currentTime: 0,
1590
+ cursorTime: 3,
1591
+ surfaceWidth: 1000,
1592
+ surfaceHeight: 160,
1593
+ })
1594
+ const rects = r.of('fillRect')
1595
+ expect(rects[0].args[0]).toBe(120 - CURSOR_WIDTH_PX / 2)
1596
+ expect(rects[1].args[0]).toBe(0 - PLAYHEAD_WIDTH_PX / 2)
1597
+ })
1598
+
1599
+ // ── The snap guide: what makes a strong magnet legible ──
1600
+
1601
+ it('draws no guide when no gesture is snapped', () => {
1602
+ const r = recordingContext()
1603
+ drawTimelineOverlay(r.ctx, {
1604
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1605
+ currentTime: 5,
1606
+ snapTime: null,
1607
+ surfaceWidth: 1000,
1608
+ surfaceHeight: 160,
1609
+ })
1610
+ expect(r.of('set:fillStyle').some(c => c.args[0] === TIMELINE_COLORS.snapGuide)).toBe(false)
1611
+ })
1612
+
1613
+ it('marks the snapped boundary in cyan, with an arrowhead at each end', () => {
1614
+ const r = recordingContext()
1615
+ drawTimelineOverlay(r.ctx, {
1616
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1617
+ currentTime: 5,
1618
+ snapTime: 2,
1619
+ surfaceWidth: 1000,
1620
+ surfaceHeight: 160,
1621
+ })
1622
+ const rects = r.of('fillRect')
1623
+ // Playhead first (5s × 40 = 200), then the guide at 2s × 40 = 80.
1624
+ expect(rects).toHaveLength(2)
1625
+ expect(rects[1].args).toEqual([80 - SNAP_GUIDE_WIDTH_PX / 2, 0, SNAP_GUIDE_WIDTH_PX, 160])
1626
+ // Two triangles — the caps are what stop this reading as a third playhead.
1627
+ expect(r.of('closePath')).toHaveLength(2)
1628
+ expect(r.of('moveTo').map(c => c.args)).toEqual([
1629
+ [80 - SNAP_GUIDE_CAP_HALF_WIDTH_PX, 0],
1630
+ [80 - SNAP_GUIDE_CAP_HALF_WIDTH_PX, 160],
1631
+ ])
1632
+ })
1633
+
1634
+ it('draws the guide LAST, so snapping to the playhead still looks snapped', () => {
1635
+ const r = recordingContext()
1636
+ drawTimelineOverlay(r.ctx, {
1637
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1638
+ currentTime: 3,
1639
+ cursorTime: 1,
1640
+ snapTime: 3,
1641
+ surfaceWidth: 1000,
1642
+ surfaceHeight: 160,
1643
+ })
1644
+ expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
1645
+ TIMELINE_COLORS.cursor,
1646
+ TIMELINE_COLORS.playhead,
1647
+ TIMELINE_COLORS.snapGuide,
1648
+ ])
1649
+ })
1650
+
1651
+ it('carries the tier through to the painter', () => {
1652
+ const r = recordingContext()
1653
+ drawTimelineOverlay(r.ctx, {
1654
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1655
+ currentTime: 5,
1656
+ snapTime: 2,
1657
+ snapStrength: 'weak',
1658
+ surfaceWidth: 1000,
1659
+ surfaceHeight: 160,
1660
+ })
1661
+ expect(r.of('set:fillStyle').map(c => c.args[0])).toEqual([
1662
+ TIMELINE_COLORS.playhead,
1663
+ TIMELINE_COLORS.snapGuideWeak,
1664
+ ])
1665
+ })
1666
+
1667
+ it('culls the guide when it scrolls off-screen', () => {
1668
+ const r = recordingContext()
1669
+ drawTimelineOverlay(r.ctx, {
1670
+ viewport: viewport({ pxPerSecond: 40, scrollSeconds: 0 }),
1671
+ currentTime: 5,
1672
+ snapTime: 400,
1673
+ surfaceWidth: 1000,
1674
+ surfaceHeight: 160,
1675
+ })
1676
+ expect(r.count('fillRect')).toBe(1)
1677
+ })
1678
+ })
1679
+
1680
+ // ── Culling: the virtualization acceptance probe ─────────────────────────
1681
+
1682
+ describe('draw culling', () => {
1683
+ /** 40 clips over two tracks and 60 audio segments, spanning ~400s. */
1684
+ function bigProject(): Project {
1685
+ const base: VisualItem[] = []
1686
+ const overlays: VisualItem[] = []
1687
+ for (let i = 0; i < 20; i++) {
1688
+ base.push(clip({ id: `base-${i}`, start: i * 20, end: i * 20 + 19 }))
1689
+ overlays.push(clip({ id: `ov-${i}`, type: 'overlay', start: i * 20 + 5, end: i * 20 + 12 }))
1690
+ }
1691
+ const audioTracks: AudioTrack[] = []
1692
+ for (let i = 0; i < 60; i++) {
1693
+ audioTracks.push(audio({ id: `au-${i}`, start: i * 6.5, end: i * 6.5 + 6, lane: i % 2 }))
1694
+ }
1695
+ return project({ tracks: [base, overlays], audio: { tracks: audioTracks } } as unknown as Partial<Project>)
1696
+ }
1697
+
1698
+ it('draws only what intersects the visible window', () => {
1699
+ const p = bigProject()
1700
+ const layout = computeTimelineLayout(p)
1701
+ // 1000px surface at 100 px/s = a 10-second window at t=100.
1702
+ const zoomedIn = scene({ project: p, layout, viewport: viewport({ pxPerSecond: 100, scrollSeconds: 100 }) })
1703
+
1704
+ const r = recordingContext()
1705
+ const stats = drawTimelineContent(r.ctx, zoomedIn)
1706
+
1707
+ const totalItems = 40 + 60
1708
+ const drawn = stats.visualItemsDrawn + stats.audioItemsDrawn
1709
+ expect(drawn + stats.itemsCulled).toBe(totalItems)
1710
+
1711
+ // A 10s window can hold at most 1 base clip + 1 overlay + 2 audio bars per
1712
+ // lane. Bound it generously and still be an order below the project size.
1713
+ expect(drawn).toBeLessThanOrEqual(8)
1714
+ expect(stats.itemsCulled).toBeGreaterThanOrEqual(totalItems - 8)
1715
+
1716
+ // And the draw-call list is bounded by the viewport too — rows, the drawn
1717
+ // items and their labels, nothing per-culled-item.
1718
+ const rowCount = layout.rows.length + layout.lanes.length
1719
+ const base = rulerBaseline(zoomedIn.viewport)
1720
+ expect(r.count('fillText') - base.fillText).toBeLessThanOrEqual(drawn)
1721
+ expect(r.count('fillRect') - base.fillRect).toBeLessThanOrEqual(rowCount + drawn * 4)
1722
+ })
1723
+
1724
+ it('bounds draw calls by viewport size, not project size', () => {
1725
+ const small = bigProject()
1726
+ const huge = project({
1727
+ // Ten times the items over ten times the duration, same visible window.
1728
+ tracks: [Array.from({ length: 400 }, (_, i) => clip({ id: `c-${i}`, start: i * 10, end: i * 10 + 9 }))],
1729
+ audio: { tracks: Array.from({ length: 600 }, (_, i) => audio({ id: `a-${i}`, start: i * 6.5, end: i * 6.5 + 6, lane: i % 2 })) },
1730
+ } as unknown as Partial<Project>)
1731
+
1732
+ const window = { pxPerSecond: 100, scrollSeconds: 100 }
1733
+ const a = recordingContext()
1734
+ drawTimelineContent(a.ctx, scene({ project: small, layout: computeTimelineLayout(small), viewport: viewport(window) }))
1735
+ const b = recordingContext()
1736
+ drawTimelineContent(b.ctx, scene({ project: huge, layout: computeTimelineLayout(huge), viewport: viewport(window) }))
1737
+
1738
+ // 10× the project, the same handful of draw calls (within one row's worth).
1739
+ // The ruler is a fixed cost at a fixed viewport, so it cancels in the
1740
+ // difference and is subtracted from the absolute bound.
1741
+ const base = rulerBaseline(viewport(window))
1742
+ expect(Math.abs(b.calls.length - a.calls.length)).toBeLessThan(60)
1743
+ expect(b.calls.length - base.calls).toBeLessThan(200)
1744
+ })
1745
+
1746
+ it('draws more, but still only what fits, when zoomed out to the whole project', () => {
1747
+ const p = bigProject()
1748
+ const layout = computeTimelineLayout(p)
1749
+ const r = recordingContext()
1750
+ const stats = drawTimelineContent(r.ctx, scene({
1751
+ project: p, layout,
1752
+ viewport: viewport({ pxPerSecond: 2.5, scrollSeconds: 0 }), // 400s across 1000px
1753
+ }))
1754
+ expect(stats.visualItemsDrawn + stats.audioItemsDrawn).toBe(100)
1755
+ expect(stats.itemsCulled).toBe(0)
1756
+ })
1757
+ })
1758
+
1759
+ describe('skipped tracks', () => {
1760
+ it('marks a disabled track\'s row so the painter dims it', () => {
1761
+ const p = {
1762
+ id: 'p',
1763
+ tracks: [
1764
+ { id: 't0', items: [clip({ id: 'c0' })] },
1765
+ { id: 't1', items: [clip({ id: 'o0', type: 'overlay' })], enabled: false },
1766
+ ],
1767
+ } as unknown as Project
1768
+ const layout = computeTimelineLayout(p)
1769
+ expect(layout.rows.find(r => r.trackIdx === 1)?.disabled).toBe(true)
1770
+ expect(layout.rows.find(r => r.trackIdx === 0)?.disabled).toBe(false)
1771
+ })
1772
+
1773
+ it('marks a muted track\'s row so the painter can gray its clips\' waveforms', () => {
1774
+ const p = {
1775
+ id: 'p',
1776
+ tracks: [
1777
+ { id: 't0', items: [clip({ id: 'c0' })] },
1778
+ { id: 't1', items: [clip({ id: 'o0', type: 'overlay' })], muted: true },
1779
+ ],
1780
+ } as unknown as Project
1781
+ const layout = computeTimelineLayout(p)
1782
+ expect(layout.rows.find(r => r.trackIdx === 1)?.trackMuted).toBe(true)
1783
+ expect(layout.rows.find(r => r.trackIdx === 0)?.trackMuted).toBe(false)
1784
+ })
1785
+
1786
+ it('keeps a skipped row at full height and in place — it stays editable', () => {
1787
+ // Dimmed, not collapsed: you have to be able to see a skipped track and
1788
+ // click its clips to turn it back on.
1789
+ const enabled = { id: 'p', tracks: [{ id: 't0', items: [clip({ id: 'c0' })] }] } as unknown as Project
1790
+ const skipped = { id: 'p', tracks: [{ id: 't0', items: [clip({ id: 'c0' })], enabled: false }] } as unknown as Project
1791
+ expect(computeTimelineLayout(skipped).rows[0].height).toBe(computeTimelineLayout(enabled).rows[0].height)
1792
+ expect(computeTimelineLayout(skipped).height).toBe(computeTimelineLayout(enabled).height)
1793
+ })
1794
+
1795
+ it('draws a skipped row at reduced alpha', () => {
1796
+ const p = { id: 'p', tracks: [{ id: 't0', items: [clip({ id: 'c0', start: 0, end: 2 })], enabled: false }] } as unknown as Project
1797
+ const r = recordingContext()
1798
+ drawTimelineContent(r.ctx, scene({ project: p, layout: computeTimelineLayout(p) }))
1799
+ expect(r.calls.some(c => c.method === 'set:globalAlpha' && c.args[0] === 0.3)).toBe(true)
1800
+ })
1801
+ })
1802
+
1803
+ describe('drawTimelineContent — an audio track with no start/end', () => {
1804
+ // Regression: `intersectsRange(undefined, undefined, range)` is false, so the
1805
+ // lane was culled and the timeline showed an empty row while the export was
1806
+ // correct. See `resolveAudioWindow` in timeline-model.ts.
1807
+ const p = {
1808
+ id: 'p',
1809
+ tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10 }]],
1810
+ audio: { tracks: [{ id: 'bed', src: 'song.mp3' }] },
1811
+ } as unknown as Project
1812
+
1813
+ it('draws the bar instead of culling it', () => {
1814
+ const layout = computeTimelineLayout(p)
1815
+ expect(layout.lanes).toHaveLength(1)
1816
+ const r = recordingContext()
1817
+ const stats = drawTimelineContent(r.ctx, scene({ project: p, layout }))
1818
+ expect(stats.audioItemsDrawn).toBe(1)
1819
+ expect(stats.itemsCulled).toBe(0)
1820
+ })
1821
+
1822
+ it('gives it a positive width', () => {
1823
+ const layout = computeTimelineLayout(p)
1824
+ const [t] = layout.lanes[0].tracks
1825
+ expect(t.end - t.start).toBeGreaterThan(0)
1826
+ })
1827
+ })
1828
+
1829
+ // ── Light/dark palette ───────────────────────────────────────────────────
1830
+ //
1831
+ // The canvas can't read a CSS variable, so the whole light theme reaches these
1832
+ // pixels through `timelinePalette(mode)`. Two things have to hold at once and
1833
+ // they pull in opposite directions: the LIGHT set must genuinely be a different
1834
+ // palette (not the dark numbers nudged), and the DARK set must be
1835
+ // byte-identical to what it always was — which is why every painter takes the
1836
+ // palette as an OPTIONAL trailing argument and every assertion above this
1837
+ // block still calls them without one.
1838
+
1839
+ /** Every colour string a palette can put on the canvas, flattened. Used to
1840
+ * ask "did any value from the OTHER mode leak into this paint?", which is the
1841
+ * question a per-key spot-check can't answer. */
1842
+ function paletteValues(p: TimelinePalette): string[] {
1843
+ return [
1844
+ ...Object.values(p.colors),
1845
+ ...p.tracks.flatMap(t => Object.values(t)),
1846
+ ...Object.values(p.caption),
1847
+ p.captionRailAccent,
1848
+ ...Object.values(p.waveform),
1849
+ p.labelShadow,
1850
+ ]
1851
+ }
1852
+
1853
+ /** Values this mode uses that the OTHER mode never does — the literals whose
1854
+ * appearance in a paint proves the wrong palette was consulted. Computed
1855
+ * rather than listed so a future key can't quietly escape the check. */
1856
+ function exclusiveTo(mode: TimelinePalette, other: TimelinePalette): string[] {
1857
+ const otherValues = new Set(paletteValues(other))
1858
+ return [...new Set(paletteValues(mode))].filter(v => !otherValues.has(v))
1859
+ }
1860
+
1861
+ /** Every colour actually written to the context during a paint. */
1862
+ function stylesWritten(r: Recorder): unknown[] {
1863
+ return r.calls
1864
+ .filter(c => c.method === 'set:fillStyle' || c.method === 'set:strokeStyle' || c.method === 'set:shadowColor')
1865
+ .map(c => c.args[0])
1866
+ }
1867
+
1868
+ /** A project exercising every painter that reads a colour: a clip (row
1869
+ * background, track fill, selection outline, handles, waveform band), a
1870
+ * labelled overlay (label text + its shadow), a caption block, an audio bar
1871
+ * with a fade, and — via `selectedIds` — the selected states of each. */
1872
+ function paletteProject(): Project {
1873
+ return project({
1874
+ tracks: [
1875
+ [clip({ id: 'c0', start: 0, end: 4 })],
1876
+ // Wide enough (60px at the fixture viewport) to clear MIN_LABEL_WIDTH_PX,
1877
+ // so the label — and the halo behind it — actually paints.
1878
+ [clip({ id: 'ov0', type: 'overlay', src: 'title.jsx', start: 0, end: 6 })],
1879
+ ],
1880
+ audio: { tracks: [audio({ id: 'a0', start: 0, end: 3, fadeIn: 0.5, fadeOut: 0.5 })] },
1881
+ captions: { segments: [captionSegment({ id: 'seg0', start: 0, end: 2 })] },
1882
+ } as unknown as Partial<Project>)
1883
+ }
1884
+
1885
+ /** A waveform lookup that always has data, so the clip band and the audio-lane
1886
+ * bars are actually painted and their colours are observable. */
1887
+ const ALWAYS_WAVEFORM = {
1888
+ clipColumns: () => [{ min: -0.5, max: 0.5 }],
1889
+ audioColumns: (_t: unknown, rect: { x: number; y: number; width: number; height: number }) =>
1890
+ ({ rect, columns: [{ min: -0.5, max: 0.5 }] }),
1891
+ } as unknown as TimelineScene['waveforms']
1892
+
1893
+ describe('timelinePalette', () => {
1894
+ it('resolves dark to the exact constants the module has always exported', () => {
1895
+ // Identity, not deep-equality: `TIMELINE_COLORS` and friends are still the
1896
+ // objects every other module imports, so nothing can have been rebuilt or
1897
+ // re-typed underneath them.
1898
+ const dark = timelinePalette('dark')
1899
+ expect(dark).toBe(DARK_TIMELINE_PALETTE)
1900
+ expect(dark.colors).toBe(TIMELINE_COLORS)
1901
+ expect(dark.tracks).toBe(TRACK_PALETTE)
1902
+ expect(dark.caption).toBe(CAPTION_PALETTE)
1903
+ expect(dark.captionRailAccent).toBe(CAPTION_RAIL_ACCENT)
1904
+ expect(dark.waveform).toBe(WAVEFORM_COLORS)
1905
+ expect(dark.labelShadow).toBe(LABEL_SHADOW_COLOR)
1906
+ })
1907
+
1908
+ it('resolves light to the light constants', () => {
1909
+ const light = timelinePalette('light')
1910
+ expect(light).toBe(LIGHT_TIMELINE_PALETTE)
1911
+ expect(light.colors).toBe(LIGHT_TIMELINE_COLORS)
1912
+ expect(light.tracks).toBe(LIGHT_TRACK_PALETTE)
1913
+ expect(light.caption).toBe(LIGHT_CAPTION_PALETTE)
1914
+ expect(light.captionRailAccent).toBe(LIGHT_CAPTION_RAIL_ACCENT)
1915
+ expect(light.waveform).toBe(LIGHT_WAVEFORM_COLORS)
1916
+ expect(light.labelShadow).toBe(LIGHT_LABEL_SHADOW_COLOR)
1917
+ })
1918
+
1919
+ it('returns the same object every call, so callers can memoize on identity', () => {
1920
+ expect(timelinePalette('light')).toBe(timelinePalette('light'))
1921
+ expect(timelinePalette('dark')).toBe(timelinePalette('dark'))
1922
+ })
1923
+
1924
+ it('differs from dark on the keys that carry the theme', () => {
1925
+ const dark = timelinePalette('dark').colors
1926
+ const light = timelinePalette('light').colors
1927
+ // The row field itself, and the chrome strip above it.
1928
+ expect(light.rowBackground).not.toBe(dark.rowBackground)
1929
+ expect(light.rowBackgroundAlt).not.toBe(dark.rowBackgroundAlt)
1930
+ expect(light.rulerBackground).not.toBe(dark.rulerBackground)
1931
+ // The selection vocabulary — white in dark, and it cannot stay white.
1932
+ expect(light.clipSelectedOutline).not.toBe(dark.clipSelectedOutline)
1933
+ expect(light.handleFill).not.toBe(dark.handleFill)
1934
+ expect(light.marqueeBorder).not.toBe(dark.marqueeBorder)
1935
+ // The colourless overlap treatment.
1936
+ expect(light.overlapHatch).not.toBe(dark.overlapHatch)
1937
+ expect(light.overlapEdge).not.toBe(dark.overlapEdge)
1938
+ // Track/caption labels have to be readable on their own fills.
1939
+ expect(light.audioText).not.toBe(dark.audioText)
1940
+ expect(LIGHT_TRACK_PALETTE[0].text).not.toBe(TRACK_PALETTE[0].text)
1941
+ expect(LIGHT_CAPTION_PALETTE.text).not.toBe(CAPTION_PALETTE.text)
1942
+ })
1943
+
1944
+ it('keeps a light entry for every dark key and vice versa', () => {
1945
+ expect(Object.keys(LIGHT_TIMELINE_COLORS).sort()).toEqual(Object.keys(TIMELINE_COLORS).sort())
1946
+ expect(Object.keys(LIGHT_WAVEFORM_COLORS).sort()).toEqual(Object.keys(WAVEFORM_COLORS).sort())
1947
+ expect(LIGHT_TRACK_PALETTE).toHaveLength(TRACK_PALETTE.length)
1948
+ })
1949
+ })
1950
+
1951
+ describe('the light palette is a real inversion, not a copy', () => {
1952
+ /** The two entries that are IDENTICAL across modes on purpose:
1953
+ * - `playhead` is red-500 and unmistakable on either ground;
1954
+ * - `keyframeDiamondFill` is amber because no other hue on this surface is
1955
+ * free to mean "keyframe", and a diamond is painted over the clip's own
1956
+ * content (an arbitrary filmstrip frame), not over the row background, so
1957
+ * it is not tuned to either ground in the first place.
1958
+ * Everything else must move. */
1959
+ const DELIBERATELY_SHARED = ['playhead', 'keyframeDiamondFill'] as const
1960
+
1961
+ it('changes every colour except the two that are deliberately shared', () => {
1962
+ const shared = (Object.keys(TIMELINE_COLORS) as (keyof typeof TIMELINE_COLORS)[])
1963
+ .filter(k => LIGHT_TIMELINE_COLORS[k] === TIMELINE_COLORS[k])
1964
+ expect(shared.sort()).toEqual([...DELIBERATELY_SHARED].sort())
1965
+ })
1966
+
1967
+ it('inverts every white-on-dark overlay to a dark-on-light one', () => {
1968
+ // These are the trap: `rgba(255,255,255,…)` reads as "the mark" on a dark
1969
+ // row and as "nothing at all" on a light one. Each must have stopped being
1970
+ // white — asserted structurally rather than by naming the replacement, so
1971
+ // the check survives a future tweak to the exact near-black used.
1972
+ const whiteInDark = [
1973
+ 'overlapFill', 'overlapHatch', 'overlapEdge',
1974
+ 'fadeEnvelopeLine', 'fadeGripSubtle', 'fadeGripActive',
1975
+ 'clipSelectedOutline', 'handleFill', 'handleFillHovered',
1976
+ 'keyframeDiamondSelectedFill', 'marqueeFill', 'marqueeBorder',
1977
+ 'audioMutedFill',
1978
+ ] as const
1979
+ for (const key of whiteInDark) {
1980
+ expect(TIMELINE_COLORS[key]).toMatch(/^(#ffffff|rgba\(255,255,255)/)
1981
+ expect(LIGHT_TIMELINE_COLORS[key]).not.toMatch(/^(#ffffff|rgba\(255,255,255)/)
1982
+ }
1983
+ expect(WAVEFORM_COLORS.clip).toMatch(/^rgba\(255,255,255/)
1984
+ expect(LIGHT_WAVEFORM_COLORS.clip).not.toMatch(/^rgba\(255,255,255/)
1985
+ expect(LIGHT_WAVEFORM_COLORS.clipMuted).not.toMatch(/^rgba\(255,255,255/)
1986
+ })
1987
+
1988
+ it('flips the hatch under-stroke so a dark hatch still has a ground', () => {
1989
+ // Dark: white stripes over a BLACK under-stroke. Light: near-black stripes
1990
+ // over a WHITE one. The under-stroke exists to be the opposite value of
1991
+ // the stripe, whatever filmstrip frame is behind them.
1992
+ expect(TIMELINE_COLORS.overlapHatchShadow).toMatch(/^rgba\(0,0,0/)
1993
+ expect(LIGHT_TIMELINE_COLORS.overlapHatchShadow).toMatch(/^rgba\(255,255,255/)
1994
+ // Same reasoning for the keyframe diamond's halo and the clip label's.
1995
+ expect(TIMELINE_COLORS.keyframeDiamondStroke).toMatch(/^rgba\(0,0,0/)
1996
+ expect(LIGHT_TIMELINE_COLORS.keyframeDiamondStroke).toMatch(/^rgba\(255,255,255/)
1997
+ expect(LABEL_SHADOW_COLOR).toMatch(/^rgba\(0,0,0/)
1998
+ expect(LIGHT_LABEL_SHADOW_COLOR).toMatch(/^rgba\(255,255,255/)
1999
+ })
2000
+
2001
+ it('keeps "no two meanings share one literal" for the overlap/mute pair', () => {
2002
+ // The dark set says 0.12 rather than the natural 0.1 precisely because
2003
+ // `audioMutedFill` already IS 0.1 — a muted bar and an overlap band must
2004
+ // stay distinguishable by colour alone. That property is a rule about the
2005
+ // palette, not about dark, so it has to hold in light too.
2006
+ expect(TIMELINE_COLORS.overlapFill).not.toBe(TIMELINE_COLORS.audioMutedFill)
2007
+ expect(LIGHT_TIMELINE_COLORS.overlapFill).not.toBe(LIGHT_TIMELINE_COLORS.audioMutedFill)
2008
+ expect(LIGHT_TIMELINE_COLORS.marqueeFill).not.toBe(LIGHT_TIMELINE_COLORS.audioMutedFill)
2009
+ })
2010
+
2011
+ it('gives every track hue its own entry in both modes', () => {
2012
+ // Six distinct hues is the whole point of the cycle — a light palette that
2013
+ // collapsed two of them would silently make two tracks look the same.
2014
+ expect(new Set(LIGHT_TRACK_PALETTE.map(t => t.fill)).size).toBe(LIGHT_TRACK_PALETTE.length)
2015
+ expect(new Set(LIGHT_TRACK_PALETTE.map(t => t.text)).size).toBe(LIGHT_TRACK_PALETTE.length)
2016
+ })
2017
+ })
2018
+
2019
+ describe('painters use the palette they are handed, not the module global', () => {
2020
+ // This is the assertion that catches the real bug: a painter that still
2021
+ // reads `TIMELINE_COLORS` directly passes any test that only checks the
2022
+ // palette CONSTANTS differ.
2023
+
2024
+ it('drawRowBackground paints the light row and its light divider', () => {
2025
+ const r = recordingContext()
2026
+ drawRowBackground(r.ctx, { x: 0, y: 0, width: 100, height: 20 }, undefined, LIGHT_TIMELINE_PALETTE)
2027
+ expect(r.of('set:fillStyle').map(c => c.args[0])).toContain(LIGHT_TIMELINE_COLORS.rowBackground)
2028
+ expect(r.of('set:strokeStyle').map(c => c.args[0])).toContain(LIGHT_TIMELINE_COLORS.rowDivider)
2029
+ expect(r.of('set:fillStyle').map(c => c.args[0])).not.toContain(TIMELINE_COLORS.rowBackground)
2030
+ expect(r.of('set:strokeStyle').map(c => c.args[0])).not.toContain(TIMELINE_COLORS.rowDivider)
2031
+ })
2032
+
2033
+ it('drawItemHandles paints the light pill and the light grip ticks', () => {
2034
+ const r = recordingContext()
2035
+ drawItemHandles(r.ctx, { x: 0, y: 0, width: 200, height: 40 }, CLIP_HANDLE_WIDTH_PX, 'in', undefined, LIGHT_TIMELINE_PALETTE)
2036
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
2037
+ expect(fills).toContain(LIGHT_TIMELINE_COLORS.handleFillHovered)
2038
+ expect(fills).toContain(LIGHT_TIMELINE_COLORS.handleGripHovered)
2039
+ expect(fills).not.toContain(TIMELINE_COLORS.handleFill)
2040
+ expect(fills).not.toContain(TIMELINE_COLORS.handleGrip)
2041
+ })
2042
+
2043
+ it('drawCaptionBlock takes its hue from the palette, not CAPTION_PALETTE', () => {
2044
+ const r = recordingContext()
2045
+ drawCaptionBlock(r.ctx, { rect: { x: 0, y: 0, width: 120, height: 20 }, selected: false, label: 'hi' }, LIGHT_TIMELINE_PALETTE)
2046
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
2047
+ expect(fills).toContain(LIGHT_CAPTION_PALETTE.fill)
2048
+ expect(fills).toContain(LIGHT_CAPTION_PALETTE.text)
2049
+ expect(fills).not.toContain(CAPTION_PALETTE.fill)
2050
+ expect(r.of('set:strokeStyle').map(c => c.args[0])).toContain(LIGHT_CAPTION_PALETTE.border)
2051
+ })
2052
+
2053
+ it('drawOverlapBand paints the light hatch over the light under-stroke', () => {
2054
+ const r = recordingContext()
2055
+ drawOverlapBand(r.ctx, { x: 0, y: 0, width: 60, height: 40 }, LIGHT_TIMELINE_PALETTE)
2056
+ const strokes = r.of('set:strokeStyle').map(c => c.args[0])
2057
+ expect(strokes).toEqual([LIGHT_TIMELINE_COLORS.overlapHatchShadow, LIGHT_TIMELINE_COLORS.overlapHatch])
2058
+ expect(r.of('set:fillStyle').map(c => c.args[0])).toContain(LIGHT_TIMELINE_COLORS.overlapEdge)
2059
+ })
2060
+
2061
+ it('drawRuler paints the light chrome strip, ticks and labels', () => {
2062
+ const r = recordingContext()
2063
+ drawRuler(r.ctx, viewport(), { y: 0, height: RULER_HEIGHT_PX }, 1000, LIGHT_TIMELINE_PALETTE)
2064
+ const fills = r.of('set:fillStyle').map(c => c.args[0])
2065
+ expect(fills).toContain(LIGHT_TIMELINE_COLORS.rulerBackground)
2066
+ expect(fills).toContain(LIGHT_TIMELINE_COLORS.rulerTick)
2067
+ expect(fills).toContain(LIGHT_TIMELINE_COLORS.rulerText)
2068
+ expect(fills).not.toContain(TIMELINE_COLORS.rulerBackground)
2069
+ })
2070
+
2071
+ it('drawMarquee, drawPlayhead, drawCursorLine and drawSnapGuide take theirs too', () => {
2072
+ const r = recordingContext()
2073
+ drawMarquee(r.ctx, { x: 0, y: 0, width: 40, height: 40 }, LIGHT_TIMELINE_PALETTE)
2074
+ drawPlayhead(r.ctx, 10, 0, 40, LIGHT_TIMELINE_PALETTE)
2075
+ drawCursorLine(r.ctx, 12, 0, 40, LIGHT_TIMELINE_PALETTE)
2076
+ drawSnapGuide(r.ctx, 14, 0, 40, 'strong', LIGHT_TIMELINE_PALETTE)
2077
+ const styles = stylesWritten(r)
2078
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
2079
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.marqueeBorder)
2080
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.cursor)
2081
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.snapGuide)
2082
+ // Shared on purpose — the light pass still paints red-500 here.
2083
+ expect(styles).toContain(TIMELINE_COLORS.playhead)
2084
+ expect(styles).not.toContain(TIMELINE_COLORS.cursor)
2085
+ expect(styles).not.toContain(TIMELINE_COLORS.marqueeBorder)
2086
+ })
2087
+ })
2088
+
2089
+ describe('drawTimelineContent / drawTimelineOverlay — mode', () => {
2090
+ it('paints the whole content layer from the light set and never from the dark one', () => {
2091
+ const p = paletteProject()
2092
+ const r = recordingContext()
2093
+ drawTimelineContent(r.ctx, scene({
2094
+ project: p,
2095
+ layout: computeTimelineLayout(p),
2096
+ selectedIds: ['c0', 'ov0', 'a0', 'seg0'],
2097
+ waveforms: ALWAYS_WAVEFORM,
2098
+ mode: 'light',
2099
+ }))
2100
+ const styles = stylesWritten(r)
2101
+
2102
+ // Spot-checks across every painter the pass runs.
2103
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.rowBackground)
2104
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.rowBackgroundAlt)
2105
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.rulerBackground)
2106
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.clipSelectedOutline)
2107
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.handleFill)
2108
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.audioFill)
2109
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.fadeEnvelopeDim)
2110
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.fadeEnvelopeLine)
2111
+ expect(styles).toContain(LIGHT_TRACK_PALETTE[0].fillSelected)
2112
+ expect(styles).toContain(LIGHT_TRACK_PALETTE[1].text)
2113
+ expect(styles).toContain(LIGHT_CAPTION_PALETTE.fillSelected)
2114
+ // Threaded into the waveform painters from the SAME resolved palette.
2115
+ expect(styles).toContain(LIGHT_WAVEFORM_COLORS.clipBand)
2116
+ expect(styles).toContain(LIGHT_WAVEFORM_COLORS.clip)
2117
+ expect(styles).toContain(LIGHT_WAVEFORM_COLORS.audioLane)
2118
+ // The clip label's halo (a shadowColor, not a fill) follows too.
2119
+ expect(styles).toContain(LIGHT_LABEL_SHADOW_COLOR)
2120
+
2121
+ // And nothing dark-only leaked through — the check a spot-check can't make.
2122
+ for (const darkOnly of exclusiveTo(DARK_TIMELINE_PALETTE, LIGHT_TIMELINE_PALETTE)) {
2123
+ expect(styles).not.toContain(darkOnly)
2124
+ }
2125
+ })
2126
+
2127
+ it('defaults to dark when no mode is given, with no light value anywhere', () => {
2128
+ // The no-regression proof: every caller and every test that predates modes
2129
+ // goes down this path.
2130
+ const p = paletteProject()
2131
+ const r = recordingContext()
2132
+ drawTimelineContent(r.ctx, scene({
2133
+ project: p,
2134
+ layout: computeTimelineLayout(p),
2135
+ selectedIds: ['c0', 'ov0', 'a0', 'seg0'],
2136
+ waveforms: ALWAYS_WAVEFORM,
2137
+ }))
2138
+ const styles = stylesWritten(r)
2139
+ expect(styles).toContain(TIMELINE_COLORS.rowBackground)
2140
+ expect(styles).toContain(TIMELINE_COLORS.clipSelectedOutline)
2141
+ expect(styles).toContain(WAVEFORM_COLORS.clip)
2142
+ expect(styles).toContain(LABEL_SHADOW_COLOR)
2143
+ for (const lightOnly of exclusiveTo(LIGHT_TIMELINE_PALETTE, DARK_TIMELINE_PALETTE)) {
2144
+ expect(styles).not.toContain(lightOnly)
2145
+ }
2146
+ })
2147
+
2148
+ it('paints an explicit dark mode identically to an omitted one', () => {
2149
+ const p = paletteProject()
2150
+ const omitted = recordingContext()
2151
+ const explicit = recordingContext()
2152
+ const args = { project: p, layout: computeTimelineLayout(p), selectedIds: ['c0'], waveforms: ALWAYS_WAVEFORM }
2153
+ drawTimelineContent(omitted.ctx, scene(args))
2154
+ drawTimelineContent(explicit.ctx, scene({ ...args, mode: 'dark' }))
2155
+ expect(explicit.calls).toEqual(omitted.calls)
2156
+ })
2157
+
2158
+ it('paints the overlay layer from the light set', () => {
2159
+ const r = recordingContext()
2160
+ drawTimelineOverlay(r.ctx, {
2161
+ viewport: viewport(),
2162
+ currentTime: 1,
2163
+ cursorTime: 1.5,
2164
+ snapTime: 2,
2165
+ snapStrength: 'weak',
2166
+ marquee: { x: 0, y: 0, width: 40, height: 40 },
2167
+ surfaceWidth: 1000,
2168
+ surfaceHeight: 200,
2169
+ mode: 'light',
2170
+ })
2171
+ const styles = stylesWritten(r)
2172
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
2173
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.cursor)
2174
+ expect(styles).toContain(LIGHT_TIMELINE_COLORS.snapGuideWeak)
2175
+ expect(styles).toContain(TIMELINE_COLORS.playhead) // shared on purpose
2176
+ expect(styles).not.toContain(TIMELINE_COLORS.cursor)
2177
+ expect(styles).not.toContain(TIMELINE_COLORS.snapGuideWeak)
2178
+ expect(styles).not.toContain(TIMELINE_COLORS.marqueeFill)
2179
+ })
2180
+
2181
+ it('leaves the overlay dark when no mode is given', () => {
2182
+ const r = recordingContext()
2183
+ drawTimelineOverlay(r.ctx, {
2184
+ viewport: viewport(),
2185
+ currentTime: 1,
2186
+ cursorTime: 1.5,
2187
+ snapTime: 2,
2188
+ marquee: { x: 0, y: 0, width: 40, height: 40 },
2189
+ surfaceWidth: 1000,
2190
+ surfaceHeight: 200,
2191
+ })
2192
+ const styles = stylesWritten(r)
2193
+ expect(styles).toContain(TIMELINE_COLORS.cursor)
2194
+ expect(styles).toContain(TIMELINE_COLORS.snapGuide)
2195
+ expect(styles).not.toContain(LIGHT_TIMELINE_COLORS.cursor)
2196
+ expect(styles).not.toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
2197
+ })
2198
+ })