@bycrux/editor 0.12.0 → 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 (266) 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 +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  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 +586 -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 +6 -2
  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 +2202 -251
  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/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -2,17 +2,32 @@ import { describe, it, expect } from 'vitest'
2
2
  import type {
3
3
  EditorAdapter,
4
4
  RenderEvent,
5
+ RenderExport,
5
6
  RenderOptions,
7
+ SampleFrameOptions,
6
8
  VersionEntry,
7
9
  WaveformChunk,
10
+ PeaksData,
11
+ FilmstripIndex,
12
+ AudioPolishAnalysis,
8
13
  } from '../types'
9
14
  import type { EditorProject, ImageElement } from '../schema'
10
15
 
11
16
  // ── Video adapter contract ────────────────────────────────────────────────────
12
- // The video editor adds four OPTIONAL adapter methods: listVersionHistory,
13
- // restoreVersion, getWaveformChunks, clearOverlayCache. This file fails to
14
- // compile if those methods are mistyped, and verifies that (a) an adapter
17
+ // The video editor adds ELEVEN optional adapter methods. This file pins EIGHT
18
+ // of them:
19
+ // listVersionHistory, restoreVersion, getWaveformChunks, clearOverlayCache,
20
+ // getWaveformPeaks, getFilmstrip, getSampleFrame, analyzeAudioPolish
21
+ // It fails to compile if those are mistyped, and verifies that (a) an adapter
15
22
  // implementing them type-checks and (b) one omitting them still type-checks.
23
+ // It also pins the `RenderOptions` shape both `render` and `renderAsync` accept.
24
+ //
25
+ // The remaining three — resolveCaptionTemplate, generateCaptions, reportContext
26
+ // — are NOT pinned here; they are exercised by their own feature tests
27
+ // (CaptionRegenModal, VideoEditor.context, use-report-context). Stated
28
+ // explicitly because this list previously read as an exhaustive enumeration of
29
+ // the optional surface while silently omitting them, which misleads anyone
30
+ // consulting it to learn what a host may implement.
16
31
 
17
32
  const project: EditorProject = {
18
33
  version: '1',
@@ -23,7 +38,7 @@ const project: EditorProject = {
23
38
  editingPrompt: '',
24
39
  settings: { resolution: [1080, 1920] },
25
40
  assets: [],
26
- tracks: [[]],
41
+ tracks: [{ id: 'trk-0', items: [] }],
27
42
  }
28
43
 
29
44
  const baseRequired = {
@@ -41,7 +56,7 @@ const baseRequired = {
41
56
  fileUrl: (path: string): string => path,
42
57
  }
43
58
 
44
- // (a) Adapter implementing all four new optional methods.
59
+ // (a) Adapter implementing all seven new optional methods.
45
60
  function makeVideoAdapter(): EditorAdapter<EditorProject> {
46
61
  return {
47
62
  ...baseRequired,
@@ -56,6 +71,34 @@ function makeVideoAdapter(): EditorAdapter<EditorProject> {
56
71
  _chunkDurationS?: number,
57
72
  ): Promise<WaveformChunk[]> => [{ path: '/wf/0.png', start: 0, end: 15 }],
58
73
  clearOverlayCache: (_src?: string): void => {},
74
+ getWaveformPeaks: async ({ samplesPerSecond }): Promise<PeaksData> => ({
75
+ samplesPerSecond,
76
+ start: 0,
77
+ duration: 15,
78
+ peaks: [0, 0],
79
+ }),
80
+ getFilmstrip: async (_args): Promise<FilmstripIndex> => ({
81
+ sheets: [{ path: '/fs/sheet_01.jpg', cols: 10, rows: 1, tiles: [{ t: 0, row: 0, col: 0 }] }],
82
+ interval: 1,
83
+ tileWidth: 160,
84
+ }),
85
+ getSampleFrame: async (
86
+ _projectId: string,
87
+ at: number,
88
+ opts?: SampleFrameOptions,
89
+ ): Promise<{ url: string }> => ({ url: `/files?at=${at}&curve=${opts?.sdrCurve ?? ''}` }),
90
+ analyzeAudioPolish: async (args): Promise<AudioPolishAnalysis> => {
91
+ if (args.piece === 'silence' || args.piece === 'fillers') {
92
+ return { piece: args.piece, removals: [{ start: 1, end: 2, text: 'um' }] }
93
+ }
94
+ if (args.piece === 'silence-check') {
95
+ return { piece: 'silence-check', keeps: [[0, 1], [2, 3]] }
96
+ }
97
+ if (args.piece === 'loudness') {
98
+ return { piece: 'loudness', measuredI: -18, measuredTP: -1, measuredLRA: 5, targetI: -14, gainDb: 4 }
99
+ }
100
+ return { piece: 'voice', vocalsPath: '/audio/vocals.wav', url: '/files?path=vocals.wav' }
101
+ },
59
102
  }
60
103
  }
61
104
 
@@ -71,12 +114,62 @@ describe('EditorAdapter video methods', () => {
71
114
  expect(typeof a.restoreVersion).toBe('function')
72
115
  expect(typeof a.getWaveformChunks).toBe('function')
73
116
  expect(typeof a.clearOverlayCache).toBe('function')
117
+ expect(typeof a.getWaveformPeaks).toBe('function')
118
+ expect(typeof a.getFilmstrip).toBe('function')
119
+ expect(typeof a.analyzeAudioPolish).toBe('function')
74
120
 
75
121
  const versions = await a.listVersionHistory!('p1')
76
122
  expect(versions[0]).toMatchObject({ hash: 'abc', message: 'init' })
77
123
 
78
124
  const chunks = await a.getWaveformChunks!('p1', 't1', 'a.mp3', 15)
79
125
  expect(chunks[0]).toMatchObject({ path: '/wf/0.png', start: 0, end: 15 })
126
+
127
+ const peaks = await a.getWaveformPeaks!({
128
+ projectId: 'p1',
129
+ src: 'a.mp4',
130
+ samplesPerSecond: 200,
131
+ start: 0,
132
+ duration: 15,
133
+ })
134
+ expect(peaks).toMatchObject({ samplesPerSecond: 200, start: 0, duration: 15 })
135
+
136
+ const filmstrip = await a.getFilmstrip!({ projectId: 'p1', src: 'a.mp4' })
137
+ expect(filmstrip.sheets[0]).toMatchObject({ path: '/fs/sheet_01.jpg', cols: 10, rows: 1 })
138
+ expect(filmstrip.sheets[0].tiles[0]).toMatchObject({ t: 0, row: 0, col: 0 })
139
+
140
+ // A directly displayable URL: the modal puts it straight into an <img>.
141
+ const sample = await a.getSampleFrame!('p1', 4.5, { sdrCurve: 'vivid1-neutral' })
142
+ expect(sample.url).toBe('/files?at=4.5&curve=vivid1-neutral')
143
+ // Options are optional; the host falls back to its default curve.
144
+ expect((await a.getSampleFrame!('p1', 4.5)).url).toBe('/files?at=4.5&curve=')
145
+
146
+ // Discriminated on `piece`; times are source time, never timeline time.
147
+ const removals = await a.analyzeAudioPolish!({ projectId: 'p1', piece: 'silence', src: 'a.mp4' })
148
+ if (removals.piece === 'silence' || removals.piece === 'fillers') {
149
+ expect(removals.removals[0]).toMatchObject({ start: 1, end: 2 })
150
+ }
151
+ const keeps = await a.analyzeAudioPolish!({ projectId: 'p1', piece: 'silence-check', src: 'a.mp4' })
152
+ if (keeps.piece === 'silence-check') {
153
+ expect(keeps.keeps[0]).toEqual([0, 1])
154
+ }
155
+ const loudness = await a.analyzeAudioPolish!({
156
+ projectId: 'p1',
157
+ piece: 'loudness',
158
+ src: 'a.mp4',
159
+ options: { targetLufs: -14 },
160
+ })
161
+ if (loudness.piece === 'loudness') {
162
+ expect(loudness).toMatchObject({ targetI: -14, gainDb: 4 })
163
+ }
164
+ const voice = await a.analyzeAudioPolish!({
165
+ projectId: 'p1',
166
+ piece: 'voice',
167
+ src: 'a.mp4',
168
+ window: { in: 0, out: 10 },
169
+ })
170
+ if (voice.piece === 'voice') {
171
+ expect(voice.url).toBe('/files?path=vocals.wav')
172
+ }
80
173
  })
81
174
 
82
175
  it('an adapter omitting the new optional methods still type-checks', () => {
@@ -85,5 +178,35 @@ describe('EditorAdapter video methods', () => {
85
178
  expect(a.restoreVersion).toBeUndefined()
86
179
  expect(a.getWaveformChunks).toBeUndefined()
87
180
  expect(a.clearOverlayCache).toBeUndefined()
181
+ expect(a.getWaveformPeaks).toBeUndefined()
182
+ expect(a.getFilmstrip).toBeUndefined()
183
+ expect(a.getSampleFrame).toBeUndefined()
184
+ expect(a.analyzeAudioPolish).toBeUndefined()
185
+ })
186
+ })
187
+
188
+ // ── Render options shape ──────────────────────────────────────────────────────
189
+ // `export` selects the deliverables for an HDR project; `sdrCurve` names the
190
+ // HDR→SDR tone curve (ids live in montaj_assets/luts/looks.json). Both optional,
191
+ // so an empty object and an omitted argument stay valid calls.
192
+
193
+ describe('RenderOptions', () => {
194
+ it('accepts every export mode plus a curve id, and stays fully optional', async () => {
195
+ const modes: RenderExport[] = ['auto', 'sdr', 'both']
196
+ const opts: RenderOptions[] = [
197
+ {},
198
+ { scale: 2 },
199
+ ...modes.map(m => ({ export: m, sdrCurve: 'vivid1' })),
200
+ ]
201
+ expect(opts.map(o => o.export)).toEqual([undefined, undefined, 'auto', 'sdr', 'both'])
202
+
203
+ // Both render entry points take the same options object.
204
+ const a = makeVideoAdapter()
205
+ for await (const ev of a.render('p1', { export: 'both', sdrCurve: 'vivid1-neutral' })) {
206
+ expect(ev.type).toBe('done')
207
+ }
208
+
209
+ const sampleOpts: SampleFrameOptions = { sdrCurve: 'vivid1' }
210
+ expect(sampleOpts.sdrCurve).toBe('vivid1')
88
211
  })
89
212
  })
@@ -8,9 +8,14 @@ interface Props {
8
8
  selectedSlideId: string | null
9
9
  adapter: EditorAdapter<Project>
10
10
  onAddElement: (slideId: string, element: CarouselElement) => void
11
+ /** Editor theme mode — light/dark. Threaded to `OverlayPicker`, and used
12
+ * locally for the upload/text/generate error hues (red-400 is sub-AA on
13
+ * the light side panel). Absent -> dark, matching every existing
14
+ * caller. */
15
+ mode?: 'light' | 'dark'
11
16
  }
12
17
 
13
- export default function AddElementMenu({ project, selectedSlideId, adapter, onAddElement }: Props) {
18
+ export default function AddElementMenu({ project, selectedSlideId, adapter, onAddElement, mode = 'dark' }: Props) {
14
19
  const [showPrompt, setShowPrompt] = useState(false)
15
20
  const [prompt, setPrompt] = useState('')
16
21
  const [generating, setGenerating] = useState(false)
@@ -171,8 +176,8 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
171
176
  + Overlay
172
177
  </Button>
173
178
  </div>
174
- {uploadError && <div className="text-xs text-red-400">{uploadError}</div>}
175
- {textError && <div className="text-xs text-red-400">{textError}</div>}
179
+ {uploadError && <div className={`text-xs ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{uploadError}</div>}
180
+ {textError && <div className={`text-xs ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{textError}</div>}
176
181
 
177
182
  {showPrompt && !disabled && (
178
183
  <div className="flex flex-col gap-2 p-3 bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-lg">
@@ -185,7 +190,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
185
190
  disabled={generating}
186
191
  onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate() }}
187
192
  />
188
- {genError && <div className="text-xs text-red-400">{genError}</div>}
193
+ {genError && <div className={`text-xs ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{genError}</div>}
189
194
  <div className="flex gap-2">
190
195
  <Button size="sm" onClick={handleGenerate} disabled={generating || !prompt.trim()} className="text-xs">
191
196
  {generating ? 'Generating…' : 'Generate'}
@@ -205,6 +210,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
205
210
  onPick={element => {
206
211
  if (selectedSlideId) onAddElement(selectedSlideId, element)
207
212
  }}
213
+ mode={mode}
208
214
  />
209
215
  </div>
210
216
  )
@@ -1,7 +1,7 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { RefreshCw, AlertCircle, Download, Info, Undo2, Redo2 } from 'lucide-react'
3
3
  import type { Project, Slide, CarouselElement, ImageElement, CarouselEditorProps, OverlayFactory } from '../types'
4
- import { applyTheme, defaultMontajTheme } from '../theme'
4
+ import { applyTheme, defaultMontajTheme, isLightTheme } from '../theme'
5
5
  import { useProjectState } from '../state/use-project-state'
6
6
  import SlideCanvas from './SlideCanvas'
7
7
  import SlidePropertyPanel from './SlidePropertyPanel'
@@ -180,6 +180,14 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
180
180
  if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
181
181
  }, [theme])
182
182
 
183
+ // Classified once here, off the same theme object VideoEditor's
184
+ // `timelineMode` uses — see that file's comment for why (three independent
185
+ // consumers resolving light/dark separately is three chances to disagree).
186
+ const mode = useMemo<'light' | 'dark'>(
187
+ () => (isLightTheme(theme ?? defaultMontajTheme) ? 'light' : 'dark'),
188
+ [theme],
189
+ )
190
+
183
191
  // ── Keyboard shortcuts: undo / redo. Guarded against text inputs. ──
184
192
  useEffect(() => {
185
193
  const onKey = (e: KeyboardEvent) => {
@@ -415,7 +423,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
415
423
  disabled={refreshing}
416
424
  className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
417
425
  refreshState === 'err'
418
- ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
426
+ ? (mode === 'light' ? 'text-red-700 border-red-300 bg-red-50 hover:bg-red-100' : 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70')
419
427
  : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
420
428
  }`}
421
429
  title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
@@ -476,9 +484,29 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
476
484
  )}
477
485
  {!slots?.pendingStatus && skillPath && (
478
486
  <div className="w-full rounded-xl border-2 border-[var(--editor-accent)] bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]/10">
479
- <p className="text-[var(--editor-accent)] text-xs font-bold uppercase tracking-widest">Send this to your agent</p>
480
- <div className="flex items-start justify-between bg-black/60 border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
481
- <span className="text-[var(--editor-text)] text-[12px] leading-relaxed break-all">
487
+ {/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
488
+ `--editor-surface`, and 12px bold is still NORMAL text under WCAG
489
+ (large starts at 18.66px bold), so it needs the 4.5:1 floor. The
490
+ border/shadow around it stay accent - this is small-TEXT only. */}
491
+ <p className={`text-xs font-bold uppercase tracking-widest ${mode === 'light' ? 'text-indigo-600' : 'text-[var(--editor-accent)]'}`}>Send this to your agent</p>
492
+ {/* Deliberately hardcoded dark chrome, not `--editor-*` tokens: this
493
+ is the literal text the user copies and pastes to their coding
494
+ agent, styled as terminal/code chrome — same precedent as the
495
+ video preview's black canvas, which also stays dark regardless
496
+ of editor theme.
497
+
498
+ This was `bg-black/60`, which composited against whatever sat
499
+ behind it: near-black over the dark surface, but only ~#666
500
+ over a light one — ~3:1 for 12px copyable text, below AA.
501
+ It is now an OPAQUE colour so the box no longer depends on
502
+ its backdrop. The specific value is not arbitrary: #070a10 is
503
+ exactly what `rgba(0,0,0,0.6)` over the dark theme's
504
+ `--editor-surface` (#111827) composited to, so dark mode is
505
+ pixel-identical to before while light mode is fixed. Text is
506
+ pinned to #f3f4f6 (= the dark theme's `--editor-text`), so it
507
+ is likewise unchanged in dark mode and ~18:1 in both. */}
508
+ <div className="flex items-start justify-between bg-[#070a10] border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
509
+ <span className="text-gray-100 text-[12px] leading-relaxed break-all">
482
510
  There is a new project pending: &quot;{project.name ?? project.id}&quot;. Please see @{skillPath} and start. Talk to me if you run into questions.
483
511
  </span>
484
512
  <button
@@ -490,7 +518,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
490
518
  setTimeout(() => setCopied(false), 2000)
491
519
  }}
492
520
  className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
493
- copied ? 'bg-green-700 text-green-200' : 'bg-white/10 text-[var(--editor-text)] hover:bg-white/20 hover:text-[var(--editor-text)]'
521
+ copied ? 'bg-green-700 text-green-200' : 'bg-white/10 text-gray-100 hover:bg-white/20 hover:text-gray-100'
494
522
  }`}
495
523
  title="Copy prompt"
496
524
  >
@@ -503,7 +531,14 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
503
531
  </div>
504
532
  ) : selectedSlide ? (
505
533
  <>
506
- <div className="flex-shrink-0" style={{ boxShadow: '0 0 0 1px rgba(255,255,255,0.08)' }}>
534
+ {/* Edge definition for the slide canvas, which sits on `--editor-bg`.
535
+ Was a fixed `rgba(255,255,255,0.08)`, which read as a hairline
536
+ highlight on the dark ground but composited to under 1/255 on the
537
+ light one — the edge silently vanished. Keyed to `--editor-text`
538
+ instead, the same "tint with the foreground colour" idiom the rest
539
+ of the chrome uses, so it is a light ring on dark and a dark ring
540
+ on light at the same subtle strength. */}
541
+ <div className="flex-shrink-0 ring-1 ring-[var(--editor-text)]/10">
507
542
  <SlideCanvas
508
543
  slide={selectedSlide}
509
544
  slideId={selectedSlide.id}
@@ -566,6 +601,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
566
601
  selectedSlideId={selectedSlideId}
567
602
  adapter={adapter}
568
603
  onAddElement={handleAddElement}
604
+ mode={mode}
569
605
  />
570
606
  </div>
571
607
  )}
@@ -587,6 +623,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
587
623
  onToggleElementVisibility={onToggleElementVisibility}
588
624
  // Fills the right column (drop the default w-80 width + left border).
589
625
  className="w-full border-l-0"
626
+ mode={mode}
590
627
  />
591
628
  </div>
592
629
  </div>
@@ -616,6 +653,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
616
653
  exportActions={slots?.exportActions}
617
654
  onClose={() => setRenderOpen(false)}
618
655
  onCancel={() => setRenderOpen(false)}
656
+ mode={mode}
619
657
  />
620
658
  )}
621
659
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import type { EditorAdapter, Project, RenderStatus } from '../types'
4
+ import { Loader } from '../ui/Loader'
4
5
 
5
6
  interface CarouselRenderModalProps {
6
7
  projectId: string
@@ -19,6 +20,10 @@ interface CarouselRenderModalProps {
19
20
  * in the done-state info panel. The package no longer hardcodes host URLs.
20
21
  */
21
22
  exportActions?: ReactNode
23
+ /** Editor theme mode — light/dark. The panel and log box follow
24
+ * `--editor-surface`/`--editor-bg`, so log/status hues need to darken in
25
+ * light mode. Absent -> dark, matching every existing caller. */
26
+ mode?: 'light' | 'dark'
22
27
  }
23
28
 
24
29
  /** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
@@ -57,12 +62,12 @@ function slidesFromMedia(media: RenderMedia[]): SlideView[] {
57
62
  .map(({ m }) => ({ url: m.url, filename: m.filename }))
58
63
  }
59
64
 
60
- function LogLine({ text }: { text: string }) {
65
+ function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark' }) {
61
66
  const t = text.replace(/^\[render\]\s*/, '')
62
67
  let color = 'text-[var(--editor-text)]/60'
63
- if (/done|complete|→/i.test(t)) color = 'text-green-400'
64
- else if (/rendering|launching|bundling/i.test(t)) color = 'text-sky-400'
65
- else if (/error|fail/i.test(t)) color = 'text-red-400'
68
+ if (/done|complete|→/i.test(t)) color = mode === 'light' ? 'text-green-700' : 'text-green-400'
69
+ else if (/rendering|launching|bundling/i.test(t)) color = mode === 'light' ? 'text-sky-700' : 'text-sky-400'
70
+ else if (/error|fail/i.test(t)) color = mode === 'light' ? 'text-red-600' : 'text-red-400'
66
71
 
67
72
  const prefix = text.startsWith('[render]')
68
73
  ? <span className="text-[var(--editor-text)]/40">[render] </span>
@@ -75,7 +80,7 @@ function LogLine({ text }: { text: string }) {
75
80
  )
76
81
  }
77
82
 
78
- export default function CarouselRenderModal({ projectId, adapter, slidesCount, resolution, onClose, onCancel, exportActions }: CarouselRenderModalProps) {
83
+ export default function CarouselRenderModal({ projectId, adapter, slidesCount, resolution, onClose, onCancel, exportActions, mode = 'dark' }: CarouselRenderModalProps) {
79
84
  const [logs, setLogs] = useState<string[]>([])
80
85
  const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
81
86
  const [media, setMedia] = useState<RenderMedia[] | null>(null)
@@ -328,12 +333,12 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
328
333
  <div className="px-5 py-8 flex flex-col items-center justify-center gap-4 min-h-[12rem]">
329
334
  {status === 'running' ? (
330
335
  <>
331
- <span className="w-6 h-6 rounded-full border-2 border-amber-400 border-t-transparent animate-spin" />
336
+ <Loader size="lg" />
332
337
  <p className="text-sm text-[var(--editor-text)]/70">Rendering slides…</p>
333
338
  <p className="text-xs text-[var(--editor-text)]/50">This can take a moment.</p>
334
339
  </>
335
340
  ) : (
336
- <p className="text-sm text-red-400 whitespace-pre-wrap break-words text-center">
341
+ <p className={`text-sm whitespace-pre-wrap break-words text-center ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>
337
342
  {errorMsg ?? 'Render failed.'}
338
343
  </p>
339
344
  )}
@@ -355,10 +360,10 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
355
360
  <span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
356
361
  )}
357
362
  {logs.map((line, i) => (
358
- <LogLine key={i} text={line} />
363
+ <LogLine key={i} text={line} mode={mode} />
359
364
  ))}
360
365
  {status === 'error' && errorMsg && (
361
- <span className="text-red-400 mt-1">{errorMsg}</span>
366
+ <span className={`mt-1 ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{errorMsg}</span>
362
367
  )}
363
368
  </div>
364
369
  </div>
@@ -368,7 +373,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
368
373
  {status === 'running' ? (
369
374
  <button
370
375
  onClick={handleCancel}
371
- className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
376
+ className={`text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] transition-colors ${mode === 'light' ? 'hover:bg-red-50 hover:border-red-300 hover:text-red-700' : 'hover:bg-red-900/40 hover:border-red-700 hover:text-red-300'}`}
372
377
  >
373
378
  Cancel
374
379
  </button>
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import type { Project, OverlayElement, GlobalOverlay, EditorAdapter } from '../types'
3
+ import { Loader } from '../ui/Loader'
3
4
 
4
5
  // TODO (v2): live thumbnail rendering for each overlay card
5
6
  // TODO (v2): respect overlay staticFrame when rendering thumbnails — requires API extension
@@ -10,9 +11,13 @@ interface Props {
10
11
  project: Project
11
12
  adapter: EditorAdapter<Project>
12
13
  onPick: (element: OverlayElement) => void
14
+ /** Editor theme mode — light/dark. Only affects the load-error text
15
+ * (red-400 is sub-AA on a light `--editor-surface`). Absent -> dark,
16
+ * matching every existing caller. */
17
+ mode?: 'light' | 'dark'
13
18
  }
14
19
 
15
- export default function OverlayPicker({ open, onClose, project, adapter, onPick }: Props) {
20
+ export default function OverlayPicker({ open, onClose, project, adapter, onPick, mode = 'dark' }: Props) {
16
21
  const [overlays, setOverlays] = useState<GlobalOverlay[]>([])
17
22
  const [loaded, setLoaded] = useState(false)
18
23
  const [loading, setLoading] = useState(false)
@@ -111,10 +116,12 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick
111
116
 
112
117
  <div className="flex-1 overflow-y-auto p-4">
113
118
  {loading && (
114
- <div className="text-center text-[var(--editor-text)]/60 text-sm py-8">Loading overlays…</div>
119
+ <div className="flex justify-center py-8">
120
+ <Loader size="md" label="Loading overlays" />
121
+ </div>
115
122
  )}
116
123
  {error && (
117
- <div className="text-center text-red-400 text-sm py-8">{error}</div>
124
+ <div className={`text-center text-sm py-8 ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{error}</div>
118
125
  )}
119
126
  {!loading && !error && overlays.length === 0 && (
120
127
  <div className="text-center text-[var(--editor-text)]/60 text-sm py-8">No overlays available</div>
@@ -9,14 +9,10 @@ import type {
9
9
  GlobalOverlayProp,
10
10
  EditorAdapter,
11
11
  } from '../types'
12
- import { Button, cn, inspectorInputClass, SwatchInput } from '../ui'
12
+ import { Button, cn, inspectorInputClass, NumberField, stepValue, SwatchInput } from '../ui'
13
+ import { Loader } from '../ui/Loader'
13
14
  import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
14
15
 
15
- function parseNumber(v: string): number | null {
16
- const n = Number(v)
17
- return Number.isFinite(n) ? n : null
18
- }
19
-
20
16
  // Shared small muted label that sits just above an inspector control.
21
17
  const fieldLabelClass = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55'
22
18
 
@@ -50,6 +46,10 @@ interface Props {
50
46
  // full-width (e.g. below the canvas) pass this to drop the default `w-80`
51
47
  // sidebar constraint.
52
48
  className?: string
49
+ /** Editor theme mode — light/dark. Threaded to `TextFormattingToolbar`'s
50
+ * delete-icon hover hue. Absent -> dark, matching every existing
51
+ * caller. */
52
+ mode?: 'light' | 'dark'
53
53
  }
54
54
 
55
55
  // Small eye toggle to hide/show the selected element in the editor preview only
@@ -78,6 +78,26 @@ function HideToggle({
78
78
  )
79
79
  }
80
80
 
81
+ // `label` doubles as NumberField's accessible `name` — there is no separate
82
+ // visible `<label htmlFor>` wired to the box, just the wrapping `<label>`
83
+ // element below (which already associates the two natively), so reusing the
84
+ // same string keeps the accessible name identical to what a screen reader
85
+ // announced before this converted from a bare `<input>`.
86
+ //
87
+ // This box has no distinct preview/commit split today — every valid keystroke
88
+ // already writes straight through the caller's `onChange`, which is a
89
+ // COMMITTED mutator (see CarouselEditor's `handlePanelElementChange`: it goes
90
+ // through `state.resizeElement(...).then(() => state.commit())`, an async
91
+ // round trip). `onPreview` reproduces that keystroke-writes-through behaviour
92
+ // exactly. `onCommit` is deliberately a no-op rather than the same `onChange`:
93
+ // the old bare `<input>` had no `onBlur` handler at all, so blur committed
94
+ // nothing extra, and because the mutator above is async, `value` cannot be
95
+ // trusted to have caught up with the last keystroke by blur time — wiring
96
+ // `onCommit` to `onChange` would risk firing a second, fully redundant
97
+ // resize/commit (and undo entry) for the same edit. What DOES change here is
98
+ // that typing no longer gets stomped mid-edit by a re-render carrying a
99
+ // stale value back down while the async write is in flight (NumberField's own
100
+ // `draft`, see its doc comment, is the fix).
81
101
  function numInput(
82
102
  label: string,
83
103
  value: number,
@@ -87,17 +107,15 @@ function numInput(
87
107
  return (
88
108
  <label className="flex flex-col gap-1">
89
109
  <span className={fieldLabelClass}>{label}</span>
90
- <input
91
- type="number"
110
+ <NumberField
111
+ name={label}
92
112
  value={value}
113
+ onPreview={onChange}
114
+ onCommit={() => {}}
93
115
  min={opts?.min}
94
116
  max={opts?.max}
95
117
  step={opts?.step ?? 1}
96
- onChange={e => {
97
- const parsed = parseNumber(e.target.value)
98
- if (parsed !== null) onChange(parsed)
99
- }}
100
- className={cn(inspectorInputClass, 'text-right')}
118
+ onStep={d => onChange(stepValue(value, d, { step: opts?.step ?? 1, min: opts?.min, max: opts?.max }))}
101
119
  />
102
120
  </label>
103
121
  )
@@ -145,15 +163,17 @@ function PropEditor({
145
163
  return (
146
164
  <label className="flex flex-col gap-1" title={description}>
147
165
  <span className={fieldLabelClass}>{name}</span>
148
- <input
149
- type="number"
166
+ {/* No distinct preview/commit split here either — see `numInput`'s
167
+ comment above for why `onCommit` is a no-op rather than `onChange`
168
+ again: `onChange` already writes through per keystroke via an
169
+ async committed mutator, and blur previously fired nothing. */}
170
+ <NumberField
171
+ name={name}
150
172
  value={Number(value ?? 0)}
151
173
  step={type === 'float' ? 0.1 : 1}
152
- onChange={e => {
153
- const parsed = parseNumber(e.target.value)
154
- if (parsed !== null) onChange(parsed)
155
- }}
156
- className={cn(inspectorInputClass, 'text-right')}
174
+ onPreview={onChange}
175
+ onCommit={() => {}}
176
+ onStep={d => onChange(stepValue(Number(value ?? 0), d, { step: type === 'float' ? 0.1 : 1 }))}
157
177
  />
158
178
  </label>
159
179
  )
@@ -190,6 +210,7 @@ export default function SlidePropertyPanel({
190
210
  hiddenElementIds,
191
211
  onToggleElementVisibility,
192
212
  className,
213
+ mode = 'dark',
193
214
  }: Props) {
194
215
  // Map of jsxPath → GlobalOverlay for overlay prop schemas
195
216
  const [overlaySchemas, setOverlaySchemas] = useState<Map<string, GlobalOverlay>>(new Map())
@@ -337,6 +358,7 @@ export default function SlidePropertyPanel({
337
358
  slideId={slide.id}
338
359
  element={overlayEl}
339
360
  updateOverlayProp={updateOverlayProp}
361
+ mode={mode}
340
362
  />
341
363
  )}
342
364
 
@@ -345,7 +367,7 @@ export default function SlidePropertyPanel({
345
367
  </div>
346
368
 
347
369
  {schemasLoading && (
348
- <div className="text-xs text-[var(--editor-text)]/55">Loading overlay props…</div>
370
+ <Loader size="sm" label="Loading overlay props" />
349
371
  )}
350
372
 
351
373
  {!schemasLoading && overlaySchema && overlaySchema.props.length > 0 && (