@bycrux/editor 0.8.8 → 0.9.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
- import { RefreshCw, AlertCircle, Download, Info } from 'lucide-react'
2
+ import { RefreshCw, AlertCircle, Download, Info, Undo2, Redo2 } from 'lucide-react'
3
3
  import type { Project, Slide, CarouselElement, ImageElement, CarouselEditorProps, OverlayFactory } from '../types'
4
4
  import { applyTheme, defaultMontajTheme } from '../theme'
5
5
  import { useProjectState } from '../state/use-project-state'
@@ -409,19 +409,39 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
409
409
  {/* TOOLBAR ROW: Refresh on the left; host toolbar actions + Render on the
410
410
  right. Pinned (shrink-0) above the scrolling canvas area. */}
411
411
  <div className="flex-shrink-0 flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--editor-border)]">
412
- <button
413
- onClick={handleRefresh}
414
- disabled={refreshing}
415
- className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
416
- refreshState === 'err'
417
- ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
418
- : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
419
- }`}
420
- title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
421
- >
422
- {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
423
- <span className="text-xs font-medium">Refresh</span>
424
- </button>
412
+ <div className="flex items-center gap-2">
413
+ <button
414
+ onClick={handleRefresh}
415
+ disabled={refreshing}
416
+ className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
417
+ refreshState === 'err'
418
+ ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
419
+ : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
420
+ }`}
421
+ title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
422
+ >
423
+ {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
424
+ <span className="text-xs font-medium">Refresh</span>
425
+ </button>
426
+ <button
427
+ onClick={() => state.undo()}
428
+ disabled={!state.canUndo}
429
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
430
+ title="Undo (Cmd/Ctrl+Z)"
431
+ aria-label="Undo"
432
+ >
433
+ <Undo2 size={18} />
434
+ </button>
435
+ <button
436
+ onClick={() => state.redo()}
437
+ disabled={!state.canRedo}
438
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
439
+ title="Redo (Cmd/Ctrl+Shift+Z)"
440
+ aria-label="Redo"
441
+ >
442
+ <Redo2 size={18} />
443
+ </button>
444
+ </div>
425
445
 
426
446
  <div className="flex items-center gap-2">
427
447
  {slots?.toolbarActions}
@@ -9,7 +9,7 @@ import type {
9
9
  GlobalOverlayProp,
10
10
  EditorAdapter,
11
11
  } from '../types'
12
- import { Button, cn, inspectorInputClass } from '../ui'
12
+ import { Button, cn, inspectorInputClass, SwatchInput } from '../ui'
13
13
  import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
14
14
 
15
15
  function parseNumber(v: string): number | null {
@@ -173,40 +173,6 @@ function PropEditor({
173
173
  )
174
174
  }
175
175
 
176
- // Color control that actually reads as one: a filled rounded swatch (the live
177
- // value) sitting next to the hex string, the whole row clickable to open the
178
- // native color picker. The transparent <input type="color"> overlays the
179
- // swatch so the OS picker anchors there.
180
- function SwatchInput({
181
- value,
182
- onChange,
183
- ariaLabel,
184
- }: {
185
- value: string
186
- onChange: (v: string) => void
187
- ariaLabel: string
188
- }) {
189
- return (
190
- <div className="flex items-center gap-2.5">
191
- <label className="relative h-7 w-7 shrink-0 cursor-pointer">
192
- <span
193
- className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
194
- style={{ backgroundColor: value }}
195
- aria-hidden
196
- />
197
- <input
198
- type="color"
199
- value={value}
200
- onChange={e => onChange(e.target.value)}
201
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
202
- aria-label={ariaLabel}
203
- />
204
- </label>
205
- <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
206
- </div>
207
- )
208
- }
209
-
210
176
  export default function SlidePropertyPanel({
211
177
  project,
212
178
  slide,
package/src/schema.ts CHANGED
@@ -43,13 +43,23 @@ export interface CaptionSegment {
43
43
  }
44
44
 
45
45
  export interface Captions {
46
- style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle'
46
+ style: 'word-by-word' | 'pop' | 'karaoke' | 'subtitle' | 'highlight-box' | 'outline' | 'clean'
47
47
  segments: CaptionSegment[]
48
- // ffmpeg-drawtext render params — ignored by JSX preview, used by render.js ffmpeg branch
48
+ // `color` and `fontsize` are read by BOTH paths: the JSX browser preview /
49
+ // Puppeteer render (spread into the caption template props; `fontsize`→`fontSize`)
50
+ // and the ffmpeg-drawtext render branch. `position` and `bgColor` are consumed
51
+ // only by the ffmpeg-drawtext branch and ignored by the JSX preview.
49
52
  position?: 'center' | 'top-left' | 'bottom-left'
50
- color?: string
53
+ color?: string // base caption text color
51
54
  fontsize?: number
52
55
  bgColor?: string
56
+ // Per-style accent color fields, each read by the matching JSX caption template.
57
+ // A single UI control writes whichever one the active style uses (see TranscriptPanel).
58
+ accentColor?: string // active-word/box accent — highlight-box, outline
59
+ highlightColor?: string // active word — karaoke
60
+ activeColor?: string // active word — pop
61
+ backgroundColor?: string// text box background — subtitle
62
+ googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
53
63
  }
54
64
 
55
65
  export interface VisualItem {
@@ -0,0 +1,57 @@
1
+ import { cn } from './utils'
2
+
3
+ // Color control that actually reads as one: a filled rounded swatch (the live
4
+ // value) with the native <input type="color"> overlaid transparently so the OS
5
+ // picker anchors on the swatch. Optionally shows the hex string beside it.
6
+ //
7
+ // `onChange` fires live on every pick (drive a cheap preview). `onCommit`, if
8
+ // given, fires on blur (when the OS picker closes) — use it to persist once
9
+ // instead of on every intermediate value, mirroring a slider's drag→commit.
10
+ export interface SwatchInputProps {
11
+ value: string
12
+ onChange: (v: string) => void
13
+ /** Fired on blur (picker closed) — for callers that preview live via onChange
14
+ * and persist only on commit. Omit to treat every onChange as final. */
15
+ onCommit?: (v: string) => void
16
+ ariaLabel: string
17
+ /** Render the hex string next to the swatch. Default true. */
18
+ showValue?: boolean
19
+ /** Swatch size. `sm` (h-5) suits compact toolbars; `md` (h-7) is the default. */
20
+ size?: 'sm' | 'md'
21
+ /** Tooltip on the row. */
22
+ title?: string
23
+ }
24
+
25
+ export function SwatchInput({
26
+ value,
27
+ onChange,
28
+ onCommit,
29
+ ariaLabel,
30
+ showValue = true,
31
+ size = 'md',
32
+ title,
33
+ }: SwatchInputProps) {
34
+ const box = size === 'sm' ? 'h-5 w-5' : 'h-7 w-7'
35
+ return (
36
+ <div className="flex items-center gap-2.5" title={title}>
37
+ <label className={cn('relative shrink-0 cursor-pointer', box)}>
38
+ <span
39
+ className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
40
+ style={{ backgroundColor: value }}
41
+ aria-hidden
42
+ />
43
+ <input
44
+ type="color"
45
+ value={value}
46
+ onChange={e => onChange(e.target.value)}
47
+ onBlur={e => onCommit?.(e.target.value)}
48
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
49
+ aria-label={ariaLabel}
50
+ />
51
+ </label>
52
+ {showValue && (
53
+ <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
54
+ )}
55
+ </div>
56
+ )
57
+ }
package/src/ui/index.ts CHANGED
@@ -9,6 +9,8 @@ export type { InputProps } from './input'
9
9
  export { Label } from './label'
10
10
  export { Select } from './select'
11
11
  export type { SelectProps } from './select'
12
+ export { SwatchInput } from './SwatchInput'
13
+ export type { SwatchInputProps } from './SwatchInput'
12
14
  export { Switch } from './switch'
13
15
  export { Textarea } from './textarea'
14
16
  export type { TextareaProps } from './textarea'
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Crop, Info, Magnet } from 'lucide-react'
2
+ import { Crop, Info, Magnet, Undo2 } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
4
  import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
5
5
  import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
@@ -475,6 +475,15 @@ function ReviewSurface<P extends Project>({
475
475
  >
476
476
  <Info size={12} />
477
477
  </button>
478
+ <button
479
+ onClick={handleUndo}
480
+ disabled={!canUndo}
481
+ title="Undo (Cmd/Ctrl+Z)"
482
+ aria-label="Undo"
483
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)] disabled:opacity-30 disabled:cursor-not-allowed"
484
+ >
485
+ <Undo2 size={12} />
486
+ </button>
478
487
  <button
479
488
  onClick={() => handleSplit()}
480
489
  title="Split at playhead (S) — selected item or all clips"
@@ -58,8 +58,17 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
58
58
 
59
59
  const frame = Math.round(currentTime * fps)
60
60
  const lastSeg = track.segments[track.segments.length - 1]
61
+
62
+ // Theme props for the template: everything on the track except style/segments
63
+ // (handled separately) and googleFonts (a render-time font-loading hint, not
64
+ // a template prop). Normalize the legacy lowercase `fontsize` key to the
65
+ // camelCase `fontSize` templates actually read.
66
+ const { style: _style, segments: _segments, googleFonts: _googleFonts, fontsize, ...theme } = track
67
+ const themeProps: Record<string, unknown> = { ...theme }
68
+ if (fontsize != null) themeProps.fontSize = fontsize
69
+
61
70
  const element = (factory && scale !== null)
62
- ? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments })
71
+ ? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments, ...themeProps })
63
72
  : null
64
73
 
65
74
  return (
@@ -138,7 +138,12 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
138
138
  const fps = project.settings?.fps ?? 30
139
139
  const frame = 1 / fps
140
140
  const onKey = (e: globalThis.KeyboardEvent) => {
141
+ // While the transcript modal is open, or the caret is in editable text
142
+ // (caption segments are contentEditable), arrows move the text cursor —
143
+ // never the playhead.
144
+ if (transcriptModalOpen) return
141
145
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
146
+ if ((e.target as HTMLElement).isContentEditable) return
142
147
  if (e.key === 'Escape') { setMarkers([null, null]); return }
143
148
  if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
144
149
  e.preventDefault()
@@ -152,7 +157,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
152
157
  }
153
158
  document.addEventListener('keydown', onKey)
154
159
  return () => document.removeEventListener('keydown', onKey)
155
- }, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps])
160
+ }, [totalDuration, currentTime, onTimeUpdate, project.settings?.fps, transcriptModalOpen])
156
161
 
157
162
  // Derive selection from two placed markers
158
163
  const selection = markers[0] !== null && markers[1] !== null
@@ -1,7 +1,29 @@
1
+ import { useEffect, useRef, useState } from 'react'
1
2
  import type { Project } from '../../types'
2
3
  import { formatTime } from './utils'
3
4
  import { EditableSegment } from './EditableSegment'
4
5
  import { makeCaptionEdit } from './makeCaptionEdit'
6
+ import { SwatchInput } from '../../ui'
7
+
8
+ // Each caption style reads a different accent-color prop in its render template
9
+ // (see render/templates/captions/*.jsx). The color control writes whichever field
10
+ // the active style uses; `clean` and `word-by-word` have no accent (omitted here).
11
+ type CaptionStyle = NonNullable<Project['captions']>['style']
12
+ type AccentField = 'accentColor' | 'highlightColor' | 'activeColor' | 'backgroundColor'
13
+ const ACCENT: Partial<Record<CaptionStyle, { field: AccentField; label: string; def: string }>> = {
14
+ karaoke: { field: 'highlightColor', label: 'Highlight', def: '#ffffff' },
15
+ pop: { field: 'activeColor', label: 'Active', def: '#ffe600' },
16
+ 'highlight-box': { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
17
+ outline: { field: 'accentColor', label: 'Accent', def: '#fbbf24' },
18
+ subtitle: { field: 'backgroundColor', label: 'Box', def: '#000000' },
19
+ }
20
+
21
+ // The native <input type="color"> only accepts #rrggbb. Stored values are always
22
+ // hex once picked; fall back to the style default for unset / non-hex (e.g. an
23
+ // rgba() template default) so the swatch never gets an invalid value.
24
+ const HEX = /^#[0-9a-f]{6}$/i
25
+ const toHex = (v: unknown, fallback: string): string =>
26
+ typeof v === 'string' && HEX.test(v) ? v : fallback
5
27
 
6
28
  interface TranscriptPanelProps {
7
29
  project: Project
@@ -17,6 +39,21 @@ interface TranscriptPanelProps {
17
39
 
18
40
  export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
19
41
  const segs = captionTrack?.segments ?? []
42
+ const [confirmRemove, setConfirmRemove] = useState(false)
43
+ const removeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
44
+ // Live slider value during a drag — only committed (persisted) on
45
+ // pointer-up/key-up so mid-drag ticks don't each trigger a server PUT.
46
+ const [fontsize, setFontsize] = useState(captionTrack?.fontsize ?? 46)
47
+
48
+ useEffect(() => {
49
+ setFontsize(captionTrack?.fontsize ?? 46)
50
+ }, [captionTrack?.fontsize])
51
+
52
+ useEffect(() => {
53
+ return () => {
54
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
55
+ }
56
+ }, [])
20
57
  // Find active segment index
21
58
  const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
22
59
  const nearIdx = activeIdx !== -1 ? activeIdx
@@ -31,8 +68,8 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
31
68
  <div className="rounded border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2.5">
32
69
  <div className="flex items-center justify-between mb-2">
33
70
  <span className="text-[10px] text-gray-500 uppercase tracking-wider">Captions</span>
34
- <div className="flex items-center gap-2">
35
- {captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle'] as const).map(style => {
71
+ <div className="flex items-center gap-2 flex-wrap justify-end">
72
+ {captionTrack && (['word-by-word', 'pop', 'karaoke', 'subtitle', 'highlight-box', 'outline', 'clean'] as const).map(style => {
36
73
  const active = captionTrack.style === style
37
74
  return (
38
75
  <button
@@ -52,6 +89,74 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
52
89
  </button>
53
90
  )
54
91
  })}
92
+ {captionTrack && (
93
+ <div className="flex items-center gap-1">
94
+ <input
95
+ type="range"
96
+ min={28}
97
+ max={120}
98
+ step={2}
99
+ value={fontsize}
100
+ className="w-20 sm:w-24 accent-purple-500"
101
+ onChange={e => {
102
+ if (!project.captions) return
103
+ const v = Number(e.target.value)
104
+ setFontsize(v)
105
+ // Live preview only — cheap local-state update, no save.
106
+ onProjectChange?.({ ...project, captions: { ...project.captions, fontsize: v } })
107
+ }}
108
+ onPointerUp={e => {
109
+ if (!project.captions) return
110
+ const v = Number((e.target as HTMLInputElement).value)
111
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
112
+ }}
113
+ onKeyUp={e => {
114
+ if (!project.captions) return
115
+ const v = Number((e.target as HTMLInputElement).value)
116
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, fontsize: v } })
117
+ }}
118
+ />
119
+ <span className="text-[10px] text-gray-500 dark:text-gray-400 font-mono w-9 text-right">
120
+ {fontsize}px
121
+ </span>
122
+ </div>
123
+ )}
124
+ {captionTrack && (() => {
125
+ const accent = ACCENT[captionTrack.style]
126
+ // onChange = live preview only (cheap, no PUT); onBlur commits one save.
127
+ const live = (patch: Record<string, string>) => {
128
+ if (!project.captions) return
129
+ onProjectChange?.({ ...project, captions: { ...project.captions, ...patch } })
130
+ }
131
+ const commit = (patch: Record<string, string>) => {
132
+ if (!project.captions) return
133
+ onCaptionEdit?.({ ...project, captions: { ...project.captions, ...patch } })
134
+ }
135
+ return (
136
+ <div className="flex items-center gap-1.5">
137
+ <SwatchInput
138
+ size="sm"
139
+ showValue={false}
140
+ title="Caption text color"
141
+ ariaLabel="Caption text color"
142
+ value={toHex(captionTrack.color, '#ffffff')}
143
+ onChange={v => live({ color: v })}
144
+ onCommit={v => commit({ color: v })}
145
+ />
146
+ {accent && (
147
+ <SwatchInput
148
+ size="sm"
149
+ showValue={false}
150
+ title={`Caption ${accent.label.toLowerCase()} color`}
151
+ ariaLabel={`Caption ${accent.label.toLowerCase()} color`}
152
+ value={toHex(captionTrack[accent.field], accent.def)}
153
+ onChange={v => live({ [accent.field]: v })}
154
+ onCommit={v => commit({ [accent.field]: v })}
155
+ />
156
+ )}
157
+ </div>
158
+ )
159
+ })()}
55
160
  {onRegenerateCaptions && (
56
161
  <button
57
162
  className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
@@ -60,6 +165,30 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
60
165
  Regenerate
61
166
  </button>
62
167
  )}
168
+ {captionTrack && (
169
+ <button
170
+ className={`text-[10px] rounded px-2 py-0.5 transition-all border ${
171
+ confirmRemove
172
+ ? 'bg-red-500/20 border-red-500/60 text-red-400'
173
+ : 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500'
174
+ }`}
175
+ onClick={() => {
176
+ if (!confirmRemove) {
177
+ setConfirmRemove(true)
178
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
179
+ removeTimeoutRef.current = setTimeout(() => setConfirmRemove(false), 3000)
180
+ return
181
+ }
182
+ if (removeTimeoutRef.current) clearTimeout(removeTimeoutRef.current)
183
+ setConfirmRemove(false)
184
+ // Persistence needs an explicit `null` to clear the field server-side
185
+ // (a live PUT test confirmed `undefined` is dropped from the JSON body).
186
+ onCaptionEdit?.({ ...project, captions: null as unknown as undefined })
187
+ }}
188
+ >
189
+ {confirmRemove ? 'Really remove?' : 'Remove'}
190
+ </button>
191
+ )}
63
192
  {segs.length > 0 && (
64
193
  <button
65
194
  className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import type { Project } from '../../../types'
4
+ import type { Captions } from '../../../schema'
5
+ import TranscriptPanel from '../TranscriptPanel'
6
+
7
+ afterEach(() => cleanup())
8
+
9
+ function makeProject(style: Captions['style'], extra: Partial<Captions> = {}): Project {
10
+ return { id: 'p1', captions: { style, segments: [], ...extra } } as unknown as Project
11
+ }
12
+
13
+ function renderPanel(style: Captions['style'], extra: Partial<Captions> = {}) {
14
+ const onCaptionEdit = vi.fn()
15
+ const onProjectChange = vi.fn()
16
+ const project = makeProject(style, extra)
17
+ render(
18
+ <TranscriptPanel
19
+ project={project}
20
+ captionTrack={project.captions}
21
+ currentTime={0}
22
+ onCaptionEdit={onCaptionEdit}
23
+ onProjectChange={onProjectChange}
24
+ onExpand={() => {}}
25
+ />,
26
+ )
27
+ return { onCaptionEdit, onProjectChange }
28
+ }
29
+
30
+ describe('TranscriptPanel caption color controls', () => {
31
+ it('previews live on change and commits on blur, writing captions.color', () => {
32
+ const { onCaptionEdit, onProjectChange } = renderPanel('karaoke')
33
+ const input = screen.getByLabelText('Caption text color') as HTMLInputElement
34
+
35
+ fireEvent.change(input, { target: { value: '#76b900' } })
36
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
37
+ expect(onProjectChange.mock.calls[0][0].captions.color).toBe('#76b900')
38
+ expect(onCaptionEdit).not.toHaveBeenCalled() // live preview must not persist
39
+
40
+ fireEvent.blur(input, { target: { value: '#76b900' } })
41
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
42
+ expect(onCaptionEdit.mock.calls[0][0].captions.color).toBe('#76b900')
43
+ })
44
+
45
+ it('writes the accent to the field the active style actually reads', () => {
46
+ // karaoke → highlightColor
47
+ let h = renderPanel('karaoke')
48
+ fireEvent.blur(screen.getByLabelText('Caption highlight color'), { target: { value: '#111111' } })
49
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.highlightColor).toBe('#111111')
50
+ cleanup()
51
+
52
+ // pop → activeColor
53
+ h = renderPanel('pop')
54
+ fireEvent.blur(screen.getByLabelText('Caption active color'), { target: { value: '#222222' } })
55
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.activeColor).toBe('#222222')
56
+ cleanup()
57
+
58
+ // subtitle → backgroundColor
59
+ h = renderPanel('subtitle')
60
+ fireEvent.blur(screen.getByLabelText('Caption box color'), { target: { value: '#333333' } })
61
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.backgroundColor).toBe('#333333')
62
+ cleanup()
63
+
64
+ // highlight-box → accentColor
65
+ h = renderPanel('highlight-box')
66
+ fireEvent.blur(screen.getByLabelText('Caption accent color'), { target: { value: '#444444' } })
67
+ expect(h.onCaptionEdit.mock.calls[0][0].captions.accentColor).toBe('#444444')
68
+ })
69
+
70
+ it('hides the accent swatch for styles with no accent', () => {
71
+ renderPanel('clean')
72
+ expect(screen.getByLabelText('Caption text color')).toBeTruthy()
73
+ expect(screen.queryByLabelText(/(accent|highlight|active|box) color/i)).toBeNull()
74
+ cleanup()
75
+
76
+ renderPanel('word-by-word')
77
+ expect(screen.queryByLabelText(/(accent|highlight|active|box) color/i)).toBeNull()
78
+ })
79
+
80
+ it('reflects the stored hex value on the swatch input', () => {
81
+ renderPanel('karaoke', { color: '#abcdef', highlightColor: '#00ff00' })
82
+ expect((screen.getByLabelText('Caption text color') as HTMLInputElement).value).toBe('#abcdef')
83
+ expect((screen.getByLabelText('Caption highlight color') as HTMLInputElement).value).toBe('#00ff00')
84
+ })
85
+ })