@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
@@ -0,0 +1,1106 @@
1
+ // Sidebar caption editor (SP5-captions Phase 5). Replaces the bottom
2
+ // TranscriptPanel + its "Expand" TranscriptModal: instead of a two-line
3
+ // vicinity strip at the bottom of the timeline pane, every caption segment
4
+ // lives in a searchable, numbered list, under three sub-tabs — "Format" (the
5
+ // track-level size/color/font controls, the default), "Styles" (the live
6
+ // style gallery, CaptionStyleGallery.tsx) and "Captions" (the list itself) —
7
+ // so the list stays the prominent thing in a ~300px-wide column. Mounted by
8
+ // BOTH VideoEditor layouts: the CapCut left panel's Captions tab and the
9
+ // classic right rail.
10
+ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
11
+ import { AlignCenter, AlignLeft, AlignRight, Loader2, RefreshCw, Search, Trash2 } from 'lucide-react'
12
+ import type { Project, OverlayFactory } from '../types'
13
+ import type { Captions } from '../schema'
14
+ import type { PlaybackClock } from './playback-clock'
15
+ import type { CaptionEditPatch } from './timeline/makeCaptionEdit'
16
+ import { EditableSegment } from './timeline/EditableSegment'
17
+ import { formatTime } from './timeline/utils'
18
+ import { NumberField, Slider, stepValue, SwatchInput } from '../ui'
19
+ import { usePersistentState } from '../ui/usePersistentState'
20
+ import { groupCaptionLanes, laneOf } from './captionLanes'
21
+ import { FontFamilyPicker, findFontOption } from '../text/FontPicker'
22
+ import CaptionStyleGallery from './CaptionStyleGallery'
23
+ import TabNav from './panels/TabNav'
24
+ import { CAPTION_STYLE_LETTER_SPACING, CAPTION_STYLE_LINE_HEIGHT, CAPTION_STYLE_TEXT_TRANSFORM } from './captionStyleDefaults'
25
+
26
+ /** Which of the panel's three sub-tabs is showing: the fine formatting
27
+ * controls, the style gallery, or the caption transcript. 'format' is the
28
+ * default — you land on the size/colors/font/case controls, the thing an
29
+ * operator reaches for most once a style is chosen, with the gallery of live
30
+ * style previews one tab over and the full transcript one further. Replaces
31
+ * the retired collapsible "Caption style" subsection; a DIFFERENT key than
32
+ * that collapse's 'montaj.editor.captionListStyleExpanded' (a boolean) so a
33
+ * stale expanded/collapsed value can never be misread as a tab name. */
34
+ const CAPTION_TAB_STORAGE_KEY = 'montaj.editor.captionPanelTab'
35
+ type CaptionPanelTab = 'styles' | 'format' | 'captions'
36
+ /** Anything unrecognised reads as "no stored preference" (→ the default), with
37
+ * ONE explicit migration: before the tab split a single 'style' tab held both
38
+ * the preset chips and the fine controls. A browser carrying that value lands
39
+ * on 'format' — the new default a returning user should see — rather than
40
+ * being silently reset, which is what a plain reject-to-default would do. */
41
+ export const reviveCaptionTab = (raw: unknown): CaptionPanelTab | null => {
42
+ if (raw === 'styles' || raw === 'format' || raw === 'captions') return raw
43
+ if (raw === 'style') return 'format' // migrate the pre-split value
44
+ return null
45
+ }
46
+ const CAPTION_TABS: readonly { value: CaptionPanelTab; label: string }[] = [
47
+ { value: 'format', label: 'Format' },
48
+ { value: 'styles', label: 'Styles' },
49
+ { value: 'captions', label: 'Captions' },
50
+ ]
51
+
52
+ // ── Ported from TranscriptPanel.tsx (that file is being deleted) ──
53
+ // Each caption style reads a different accent-color prop in its render template
54
+ // (see render/templates/captions/*.jsx). The color control writes whichever field
55
+ // the active style uses; `clean` and `word-by-word` have no accent (omitted here).
56
+ type CaptionStyle = NonNullable<Project['captions']>['style']
57
+ type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
58
+ const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
59
+ karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
60
+ pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
61
+ 'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
62
+ outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
63
+ subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
64
+ }
65
+
66
+ // The native <input type="color"> only accepts #rrggbb. Stored values are always
67
+ // hex once picked; fall back to the style default for unset / non-hex (e.g. an
68
+ // rgba() template default) so the swatch never gets an invalid value.
69
+ const HEX = /^#[0-9a-f]{6}$/i
70
+ const toHex = (v: unknown, fallback: string): string =>
71
+ typeof v === 'string' && HEX.test(v) ? v : fallback
72
+
73
+ // ── Text styling: case + alignment button groups ──
74
+ // Glyph labels only (the rail is ~300px), so each carries an aria-label — "TT"
75
+ // is not a word a screen reader can announce usefully.
76
+ const CASES = [
77
+ { value: 'uppercase', glyph: 'TT', label: 'Uppercase' },
78
+ { value: 'lowercase', glyph: 'tt', label: 'Lowercase' },
79
+ { value: 'capitalize', glyph: 'Tt', label: 'Capitalize' },
80
+ ] as const
81
+
82
+ const ALIGNMENTS = [
83
+ { value: 'left', Icon: AlignLeft, label: 'Align left' },
84
+ { value: 'center', Icon: AlignCenter, label: 'Align center' },
85
+ { value: 'right', Icon: AlignRight, label: 'Align right' },
86
+ ] as const
87
+
88
+ /** The panel's one chip look, worn by every toggle on the Format tab (Bold,
89
+ * case, alignment). The inactive foreground is
90
+ * `text-[var(--editor-text)] opacity-60` rather than
91
+ * `text-[var(--editor-text)]/60` — Tailwind cannot generate an opacity
92
+ * modifier on an arbitrary var() color, so that class is a silent no-op (see
93
+ * the longer note on the row index span at the bottom of this file). The
94
+ * row-filter chips on the Captions tab hand-roll the same look with the older
95
+ * `/N` classes; that predates the finding above and is left alone. */
96
+ const chipClass = (active: boolean): string =>
97
+ `text-[10px] rounded px-2 py-0.5 transition-all border ${
98
+ active
99
+ ? 'bg-[var(--editor-accent)]/20 border-[var(--editor-accent)]/60 text-[var(--editor-accent)]'
100
+ : 'bg-[var(--editor-surface)] border-[var(--editor-border)] text-[var(--editor-text)] opacity-60 hover:opacity-100'
101
+ }`
102
+
103
+ /** One labeled row on the Format tab — a label on the left, its control filling
104
+ * the rest. Worn by every row there so the whole tab reads as a two-column
105
+ * grid (label · control) rather than a stack of loose widgets. */
106
+ const rowClass = 'flex items-center gap-1.5'
107
+ /** The label cell of a `rowClass` row. The width is FIXED rather than intrinsic
108
+ * — that is the entire point: it is what makes the labels a column, so every
109
+ * control starts at the same x instead of "Font" and "Alignment" indenting
110
+ * their controls differently. `w-16` (64px) clears the longest label
111
+ * ("Alignment") at this size and still leaves the ~300px rail enough room for
112
+ * the widest control (the font picker + Bold).
113
+ *
114
+ * `opacity-60`, NOT `text-[var(--editor-text)]/60` — an opacity modifier on an
115
+ * arbitrary var() color is a silent no-op; see the note on `chipClass` above.
116
+ *
117
+ * These labels are PRESENTATIONAL: plain sibling spans, never a
118
+ * `<label htmlFor>`. A bound <label> REPLACES the control's accessible name,
119
+ * which would silently rewrite "Caption font size" to "Font size" — losing the
120
+ * disambiguation a screen reader needs in a rail that also carries overlay and
121
+ * clip controls. Every control here keeps its own `aria-label` unchanged, and
122
+ * a span that would then repeat those words as loose text beside it carries
123
+ * `aria-hidden` so they aren't announced twice. */
124
+ const rowLabelClass = 'w-16 shrink-0 text-xs text-[var(--editor-text)] opacity-60'
125
+
126
+ /** Display value for the letter-spacing box: the numeric part of a stored CSS
127
+ * length, read as em. Tolerates absent, '0.02em', '-0.02em', '.02em',
128
+ * ' 0.02 em ' and a bare number. A value stored in some OTHER unit ('2px')
129
+ * deliberately reads as EMPTY rather than as "2" — showing it would silently
130
+ * re-interpret it as 2em the moment the operator nudges the field. */
131
+ function parseEmValue(value: string | undefined): string {
132
+ if (!value) return ''
133
+ const m = /^\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*(?:em)?\s*$/i.exec(value)
134
+ return m ? m[1] : ''
135
+ }
136
+
137
+ /** Display value for the line-height box. `Captions.lineHeight` is
138
+ * `number | string`; a string is accepted only when it is unitless, which is
139
+ * the form CSS reads as a multiple of the font size and the only form this
140
+ * field writes. Anything else ('120%', '1.3em') reads as empty, same
141
+ * reasoning as above. */
142
+ function parseUnitlessValue(value: number | string | undefined): string {
143
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''
144
+ if (!value) return ''
145
+ const m = /^\s*(\d+(?:\.\d+)?|\.\d+)\s*$/.exec(value)
146
+ return m ? m[1] : ''
147
+ }
148
+
149
+ /** Strip float-step noise (0.1 + 0.01 = 0.11000000000000001) before the value
150
+ * is written into the project — and, for letter spacing, before it is
151
+ * stringified into a CSS length that ends up in the saved JSON. */
152
+ const roundTo = (n: number, places: number): number => Number(n.toFixed(places))
153
+
154
+ /** Clamps a typed/stepped value to a field's own range. `NumberField` (see its
155
+ * doc comment) deliberately does NOT bound a typed value against its `min`/
156
+ * `max` — those are a native spinner hint only — so every numeric field in
157
+ * this panel that wants a hard ceiling/floor (font size, letter spacing, line
158
+ * height) applies it here, on the write path, exactly where the retired
159
+ * `StepperField.numeric()` used to. */
160
+ const clampToRange = (n: number, min: number, max: number): number => Math.min(max, Math.max(min, n))
161
+
162
+ /** Font-size floor/ceiling for the typeable box. The `type="range"` slider
163
+ * this replaces was physically stuck at 28-120; a typed value now isn't, on
164
+ * purpose (Sam's ask is "type 52, arrow it up/down" with no artificial
165
+ * slider ceiling) — but 0/negative would break every caption template's
166
+ * layout math, and an unbounded ceiling risks a canvas-filling render that
167
+ * is effectively broken output. `8` sits well BELOW the old 28 floor (so the
168
+ * box is strictly more permissive than the slider, never less) while keeping
169
+ * text nominally visible; `400` is generous enough for a full-frame "giant
170
+ * word" caption style on a 1080-wide vertical export without inviting a
171
+ * pathological value. Both are judgement calls, not derived from a template
172
+ * constraint — revisit if a real project needs more. */
173
+ const CAPTION_FONT_SIZE_MIN = 8
174
+ const CAPTION_FONT_SIZE_MAX = 400
175
+
176
+ /** A double-click-to-edit request from the canvas timeline (Phase 6). `nonce`
177
+ * changes on every request even if `id` repeats, so re-double-clicking the
178
+ * same segment re-triggers the scroll + focus instead of being a no-op. */
179
+ export interface CaptionEditFocusRequest {
180
+ id: string
181
+ nonce: number
182
+ }
183
+
184
+ /** Builds the NEXT edit-focus request from whatever VideoEditor's `editFocusId`
185
+ * state currently holds. Pure and exported (rather than inlined at the one
186
+ * call site, VideoEditor.tsx's `handleEditCaption`) so the increment can be
187
+ * unit-tested directly instead of only through source-text matching, which
188
+ * pins formatting rather than behaviour and would miss a wrong-operator bug.
189
+ * The nonce climbs off the PREVIOUS request regardless of id — not a
190
+ * per-id counter — so double-clicking the SAME caption twice always produces
191
+ * two distinct requests (see the doc comment on the type above). */
192
+ export function nextEditFocus(prev: CaptionEditFocusRequest | null, id: string): CaptionEditFocusRequest {
193
+ return { id, nonce: (prev?.nonce ?? 0) + 1 }
194
+ }
195
+
196
+ export interface CaptionListPanelProps {
197
+ captionTrack: Project['captions'] | undefined
198
+ project: Project
199
+ /** Playhead time — drives the active-segment highlight. Supplied by a
200
+ * clock-subscribing wrapper at the mount site (see VideoEditor.tsx's
201
+ * `CaptionListPanelWithClock`, mirroring Timeline.tsx's
202
+ * `TranscriptPanelWithClock`) so only this leaf re-renders per tick. */
203
+ currentTime: number
204
+ /** Unified selection (D1: caption ids share `selectedIds` with clips and
205
+ * audio). Used only to derive which segment (if any) is selected. */
206
+ selectedIds: string[]
207
+ onSelectCaption: (id: string | null) => void
208
+ /** Commit a single-segment patch — text edits and the per-segment color
209
+ * swatch. */
210
+ onCaptionSegmentChange?: (segmentId: string, patch: CaptionEditPatch) => void
211
+ /** Whole-project commit — style/fontsize/track-color changes and "Remove
212
+ * all". */
213
+ onCaptionEdit?: (project: Project) => void
214
+ /** Live preview only, no save, no undo entry — fontsize drag and
215
+ * track/segment color picks fire this on every intermediate value. */
216
+ onProjectChange?: (project: Project) => void
217
+ /** Delete one segment by id, via the whole-track channel. There is no add
218
+ * counterpart (R4): captions come from transcription, and Regenerate
219
+ * rebuilds the whole track. */
220
+ onCaptionSegmentDelete?: (segmentId: string) => void
221
+ /** Opens the caption-regeneration modal. Provided only when the host
222
+ * adapter supports `generateCaptions`; absent → the button is hidden. */
223
+ onRegenerateCaptions?: () => void
224
+ /** Whether a generate/regenerate job is currently streaming (the
225
+ * `CaptionRegenModal` is open and running). Disables the trigger so it
226
+ * can't be fired twice from this panel; the modal itself owns the
227
+ * progress UI, so this panel does nothing more than go inert. Optional
228
+ * and defaults to falsy — a host that never passes it (or that stops
229
+ * passing `true` once a job ends, including a failed one) leaves the
230
+ * trigger enabled exactly as it is today. */
231
+ captionsGenerating?: boolean
232
+ fps: number
233
+ /** Imperative seek target for a row click (see the half-frame comment
234
+ * below) — separate from `currentTime`, which only drives the highlight. */
235
+ clock: PlaybackClock
236
+ /** Phase 6 wires the canvas double-click into this; scrolls the target row
237
+ * into view and focuses its `EditableSegment` when `nonce` changes. */
238
+ editFocusId?: CaptionEditFocusRequest | null
239
+ /** Compile a JSX overlay template into an `OverlayFactory` (sourced from
240
+ * `adapter.compileOverlay`, same as `CaptionPreview`). Optional — absent
241
+ * ⇒ the style gallery falls back to a static specimen card instead of a
242
+ * live-rendered one, so hosts without a compiler (e.g. Hub/LP) degrade
243
+ * gracefully rather than breaking. */
244
+ compileOverlay?: (src: string) => Promise<OverlayFactory>
245
+ /** Resolve a caption style name to the template identifier `compileOverlay`
246
+ * expects (sourced from `adapter.resolveCaptionTemplate`). Optional, and
247
+ * only meaningful alongside `compileOverlay` — without either, the gallery
248
+ * falls back to the static specimen card. */
249
+ resolveCaptionTemplate?: (style: string) => string
250
+ /** Editor theme mode — light/dark. Only affects the delete/"remove all"
251
+ * destructive hues (red-400 is sub-AA on a light `--editor-surface`).
252
+ * Absent -> dark, matching every existing caller. */
253
+ mode?: 'light' | 'dark'
254
+ }
255
+
256
+ export default function CaptionListPanel({
257
+ captionTrack,
258
+ project,
259
+ currentTime,
260
+ selectedIds,
261
+ onSelectCaption,
262
+ onCaptionSegmentChange,
263
+ onCaptionEdit,
264
+ onProjectChange,
265
+ onCaptionSegmentDelete,
266
+ onRegenerateCaptions,
267
+ captionsGenerating,
268
+ fps,
269
+ clock,
270
+ editFocusId,
271
+ compileOverlay,
272
+ resolveCaptionTemplate,
273
+ mode = 'dark',
274
+ }: CaptionListPanelProps) {
275
+ const segs = captionTrack?.segments ?? []
276
+
277
+ // Nothing to show and nothing to offer — no captions yet, and the host
278
+ // doesn't support generating them from here either.
279
+ if (segs.length === 0 && !captionTrack && !onRegenerateCaptions) return null
280
+
281
+ return (
282
+ <CaptionListPanelBody
283
+ captionTrack={captionTrack}
284
+ project={project}
285
+ currentTime={currentTime}
286
+ selectedIds={selectedIds}
287
+ onSelectCaption={onSelectCaption}
288
+ onCaptionSegmentChange={onCaptionSegmentChange}
289
+ onCaptionEdit={onCaptionEdit}
290
+ onProjectChange={onProjectChange}
291
+ onCaptionSegmentDelete={onCaptionSegmentDelete}
292
+ onRegenerateCaptions={onRegenerateCaptions}
293
+ captionsGenerating={captionsGenerating}
294
+ fps={fps}
295
+ clock={clock}
296
+ editFocusId={editFocusId}
297
+ compileOverlay={compileOverlay}
298
+ resolveCaptionTemplate={resolveCaptionTemplate}
299
+ mode={mode}
300
+ />
301
+ )
302
+ }
303
+
304
+ // Split from the default export so the early `return null` above stays a
305
+ // clean guard clause — the hooks below can't run conditionally, and this
306
+ // component only mounts once that guard has already passed.
307
+ function CaptionListPanelBody({
308
+ captionTrack,
309
+ project,
310
+ currentTime,
311
+ selectedIds,
312
+ onSelectCaption,
313
+ onCaptionSegmentChange,
314
+ onCaptionEdit,
315
+ onProjectChange,
316
+ onCaptionSegmentDelete,
317
+ onRegenerateCaptions,
318
+ captionsGenerating,
319
+ fps,
320
+ clock,
321
+ editFocusId,
322
+ compileOverlay,
323
+ resolveCaptionTemplate,
324
+ mode = 'dark',
325
+ }: CaptionListPanelProps) {
326
+ const segs = captionTrack?.segments ?? []
327
+ const [search, setSearch] = useState('')
328
+ // `null` = the "All" chip (no row filter). Set to a lane index by clicking
329
+ // a "Row N" chip. Lives beside `search` rather than folded into it — they
330
+ // compose (both apply) instead of one being a mode that disables the other.
331
+ const [rowFilter, setRowFilter] = useState<number | null>(null)
332
+ const [tab, setTab] = usePersistentState<CaptionPanelTab>(CAPTION_TAB_STORAGE_KEY, 'format', reviveCaptionTab)
333
+
334
+ // ── Remove all (confirm-twice, ported from TranscriptPanel) ──
335
+ const [confirmRemove, setConfirmRemove] = useState(false)
336
+ const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
337
+ useEffect(() => {
338
+ return () => {
339
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
340
+ }
341
+ }, [])
342
+
343
+ // Live value while dragging the font-size slider or typing into its box —
344
+ // previewed on every drag-frame/keystroke, committed (persisted) on
345
+ // release/blur/Enter so mid-gesture updates don't each trigger a server PUT.
346
+ // Both the slider and the box read and write THIS state, so they stay in
347
+ // lockstep and the preview tracks the edit as it happens.
348
+ const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
349
+ useEffect(() => {
350
+ setFontsize(captionTrack?.fontsize ?? 46)
351
+ }, [captionTrack?.fontsize])
352
+
353
+ // The selected segment, if any — derived from the unified `selectedIds`
354
+ // the same way VideoEditor derives its own `selectedCaptionId` mirror: a
355
+ // marquee can put more than one caption id in there, but only the preview's
356
+ // single selection box (and this swatch) needs to pick one, so take the
357
+ // first that resolves to a real segment.
358
+ const selectedSeg = useMemo(() => {
359
+ if (!captionTrack) return undefined
360
+ const idSet = new Set(selectedIds)
361
+ return captionTrack.segments.find(s => s.id && idSet.has(s.id))
362
+ }, [captionTrack, selectedIds])
363
+
364
+ // One group per lane (0..maxLane, holes included) via the same module the
365
+ // canvas timeline reads lanes through — see captionLanes.ts. `multiLane`
366
+ // gates ONLY the header/chip UI below; the grouping, search and index math
367
+ // run unconditionally so a single-lane project (every project has exactly
368
+ // one lane until Phase 3/4's drag gesture is used) takes the exact same
369
+ // code path as a multi-lane one, rather than a separate `length === 1`
370
+ // branch — with one group, its segments ARE `segs` in original order, so
371
+ // the per-group index below already equals the pre-Phase-5 global index.
372
+ const captionGroups = useMemo(() => (segs.length > 0 ? groupCaptionLanes(segs) : []), [segs])
373
+ const multiLane = captionGroups.length > 1
374
+
375
+ // Chip filter and search compose: a group is dropped by the chip (lane
376
+ // mismatch) before its rows are tested against the search query, and a row
377
+ // survives only if it passes both. Per-group index restarts at 0 within
378
+ // each group (group.segments is that lane's slice, in original order), so
379
+ // in the single-lane case it's identical to the old global `segs` index.
380
+ // A group with zero surviving rows is dropped entirely rather than kept
381
+ // with an empty body — this way search never leaves a dangling "Row N"
382
+ // header with nothing under it, and neither does a would-be hole lane.
383
+ const visibleGroups = useMemo(() => {
384
+ const q = search.trim().toLowerCase()
385
+ return captionGroups
386
+ .filter(g => rowFilter === null || g.lane === rowFilter)
387
+ .map(g => ({
388
+ lane: g.lane,
389
+ rows: g.segments
390
+ .map((seg, index) => ({ seg, index }))
391
+ .filter(({ seg }) => !q || seg.text.toLowerCase().includes(q)),
392
+ }))
393
+ .filter(g => g.rows.length > 0)
394
+ }, [captionGroups, rowFilter, search])
395
+ const totalVisible = useMemo(() => visibleGroups.reduce((sum, g) => sum + g.rows.length, 0), [visibleGroups])
396
+
397
+ // ── editFocusId: scroll + focus the target row (Phase 6's canvas
398
+ // double-click wires into this). `lastHandledNonce` guards against
399
+ // re-focusing on every unrelated re-render once a given request has been
400
+ // served — only a NEW nonce (even for the same segment id) re-triggers it. ──
401
+ const rowRefs = useRef(new Map<string, HTMLDivElement>())
402
+ const lastHandledNonceRef = useRef<number | null>(null)
403
+ useEffect(() => {
404
+ if (!editFocusId || editFocusId.nonce === lastHandledNonceRef.current) return
405
+ const seg = segs.find(s => s.id === editFocusId.id)
406
+ if (!seg) return
407
+ // The transcript rows only render on the "Captions" tab; a canvas
408
+ // double-click that arrives while the "Styles" or "Format" tab is showing
409
+ // has to flip there first. Same one-clear-per-pass shape as the
410
+ // search/rowFilter guards below — this effect re-runs (tab is a dep) once
411
+ // the Captions tab has actually mounted the target row, and only then does
412
+ // the lookup below find it. Without this, a double-click on the canvas
413
+ // would silently do nothing whenever the panel was on another tab.
414
+ if (tab !== 'captions') {
415
+ setTab('captions')
416
+ return
417
+ }
418
+ // A live search filtering the target row out of the DOM would make the
419
+ // row-ref lookup below miss. Clear it and let this effect re-run once the
420
+ // list re-renders with the row present.
421
+ const q = search.trim().toLowerCase()
422
+ if (q && !seg.text.toLowerCase().includes(q)) {
423
+ setSearch('')
424
+ return
425
+ }
426
+ // Same guard, extended to the row chip: a canvas double-click can target
427
+ // a caption in a lane the active "Row N" chip has filtered out (e.g. a
428
+ // double-click on a row-2 caption while "Row 1" is selected), which would
429
+ // otherwise leave the row-ref lookup below silently missing forever. Each
430
+ // `setState` here only clears ONE filter and returns; if both are
431
+ // blocking the target, this effect just re-runs (via the `search` /
432
+ // `rowFilter` deps below) and clears the other on the next pass.
433
+ if (rowFilter !== null && laneOf(seg) !== rowFilter) {
434
+ setRowFilter(null)
435
+ return
436
+ }
437
+ const el = rowRefs.current.get(editFocusId.id)
438
+ if (!el) return
439
+ lastHandledNonceRef.current = editFocusId.nonce
440
+ el.scrollIntoView({ block: 'nearest' })
441
+ el.querySelector<HTMLElement>('[contenteditable="true"]')?.focus()
442
+ }, [editFocusId, search, rowFilter, segs, tab, setTab])
443
+
444
+ function handleRowClick(segId: string | undefined, start: number) {
445
+ if (!segId) return
446
+ onSelectCaption(segId)
447
+ // `CaptionPreview` snaps the clock to the frame grid (`round(t*fps)/fps`)
448
+ // BEFORE testing `t >= start && t < end` against it. Caption starts are
449
+ // arbitrary floats from transcription, not frame-aligned, so seeking to
450
+ // exactly `start` rounds DOWN into the previous segment about half the
451
+ // time once snapped. The half-frame nudge lands inside the segment after
452
+ // rounding, every time.
453
+ //
454
+ // `fps` comes from `project.settings?.fps ?? 30`, which passes an
455
+ // explicit `0` through untouched (falsy, but not nullish) — and
456
+ // `0.5 / 0` is `Infinity`, which parks the playhead at `totalDuration`
457
+ // instead of inside the segment. Guarded here too.
458
+ const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30
459
+ clock.set(start + 0.5 / safeFps)
460
+ }
461
+
462
+ function handleDelete(segId: string | undefined) {
463
+ if (!segId) return
464
+ onCaptionSegmentDelete?.(segId)
465
+ }
466
+
467
+ function handleRemoveAllClick() {
468
+ if (!confirmRemove) {
469
+ setConfirmRemove(true)
470
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
471
+ removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
472
+ return
473
+ }
474
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
475
+ setConfirmRemove(false)
476
+ // Persistence needs an explicit `null` to clear the field server-side (a
477
+ // live PUT test confirmed `undefined` is dropped from the JSON body).
478
+ onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
479
+ }
480
+
481
+ const accent = captionTrack ? ACCENT[captionTrack.style] : undefined
482
+
483
+ // Bold reads "on" whenever `fontWeight` is ABSENT — each of the seven JSX
484
+ // caption templates then renders at its OWN designed weight (outline's 900
485
+ // stencil stamp, subtitle's lighter 600), which is what every existing
486
+ // project renders today, so bold has to read as "on" by default. It also
487
+ // reads "on" for anything already stored at 600+ (a project that happens to
488
+ // carry an explicit weight there). Off is the single explicit value 400.
489
+ //
490
+ // `fontWeight` is `number | string` (schema.ts) and CSS accepts the string
491
+ // keywords 'bold'/'bolder' as legal values — `Number('bold')` is `NaN`,
492
+ // which fails the `>= 600` check, so a project carrying the keyword read as
493
+ // OFF even though it renders bold. Checked as its own case rather than
494
+ // folded into the numeric one.
495
+ const isBoldKeyword = typeof captionTrack?.fontWeight === 'string'
496
+ && ['bold', 'bolder'].includes(captionTrack.fontWeight.trim().toLowerCase())
497
+ const boldOn = captionTrack
498
+ ? (captionTrack.fontWeight == null || isBoldKeyword || Number(captionTrack.fontWeight) >= 600)
499
+ : false
500
+
501
+ // onChange = live preview only (cheap, no PUT); onCommit persists once.
502
+ // `Partial<Captions>`, not `Record<string, string>`: the text-styling
503
+ // controls below write a `string[]` (googleFonts) and a number
504
+ // (lineHeight), and stringifying those to fit a narrower patch type would
505
+ // put values in the project that the caption templates can't read.
506
+ const liveTrack = (patch: Partial<Captions>) => {
507
+ if (!project.captions) return
508
+ onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
509
+ }
510
+ const commitTrack = (patch: Partial<Captions>) => {
511
+ if (!project.captions) return
512
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
513
+ }
514
+ // Deletes a key from the persisted captions object entirely, rather than
515
+ // overwriting it. `commitTrack` above can only ADD/OVERWRITE keys via its
516
+ // patch spread — writing `{ [key]: undefined }` through it would leave the
517
+ // key present (with an `undefined` value) in the live-preview project, but
518
+ // JSON.stringify DROPS `undefined` keys on the way to the server, so the
519
+ // preview and the persisted project would end up disagreeing about whether
520
+ // the field is set at all. Building the object explicitly and destructuring
521
+ // the key out of it is the only way both paths agree. Used by the Bold
522
+ // toggle: switching bold back ON has to DELETE `fontWeight`, not write 700
523
+ // (see the toggle's own comment for why a flat 700 is wrong).
524
+ const commitTrackOmitting = (key: keyof Captions) => {
525
+ if (!project.captions) return
526
+ const { [key]: _dropped, ...rest } = project.captions
527
+ onCaptionEdit?.({ ...project, captions: rest as Captions })
528
+ }
529
+ // Per-segment color: when a real segment is selected, the base swatch
530
+ // targets ITS color instead of the track's. Live preview still flows
531
+ // through `onProjectChange` — a locally patched project, exactly the
532
+ // channel every other control here uses for live preview — but the commit
533
+ // goes through `onCaptionSegmentChange`, the single-segment patch channel,
534
+ // instead of rewriting the whole captions object through `onCaptionEdit`.
535
+ // Each fires from exactly one of SwatchInput's onChange/onCommit, so one
536
+ // gesture produces exactly one commit — never both channels for one value.
537
+ const liveSegColor = (v: string) => {
538
+ if (!project.captions || !selectedSeg) return
539
+ onProjectChange?.({
540
+ ...project,
541
+ captions: {
542
+ ...project.captions,
543
+ segments: project.captions.segments.map(s => s.id === selectedSeg.id ? { ...s, color: v } : s),
544
+ },
545
+ })
546
+ }
547
+ const commitSegColor = (v: string) => {
548
+ if (!selectedSeg?.id) return
549
+ onCaptionSegmentChange?.(selectedSeg.id, { color: v })
550
+ }
551
+
552
+ return (
553
+ <div className="flex flex-col h-full min-h-0">
554
+ {/* ── Header: title + count + the Styles / Format / Captions tab switch.
555
+ The strip itself is the shared `TabNav` (panels/TabNav.tsx), which
556
+ every small in-panel tab switch in the editor now renders: uppercase
557
+ labels, an accent underline under the active tab sitting on this
558
+ header's bottom border, muted inactive — NOT a filled segmented
559
+ control — so the left rail's panels share one tab language. ── */}
560
+ <div className="shrink-0 border-b border-[var(--editor-border)] flex flex-col">
561
+ <div className="px-3 pt-2 pb-1 flex items-center justify-between gap-2">
562
+ <span className="text-xs font-medium text-[var(--editor-text)]/60 uppercase tracking-wide">
563
+ Captions
564
+ {segs.length > 0 && (
565
+ <span className="ml-1.5 text-[var(--editor-text)]/40 normal-case tracking-normal">{segs.length}</span>
566
+ )}
567
+ </span>
568
+ </div>
569
+
570
+ {/* Only mounts once there are captions to split between the gallery,
571
+ the format controls and the transcript; 'Format' is the default.
572
+ No `className` is passed: TabNav's own base is `flex items-center`,
573
+ which is exactly what this strip carried inline, and the header's
574
+ bottom border (which the active tab's underline sits on) belongs to
575
+ the wrapper ABOVE — not to the strip.
576
+
577
+ TabNav renders `aria-pressed` buttons under a `role="group"` rather
578
+ than a `role="tab"`/`tablist` pair; its own doc comment has the
579
+ general reasoning, which bites doubly here because the host's LEFT
580
+ rail (LeftPanelTabs) already owns a tab literally named "Captions",
581
+ so a second `role="tab"` of that name would put two "Captions tab"
582
+ nodes in the a11y tree. */}
583
+ {segs.length > 0 && captionTrack && (
584
+ <TabNav tabs={CAPTION_TABS} value={tab} onChange={setTab} ariaLabel="Caption panel view" />
585
+ )}
586
+ </div>
587
+
588
+ {segs.length === 0 ? (
589
+ /* ── Empty / loading state (no captions yet) — spans the whole panel,
590
+ no tabs. While a background job runs this is a loading placeholder
591
+ that agrees with the top-bar progress indicator; otherwise it is
592
+ the idle prompt and its one primary trigger. ── */
593
+ <div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
594
+ {captionsGenerating ? (
595
+ <div className="flex flex-col items-center gap-3 text-center mt-10 px-4">
596
+ <Loader2 size={22} className="animate-spin text-[var(--editor-accent)]" aria-hidden="true" />
597
+ <div className="flex flex-col gap-1" role="status" aria-live="polite">
598
+ <p className="text-xs font-medium text-[var(--editor-text)]">Generating captions…</p>
599
+ <p className="text-[11px] text-[var(--editor-text)] opacity-50 leading-relaxed">
600
+ Transcribing the timeline's audio. You can keep editing while this runs.
601
+ </p>
602
+ </div>
603
+ </div>
604
+ ) : (
605
+ <div className="flex flex-col items-center gap-2 text-center mt-4 px-2">
606
+ <p className="text-xs text-[var(--editor-text)]/50 leading-relaxed">
607
+ {onRegenerateCaptions
608
+ ? "Captions are generated from the timeline's audio."
609
+ : 'No captions yet. Captions are generated during transcription.'}
610
+ </p>
611
+ {/* Primary action — the only thing to do on an otherwise empty
612
+ panel, so it gets the accent treatment rather than the small
613
+ ghost buttons in the footer. Absent entirely when the host has
614
+ no `generateCaptions` (Hub/LP): a button with no working
615
+ handler is worse than no button. */}
616
+ {onRegenerateCaptions && (
617
+ <button
618
+ type="button"
619
+ onClick={onRegenerateCaptions}
620
+ className="text-xs font-medium rounded-md px-3 py-1.5 bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-opacity"
621
+ >
622
+ Generate captions
623
+ </button>
624
+ )}
625
+ </div>
626
+ )}
627
+ </div>
628
+ ) : tab === 'styles' ? (
629
+ /* ── Styles tab — the gallery of style previews, replacing the preset
630
+ chip row that used to sit above the fine controls. The gallery
631
+ renders a plain grid and owns no scrolling of its own, so it sits
632
+ inside the same scroll wrapper the chip row did. ── */
633
+ <div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
634
+ {captionTrack && (
635
+ <CaptionStyleGallery
636
+ captions={captionTrack}
637
+ project={project}
638
+ onCaptionEdit={onCaptionEdit}
639
+ compileOverlay={compileOverlay}
640
+ resolveCaptionTemplate={resolveCaptionTemplate}
641
+ />
642
+ )}
643
+ </div>
644
+ ) : tab === 'format' ? (
645
+ /* ── Format tab — the fine caption controls (size, colors, font, case,
646
+ alignment, spacing), always visible (no collapse), scrollable.
647
+ Every control sits on a LABELED ROW: a fixed-width label cell then
648
+ the control (`rowClass` / `rowLabelClass`), so the tab reads as one
649
+ two-column grid. ── */
650
+ <div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
651
+ {captionTrack && (
652
+ <div className="flex flex-col gap-1.5">
653
+ <div className={rowClass}>
654
+ <span className={rowLabelClass} aria-hidden="true">Font size</span>
655
+ {/* Both controls drive the same value: drag the slider for a
656
+ quick size, or type/arrow an exact one in the box. Slider
657
+ previews per drag-frame and commits on release; the box
658
+ previews per keystroke and commits on blur/Enter.
659
+
660
+ They carry DIFFERENT accessible names on purpose. Two
661
+ controls sharing one name is ambiguous to a screen reader
662
+ (and makes `getByLabelText` throw on the duplicate), so
663
+ the box keeps the canonical "Caption font size" that the
664
+ behavioural tests query and the slider is named for what
665
+ it is — the same split OverlayInspector uses for its
666
+ "Scale" box and "Scale slider". */}
667
+ <Slider
668
+ aria-label="Caption font size slider"
669
+ className="min-w-0 flex-1"
670
+ step={1}
671
+ min={CAPTION_FONT_SIZE_MIN}
672
+ max={CAPTION_FONT_SIZE_MAX}
673
+ value={fontsize}
674
+ onChange={v => {
675
+ if (!project.captions) return
676
+ const next = clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX)
677
+ setFontsize(next)
678
+ onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: next } })
679
+ }}
680
+ onCommit={v => {
681
+ if (!project.captions) return
682
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX) } })
683
+ }}
684
+ />
685
+ <NumberField
686
+ name="Caption font size"
687
+ className="w-20"
688
+ unit="px"
689
+ step={1}
690
+ min={CAPTION_FONT_SIZE_MIN}
691
+ max={CAPTION_FONT_SIZE_MAX}
692
+ value={fontsize}
693
+ onPreview={v => {
694
+ if (!project.captions) return
695
+ const next = clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX)
696
+ setFontsize(next)
697
+ // Live preview only — cheap local-state update, no save.
698
+ onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: next } })
699
+ }}
700
+ onCommit={v => {
701
+ if (!project.captions) return
702
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: clampToRange(v, CAPTION_FONT_SIZE_MIN, CAPTION_FONT_SIZE_MAX) } })
703
+ }}
704
+ onStep={d => {
705
+ if (!project.captions) return
706
+ const next = stepValue(fontsize, d, { step: 1, min: CAPTION_FONT_SIZE_MIN, max: CAPTION_FONT_SIZE_MAX })
707
+ setFontsize(next)
708
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: next } })
709
+ }}
710
+ />
711
+ </div>
712
+ <div className={rowClass}>
713
+ {/* One label for both swatches: the base one (track, or the
714
+ selected segment) and the active style's accent, when it
715
+ has one. Each still carries its own `aria-label` naming
716
+ exactly which color it writes. */}
717
+ <span className={rowLabelClass} aria-hidden="true">Color</span>
718
+ <SwatchInput
719
+ size="sm"
720
+ showValue={false}
721
+ title={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
722
+ ariaLabel={selectedSeg ? 'Selected segment text color' : 'Caption text color'}
723
+ value={toHex(selectedSeg ? (selectedSeg.color ?? captionTrack.color) : captionTrack.color, '#ffffff')}
724
+ onChange={v => selectedSeg ? liveSegColor(v) : liveTrack({ color: v })}
725
+ onCommit={v => selectedSeg ? commitSegColor(v) : commitTrack({ color: v })}
726
+ />
727
+ {accent && (
728
+ <SwatchInput
729
+ size="sm"
730
+ showValue={false}
731
+ title={`Caption ${accent.label.toLowerCase()} color`}
732
+ ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
733
+ value={toHex(captionTrack[accent.field], accent.def)}
734
+ onChange={v => liveTrack({ [accent.field]: v })}
735
+ onCommit={v => commitTrack({ [accent.field]: v })}
736
+ />
737
+ )}
738
+ </div>
739
+
740
+ {/* ── Text styling ──
741
+ Every control here writes a track-level `Captions` field
742
+ that all seven JSX caption templates already read as a
743
+ prop, so one write reaches the editor preview and the
744
+ export together — no separate render-side plumbing. */}
745
+ <div className="flex flex-col gap-1.5 border-t border-[var(--editor-border)] pt-2">
746
+ <div className={rowClass}>
747
+ <span className={rowLabelClass} aria-hidden="true">Font</span>
748
+ <FontFamilyPicker
749
+ value={captionTrack.fontFamily ?? ''}
750
+ onChange={value => {
751
+ // `fontFamily` and `googleFonts` MUST be written in ONE
752
+ // patch. A family whose font file is never fetched
753
+ // renders as the fallback face — in the preview AND in
754
+ // the export — which is the exact failure this control
755
+ // exists to prevent; and two patches would also stack
756
+ // two undo entries and could interleave with another
757
+ // edit between them. The System option carries no
758
+ // `spec`, and `[]` is the right write for it:
759
+ // explicitly empty, so a previously picked family's
760
+ // spec cannot linger and keep being fetched.
761
+ //
762
+ // The spec is resolved here rather than handed over by
763
+ // `FontFamilyPicker` (whose onChange gives only the CSS
764
+ // value) so its signature — and its other caller,
765
+ // text/TextFormattingToolbar.tsx — stays untouched.
766
+ // `findFontOption` is an exact round-trip on an
767
+ // option's own `value`, so this always finds the option
768
+ // the operator just clicked.
769
+ const opt = findFontOption(value)
770
+ commitTrack({ fontFamily: value, googleFonts: opt?.spec ? [opt.spec] : [] })
771
+ }}
772
+ className="flex-1 min-w-0"
773
+ buttonClassName="flex h-7 w-full items-center gap-1 rounded border border-[var(--editor-border)] bg-[var(--editor-surface)] px-1.5 text-[11px] text-[var(--editor-text)] hover:border-[var(--editor-accent)] focus:outline-none focus:border-[var(--editor-accent)]"
774
+ />
775
+ <button
776
+ type="button"
777
+ aria-pressed={boldOn}
778
+ aria-label="Bold"
779
+ // Surprising on first read, so said explicitly: ON means
780
+ // the key is ABSENT, not "set to some bold number". Each
781
+ // of the seven caption templates has its own designed
782
+ // weight (outline's 900 stencil, subtitle's 600, …), and
783
+ // that only comes through when `fontWeight` isn't in the
784
+ // project at all. Writing 700 here would flatten every
785
+ // style onto the same weight and quietly break each
786
+ // style's look — so turning bold back ON has to DELETE
787
+ // the key (via `commitTrackOmitting`), never re-write it.
788
+ onClick={() => boldOn ? commitTrack({ fontWeight: 400 }) : commitTrackOmitting('fontWeight')}
789
+ className={`${chipClass(boldOn)} font-bold shrink-0`}
790
+ >
791
+ B
792
+ </button>
793
+ </div>
794
+
795
+ <div className={rowClass}>
796
+ <span className={rowLabelClass} aria-hidden="true">Case</span>
797
+ <div role="group" aria-label="Caption text case" className="flex items-center gap-1">
798
+ {/* Absent `textTransform` seeds from the active style's
799
+ own default (same reasoning as `boldOn` above) — a
800
+ fresh `outline` caption renders uppercase before any
801
+ case chip is ever clicked, so the chip has to read
802
+ pressed for that to be true rather than lying about
803
+ the caption's real on-screen case. An EXPLICIT
804
+ 'none' (written when the operator turns a case back
805
+ off) is not "absent" and never falls back. */}
806
+ {CASES.map(({ value, glyph, label }) => {
807
+ const effectiveTransform = captionTrack.textTransform ?? CAPTION_STYLE_TEXT_TRANSFORM[captionTrack.style]
808
+ const active = effectiveTransform === value
809
+ return (
810
+ <button
811
+ key={value}
812
+ type="button"
813
+ aria-label={label}
814
+ aria-pressed={active}
815
+ // Re-clicking the active case clears it. Writes
816
+ // 'none' rather than dropping the key: an absent
817
+ // field can't overwrite the value already saved on
818
+ // the project, so "off" has to be said out loud.
819
+ onClick={() => commitTrack({ textTransform: active ? 'none' : value })}
820
+ className={`${chipClass(active)} font-mono`}
821
+ >
822
+ {glyph}
823
+ </button>
824
+ )
825
+ })}
826
+ </div>
827
+ </div>
828
+
829
+ {/* Alignment used to ride on the RIGHT of the case row. Its
830
+ own labeled row now, so it reads as a named control like
831
+ every other one here instead of an unlabeled trio of icons
832
+ pushed to the far edge. */}
833
+ <div className={rowClass}>
834
+ <span className={rowLabelClass} aria-hidden="true">Alignment</span>
835
+ <div role="group" aria-label="Caption text alignment" className="flex items-center gap-1">
836
+ {ALIGNMENTS.map(({ value, Icon, label }) => {
837
+ const active = captionTrack.textAlign === value
838
+ return (
839
+ <button
840
+ key={value}
841
+ type="button"
842
+ aria-label={label}
843
+ aria-pressed={active}
844
+ onClick={() => commitTrack({ textAlign: value })}
845
+ className={`${chipClass(active)} flex items-center justify-center px-1.5`}
846
+ >
847
+ <Icon size={11} />
848
+ </button>
849
+ )
850
+ })}
851
+ </div>
852
+ </div>
853
+
854
+ {/* One row each, same `rowClass`/`rowLabelClass` pair every
855
+ other row on this tab wears, now that these are plain
856
+ `NumberField`s rather than the retired `StepperField`
857
+ (which drew its own bound <label> — see the note on
858
+ `rowLabelClass` above for why a bound label was never
859
+ needed even there). */}
860
+ <div className={rowClass}>
861
+ <span className={rowLabelClass} aria-hidden="true">Spacing</span>
862
+ <NumberField
863
+ name="Caption letter spacing"
864
+ className="w-20"
865
+ unit="em"
866
+ min={-0.1}
867
+ max={0.5}
868
+ step={0.01}
869
+ value={parseEmValue(captionTrack.letterSpacing)}
870
+ placeholder={parseEmValue(CAPTION_STYLE_LETTER_SPACING[captionTrack.style])}
871
+ // NumberField doesn't clamp a typed value (native
872
+ // min/max are a spinner hint only) — reuse the same
873
+ // clamp+round the retired StepperField's `numeric()`
874
+ // applied, on both the live and the committed write.
875
+ onPreview={n => liveTrack({ letterSpacing: `${roundTo(clampToRange(n, -0.1, 0.5), 3)}em` })}
876
+ onCommit={n => commitTrack({ letterSpacing: `${roundTo(clampToRange(n, -0.1, 0.5), 3)}em` })}
877
+ onStep={d => {
878
+ // Nudge off whatever is actually IN FORCE — the
879
+ // stored value, or (nothing stored) the active
880
+ // style's own default shown as the placeholder —
881
+ // never off a bare 0, which would ignore what the
882
+ // caption is really rendering at right now.
883
+ const base = Number(
884
+ parseEmValue(captionTrack.letterSpacing) || parseEmValue(CAPTION_STYLE_LETTER_SPACING[captionTrack.style]) || 0,
885
+ )
886
+ commitTrack({ letterSpacing: `${stepValue(base, d, { step: 0.01, min: -0.1, max: 0.5 })}em` })
887
+ }}
888
+ />
889
+ </div>
890
+ <div className={rowClass}>
891
+ <span className={rowLabelClass} aria-hidden="true">Line</span>
892
+ <NumberField
893
+ name="Caption line height"
894
+ className="w-20"
895
+ min={0.8}
896
+ max={2.5}
897
+ step={0.05}
898
+ value={parseUnitlessValue(captionTrack.lineHeight)}
899
+ placeholder={parseUnitlessValue(CAPTION_STYLE_LINE_HEIGHT[captionTrack.style])}
900
+ onPreview={n => liveTrack({ lineHeight: roundTo(clampToRange(n, 0.8, 2.5), 2) })}
901
+ onCommit={n => commitTrack({ lineHeight: roundTo(clampToRange(n, 0.8, 2.5), 2) })}
902
+ onStep={d => {
903
+ const base = Number(
904
+ parseUnitlessValue(captionTrack.lineHeight) || parseUnitlessValue(CAPTION_STYLE_LINE_HEIGHT[captionTrack.style]) || 0,
905
+ )
906
+ commitTrack({ lineHeight: stepValue(base, d, { step: 0.05, min: 0.8, max: 2.5 }) })
907
+ }}
908
+ />
909
+ </div>
910
+ </div>
911
+ </div>
912
+ )}
913
+ </div>
914
+ ) : (
915
+ /* ── Captions tab — search, row filters, the numbered transcript, and
916
+ the generate / remove actions. Reachable only once captions exist
917
+ (segs.length > 0), so search and the footer are unconditional here
918
+ rather than re-gated on the count. ── */
919
+ <>
920
+ <div className="shrink-0 border-b border-[var(--editor-border)] px-3 py-2 flex flex-col gap-2">
921
+ {/* ── Search ── */}
922
+ <div className="relative">
923
+ <Search size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--editor-text)]/40 pointer-events-none" />
924
+ <input
925
+ type="text"
926
+ value={search}
927
+ onChange={e => setSearch(e.target.value)}
928
+ placeholder="Search captions…"
929
+ aria-label="Search captions"
930
+ className="w-full h-7 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] pl-6 pr-2 text-xs text-[var(--editor-text)] placeholder-[var(--editor-text)]/40 focus:outline-none focus:border-[var(--editor-accent)]"
931
+ />
932
+ </div>
933
+
934
+ {/* ── Row filter chips (only when there's more than one lane to filter
935
+ between — a single-lane project never mounts this, keeping its
936
+ toolbar byte-for-byte what it was before lanes existed). Composes
937
+ with search rather than replacing it: both narrow the same
938
+ `visibleGroups` computation above. Same button look as the
939
+ Format tab's chips, for one visual chip language in this
940
+ panel. ── */}
941
+ {multiLane && (
942
+ <div role="group" aria-label="Filter captions by row" className="flex flex-wrap gap-1">
943
+ <button
944
+ type="button"
945
+ aria-pressed={rowFilter === null}
946
+ onClick={() => setRowFilter(null)}
947
+ className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
948
+ rowFilter === null
949
+ ? 'bg-[var(--editor-accent)]/20 border-[var(--editor-accent)]/60 text-[var(--editor-accent)]'
950
+ : 'bg-[var(--editor-surface)] border-[var(--editor-border)] text-[var(--editor-text)]/50 hover:text-[var(--editor-text)]/80 hover:border-[var(--editor-text)]/30'
951
+ }`}
952
+ >
953
+ All
954
+ </button>
955
+ {captionGroups.map(g => (
956
+ <button
957
+ key={g.lane}
958
+ type="button"
959
+ aria-pressed={rowFilter === g.lane}
960
+ onClick={() => setRowFilter(g.lane)}
961
+ className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
962
+ rowFilter === g.lane
963
+ ? 'bg-[var(--editor-accent)]/20 border-[var(--editor-accent)]/60 text-[var(--editor-accent)]'
964
+ : 'bg-[var(--editor-surface)] border-[var(--editor-border)] text-[var(--editor-text)]/50 hover:text-[var(--editor-text)]/80 hover:border-[var(--editor-text)]/30'
965
+ }`}
966
+ >
967
+ {`Row ${g.lane + 1}`}
968
+ </button>
969
+ ))}
970
+ </div>
971
+ )}
972
+ </div>
973
+
974
+ {/* ── Scrollable numbered list ── */}
975
+ <div role="list" aria-label="Caption segments" className="flex-1 min-h-0 overflow-y-auto px-2 py-2 flex flex-col gap-1">
976
+ {totalVisible === 0 ? (
977
+ <p className="text-xs text-[var(--editor-text)]/50 italic text-center mt-4 px-2">No matching captions.</p>
978
+ ) : (
979
+ visibleGroups.map(group => (
980
+ // A single-lane project has exactly one entry here, so this
981
+ // Fragment (which renders no DOM node of its own) is the only
982
+ // thing standing between this map and a bare `rows.map(...)` —
983
+ // the header below stays unmounted (`multiLane` false), leaving
984
+ // the row markup underneath byte-for-byte what it was pre-Phase-5.
985
+ <Fragment key={group.lane}>
986
+ {multiLane && (
987
+ // No "move to row" control here (v1, deliberate): row
988
+ // membership is a timeline DRAG gesture (Phase 3/4's
989
+ // resolveDropLane), and a sidebar dropdown doing the same
990
+ // move by a different mechanism invites the two to disagree
991
+ // about what a drag means. Follow-up, not YAGNI-scope here.
992
+ <div className="sticky top-0 z-10 -mx-2 px-2 py-1 bg-[var(--editor-surface)] border-b border-[var(--editor-border)] text-[10px] font-medium text-[var(--editor-text)]/50 uppercase tracking-wide">
993
+ {`Row ${group.lane + 1}`}
994
+ </div>
995
+ )}
996
+ {group.rows.map(({ seg, index }) => {
997
+ const isActive = currentTime >= seg.start && currentTime < seg.end
998
+ const isSelected = !!seg.id && selectedIds.includes(seg.id)
999
+ return (
1000
+ <div
1001
+ key={seg.id ?? index}
1002
+ role="listitem"
1003
+ // `aria-current`, not `aria-selected`: each row contains a
1004
+ // contenteditable and a delete button, and ARIA forbids
1005
+ // interactive descendants inside `option`/`listbox` — a
1006
+ // screen reader could not reliably reach either control. A
1007
+ // plain `list`/`listitem` has no such restriction.
1008
+ aria-current={isSelected ? 'true' : undefined}
1009
+ ref={el => {
1010
+ if (!seg.id) return
1011
+ if (el) rowRefs.current.set(seg.id, el)
1012
+ else rowRefs.current.delete(seg.id)
1013
+ }}
1014
+ onClick={() => handleRowClick(seg.id, seg.start)}
1015
+ // Every segment reads as its own card: a solid surface fill
1016
+ // plus a visible border always, so rows don't melt into the
1017
+ // panel. Accent (indigo-500 == --editor-accent #6366f1) is
1018
+ // used through real Tailwind classes, not `/N` on the arbitrary
1019
+ // var — the latter is a silent no-op on a hex-valued var.
1020
+ className={`group flex items-start gap-2.5 rounded-md px-2.5 py-2 cursor-pointer border transition-colors ${
1021
+ isSelected
1022
+ ? 'bg-indigo-500/10 border-indigo-500/60'
1023
+ : isActive
1024
+ ? 'bg-[var(--editor-surface)] border-indigo-500/30'
1025
+ : 'bg-[var(--editor-surface)] border-[var(--editor-border)] hover:border-indigo-400/40'
1026
+ }`}
1027
+ >
1028
+ {/* Index + timestamp, stacked — the two labels are complementary
1029
+ (R3: nothing from the retired TranscriptPanel/TranscriptModal
1030
+ dropped). The index gives ordinal position; the timestamp
1031
+ gives where in the video the line actually lands, which is
1032
+ what you navigate by. Both small/mono/muted so neither
1033
+ competes with the caption text beside them. */}
1034
+ <div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5 w-10">
1035
+ {/* NOT `text-[var(--editor-text)]/N` — Tailwind cannot generate a
1036
+ rule for an opacity modifier on an arbitrary var() color, so
1037
+ that class is a silent no-op and these spans inherit the row's
1038
+ own (near-identical) foreground color instead. `opacity-N` is a
1039
+ real utility and, on a plain text span with no background,
1040
+ renders identically to the alpha-blended color that was
1041
+ intended. See CaptionListPanel.test.tsx for the regression
1042
+ guard. */}
1043
+ <span className="text-[10px] font-mono text-[var(--editor-text)] opacity-60">{index + 1}</span>
1044
+ <span className="text-[9px] font-mono text-[var(--editor-text)] opacity-50">{formatTime(seg.start)}</span>
1045
+ </div>
1046
+ <span className="flex-1 min-w-0 text-xs text-[var(--editor-text)] leading-snug">
1047
+ <EditableSegment
1048
+ seg={seg}
1049
+ onEdit={text => { if (seg.id) onCaptionSegmentChange?.(seg.id, { text }) }}
1050
+ />
1051
+ </span>
1052
+ <button
1053
+ className={`shrink-0 opacity-0 group-hover:opacity-100 text-[var(--editor-text)]/40 transition-opacity ${mode === 'light' ? 'hover:text-red-600' : 'hover:text-red-400'}`}
1054
+ onClick={e => { e.stopPropagation(); handleDelete(seg.id) }}
1055
+ title="Delete caption"
1056
+ aria-label="Delete caption"
1057
+ >
1058
+ <Trash2 size={12} />
1059
+ </button>
1060
+ </div>
1061
+ )
1062
+ })}
1063
+ </Fragment>
1064
+ ))
1065
+ )}
1066
+ </div>
1067
+
1068
+ {/* ── Actions footer ──
1069
+ The generate / remove triggers live at the BOTTOM of the Captions
1070
+ tab, out of the way of the list. Reachable only when captions
1071
+ exist, so this is unconditional rather than re-gated on the count;
1072
+ the empty state above keeps its own primary Generate button. */}
1073
+ <div className="shrink-0 border-t border-[var(--editor-border)] px-3 py-2.5 flex items-center gap-2">
1074
+ {onRegenerateCaptions && (
1075
+ <button
1076
+ type="button"
1077
+ onClick={onRegenerateCaptions}
1078
+ disabled={captionsGenerating}
1079
+ className="flex-1 flex items-center justify-center gap-1.5 h-8 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] text-xs font-medium text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:text-[var(--editor-accent)] disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none"
1080
+ >
1081
+ <RefreshCw size={13} className={captionsGenerating ? 'animate-spin' : undefined} />
1082
+ Regenerate captions
1083
+ </button>
1084
+ )}
1085
+ {captionTrack && (
1086
+ <button
1087
+ type="button"
1088
+ onClick={handleRemoveAllClick}
1089
+ className={`flex items-center justify-center gap-1.5 h-8 px-3 rounded-md border text-xs font-medium transition-colors ${
1090
+ confirmRemove
1091
+ ? (mode === 'light' ? 'bg-red-50 border-red-400 text-red-700' : 'bg-red-500/15 border-red-500/60 text-red-400')
1092
+ : (mode === 'light'
1093
+ ? 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:border-red-400 hover:text-red-700'
1094
+ : 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:border-red-500/50 hover:text-red-400')
1095
+ }`}
1096
+ >
1097
+ <Trash2 size={13} />
1098
+ {confirmRemove ? 'Really remove?' : 'Remove all'}
1099
+ </button>
1100
+ )}
1101
+ </div>
1102
+ </>
1103
+ )}
1104
+ </div>
1105
+ )
1106
+ }