@bycrux/editor 0.12.0 → 1.0.2

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 +161 -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
@@ -258,6 +258,62 @@ describe('useProjectSync — transient gestures', () => {
258
258
  await act(async () => { result.current.undo() })
259
259
  expect(result.current.project.name).toBe('Test Project')
260
260
  })
261
+
262
+ it('discardTransient restores the pre-gesture state without saving or touching undo', async () => {
263
+ const adapter = makeFakeAdapter()
264
+ const initial = makeProject()
265
+ const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
266
+ const canUndoBefore = result.current.canUndo
267
+
268
+ act(() => {
269
+ result.current.mutateTransient((p) => ({ ...p, name: 'Drag 1' }))
270
+ result.current.mutateTransient((p) => ({ ...p, name: 'Drag 2' }))
271
+ })
272
+ expect(result.current.project.name).toBe('Drag 2')
273
+
274
+ act(() => { result.current.discardTransient() })
275
+
276
+ expect(result.current.project).toEqual(initial)
277
+ expect(adapter.saveProject).not.toHaveBeenCalled()
278
+ expect(result.current.canUndo).toBe(canUndoBefore)
279
+ })
280
+
281
+ it('discardTransient clears the baseline so a later unrelated commit does not push a stale undo step', async () => {
282
+ const adapter = makeFakeAdapter()
283
+ const initial = makeProject()
284
+ const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
285
+
286
+ // Gesture that gets abandoned.
287
+ act(() => {
288
+ result.current.mutateTransient((p) => ({ ...p, name: 'Abandoned Drag' }))
289
+ })
290
+ act(() => { result.current.discardTransient() })
291
+ expect(result.current.project.name).toBe('Test Project')
292
+ expect(result.current.canUndo).toBe(false)
293
+
294
+ // A later, unrelated commit() — NOT preceded by any new mutateTransient —
295
+ // must not resurrect the discarded gesture's baseline as a phantom undo
296
+ // step. If discardTransient had merely restored state (the naive
297
+ // `mutateTransient(() => baseline)` a caller would otherwise have to do)
298
+ // without clearing `transientBaseline`, this commit() would call
299
+ // pushUndo(baseline) here and canUndo would flip to true despite no new
300
+ // gesture ever having happened.
301
+ await act(async () => { await result.current.commit() })
302
+
303
+ expect(result.current.canUndo).toBe(false)
304
+ })
305
+
306
+ it('discardTransient with no transient in flight is a safe no-op', async () => {
307
+ const adapter = makeFakeAdapter()
308
+ const initial = makeProject()
309
+ const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
310
+
311
+ act(() => { result.current.discardTransient() })
312
+
313
+ expect(result.current.project).toEqual(initial)
314
+ expect(adapter.saveProject).not.toHaveBeenCalled()
315
+ expect(result.current.canUndo).toBe(false)
316
+ })
261
317
  })
262
318
 
263
319
  describe('useProjectSync — stale baseline regression', () => {
@@ -52,6 +52,8 @@ export interface UseProjectSync<P extends Project = Project> {
52
52
  mutateTransient: (fn: (p: P) => P) => void
53
53
  /** One queued save for the accumulated transient state; one undo step. */
54
54
  commit: () => Promise<void>
55
+ /** Abandon an in-progress transient gesture — no save, no undo push. */
56
+ discardTransient: () => void
55
57
  /** Apply server-authored state — no save, no undo push (e.g. caption regen). */
56
58
  applyExternal: (p: P) => void
57
59
  undo: () => void
@@ -230,6 +232,20 @@ export function useProjectSync<P extends Project = Project>(
230
232
  )
231
233
  }, [save, pushUndo])
232
234
 
235
+ // Abandon an in-progress transient gesture: restore projectRef/state to the
236
+ // pre-gesture baseline and clear it. No save, no undo push. A no-op when no
237
+ // transient baseline is set. Exists so callers don't have to reproduce this
238
+ // themselves via `mutateTransient(() => baseline)`, which would restore the
239
+ // state but leave `transientBaseline` set — corrupting the NEXT unrelated
240
+ // commit() into pushing a stale baseline for a gesture that never happened.
241
+ const discardTransient = useCallback((): void => {
242
+ const baseline = transientBaseline.current
243
+ if (baseline === null) return
244
+ transientBaseline.current = null
245
+ projectRef.current = baseline
246
+ setProject(baseline)
247
+ }, [])
248
+
233
249
  // undo()/redo() — snapshot swap. Pops the target stack, pushes current state
234
250
  // to the opposite stack, replaces the entire state, and enqueues a save so the
235
251
  // host persists the swap. Also clears transientBaseline: it replaces state
@@ -297,6 +313,7 @@ export function useProjectSync<P extends Project = Project>(
297
313
  mutate,
298
314
  mutateTransient,
299
315
  commit,
316
+ discardTransient,
300
317
  applyExternal,
301
318
  undo,
302
319
  redo,
package/src/test-setup.ts CHANGED
@@ -1 +1,33 @@
1
1
  import '@testing-library/jest-dom'
2
+
3
+ // SP3 fix B2: jsdom's canPlayType returns '' for everything, which would make
4
+ // the proxy capability gate strip proxySrc in EVERY test and silently change
5
+ // what the src-selection tests assert. Report the proxy codec as playable so
6
+ // tests exercise the supported path by default; the unsupported path is
7
+ // covered explicitly via __setProxySupportForTests in proxySupport.test.ts.
8
+ const realCanPlayType = HTMLMediaElement.prototype.canPlayType
9
+ HTMLMediaElement.prototype.canPlayType = function (type: string) {
10
+ if (type.includes('avc1')) return 'probably'
11
+ return realCanPlayType.call(this, type)
12
+ }
13
+
14
+ // jsdom implements no ResizeObserver, and several editor components measure
15
+ // themselves with one (CaptionSpecimen, SocialSafeZoneOverlay, the preview's
16
+ // scale observer). Without a stub, merely MOUNTING one of them throws
17
+ // `ReferenceError: ResizeObserver is not defined` — a test-infrastructure
18
+ // failure that reads as a behaviour regression. Eight test files had each
19
+ // grown their own local stub; this is that same stub, installed once.
20
+ //
21
+ // A no-op is the right shape: the callback never fires, so every consumer
22
+ // takes its "not measured yet" path, which each one already handles (e.g.
23
+ // CaptionSpecimen clamps to its readable font-size floor). Tests that need
24
+ // real measurements still override this with their own fake.
25
+ //
26
+ // Guarded so a future jsdom that ships a real implementation wins over ours.
27
+ if (typeof globalThis.ResizeObserver === 'undefined') {
28
+ globalThis.ResizeObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ } as unknown as typeof ResizeObserver
33
+ }
@@ -1,37 +1,62 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { ChevronDown } from 'lucide-react'
3
+ import { NumberField, stepValue } from '../ui'
3
4
 
4
5
  export type FontOption = {
5
6
  label: string
6
7
  value: string
7
8
  isGoogleFont: boolean
9
+ /** The `googleFonts` fetch spec for this family (e.g. 'Baloo+2:wght@400;700').
10
+ * A caller that persists `fontFamily` must persist this alongside it — a
11
+ * family whose file is never fetched renders as the fallback face. Absent
12
+ * for the non-Google System option. */
13
+ spec?: string
8
14
  }
9
15
 
16
+ // No spec below requests a weight above 800, even though `highlight-box` and
17
+ // `outline` (captionStyleDefaults.ts) default to 900 — deliberate, not an
18
+ // oversight. Requesting a weight a family doesn't publish makes Google Fonts
19
+ // drop that family from the returned CSS ENTIRELY, so a synthesized
20
+ // (browser-faked) 900 is strictly better than a font that silently fails to
21
+ // load. Do not "fix" this by adding 900 to any spec.
10
22
  export const FONT_OPTIONS: FontOption[] = [
11
23
  { label: 'System', value: 'system-ui, -apple-system, "Helvetica Neue", sans-serif', isGoogleFont: false },
12
- { label: 'Inter', value: '"Inter", system-ui, sans-serif', isGoogleFont: true },
13
- { label: 'Roboto', value: '"Roboto", system-ui, sans-serif', isGoogleFont: true },
14
- { label: 'Open Sans', value: '"Open Sans", system-ui, sans-serif', isGoogleFont: true },
15
- { label: 'Lato', value: '"Lato", system-ui, sans-serif', isGoogleFont: true },
16
- { label: 'Montserrat', value: '"Montserrat", system-ui, sans-serif', isGoogleFont: true },
17
- { label: 'Poppins', value: '"Poppins", system-ui, sans-serif', isGoogleFont: true },
18
- { label: 'Raleway', value: '"Raleway", system-ui, sans-serif', isGoogleFont: true },
19
- { label: 'Nunito', value: '"Nunito", system-ui, sans-serif', isGoogleFont: true },
20
- { label: 'Work Sans', value: '"Work Sans", system-ui, sans-serif', isGoogleFont: true },
21
- { label: 'DM Sans', value: '"DM Sans", system-ui, sans-serif', isGoogleFont: true },
22
- { label: 'Rubik', value: '"Rubik", system-ui, sans-serif', isGoogleFont: true },
23
- { label: 'Oswald', value: '"Oswald", system-ui, sans-serif', isGoogleFont: true },
24
- { label: 'Bebas Neue', value: '"Bebas Neue", system-ui, sans-serif', isGoogleFont: true },
25
- { label: 'Playfair Display', value: '"Playfair Display", Georgia, serif', isGoogleFont: true },
26
- { label: 'Merriweather', value: '"Merriweather", Georgia, serif', isGoogleFont: true },
27
- { label: 'Source Serif 4', value: '"Source Serif 4", Georgia, serif', isGoogleFont: true },
28
- { label: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace', isGoogleFont: true },
24
+ { label: 'Inter', value: '"Inter", system-ui, sans-serif', isGoogleFont: true, spec: 'Inter:wght@400;700' },
25
+ { label: 'Roboto', value: '"Roboto", system-ui, sans-serif', isGoogleFont: true, spec: 'Roboto:wght@400;700' },
26
+ { label: 'Open Sans', value: '"Open Sans", system-ui, sans-serif', isGoogleFont: true, spec: 'Open+Sans:wght@400;700' },
27
+ { label: 'Lato', value: '"Lato", system-ui, sans-serif', isGoogleFont: true, spec: 'Lato:wght@400;700' },
28
+ { label: 'Montserrat', value: '"Montserrat", system-ui, sans-serif', isGoogleFont: true, spec: 'Montserrat:wght@400;700' },
29
+ { label: 'Poppins', value: '"Poppins", system-ui, sans-serif', isGoogleFont: true, spec: 'Poppins:wght@400;700' },
30
+ { label: 'Raleway', value: '"Raleway", system-ui, sans-serif', isGoogleFont: true, spec: 'Raleway:wght@400;700' },
31
+ { label: 'Nunito', value: '"Nunito", system-ui, sans-serif', isGoogleFont: true, spec: 'Nunito:wght@400;700' },
32
+ { label: 'Work Sans', value: '"Work Sans", system-ui, sans-serif', isGoogleFont: true, spec: 'Work+Sans:wght@400;700' },
33
+ { label: 'DM Sans', value: '"DM Sans", system-ui, sans-serif', isGoogleFont: true, spec: 'DM+Sans:wght@400;700' },
34
+ { label: 'Rubik', value: '"Rubik", system-ui, sans-serif', isGoogleFont: true, spec: 'Rubik:wght@400;700' },
35
+ { label: 'Oswald', value: '"Oswald", system-ui, sans-serif', isGoogleFont: true, spec: 'Oswald:wght@400;700' },
36
+ { label: 'Bebas Neue', value: '"Bebas Neue", system-ui, sans-serif', isGoogleFont: true, spec: 'Bebas+Neue:wght@400;700' },
37
+ { label: 'Playfair Display', value: '"Playfair Display", Georgia, serif', isGoogleFont: true, spec: 'Playfair+Display:wght@400;700' },
38
+ { label: 'Merriweather', value: '"Merriweather", Georgia, serif', isGoogleFont: true, spec: 'Merriweather:wght@400;700' },
39
+ { label: 'Source Serif 4', value: '"Source Serif 4", Georgia, serif', isGoogleFont: true, spec: 'Source+Serif+4:wght@400;700' },
40
+ { label: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace', isGoogleFont: true, spec: 'JetBrains+Mono:wght@400;700' },
41
+ // Rounded / display group — approved for the @cruxthedoberman account.
42
+ { label: 'Baloo 2', value: '"Baloo 2", system-ui, sans-serif', isGoogleFont: true, spec: 'Baloo+2:wght@400;500;600;700;800' },
43
+ { label: 'Fredoka', value: '"Fredoka", system-ui, sans-serif', isGoogleFont: true, spec: 'Fredoka:wght@300;400;500;600;700' },
44
+ { label: 'Sniglet', value: '"Sniglet", system-ui, sans-serif', isGoogleFont: true, spec: 'Sniglet:wght@400;800' },
29
45
  ]
30
46
 
47
+ // Fetches exactly the weights each family publishes, via the `spec` carried
48
+ // on every Google-font option — the same spec a caller persists alongside
49
+ // fontFamily. Falls back to the old label-derived guess only if `spec` is
50
+ // somehow missing, so a stale/partial FontOption still resolves to a URL.
51
+ // The list is now a slightly bigger stylesheet than the old flat
52
+ // wght@400;700-everywhere version (Baloo 2/Fredoka/Sniglet pull in
53
+ // 300/500/600/800), but that's the correct trade: the picker preview and
54
+ // the persisted spec fetch identical weights, so what you see in the
55
+ // dropdown is what actually renders.
31
56
  const GOOGLE_FONTS_URL = (() => {
32
57
  const params = FONT_OPTIONS
33
58
  .filter((f) => f.isGoogleFont)
34
- .map((f) => `family=${f.label.replace(/ /g, '+')}:wght@400;700`)
59
+ .map((f) => `family=${f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`}`)
35
60
  .join('&')
36
61
  return `https://fonts.googleapis.com/css2?${params}&display=swap`
37
62
  })()
@@ -160,59 +185,68 @@ type FontSizePickerProps = {
160
185
 
161
186
  // Extract the numeric portion of a stored fontSize. Tolerates "25", "25px",
162
187
  // " 25px ", "25 px", or a bare number. Returns '' when no numeric prefix is
163
- // present so the <input type="number"> can render empty instead of NaN-empty.
188
+ // present so the NumberField renders empty instead of NaN-empty — `''` is
189
+ // exactly how that component spells "no explicit value" (see its `value` doc).
164
190
  function parseFontSizeNumeric(value: string): string {
165
191
  const m = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
166
192
  return m ? m[1] : ''
167
193
  }
168
194
 
169
195
  export function FontSizePicker({ value, onChange, disabled, min = 8, max = 9999, className }: FontSizePickerProps) {
170
- const [local, setLocal] = useState(parseFontSizeNumeric(value))
171
- const isFocused = useRef(false)
172
- const onChangeRef = useRef(onChange)
173
- useEffect(() => {
174
- onChangeRef.current = onChange
175
- }, [onChange])
176
-
177
- useEffect(() => {
178
- if (!isFocused.current) setLocal(parseFontSizeNumeric(value))
179
- }, [value])
196
+ // NumberField carries its own "what's on screen while typing" draft (see its
197
+ // doc comment) — the bespoke `local` state and `isFocused` ref this used to
198
+ // hand-roll did exactly that job, so converting drops them rather than
199
+ // keeping a second copy of the same fix alongside NumberField's.
200
+ const numericValue = parseFontSizeNumeric(value)
180
201
 
181
202
  // Commit immediately on every keystroke. Debouncing here is the wrong call
182
203
  // for typography — the rendered text is the live preview, so delaying the
183
- // PUT delays user feedback.
204
+ // write delays user feedback. `onPreview` points at this function for that
205
+ // reason: there is no separate "final" edit here, every valid keystroke
206
+ // already writes through — and that write is itself a COMMITTED, async
207
+ // mutator (`onChange` flows to `TextFormattingToolbar`'s
208
+ // `void updateOverlayProp(...)`, a real round trip; `value` only reflects
209
+ // it once the promise resolves). `onCommit` is therefore a no-op below,
210
+ // NOT this same function: blur has nothing left to do once typing already
211
+ // persisted the edit, and re-firing `commit` there would race the async
212
+ // write — `outgoing !== value` could still read true for a value
213
+ // `onPreview` already sent, because `value` has not caught up yet, and
214
+ // that would fire a second, fully redundant `updateOverlayProp` (and
215
+ // undo entry) for the same edit. `SlidePropertyPanel`'s `numInput` faces
216
+ // the identical hazard against its own async mutator — see its comment.
184
217
  //
185
218
  // CSS font-size requires a unit; a unitless value is invalid and the
186
219
  // overlay template falls back to its default. The picker is number-only
187
220
  // for the operator, so we attach `px` here before writing — never asking
188
- // the user to type the unit themselves.
189
- function commit(next: string): void {
190
- setLocal(next)
191
- if (next === '') return
221
+ // the user to type the unit themselves. The `outgoing !== value` guard
222
+ // skips a write that would only echo back what is already stored.
223
+ //
224
+ function commit(next: number): void {
192
225
  const outgoing = `${next}px`
193
- if (outgoing !== value) onChangeRef.current(outgoing)
226
+ if (outgoing !== value) onChange(outgoing)
227
+ }
228
+
229
+ /** A stepper nudge. Falls back to `min` when the box is empty — the value is
230
+ * a free-text CSS length that may carry no numeric prefix at all, and
231
+ * nudging from `NaN` would write `NaNpx`. */
232
+ function step(direction: 1 | -1): void {
233
+ const from = numericValue === '' ? min : Number(numericValue)
234
+ commit(stepValue(from, direction, { step: 1, min, max }))
194
235
  }
195
236
 
196
237
  return (
197
- <input
198
- type="number"
238
+ <NumberField
239
+ name="Font size"
240
+ value={numericValue}
241
+ onPreview={commit}
242
+ onCommit={() => {}}
243
+ onStep={step}
244
+ disabled={disabled}
199
245
  min={min}
200
246
  max={max}
201
- disabled={disabled}
202
- value={local}
203
247
  placeholder="size"
204
- onChange={(e) => commit(e.target.value)}
205
- onFocus={() => {
206
- isFocused.current = true
207
- }}
208
- onBlur={() => {
209
- isFocused.current = false
210
- }}
211
- className={
212
- className ??
213
- 'h-8 w-14 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 text-sm text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
214
- }
215
- aria-label="Font size"
248
+ unit="px"
249
+ className={className ?? 'w-14'}
216
250
  />
217
251
  )
218
252
  }
@@ -60,6 +60,10 @@ export type TextFormattingToolbarProps = {
60
60
  element: OverlayElement
61
61
  updateOverlayProp: (slideId: string, elementId: string, key: string, value: string) => Promise<unknown>
62
62
  onDelete?: () => void
63
+ /** Editor theme mode — light/dark. Only affects the delete button's hover
64
+ * hue (red-400 is sub-AA on a light `--editor-surface`). Absent -> dark,
65
+ * matching every existing caller. */
66
+ mode?: 'light' | 'dark'
63
67
  }
64
68
 
65
69
  export function TextFormattingToolbar({
@@ -67,6 +71,7 @@ export function TextFormattingToolbar({
67
71
  element,
68
72
  updateOverlayProp,
69
73
  onDelete,
74
+ mode = 'dark',
70
75
  }: TextFormattingToolbarProps) {
71
76
  const props = element.overlay.props
72
77
 
@@ -238,7 +243,7 @@ export function TextFormattingToolbar({
238
243
  type="button"
239
244
  aria-label="Delete text overlay"
240
245
  onClick={onDelete}
241
- className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/60 hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400`}
246
+ className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/60 ${mode === 'light' ? 'hover:border-red-300 hover:bg-red-50 hover:text-red-700' : 'hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400'}`}
242
247
  >
243
248
  <Trash2 className="h-3.5 w-3.5" />
244
249
  </button>
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { FONT_OPTIONS, findFontOption } from '../FontPicker'
3
+
4
+ describe('FONT_OPTIONS', () => {
5
+ it('gives every Google font a non-empty spec, and no non-Google font a spec', () => {
6
+ for (const opt of FONT_OPTIONS) {
7
+ if (opt.isGoogleFont) {
8
+ expect(opt.spec, `${opt.label} should have a spec`).toBeTruthy()
9
+ } else {
10
+ expect(opt.spec, `${opt.label} should not have a spec`).toBeUndefined()
11
+ }
12
+ }
13
+ })
14
+
15
+ it('includes the rounded/display group: Baloo 2, Fredoka, Sniglet', () => {
16
+ const labels = FONT_OPTIONS.map((f) => f.label)
17
+ expect(labels).toContain('Baloo 2')
18
+ expect(labels).toContain('Fredoka')
19
+ expect(labels).toContain('Sniglet')
20
+ })
21
+
22
+ it('resolves Baloo 2 via findFontOption by its CSS value', () => {
23
+ const resolved = findFontOption('"Baloo 2", system-ui, sans-serif')
24
+ expect(resolved?.label).toBe('Baloo 2')
25
+ })
26
+
27
+ it('does not request weight 700 for Sniglet, which only publishes 400 and 800', () => {
28
+ const sniglet = FONT_OPTIONS.find((f) => f.label === 'Sniglet')
29
+ expect(sniglet?.spec).toBeTruthy()
30
+ const weights = sniglet!.spec!.split('wght@')[1]?.split(';') ?? []
31
+ expect(weights).not.toContain('700')
32
+ expect(weights).toContain('400')
33
+ expect(weights).toContain('800')
34
+ })
35
+
36
+ it('has no literal spaces in any spec (URL-safe as a css2 family param)', () => {
37
+ for (const opt of FONT_OPTIONS) {
38
+ if (opt.spec) {
39
+ expect(opt.spec.includes(' '), `${opt.label} spec "${opt.spec}" contains a space`).toBe(false)
40
+ }
41
+ }
42
+ })
43
+ })
@@ -347,7 +347,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
347
347
  expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
348
348
  expect(screen.getByRole('button', { name: /text case/i })).toBeInTheDocument();
349
349
  expect(screen.getByLabelText(/text color/i)).toBeInTheDocument();
350
- expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
350
+ expect(screen.getByLabelText('Font size')).toBeInTheDocument();
351
351
  expect(screen.getByLabelText(/font family/i)).toBeInTheDocument();
352
352
  expect(screen.getByRole('radio', { name: /align left/i })).toBeInTheDocument();
353
353
  });
@@ -365,7 +365,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
365
365
  expect(screen.queryByRole('button', { name: /italic/i })).not.toBeInTheDocument();
366
366
  expect(screen.queryByRole('button', { name: /text case/i })).not.toBeInTheDocument();
367
367
  expect(screen.queryByLabelText(/text color/i)).not.toBeInTheDocument();
368
- expect(screen.queryByLabelText(/font size/i)).toBeInTheDocument();
368
+ expect(screen.queryByLabelText('Font size')).toBeInTheDocument();
369
369
  expect(screen.queryByLabelText(/font family/i)).not.toBeInTheDocument();
370
370
  expect(screen.queryByRole('radio', { name: /align left/i })).not.toBeInTheDocument();
371
371
  });
@@ -380,7 +380,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
380
380
  />,
381
381
  );
382
382
  expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
383
- expect(screen.queryByLabelText(/font size/i)).not.toBeInTheDocument();
383
+ expect(screen.queryByLabelText('Font size')).not.toBeInTheDocument();
384
384
  expect(screen.getByText(/property panel/i)).toBeInTheDocument();
385
385
  });
386
386
 
@@ -396,7 +396,7 @@ describe('TextFormattingToolbar — adaptive rendering by element prop schema',
396
396
  updateOverlayProp={vi.fn(() => Promise.resolve())}
397
397
  />,
398
398
  );
399
- expect(screen.queryByLabelText(/font size/i)).not.toBeInTheDocument();
399
+ expect(screen.queryByLabelText('Font size')).not.toBeInTheDocument();
400
400
  expect(screen.queryByRole('button', { name: /bold/i })).not.toBeInTheDocument();
401
401
  expect(screen.getByText(/property panel/i)).toBeInTheDocument();
402
402
  });
package/src/theme.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * ── CSS variable naming convention ───────────────────────────────────────────
7
7
  * Every token is written as a custom property prefixed `--editor-`:
8
8
  * colors → --editor-bg, --editor-surface, --editor-accent,
9
- * --editor-accent-foreground, --editor-text, --editor-border,
10
- * --editor-selection
9
+ * --editor-accent-foreground, --editor-accent-text, --editor-text,
10
+ * --editor-border, --editor-selection
11
11
  * fonts → --editor-font-sans, --editor-font-serif, --editor-font-display
12
12
  * radii → --editor-radius-{sm|md|lg}
13
13
  * spacing → --editor-space-{n} (n = scale step)
@@ -69,14 +69,113 @@ export const defaultMontajTheme: EditorTheme = {
69
69
  },
70
70
  }
71
71
 
72
+ /**
73
+ * Montaj's light editor theme — the counterpart to `defaultMontajTheme` for
74
+ * hosts that toggle to light mode. Values are chosen to mirror the dark
75
+ * palette's relationships rather than its literal colors:
76
+ * background gray-100 (#f3f4f6) — the editor shell
77
+ * surface white (#ffffff) — raised panels/inputs/buttons, brighter
78
+ * than the shell (the dark theme's surface is
79
+ * lighter than its bg too, just inverted in sense)
80
+ * border gray-200 (#e5e7eb) — the hairline
81
+ * text gray-900 (#111827) — primary text
82
+ * accent indigo-500 (#6366f1) — unchanged from the dark theme; Montaj's
83
+ * interactive accent reads fine on either ground
84
+ * accentForeground white (#ffffff) — unchanged; readable on the indigo accent
85
+ * selection indigo-500 (#6366f1) — deliberately darker than the dark
86
+ * theme's indigo-400 (#818cf8) so the selection
87
+ * outline has enough contrast against a light ground
88
+ * font Inter (identical to the dark theme)
89
+ *
90
+ * fonts/radii/spacing are identical to `defaultMontajTheme` — only the color
91
+ * ramp changes between modes.
92
+ */
93
+ export const lightMontajTheme: EditorTheme = {
94
+ colors: {
95
+ background: '#f3f4f6',
96
+ surface: '#ffffff',
97
+ accent: '#6366f1',
98
+ accentForeground: '#ffffff',
99
+ text: '#111827',
100
+ border: '#e5e7eb',
101
+ selection: '#6366f1',
102
+ },
103
+ fonts: {
104
+ sans: "'Inter', system-ui, -apple-system, sans-serif",
105
+ },
106
+ radii: {
107
+ sm: '0.25rem',
108
+ md: '0.5rem',
109
+ lg: '0.75rem',
110
+ },
111
+ spacing: {
112
+ 1: '0.25rem',
113
+ 2: '0.5rem',
114
+ 3: '0.75rem',
115
+ 4: '1rem',
116
+ 6: '1.5rem',
117
+ 8: '2rem',
118
+ },
119
+ }
120
+
121
+ /**
122
+ * Relative-luminance test on `theme.colors.background`, used to classify a
123
+ * theme as light or dark. Exists because the canvas timeline and
124
+ * `VideoEditor` render onto an HTML canvas — they can't rely on CSS to pick
125
+ * their palette, so they need a cheap, direct way to ask "is this theme
126
+ * light?" without the `EditorTheme` type growing timeline-specific color
127
+ * tokens (background/grid/playhead/etc.) that only those two consumers care
128
+ * about. Parses 3- and 6-digit `#rgb`/`#rrggbb` hex; an unparseable or
129
+ * unrecognized color string falls back to `false` (dark), since dark has
130
+ * always been the default here and must never silently regress.
131
+ */
132
+ export function isLightTheme(theme: EditorTheme): boolean {
133
+ const hex = theme.colors.background.trim()
134
+ const match = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex)
135
+ if (!match) return false
136
+
137
+ const digits = match[1]
138
+ const expand = (c: string): number => parseInt(c + c, 16)
139
+
140
+ let r: number
141
+ let g: number
142
+ let b: number
143
+ if (digits.length === 3) {
144
+ r = expand(digits[0])
145
+ g = expand(digits[1])
146
+ b = expand(digits[2])
147
+ } else {
148
+ r = parseInt(digits.slice(0, 2), 16)
149
+ g = parseInt(digits.slice(2, 4), 16)
150
+ b = parseInt(digits.slice(4, 6), 16)
151
+ }
152
+
153
+ // Standard relative-luminance weighting (ITU-R BT.601-ish perceptual
154
+ // weights); thresholded at the midpoint of the 0-255 range.
155
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b
156
+ return luminance > 127.5
157
+ }
158
+
72
159
  /**
73
160
  * Write `theme`'s tokens onto `el` as CSS custom properties following the
74
161
  * convention documented at the top of this file. Idempotent — calling it again
75
162
  * with a different theme overwrites the previously-set vars.
76
163
  */
77
164
  export function applyTheme(el: HTMLElement, theme: EditorTheme): void {
78
- const { style } = el
165
+ // Write the vars onto the editor container AND :root. The container is the
166
+ // primary target (scopes the theme to the editor); mirroring to
167
+ // document.documentElement lets components that portal OUTSIDE the container —
168
+ // RenderModal renders to document.body so a transformed/filtered host ancestor
169
+ // can't break its `fixed` overlay — still resolve `var(--editor-*)`. Without
170
+ // this, those var()-based backgrounds fall back to transparent. Harmless: the
171
+ // vars are all `--editor-*` prefixed and unused outside editor components.
172
+ writeThemeVars(el.style, theme)
173
+ if (typeof document !== 'undefined') {
174
+ writeThemeVars(document.documentElement.style, theme)
175
+ }
176
+ }
79
177
 
178
+ function writeThemeVars(style: CSSStyleDeclaration, theme: EditorTheme): void {
80
179
  // Colors
81
180
  style.setProperty('--editor-bg', theme.colors.background)
82
181
  style.setProperty('--editor-surface', theme.colors.surface)
@@ -84,6 +183,12 @@ export function applyTheme(el: HTMLElement, theme: EditorTheme): void {
84
183
  // Accent-foreground falls back to `text` so it's never empty (e.g. a host
85
184
  // theme that omits it still gets a readable foreground for accent controls).
86
185
  style.setProperty('--editor-accent-foreground', theme.colors.accentForeground ?? theme.colors.text)
186
+ // Accent as small TEXT. In LIGHT mode the indigo-500 accent falls just under
187
+ // the 4.5:1 AA floor as ~10px text on the light ground (~4.06:1), so accent
188
+ // text nudges to indigo-600 (#4f46e5) there. Dark mode (and any host theme)
189
+ // keeps the accent unchanged. Fills, borders, rings and buttons keep
190
+ // `--editor-accent`; ONLY small accent text should read this token.
191
+ style.setProperty('--editor-accent-text', isLightTheme(theme) ? '#4f46e5' : theme.colors.accent)
87
192
  style.setProperty('--editor-text', theme.colors.text)
88
193
  style.setProperty('--editor-border', theme.colors.border)
89
194
  style.setProperty('--editor-selection', theme.colors.selection)