@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.11.2",
3
+ "version": "1.0.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,9 +17,11 @@
17
17
  "lint": "eslint src"
18
18
  },
19
19
  "dependencies": {
20
+ "@bycrux/timeline-core": "^0.1.0",
20
21
  "class-variance-authority": "^0.7",
21
22
  "clsx": "^2",
22
23
  "lucide-react": "^0.400",
24
+ "mp4box": "^0.5.4",
23
25
  "tailwind-merge": "^2"
24
26
  },
25
27
  "peerDependencies": {
@@ -1,10 +1,46 @@
1
- import { useEffect } from 'react'
2
- import { X } from 'lucide-react'
1
+ import { useEffect, type ComponentType } from 'react'
2
+ import {
3
+ ChevronsLeftRight,
4
+ Clapperboard,
5
+ Command,
6
+ Crop,
7
+ Keyboard,
8
+ LayoutPanelTop,
9
+ Magnet,
10
+ Maximize2,
11
+ MousePointer2,
12
+ MousePointerClick,
13
+ Move,
14
+ MoveHorizontal,
15
+ RotateCw,
16
+ SeparatorVertical,
17
+ SlidersHorizontal,
18
+ Timer,
19
+ X,
20
+ type LucideProps,
21
+ } from 'lucide-react'
3
22
 
4
23
  /** A single control/shortcut row. `keys` renders as <kbd> chips; omit for a pure gesture. */
5
24
  export interface ControlEntry {
6
25
  keys?: string[]
7
26
  label: string
27
+ /** The row's own glyph, drawn in place of its bullet: a toolbar button's
28
+ * actual icon, or a picture of the gesture (four-way arrows for a move,
29
+ * facing chevrons for an edge-trim). It carries the meaning ahead of the
30
+ * sentence — and for a toolbar row it also ties the words to a button
31
+ * that's easy to miss, the crop one especially, since it's a 12px glyph
32
+ * that greys out whenever no clip is selected. */
33
+ icon?: ComponentType<LucideProps>
34
+ /** Where the gesture applies — "Preview", "Timeline". Rendered as a pill on
35
+ * the right, in the same slot the keyboard rows put their key chips.
36
+ *
37
+ * This exists because Preview and Timeline used to be two separate cards,
38
+ * and the heading was the only thing saying which surface a gesture was
39
+ * for. Merging them into one Mouse card would have thrown that away; the
40
+ * pill puts it back per row, where it's easier to read anyway — you no
41
+ * longer have to look up to a heading to find out where "corner-drag to
42
+ * scale" applies. */
43
+ where?: string
8
44
  }
9
45
 
10
46
  export interface ControlSection {
@@ -18,11 +54,47 @@ export interface ControlsInfoModalProps {
18
54
  onClose: () => void
19
55
  }
20
56
 
57
+ /** Heading → icon. Lives here rather than on `ControlSection` so the content
58
+ * arrays below stay pure data (a heading and some labels) and a host passing
59
+ * its own sections doesn't have to know about lucide. Unknown headings fall
60
+ * back to the neutral slider glyph rather than rendering a hole. */
61
+ const SECTION_ICONS: Record<string, ComponentType<LucideProps>> = {
62
+ Mouse: MousePointer2,
63
+ Canvas: MousePointer2,
64
+ Toolbar: SlidersHorizontal,
65
+ Keyboard: Keyboard,
66
+ // Not used by the two content arrays below any more (Preview and Timeline
67
+ // merged into Mouse), but kept for a host passing sections of its own.
68
+ Preview: Clapperboard,
69
+ Timeline: LayoutPanelTop,
70
+ }
71
+
21
72
  /**
22
73
  * Read-only "how to drive this editor" modal. Content is passed in per editor
23
74
  * (video vs carousel) so this component stays a dumb, themed renderer. Matches
24
- * the backdrop/Esc/close conventions of the other editor modals (TranscriptModal
75
+ * the backdrop/Esc/close conventions of the other editor modals (RenderModal
25
76
  * et al.) and uses the shared `--editor-*` theme tokens.
77
+ *
78
+ * The layout is deliberately not a flat list: each section is its own card with
79
+ * an icon and a heading, and each row is a hoverable line with the gesture or
80
+ * shortcut right-aligned. A dense unbroken list of ~20 rows is technically the
81
+ * same information, but nobody scans it — which is what made the old ⓘ feel
82
+ * like fine print rather than help.
83
+ *
84
+ * Sizing: the panel is wide (max-w-4xl) and the cards flow into TWO columns from
85
+ * `sm` up. At one narrow column the video reference ran past the viewport and
86
+ * the Keyboard section — the half people actually come here for — sat below the
87
+ * fold. Two columns put the whole thing on screen at once, which is also why the
88
+ * body text is a full 14px rather than the 12px used elsewhere in the chrome:
89
+ * this is a document you read, not a toolbar you glance at.
90
+ *
91
+ * Sections are cut by INPUT, not by surface: Mouse, Toolbar, Keyboard. "Where
92
+ * does this apply" then lives on the row that needs it (`ControlEntry.where`)
93
+ * instead of forcing a whole card per surface.
94
+ *
95
+ * Accent use is intentionally cheap to theme: everything tinted reads
96
+ * `var(--editor-accent)` directly (section and toolbar icons, row dots, the
97
+ * header wash), so a host theme re-skins the modal without touching this file.
26
98
  */
27
99
  export default function ControlsInfoModal({ title, sections, onClose }: ControlsInfoModalProps) {
28
100
  useEffect(() => {
@@ -42,7 +114,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
42
114
  onClick={onClose}
43
115
  >
44
116
  <div
45
- className="relative mx-4 flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-lg border shadow-2xl"
117
+ className="relative mx-4 flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl border shadow-2xl"
46
118
  style={{
47
119
  background: 'var(--editor-surface)',
48
120
  borderColor: 'var(--editor-border)',
@@ -51,86 +123,204 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
51
123
  onClick={(e) => e.stopPropagation()}
52
124
  >
53
125
  <div
54
- className="flex shrink-0 items-center justify-between border-b px-4 py-3"
126
+ className="relative shrink-0 overflow-hidden border-b px-6 py-5"
55
127
  style={{ borderColor: 'var(--editor-border)' }}
56
128
  >
57
- <h2 className="text-sm font-semibold">{title}</h2>
58
- <button
59
- type="button"
60
- onClick={onClose}
61
- aria-label="Close"
62
- className="cursor-pointer opacity-60 transition-opacity hover:opacity-100"
63
- >
64
- <X size={16} />
65
- </button>
129
+ {/* Accent wash. A gradient that fades the accent var straight to
130
+ `transparent` needs no color-mix support, so it renders the same
131
+ everywhere; the opacity keeps it a tint rather than a colour. */}
132
+ <div
133
+ aria-hidden
134
+ className="pointer-events-none absolute inset-0 opacity-[0.13]"
135
+ style={{
136
+ background:
137
+ 'linear-gradient(105deg, var(--editor-accent), transparent 62%)',
138
+ }}
139
+ />
140
+ <div className="relative flex items-start justify-between gap-4">
141
+ <div className="flex items-center gap-3">
142
+ <span
143
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border bg-[var(--editor-text)]/[0.06]"
144
+ style={{ borderColor: 'var(--editor-border)' }}
145
+ >
146
+ <Command size={17} style={{ color: 'var(--editor-accent)' }} />
147
+ </span>
148
+ <div>
149
+ <h2 className="text-lg font-semibold leading-tight tracking-tight">{title}</h2>
150
+ <p className="mt-0.5 text-[13px] opacity-55">
151
+ Everything this editor responds to.
152
+ </p>
153
+ </div>
154
+ </div>
155
+ <button
156
+ type="button"
157
+ onClick={onClose}
158
+ aria-label="Close"
159
+ className="-mr-1 -mt-1 shrink-0 cursor-pointer rounded-md p-1.5 opacity-55 transition-all hover:bg-[var(--editor-text)]/10 hover:opacity-100"
160
+ >
161
+ <X size={18} />
162
+ </button>
163
+ </div>
66
164
  </div>
67
165
 
68
- <div className="space-y-4 overflow-y-auto px-4 py-3">
69
- {sections.map((section) => (
70
- <div key={section.heading}>
71
- <p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide opacity-50">
72
- {section.heading}
73
- </p>
74
- <ul className="space-y-1.5">
75
- {section.entries.map((entry, i) => (
76
- <li key={i} className="flex items-start justify-between gap-3 text-xs">
77
- <span className="opacity-80">{entry.label}</span>
78
- {entry.keys && entry.keys.length > 0 && (
79
- <span className="flex shrink-0 items-center gap-1">
80
- {entry.keys.map((k, j) => (
81
- <kbd
82
- key={j}
83
- className="rounded border px-1.5 py-0.5 font-mono text-[10px] leading-none"
84
- style={{ borderColor: 'var(--editor-border)', background: 'var(--editor-bg)' }}
166
+ <div className="overflow-y-auto px-6 py-5">
167
+ {/* CSS columns rather than a grid: the sections are wildly uneven (a
168
+ few gestures vs eight shortcuts) and columns balance the total
169
+ height instead of pairing them row-by-row and leaving a tall
170
+ gap. */}
171
+ <div className="gap-5 sm:columns-2">
172
+ {sections.map((section) => {
173
+ const Icon = SECTION_ICONS[section.heading] ?? SlidersHorizontal
174
+ return (
175
+ <section
176
+ key={section.heading}
177
+ className="mb-5 break-inside-avoid rounded-lg border last:mb-0"
178
+ style={{ borderColor: 'var(--editor-border)', background: 'var(--editor-bg)' }}
179
+ >
180
+ <div
181
+ className="flex items-center gap-2.5 border-b px-3.5 py-3"
182
+ style={{ borderColor: 'var(--editor-border)' }}
183
+ >
184
+ <span
185
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[var(--editor-text)]/[0.06]"
186
+ >
187
+ <Icon size={14} style={{ color: 'var(--editor-accent)' }} />
188
+ </span>
189
+ <h3 className="text-xs font-semibold uppercase tracking-[0.14em] opacity-70">
190
+ {section.heading}
191
+ </h3>
192
+ </div>
193
+ <ul className="p-2">
194
+ {section.entries.map((entry, i) => (
195
+ <li
196
+ key={i}
197
+ className="flex items-center justify-between gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-[var(--editor-text)]/[0.06]"
198
+ >
199
+ <span className="flex items-start gap-2.5 opacity-90">
200
+ {/* A toolbar row shows that button's real glyph;
201
+ everything else gets a dot. Both occupy the same
202
+ width, so the labels stay on one left edge to scan
203
+ down, and the chip-less gesture rows don't read as
204
+ orphaned text beside the shortcut rows. */}
205
+ {entry.icon ? (
206
+ <entry.icon
207
+ size={14}
208
+ aria-hidden
209
+ className="mt-[1px] shrink-0"
210
+ style={{ color: 'var(--editor-accent)' }}
211
+ />
212
+ ) : (
213
+ <span
214
+ aria-hidden
215
+ className="mt-[7px] h-1 w-1 shrink-0 rounded-full opacity-50"
216
+ style={{ background: 'var(--editor-accent)' }}
217
+ />
218
+ )}
219
+ {entry.label}
220
+ </span>
221
+ {entry.where && (
222
+ /* Deliberately NOT a <kbd>: a surface name is not
223
+ something you press. Pill, not keycap. */
224
+ <span
225
+ className="shrink-0 rounded-full border px-1.5 py-[3px] text-[10px] uppercase opacity-55"
226
+ style={{
227
+ borderColor: 'var(--editor-border)',
228
+ background: 'var(--editor-surface)',
229
+ }}
85
230
  >
86
- {k}
87
- </kbd>
88
- ))}
89
- </span>
90
- )}
91
- </li>
92
- ))}
93
- </ul>
94
- </div>
95
- ))}
231
+ {entry.where}
232
+ </span>
233
+ )}
234
+ {entry.keys && entry.keys.length > 0 && (
235
+ <span className="flex shrink-0 items-center gap-1">
236
+ {entry.keys.map((k, j) => (
237
+ <kbd
238
+ key={j}
239
+ className="rounded-md border border-b-2 px-2 py-1 font-mono text-[11px] leading-none shadow-sm"
240
+ style={{
241
+ borderColor: 'var(--editor-border)',
242
+ background: 'var(--editor-surface)',
243
+ }}
244
+ >
245
+ {k}
246
+ </kbd>
247
+ ))}
248
+ </span>
249
+ )}
250
+ </li>
251
+ ))}
252
+ </ul>
253
+ </section>
254
+ )
255
+ })}
256
+ </div>
257
+ </div>
258
+
259
+ <div
260
+ className="flex shrink-0 items-center justify-end gap-2 border-t px-6 py-3 text-[13px]"
261
+ style={{ borderColor: 'var(--editor-border)' }}
262
+ >
263
+ <kbd
264
+ className="rounded-md border border-b-2 px-2 py-1 font-mono text-[11px] leading-none shadow-sm"
265
+ style={{ borderColor: 'var(--editor-border)', background: 'var(--editor-bg)' }}
266
+ >
267
+ Esc
268
+ </kbd>
269
+ <span className="opacity-55">to close</span>
96
270
  </div>
97
271
  </div>
98
272
  </div>
99
273
  )
100
274
  }
101
275
 
102
- /** Controls reference for the video/timeline editor. Sourced from VideoEditor's
103
- * key handler (S / ⌘Z), the track-controls toolbar, and PreviewPlayer's
104
- * on-canvas transform (drag / corner-drag / scroll / rotate). */
276
+ /** Controls reference for the video/timeline editor. Sourced from the shared
277
+ * keymap (`video/keymap.ts`, mounted by VideoEditor's ReviewSurface and
278
+ * Timeline), the track-controls toolbar, and PreviewPlayer's on-canvas
279
+ * transform (drag / corner-drag / scroll / rotate). */
105
280
  export const VIDEO_CONTROLS: ControlSection[] = [
106
281
  {
107
- heading: 'Preview',
108
- entries: [
109
- { label: 'Drag to move the selected clip or overlay' },
110
- { label: 'Corner-drag or scroll to scale it' },
111
- { label: 'Drag the rotate handle to turn an overlay' },
112
- ],
113
- },
114
- {
115
- heading: 'Timeline',
282
+ // One card for every mouse gesture, Preview and Timeline alike. They were
283
+ // two cards, which read as two subjects when they are one — "what the
284
+ // mouse does" — and split seven short rows across a column break. The
285
+ // per-row `where` pill carries the surface the heading used to.
286
+ heading: 'Mouse',
116
287
  entries: [
117
- { label: 'Drag a clip to reposition it' },
118
- { label: "Drag a clip's edge to trim it" },
119
- { label: 'Select a clip, then click ⓘ to inspect it' },
288
+ { icon: Move, where: 'Preview', label: 'Drag to move the selected clip or overlay' },
289
+ { icon: Maximize2, where: 'Preview', label: 'Corner-drag or scroll to scale it' },
290
+ { icon: RotateCw, where: 'Preview', label: 'Drag the rotate handle to turn an overlay' },
291
+ { icon: MoveHorizontal, where: 'Timeline', label: 'Drag a clip to reposition it' },
292
+ { icon: ChevronsLeftRight, where: 'Timeline', label: "Drag a clip's edge to trim it" },
293
+ { icon: MousePointerClick, where: 'Timeline', label: 'Double-click a clip to inspect it' },
294
+ { icon: Timer, where: 'Timeline', label: 'Click the time readout to go to a timecode' },
120
295
  ],
121
296
  },
122
297
  {
298
+ // Icons here are the very buttons in the track-controls bar, in the order
299
+ // they sit there. Keep the two in step: a row whose glyph no longer matches
300
+ // its button is worse than no glyph at all.
123
301
  heading: 'Toolbar',
124
302
  entries: [
125
- { label: 'Ripple: edits close the gap instead of leaving it' },
126
- { label: 'Crop source: non-destructively crop the selected clip' },
303
+ { icon: SeparatorVertical, label: 'Preview axis: hover the timeline to preview that frame' },
304
+ { icon: Magnet, label: 'Ripple: edits close the gap instead of leaving it' },
305
+ { icon: Crop, label: 'Crop source: non-destructively crop the selected clip (greyed out until you select one)' },
127
306
  ],
128
307
  },
129
308
  {
130
309
  heading: 'Keyboard',
131
310
  entries: [
132
311
  { keys: ['S'], label: 'Split at the playhead' },
312
+ { keys: ['⌘/Ctrl', 'A'], label: 'Toggle the preview axis' },
313
+ { keys: ['⇧', 'Delete'], label: 'Ripple-delete the selection' },
133
314
  { keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
315
+ { keys: ['⌘/Ctrl', '⇧', 'Z'], label: 'Redo' },
316
+ { keys: ['⌘/Ctrl', 'C'], label: 'Copy the selection' },
317
+ { keys: ['⌘/Ctrl', 'V'], label: 'Paste at the playhead' },
318
+ { keys: ['⌘/Ctrl', 'D'], label: 'Duplicate the selection in place' },
319
+ { keys: ['⌘/Ctrl', '⌥/Alt', 'V'], label: 'Paste attributes onto the selection' },
320
+ { keys: ['⌘/Ctrl', 'K'], label: 'Command palette' },
321
+ { keys: ['J', 'K', 'L'], label: 'Shuttle backward / stop / forward' },
322
+ { keys: ['←', '→'], label: 'Step one frame (⇧ for ten frames)' },
323
+ { keys: ['F'], label: 'Toggle fullscreen preview' },
134
324
  ],
135
325
  },
136
326
  ]
@@ -141,9 +331,9 @@ export const CAROUSEL_CONTROLS: ControlSection[] = [
141
331
  {
142
332
  heading: 'Canvas',
143
333
  entries: [
144
- { label: 'Drag an element to reposition it' },
145
- { label: 'Resize or rotate it via the handles' },
146
- { label: 'Double-click text to edit it' },
334
+ { icon: Move, label: 'Drag an element to reposition it' },
335
+ { icon: Maximize2, label: 'Resize or rotate it via the handles' },
336
+ { icon: MousePointerClick, label: 'Double-click text to edit it' },
147
337
  ],
148
338
  },
149
339
  {
@@ -59,4 +59,47 @@ describe('ControlsInfoModal', () => {
59
59
  expect(VIDEO_CONTROLS.length).toBeGreaterThan(0)
60
60
  expect(CAROUSEL_CONTROLS.length).toBeGreaterThan(0)
61
61
  })
62
+
63
+ it('renders an entry\'s `where` as a pill, not as a key chip', () => {
64
+ render(
65
+ <ControlsInfoModal
66
+ title="Editor controls"
67
+ sections={[{ heading: 'Mouse', entries: [{ where: 'Timeline', label: 'Drag a clip' }] }]}
68
+ onClose={vi.fn()}
69
+ />,
70
+ )
71
+ // A surface name is not something you press, so it must not be a <kbd> —
72
+ // that element is the modal's promise that a row names a keystroke.
73
+ expect(screen.getByText('Timeline').tagName).not.toBe('KBD')
74
+ })
75
+
76
+ it('keeps every mouse gesture in ONE section, tagged by surface', () => {
77
+ // Preview and Timeline were two cards; merging them into "Mouse" moved the
78
+ // surface onto each row. Re-splitting is fine, but silently dropping the
79
+ // `where` tags would lose which surface a gesture applies to entirely.
80
+ const mouse = VIDEO_CONTROLS.filter((s) => s.entries.some((e) => e.where))
81
+ expect(mouse).toHaveLength(1)
82
+ expect(mouse[0].heading).toBe('Mouse')
83
+ expect(mouse[0].entries.every((e) => e.where && e.icon)).toBe(true)
84
+ expect(new Set(mouse[0].entries.map((e) => e.where))).toEqual(new Set(['Preview', 'Timeline']))
85
+ })
86
+
87
+ it('gives every toolbar row the glyph of its button', () => {
88
+ const toolbar = VIDEO_CONTROLS.find((s) => s.heading === 'Toolbar')!
89
+ expect(toolbar.entries.every((e) => e.icon)).toBe(true)
90
+ })
91
+
92
+ it('lists the clipboard and fullscreen shortcuts under Keyboard', () => {
93
+ const keyboard = VIDEO_CONTROLS.find((s) => s.heading === 'Keyboard')!
94
+ const labels = keyboard.entries.map((e) => e.label)
95
+ expect(labels).toEqual(
96
+ expect.arrayContaining([
97
+ 'Copy the selection',
98
+ 'Paste at the playhead',
99
+ 'Duplicate the selection in place',
100
+ 'Paste attributes onto the selection',
101
+ 'Toggle fullscreen preview',
102
+ ]),
103
+ )
104
+ })
62
105
  })
@@ -7,7 +7,7 @@ import type {
7
7
  MediaScope,
8
8
  } from '../types'
9
9
  import type { Project, ImageElement } from '../types'
10
- import { defaultMontajTheme, applyTheme } from '../theme'
10
+ import { defaultMontajTheme, lightMontajTheme, applyTheme, isLightTheme } from '../theme'
11
11
 
12
12
  // ── (a) A fake adapter literal must satisfy the EditorAdapter interface ──────
13
13
  // This test is purely structural: it type-checks at build time. The runtime
@@ -197,4 +197,62 @@ describe('applyTheme', () => {
197
197
  defaultMontajTheme.spacing[2],
198
198
  )
199
199
  })
200
+
201
+ it('writes editor CSS vars onto a div for lightMontajTheme', () => {
202
+ const el = document.createElement('div')
203
+ applyTheme(el, lightMontajTheme)
204
+
205
+ expect(el.style.getPropertyValue('--editor-bg')).toBe(
206
+ lightMontajTheme.colors.background,
207
+ )
208
+ expect(el.style.getPropertyValue('--editor-surface')).toBe(
209
+ lightMontajTheme.colors.surface,
210
+ )
211
+ expect(el.style.getPropertyValue('--editor-accent')).toBe(
212
+ lightMontajTheme.colors.accent,
213
+ )
214
+ expect(el.style.getPropertyValue('--editor-accent-foreground')).toBe(
215
+ lightMontajTheme.colors.accentForeground,
216
+ )
217
+ expect(el.style.getPropertyValue('--editor-text')).toBe(
218
+ lightMontajTheme.colors.text,
219
+ )
220
+ expect(el.style.getPropertyValue('--editor-border')).toBe(
221
+ lightMontajTheme.colors.border,
222
+ )
223
+ expect(el.style.getPropertyValue('--editor-selection')).toBe(
224
+ lightMontajTheme.colors.selection,
225
+ )
226
+ expect(el.style.getPropertyValue('--editor-font-sans')).toBe(
227
+ lightMontajTheme.fonts.sans,
228
+ )
229
+
230
+ expect(lightMontajTheme.colors.background).not.toBe(defaultMontajTheme.colors.background)
231
+ expect(lightMontajTheme.colors.text).not.toBe(defaultMontajTheme.colors.text)
232
+ })
233
+ })
234
+
235
+ // ── (d) isLightTheme classifies a theme by its background's luminance ───────
236
+ describe('isLightTheme', () => {
237
+ it('returns true for lightMontajTheme', () => {
238
+ expect(isLightTheme(lightMontajTheme)).toBe(true)
239
+ })
240
+
241
+ it('returns false for defaultMontajTheme', () => {
242
+ expect(isLightTheme(defaultMontajTheme)).toBe(false)
243
+ })
244
+
245
+ it('falls back to false for an unparseable background color', () => {
246
+ expect(
247
+ isLightTheme({
248
+ ...defaultMontajTheme,
249
+ colors: { ...defaultMontajTheme.colors, background: 'not-a-color' },
250
+ }),
251
+ ).toBe(false)
252
+ })
253
+
254
+ it('parses 3-digit hex backgrounds', () => {
255
+ expect(isLightTheme({ ...defaultMontajTheme, colors: { ...defaultMontajTheme.colors, background: '#fff' } })).toBe(true)
256
+ expect(isLightTheme({ ...defaultMontajTheme, colors: { ...defaultMontajTheme.colors, background: '#000' } })).toBe(false)
257
+ })
200
258
  })
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { EASING_NAMES } from '@bycrux/timeline-core'
3
+ import type {
4
+ Keyframe as CoreKeyframe,
5
+ KeyframeTrack as CoreKeyframeTrack,
6
+ EasingName as CoreEasingName,
7
+ KeyframeProp as CoreKeyframeProp,
8
+ } from '@bycrux/timeline-core'
9
+ import type {
10
+ Keyframe as EditorKeyframe,
11
+ KeyframeTrack as EditorKeyframeTrack,
12
+ EasingName as EditorEasingName,
13
+ KeyframeProp as EditorKeyframeProp,
14
+ } from '../schema'
15
+
16
+ // schema.ts re-declares Keyframe/KeyframeTrack/EasingName/KeyframeProp
17
+ // instead of importing them — schema.ts is deliberately import-free (see its
18
+ // module header). The duplication is intentional but must stay structurally
19
+ // identical to the canonical definitions in @bycrux/timeline-core
20
+ // (src/curves.js, declared in its index.d.ts), which is where geometryAt/
21
+ // sampleTrack actually interpret these shapes for BOTH the preview and the
22
+ // render. This file is a compile-time tripwire: if either side's shape
23
+ // drifts, one of the assignments below fails to type-check — and thus fails
24
+ // `tsc`/the vitest build — long before it becomes a silent runtime bug.
25
+ //
26
+ // Mirrors montaj_assets/ui/src/lib/types/__tests__/schema-assignability.test.ts,
27
+ // extended to prove MUTUAL assignability (both directions) since neither side
28
+ // here is a superset of the other — they're meant to be the same shape twice.
29
+ function mutuallyAssignable<A, B>(_forward: (a: A) => B, _backward: (b: B) => A) {}
30
+
31
+ describe('editor schema Keyframe types <-> @bycrux/timeline-core Keyframe types', () => {
32
+ it('EasingName is mutually assignable', () => {
33
+ mutuallyAssignable<CoreEasingName, EditorEasingName>((a) => a, (b) => b)
34
+ })
35
+
36
+ it('KeyframeProp is mutually assignable', () => {
37
+ mutuallyAssignable<CoreKeyframeProp, EditorKeyframeProp>((a) => a, (b) => b)
38
+ })
39
+
40
+ it('Keyframe is mutually assignable', () => {
41
+ mutuallyAssignable<CoreKeyframe, EditorKeyframe>((a) => a, (b) => b)
42
+
43
+ const sample: EditorKeyframe = { t: 0, value: 1, easing: 'ease-in-out' }
44
+ const asCore: CoreKeyframe = sample
45
+ const asEditor: EditorKeyframe = asCore
46
+ expect(asEditor.t).toBe(0)
47
+ })
48
+
49
+ it('KeyframeTrack is mutually assignable', () => {
50
+ mutuallyAssignable<CoreKeyframeTrack, EditorKeyframeTrack>((a) => a, (b) => b)
51
+
52
+ const sample: EditorKeyframeTrack = { prop: 'scale', points: [{ t: 0, value: 1 }] }
53
+ const asCore: CoreKeyframeTrack = sample
54
+ const asEditor: EditorKeyframeTrack = asCore
55
+ expect(asEditor.prop).toBe('scale')
56
+ })
57
+ })
58
+
59
+ // The assignability checks above are type-level ONLY, so they fire under
60
+ // `tsc --noEmit` but NOT under `vitest run` — vitest's transform strips types
61
+ // without checking them, and the editor's `npm test` is `vitest run` with no
62
+ // typecheck step. That leaves the likeliest real drift unguarded in the command
63
+ // people actually run: someone adds an easing preset to timeline-core's
64
+ // curves.js and forgets schema.ts's `EasingName` union.
65
+ //
66
+ // This block closes that at runtime. EASING_NAMES is a real exported VALUE, so
67
+ // comparing it against the union's members catches the drift under vitest, and
68
+ // the exhaustiveness map makes the reverse direction (a member added to the
69
+ // union but not to the list below) a compile error.
70
+ describe('EasingName stays in sync with timeline-core at RUNTIME', () => {
71
+ // Adding a member to EditorEasingName without adding it here fails to compile:
72
+ // the Record must cover every member of the union exactly.
73
+ const EXHAUSTIVE: Record<EditorEasingName, true> = {
74
+ linear: true,
75
+ ease: true,
76
+ 'ease-in': true,
77
+ 'ease-out': true,
78
+ 'ease-in-out': true,
79
+ hold: true,
80
+ }
81
+ const DECLARED = Object.keys(EXHAUSTIVE) as EditorEasingName[]
82
+
83
+ it('schema.ts declares exactly the easings timeline-core implements', () => {
84
+ expect([...DECLARED].sort()).toEqual([...EASING_NAMES].sort())
85
+ })
86
+
87
+ it('every name timeline-core exports is assignable to the editor union', () => {
88
+ for (const name of EASING_NAMES) {
89
+ const asEditor: EditorEasingName = name
90
+ expect(DECLARED).toContain(asEditor)
91
+ }
92
+ })
93
+ })
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import type {
3
+ CaptionSegment,
3
4
  EditorProject,
4
5
  ImageElement,
5
6
  OverlayElement,
6
7
  Slide,
7
8
  } from '../schema'
9
+ import { laneOf } from '../video/captionLanes'
8
10
 
9
11
  // Compile-time assignability helper: if the argument doesn't satisfy T,
10
12
  // this fails to type-check (and thus fails `tsc`/build, which vitest runs through).
@@ -101,4 +103,17 @@ describe('editor schema', () => {
101
103
  expectAssignable<Slide>(slide)
102
104
  expect(slide.elements).toHaveLength(2)
103
105
  })
106
+
107
+ it('round-trips CaptionSegment.lane both present and absent', () => {
108
+ const withLane: CaptionSegment = { text: 'hi', start: 0, end: 1, lane: 2 }
109
+ const withoutLane: CaptionSegment = { text: 'hi', start: 0, end: 1 }
110
+ expectAssignable<CaptionSegment>(withLane)
111
+ expectAssignable<CaptionSegment>(withoutLane)
112
+ expect(withLane.lane).toBe(2)
113
+ expect(withoutLane.lane).toBeUndefined()
114
+ // Absent lane reads as lane 0 through the module every consumer goes
115
+ // through — not just `undefined` left for each caller to default itself.
116
+ expect(laneOf(withoutLane)).toBe(0)
117
+ expect(laneOf(withLane)).toBe(2)
118
+ })
104
119
  })