@bycrux/editor 0.11.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,298 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { NumberField, stepValue } from '../NumberField'
4
+
5
+ /** Renders with sensible defaults, returning the spies and `rerender` so a
6
+ * test can simulate the host re-rendering with a CHANGED value — the playback
7
+ * tick this component's `draft` exists to survive. */
8
+ function setup(props: Partial<React.ComponentProps<typeof NumberField>> = {}) {
9
+ const onPreview = vi.fn()
10
+ const onCommit = vi.fn()
11
+ const onStep = vi.fn()
12
+ const all = { name: 'Offset X', value: 10, onPreview, onCommit, onStep, step: 1, ...props }
13
+ const { rerender } = render(<NumberField {...all} />)
14
+ return {
15
+ onPreview,
16
+ onCommit,
17
+ onStep,
18
+ input: () => screen.getByLabelText(all.name) as HTMLInputElement,
19
+ rerender: (next: Partial<React.ComponentProps<typeof NumberField>>) =>
20
+ rerender(<NumberField {...all} {...next} />),
21
+ }
22
+ }
23
+
24
+ describe('NumberField — typing gesture', () => {
25
+ it('shows the controlled value under its accessible name', () => {
26
+ const { input } = setup()
27
+ expect(input()).toHaveValue(10)
28
+ })
29
+
30
+ it('previews the parsed number on every keystroke, without committing', () => {
31
+ const { input, onPreview, onCommit } = setup()
32
+
33
+ fireEvent.change(input(), { target: { value: '4' } })
34
+ fireEvent.change(input(), { target: { value: '42' } })
35
+
36
+ expect(onPreview.mock.calls).toEqual([[4], [42]])
37
+ expect(onCommit).not.toHaveBeenCalled()
38
+ })
39
+
40
+ it('commits ONCE on blur, carrying the last previewed value', () => {
41
+ const { input, onCommit } = setup()
42
+
43
+ fireEvent.change(input(), { target: { value: '4' } })
44
+ fireEvent.change(input(), { target: { value: '42' } })
45
+ fireEvent.blur(input())
46
+
47
+ expect(onCommit).toHaveBeenCalledTimes(1)
48
+ expect(onCommit).toHaveBeenCalledWith(42)
49
+ })
50
+
51
+ it('commits on Enter, through that same blur path', () => {
52
+ const { input, onCommit } = setup()
53
+
54
+ input().focus()
55
+ fireEvent.change(input(), { target: { value: '42' } })
56
+ fireEvent.keyDown(input(), { key: 'Enter' })
57
+
58
+ expect(onCommit).toHaveBeenCalledTimes(1)
59
+ expect(document.activeElement).not.toBe(input())
60
+ })
61
+
62
+ it('does not double-fire when Enter is followed by a blur', () => {
63
+ const { input, onCommit } = setup()
64
+
65
+ input().focus()
66
+ fireEvent.change(input(), { target: { value: '42' } })
67
+ fireEvent.keyDown(input(), { key: 'Enter' })
68
+ fireEvent.blur(input())
69
+
70
+ expect(onCommit).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('commits NOTHING when the field was focused but never edited', () => {
74
+ // A pointless save and an undo entry for a no-op.
75
+ const { input, onPreview, onCommit } = setup()
76
+
77
+ fireEvent.focus(input())
78
+ fireEvent.blur(input())
79
+
80
+ expect(onPreview).not.toHaveBeenCalled()
81
+ expect(onCommit).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('commits NOTHING when the only keystrokes were unparseable', () => {
85
+ const { input, onCommit } = setup()
86
+
87
+ fireEvent.change(input(), { target: { value: '' } })
88
+ fireEvent.blur(input())
89
+
90
+ expect(onCommit).not.toHaveBeenCalled()
91
+ })
92
+
93
+ it('re-arms for the NEXT gesture after committing', () => {
94
+ const { input, onCommit } = setup()
95
+
96
+ fireEvent.change(input(), { target: { value: '42' } })
97
+ fireEvent.blur(input())
98
+
99
+ fireEvent.change(input(), { target: { value: '43' } })
100
+ fireEvent.blur(input())
101
+
102
+ expect(onCommit).toHaveBeenCalledTimes(2)
103
+ expect(onCommit).toHaveBeenLastCalledWith(43)
104
+ })
105
+
106
+ it('emits nothing for the mid-typing states an operator passes through', () => {
107
+ const { input, onPreview } = setup()
108
+
109
+ fireEvent.change(input(), { target: { value: '' } }) // cleared the box
110
+ fireEvent.change(input(), { target: { value: '-' } }) // typing "-5"
111
+
112
+ expect(onPreview).not.toHaveBeenCalled()
113
+ })
114
+
115
+ it('holds the emptied box open rather than snapping back to the live value', () => {
116
+ // Nothing reaches the caller (previous test), but a box that refilled
117
+ // itself the instant it was cleared would be unusable — the draft has to
118
+ // stay authoritative through an unparseable state too, not just a valid
119
+ // one, and through a value change arriving during it.
120
+ const { input, rerender } = setup()
121
+
122
+ fireEvent.change(input(), { target: { value: '' } })
123
+ expect(input().value).toBe('')
124
+
125
+ rerender({ value: 77 })
126
+ expect(input().value).toBe('')
127
+ })
128
+ })
129
+
130
+ describe('NumberField — a mid-typed value survives a value change (the reason `draft` exists)', () => {
131
+ it('does not clobber the typed text when the host re-renders with a new value', () => {
132
+ const { input, rerender } = setup()
133
+
134
+ fireEvent.change(input(), { target: { value: '4' } }) // mid-typing "4" of "42"
135
+ expect(input()).toHaveValue(4)
136
+
137
+ // A playback tick arrives while the field is still mid-edit. Before
138
+ // `draft`, this forced the controlled input back to the incoming prop and
139
+ // ate the keystroke.
140
+ rerender({ value: 77 })
141
+ expect(input()).toHaveValue(4)
142
+
143
+ fireEvent.change(input(), { target: { value: '42' } })
144
+ expect(input()).toHaveValue(42)
145
+ })
146
+
147
+ it('goes back to tracking the live value once the gesture commits', () => {
148
+ const { input, rerender } = setup()
149
+
150
+ fireEvent.change(input(), { target: { value: '4' } })
151
+ rerender({ value: 77 })
152
+ fireEvent.blur(input())
153
+
154
+ // Proves blur RELEASED the draft rather than freezing the typed text.
155
+ expect(input()).toHaveValue(77)
156
+ })
157
+
158
+ it('tracks the live value while untouched — an animating prop still animates', () => {
159
+ const { input, rerender } = setup()
160
+
161
+ rerender({ value: 55 })
162
+
163
+ expect(input()).toHaveValue(55)
164
+ })
165
+ })
166
+
167
+ describe('NumberField — steppers', () => {
168
+ it('nudges in each direction as a discrete edit, never a preview/commit pair', () => {
169
+ const { onStep, onPreview, onCommit } = setup({ name: 'Opacity' })
170
+
171
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Opacity' }))
172
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease Opacity' }))
173
+
174
+ expect(onStep.mock.calls).toEqual([[1], [-1]])
175
+ expect(onPreview).not.toHaveBeenCalled()
176
+ expect(onCommit).not.toHaveBeenCalled()
177
+ })
178
+
179
+ it('renders no steppers at all when the caller has no discrete nudge for them', () => {
180
+ setup({ onStep: undefined })
181
+
182
+ expect(screen.queryByRole('button')).toBeNull()
183
+ })
184
+ })
185
+
186
+ describe('NumberField — decoration', () => {
187
+ it('renders the prefix and unit, hidden from assistive tech', () => {
188
+ setup({ prefix: 'X', unit: '%' })
189
+
190
+ // The accessible name still reads once, from the box's own aria-label.
191
+ expect(screen.getByLabelText('Offset X')).toBeInTheDocument()
192
+ expect(screen.getByText('X')).toHaveAttribute('aria-hidden', 'true')
193
+ expect(screen.getByText('%')).toHaveAttribute('aria-hidden', 'true')
194
+ })
195
+
196
+ it('shows an empty box for a value the caller has no explicit setting for', () => {
197
+ // `''` is not zero — it means "nothing stored", and the placeholder is
198
+ // what makes the field honest about what is actually in force.
199
+ const { input } = setup({ value: '', placeholder: '1.3' })
200
+
201
+ expect(input().value).toBe('')
202
+ expect(input()).toHaveAttribute('placeholder', '1.3')
203
+ })
204
+
205
+ it('renders an EMPTY placeholder as a real attribute, not an absent one', () => {
206
+ // A caller whose active default has no value to report still needs the
207
+ // attribute there — dropping it would fall back to some other placeholder.
208
+ const { input } = setup({ value: '', placeholder: '' })
209
+
210
+ expect(input()).toHaveAttribute('placeholder', '')
211
+ })
212
+
213
+ it('shows a stored value rather than the placeholder when it has one', () => {
214
+ const { input } = setup({ value: '0.08', placeholder: '-0.02' })
215
+
216
+ expect(input().value).toBe('0.08')
217
+ expect(input()).toHaveAttribute('placeholder', '-0.02')
218
+ })
219
+
220
+ it('snaps an emptied box back to the live value on blur', () => {
221
+ const { input } = setup({ value: 10 })
222
+
223
+ fireEvent.change(input(), { target: { value: '' } })
224
+ expect(input().value).toBe('')
225
+
226
+ fireEvent.blur(input())
227
+
228
+ expect(input()).toHaveValue(10)
229
+ })
230
+
231
+ it('passes step/min/max to the box for native validation', () => {
232
+ const { input } = setup({ step: 0.01, min: 0, max: 1, value: 0.5 })
233
+
234
+ expect(input()).toHaveAttribute('step', '0.01')
235
+ expect(input()).toHaveAttribute('min', '0')
236
+ expect(input()).toHaveAttribute('max', '1')
237
+ })
238
+
239
+ it('does NOT clamp a typed value — a bound only limits the stepper', () => {
240
+ // Both assertions deliberately use values OUTSIDE the bounds. A value on
241
+ // the boundary would prove nothing: `Math.max(min, 5)` is also 5, so a
242
+ // clamping implementation would pass such a test unchanged.
243
+ const { input, onPreview } = setup({ min: 5, max: 400, value: 100 })
244
+
245
+ // Past the ceiling: `500` typed on the way to a bigger number must arrive
246
+ // whole, not rewritten to 400 between keystrokes.
247
+ fireEvent.change(input(), { target: { value: '500' } })
248
+ expect(onPreview).toHaveBeenLastCalledWith(500)
249
+
250
+ // And under the floor: `1` typed on the way to `12` must not become 5.
251
+ fireEvent.change(input(), { target: { value: '1' } })
252
+ expect(onPreview).toHaveBeenLastCalledWith(1)
253
+ })
254
+ })
255
+
256
+ describe('NumberField — disabled', () => {
257
+ it('disables the box itself', () => {
258
+ const { input } = setup({ disabled: true })
259
+ expect(input()).toBeDisabled()
260
+ })
261
+
262
+ it('disables the steppers too — a live arrow beside a dead box would be a bug', () => {
263
+ setup({ name: 'Opacity', disabled: true })
264
+
265
+ expect(screen.getByRole('button', { name: 'Increase Opacity' })).toBeDisabled()
266
+ expect(screen.getByRole('button', { name: 'Decrease Opacity' })).toBeDisabled()
267
+ })
268
+
269
+ it('a disabled stepper click does not fire onStep', () => {
270
+ const { onStep } = setup({ name: 'Opacity', disabled: true })
271
+
272
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Opacity' }))
273
+
274
+ expect(onStep).not.toHaveBeenCalled()
275
+ })
276
+ })
277
+
278
+ describe('stepValue', () => {
279
+ it('increments and decrements by one step', () => {
280
+ expect(stepValue(10, 1, { step: 1 })).toBe(11)
281
+ expect(stepValue(10, -1, { step: 1 })).toBe(9)
282
+ })
283
+
284
+ it('rounds away the float noise the nudge itself introduces', () => {
285
+ // 1.2 + 0.01 is 1.2100000000000002 in IEEE 754.
286
+ expect(stepValue(1.2, 1, { step: 0.01 })).toBe(1.21)
287
+ })
288
+
289
+ it('clamps at min and max', () => {
290
+ expect(stepValue(0.01, -1, { step: 0.01, min: 0.01 })).toBe(0.01)
291
+ expect(stepValue(1, 1, { step: 0.01, min: 0, max: 1 })).toBe(1)
292
+ })
293
+
294
+ it('leaves an unbounded value unbounded', () => {
295
+ expect(stepValue(-5, -1, { step: 1 })).toBe(-6)
296
+ expect(stepValue(999, 1, { step: 1 })).toBe(1000)
297
+ })
298
+ })
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { Slider } from '../Slider'
4
+
5
+ function setup(props: Partial<React.ComponentProps<typeof Slider>> = {}) {
6
+ const onChange = vi.fn()
7
+ const onCommit = vi.fn()
8
+ const all = {
9
+ 'aria-label': 'Scale slider' as const,
10
+ value: 1,
11
+ min: 0,
12
+ max: 4,
13
+ step: 0.01,
14
+ onChange,
15
+ onCommit,
16
+ ...props,
17
+ }
18
+ render(<Slider {...all} />)
19
+ return {
20
+ onChange,
21
+ onCommit,
22
+ slider: () => screen.getByRole('slider', { name: all['aria-label'] }) as HTMLInputElement,
23
+ }
24
+ }
25
+
26
+ describe('Slider — value + semantics', () => {
27
+ it('exposes the range natively, under its accessible name', () => {
28
+ const { slider } = setup()
29
+
30
+ expect(slider().value).toBe('1')
31
+ expect(slider()).toHaveAttribute('type', 'range')
32
+ expect(slider()).toHaveAttribute('min', '0')
33
+ expect(slider()).toHaveAttribute('max', '4')
34
+ expect(slider()).toHaveAttribute('step', '0.01')
35
+ })
36
+
37
+ it('previews the parsed number on every change', () => {
38
+ const { slider, onChange } = setup()
39
+
40
+ fireEvent.change(slider(), { target: { value: '1.5' } })
41
+ fireEvent.change(slider(), { target: { value: '1.6' } })
42
+
43
+ expect(onChange.mock.calls).toEqual([[1.5], [1.6]])
44
+ })
45
+ })
46
+
47
+ describe('Slider — one commit per gesture', () => {
48
+ it('commits ONCE on pointer up, not per change, and not again on the trailing blur', () => {
49
+ const { slider, onCommit } = setup()
50
+
51
+ fireEvent.change(slider(), { target: { value: '1.5' } })
52
+ fireEvent.change(slider(), { target: { value: '1.6' } })
53
+ expect(onCommit).not.toHaveBeenCalled()
54
+
55
+ fireEvent.pointerUp(slider())
56
+ expect(onCommit).toHaveBeenCalledTimes(1)
57
+
58
+ // A pointerup is normally followed by a blur — that must not stack a
59
+ // second undo entry onto the same drag.
60
+ fireEvent.blur(slider())
61
+ expect(onCommit).toHaveBeenCalledTimes(1)
62
+ })
63
+
64
+ it('reports the last previewed value to the commit', () => {
65
+ const { slider, onCommit } = setup()
66
+
67
+ fireEvent.change(slider(), { target: { value: '1.5' } })
68
+ fireEvent.change(slider(), { target: { value: '2.25' } })
69
+ fireEvent.pointerUp(slider())
70
+
71
+ expect(onCommit).toHaveBeenCalledWith(2.25)
72
+ })
73
+
74
+ it('commits a keyboard adjustment on key up', () => {
75
+ const { slider, onCommit } = setup()
76
+
77
+ fireEvent.change(slider(), { target: { value: '1.3' } })
78
+ fireEvent.keyUp(slider(), { key: 'ArrowRight' })
79
+
80
+ expect(onCommit).toHaveBeenCalledTimes(1)
81
+ })
82
+
83
+ it('commits NOTHING for a gesture that never changed anything', () => {
84
+ // A click landing on the thumb's current position, or a focus/blur that
85
+ // touched nothing, must not spend an undo entry on a no-op.
86
+ const { slider, onCommit } = setup()
87
+
88
+ fireEvent.pointerUp(slider())
89
+ fireEvent.keyUp(slider(), { key: 'ArrowRight' })
90
+ fireEvent.blur(slider())
91
+
92
+ expect(onCommit).not.toHaveBeenCalled()
93
+ })
94
+
95
+ it('re-arms for the NEXT gesture after committing', () => {
96
+ const { slider, onCommit } = setup()
97
+
98
+ fireEvent.change(slider(), { target: { value: '1.5' } })
99
+ fireEvent.pointerUp(slider())
100
+
101
+ fireEvent.change(slider(), { target: { value: '2' } })
102
+ fireEvent.pointerUp(slider())
103
+
104
+ expect(onCommit).toHaveBeenCalledTimes(2)
105
+ })
106
+
107
+ it('is usable with no onCommit at all — a slider whose changes are already final', () => {
108
+ const { slider, onChange } = setup({ onCommit: undefined })
109
+
110
+ fireEvent.change(slider(), { target: { value: '2' } })
111
+ expect(() => fireEvent.pointerUp(slider())).not.toThrow()
112
+ expect(onChange).toHaveBeenCalledWith(2)
113
+ })
114
+ })
115
+
116
+ describe('Slider — keyboard', () => {
117
+ it('is focusable, so the native range keyboard bindings apply', () => {
118
+ // The look is replaced, the control is not: it stays a native
119
+ // `<input type="range">` precisely so arrows/Home/End keep working.
120
+ const { slider } = setup()
121
+
122
+ slider().focus()
123
+
124
+ expect(document.activeElement).toBe(slider())
125
+ expect(slider()).not.toHaveAttribute('tabindex', '-1')
126
+ })
127
+
128
+ it('honours `disabled`', () => {
129
+ const { slider } = setup({ disabled: true })
130
+
131
+ expect(slider()).toBeDisabled()
132
+ })
133
+ })
134
+
135
+ describe('Slider — theming', () => {
136
+ it('draws track and thumb from theme vars, never a literal color', () => {
137
+ // The control has to read correctly on both the light and dark editor
138
+ // grounds; a hardcoded fill would only work on one of them. This is
139
+ // deliberately the ONLY assertion here — checking for the presence of
140
+ // specific `bg-[var(--editor-*)]` classes would just be `SLIDER_CLASS`
141
+ // copied into the test: it could only fail on a rename (which teaches
142
+ // nothing about rendering) and would false-fail the first legitimate
143
+ // token rename. The real rule this control must never break is "no
144
+ // literal color", which is what stays under test.
145
+ const { slider } = setup()
146
+ const cls = slider().className
147
+
148
+ expect(cls).not.toMatch(/#[0-9a-f]{3,8}\b/i)
149
+ })
150
+ })
@@ -0,0 +1,105 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The styled hover pill that replaced native `title=` on the editor's tool
4
+ * buttons. What matters is that it appears on hover with no delay, leaves on
5
+ * every exit path, and renders outside the trigger's clipping ancestors.
6
+ */
7
+ import { describe, it, expect, vi, afterEach } from 'vitest'
8
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
9
+ import { Tooltip } from '../Tooltip'
10
+
11
+ afterEach(() => cleanup())
12
+
13
+ function renderTooltip(props: Partial<React.ComponentProps<typeof Tooltip>> = {}) {
14
+ return render(
15
+ <Tooltip label="Undo" {...props}>
16
+ <button aria-label="Undo">u</button>
17
+ </Tooltip>,
18
+ )
19
+ }
20
+
21
+ describe('Tooltip', () => {
22
+ it('is absent until hover, then shows the label immediately', () => {
23
+ renderTooltip()
24
+ expect(screen.queryByRole('tooltip')).toBeNull()
25
+
26
+ fireEvent.mouseEnter(screen.getByLabelText('Undo').parentElement!)
27
+ // No timers advanced: the pill is up on the first event, unlike `title=`.
28
+ expect(screen.getByRole('tooltip').textContent).toContain('Undo')
29
+ })
30
+
31
+ it('renders shortcut chips as <kbd> after the label', () => {
32
+ renderTooltip({ keys: ['⌘', 'Z'] })
33
+ fireEvent.mouseEnter(screen.getByLabelText('Undo').parentElement!)
34
+
35
+ const chips = screen.getAllByText(/^[⌘Z]$/)
36
+ expect(chips.map((c) => c.tagName)).toEqual(['KBD', 'KBD'])
37
+ })
38
+
39
+ it('hides on mouse leave', () => {
40
+ renderTooltip()
41
+ const wrapper = screen.getByLabelText('Undo').parentElement!
42
+ fireEvent.mouseEnter(wrapper)
43
+ expect(screen.getByRole('tooltip')).toBeTruthy()
44
+
45
+ fireEvent.mouseLeave(wrapper)
46
+ expect(screen.queryByRole('tooltip')).toBeNull()
47
+ })
48
+
49
+ it('hides on press, so a button that opens a modal leaves no orphan pill', () => {
50
+ renderTooltip()
51
+ const wrapper = screen.getByLabelText('Undo').parentElement!
52
+ fireEvent.mouseEnter(wrapper)
53
+ fireEvent.mouseDown(wrapper)
54
+ expect(screen.queryByRole('tooltip')).toBeNull()
55
+ })
56
+
57
+ it('shows on keyboard focus and hides on blur', () => {
58
+ renderTooltip()
59
+ const button = screen.getByLabelText('Undo')
60
+ fireEvent.focus(button)
61
+ expect(screen.getByRole('tooltip')).toBeTruthy()
62
+
63
+ fireEvent.blur(button)
64
+ expect(screen.queryByRole('tooltip')).toBeNull()
65
+ })
66
+
67
+ it('portals the pill to document.body, escaping the trigger\'s overflow ancestors', () => {
68
+ const { container } = render(
69
+ <div style={{ overflow: 'hidden' }}>
70
+ <Tooltip label="Fit to view">
71
+ <button aria-label="Fit to view">fit</button>
72
+ </Tooltip>
73
+ </div>,
74
+ )
75
+ fireEvent.mouseEnter(screen.getByLabelText('Fit to view').parentElement!)
76
+
77
+ const pill = screen.getByRole('tooltip')
78
+ expect(container.contains(pill)).toBe(false)
79
+ expect(document.body.contains(pill)).toBe(true)
80
+ // Fixed positioning is what makes the escape work — an absolute pill would
81
+ // still resolve against a positioned ancestor.
82
+ expect(pill.className).toContain('fixed')
83
+ })
84
+
85
+ it('is inert to the pointer, so it can never eat the click it describes', () => {
86
+ const onClick = vi.fn()
87
+ render(
88
+ <Tooltip label="Split at playhead">
89
+ <button aria-label="Split" onClick={onClick}>s</button>
90
+ </Tooltip>,
91
+ )
92
+ const button = screen.getByLabelText('Split')
93
+ fireEvent.mouseEnter(button.parentElement!)
94
+ expect(screen.getByRole('tooltip').className).toContain('pointer-events-none')
95
+
96
+ fireEvent.click(button)
97
+ expect(onClick).toHaveBeenCalledTimes(1)
98
+ })
99
+
100
+ it('puts layout classes on the wrapper, which is the flex item', () => {
101
+ renderTooltip({ className: 'mr-auto' })
102
+ const wrapper = screen.getByLabelText('Undo').parentElement!
103
+ expect(wrapper.className).toBe('inline-flex mr-auto')
104
+ })
105
+ })
@@ -0,0 +1,112 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The layout preferences that survive a reload (timeline pane height, caption
4
+ * row visibility). What matters is that a bad or hostile stored value can never
5
+ * take the editor down — a preference is not worth a crash the user can only
6
+ * escape by clearing site data.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
+ import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'
10
+ import { reviveBoolean, reviveNumberInRange, usePersistentState } from '../usePersistentState'
11
+
12
+ const KEY = 'test.pref'
13
+
14
+ function Probe({ revive = reviveNumberInRange(10, 100), initial = 50 } = {}) {
15
+ const [value, set] = usePersistentState(KEY, initial, revive)
16
+ return (
17
+ <div>
18
+ <span data-testid="value">{String(value)}</span>
19
+ <button onClick={() => set(77 as never)}>persist</button>
20
+ <button onClick={() => set(88 as never, { persist: false })}>transient</button>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ beforeEach(() => window.localStorage.clear())
26
+ afterEach(() => cleanup())
27
+
28
+ describe('usePersistentState', () => {
29
+ it('starts at the initial value when nothing is stored', () => {
30
+ render(<Probe />)
31
+ expect(screen.getByTestId('value').textContent).toBe('50')
32
+ })
33
+
34
+ it('restores a previously stored value', () => {
35
+ window.localStorage.setItem(KEY, '42')
36
+ render(<Probe />)
37
+ expect(screen.getByTestId('value').textContent).toBe('42')
38
+ })
39
+
40
+ it('writes through on a normal set', () => {
41
+ render(<Probe />)
42
+ fireEvent.click(screen.getByText('persist'))
43
+ expect(screen.getByTestId('value').textContent).toBe('77')
44
+ expect(window.localStorage.getItem(KEY)).toBe('77')
45
+ })
46
+
47
+ it('applies a transient set without writing it — the drag-frame case', () => {
48
+ // Every mousemove of a divider drag calls this; persisting each one would
49
+ // mean ~60 localStorage writes a second for a value only the last of which
50
+ // matters.
51
+ render(<Probe />)
52
+ fireEvent.click(screen.getByText('transient'))
53
+ expect(screen.getByTestId('value').textContent).toBe('88')
54
+ expect(window.localStorage.getItem(KEY)).toBeNull()
55
+ })
56
+
57
+ it('rejects a stored value outside the allowed range', () => {
58
+ // A height saved on a much taller window must not open the editor with the
59
+ // preview pushed off-screen.
60
+ window.localStorage.setItem(KEY, '99999')
61
+ render(<Probe />)
62
+ expect(screen.getByTestId('value').textContent).toBe('50')
63
+ })
64
+
65
+ it('falls back to the initial value on unparseable JSON', () => {
66
+ window.localStorage.setItem(KEY, 'not json{')
67
+ render(<Probe />)
68
+ expect(screen.getByTestId('value').textContent).toBe('50')
69
+ })
70
+
71
+ it('falls back on a value of the wrong type', () => {
72
+ window.localStorage.setItem(KEY, '"tall"')
73
+ render(<Probe />)
74
+ expect(screen.getByTestId('value').textContent).toBe('50')
75
+ })
76
+
77
+ it('survives localStorage throwing on read — Safari private mode', () => {
78
+ const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('denied') })
79
+ render(<Probe />)
80
+ expect(screen.getByTestId('value').textContent).toBe('50')
81
+ spy.mockRestore()
82
+ })
83
+
84
+ it('survives localStorage throwing on write, keeping the value in memory', () => {
85
+ const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota') })
86
+ render(<Probe />)
87
+ act(() => { fireEvent.click(screen.getByText('persist')) })
88
+ expect(screen.getByTestId('value').textContent).toBe('77')
89
+ spy.mockRestore()
90
+ })
91
+ })
92
+
93
+ describe('revivers', () => {
94
+ it('reviveNumberInRange accepts the bounds and rejects everything unusable', () => {
95
+ const revive = reviveNumberInRange(10, 100)
96
+ expect(revive(10)).toBe(10)
97
+ expect(revive(100)).toBe(100)
98
+ expect(revive(9)).toBeNull()
99
+ expect(revive(101)).toBeNull()
100
+ expect(revive(Number.NaN)).toBeNull()
101
+ expect(revive(Number.POSITIVE_INFINITY)).toBeNull()
102
+ expect(revive('50')).toBeNull()
103
+ expect(revive(null)).toBeNull()
104
+ })
105
+
106
+ it('reviveBoolean takes only real booleans', () => {
107
+ expect(reviveBoolean(true)).toBe(true)
108
+ expect(reviveBoolean(false)).toBe(false)
109
+ expect(reviveBoolean('true')).toBeNull()
110
+ expect(reviveBoolean(1)).toBeNull()
111
+ })
112
+ })
package/src/ui/badge.tsx CHANGED
@@ -6,10 +6,18 @@ const badgeVariants = cva(
6
6
  {
7
7
  variants: {
8
8
  variant: {
9
- pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300',
10
- draft: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300',
11
- final: 'bg-emerald-100 text-emerald-700 dark:bg-green-900/50 dark:text-green-300',
12
- default: 'bg-gray-100 text-gray-600 dark:bg-[var(--editor-surface)] dark:text-[var(--editor-text)]',
9
+ // Status hues are deliberately a single fixed appearance rather than
10
+ // a `dark:`-split one: `dark:` here would follow the HOST page's
11
+ // `<html class="dark">`, not the `theme` prop passed to this editor
12
+ // instance — a host can pass `lightMontajTheme` while its own chrome
13
+ // stays dark (or vice versa), so the two can disagree. A soft pastel
14
+ // chip is legible on any editor ground (verified ~4.6:1+ on a light
15
+ // surface, the new case this sweep adds; it simply pops as a bright
16
+ // chip on a dark one, which reads fine for a status indicator).
17
+ pending: 'bg-amber-100 text-amber-700',
18
+ draft: 'bg-blue-100 text-blue-700',
19
+ final: 'bg-emerald-100 text-emerald-700',
20
+ default: 'bg-[var(--editor-surface)] text-[var(--editor-text)]',
13
21
  },
14
22
  },
15
23
  defaultVariants: { variant: 'default' },