@bycrux/editor 1.3.0 → 1.4.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": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -110,7 +110,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
110
110
  role="dialog"
111
111
  aria-modal="true"
112
112
  aria-label={title}
113
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
113
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm text-[var(--editor-text)]"
114
114
  onClick={onClose}
115
115
  >
116
116
  <div
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { dirname, join, relative } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ // See ./node-shims.d.ts for why the `node:*` imports typecheck here.
6
+
7
+ // Anything this package portals to `document.body` lands OUTSIDE the editor
8
+ // root, so it no longer inherits the editor's `text-[var(--editor-text)]`.
9
+ // It inherits the HOST page's text colour instead: in the Montaj desktop app
10
+ // that is `text-gray-900` (#111827), which is exactly the dark theme's
11
+ // `--editor-surface`. Any child without its own text class (or whose class the
12
+ // host's Tailwind failed to emit) then renders dark on dark, which is how the
13
+ // Export dialog's Cancel label and tier dims went invisible.
14
+ //
15
+ // Guard: in every file that calls `createPortal`, every `fixed` overlay root
16
+ // must set its own text colour, so portalled content inherits the editor's
17
+ // text, never the host's.
18
+
19
+ const SRC_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
20
+ const TEXT_COLOUR = /(^|\s)text-(\[var\(--editor-text\)\]|white)(\s|$)/
21
+
22
+ function collectSourceFiles(dir: string, out: string[] = []): string[] {
23
+ for (const entry of readdirSync(dir)) {
24
+ if (entry === 'node_modules' || entry === '__tests__') continue
25
+ const full = join(dir, entry)
26
+ if (statSync(full).isDirectory()) collectSourceFiles(full, out)
27
+ else if (/\.tsx$/.test(entry) && !/\.test\.tsx$/.test(entry)) out.push(full)
28
+ }
29
+ return out
30
+ }
31
+
32
+ describe('portal roots set the editor text colour', () => {
33
+ it('every `fixed` root in a portalling component carries a text colour', () => {
34
+ const offenders: string[] = []
35
+ let checked = 0
36
+ for (const file of collectSourceFiles(SRC_ROOT)) {
37
+ const src = readFileSync(file, 'utf8')
38
+ if (!src.includes('createPortal(')) continue
39
+ // Static className strings and the static head of template literals.
40
+ const re = /className=(?:"([^"]*)"|\{`([^`]*)`\})/g
41
+ let m: RegExpExecArray | null
42
+ while ((m = re.exec(src)) !== null) {
43
+ const cls = (m[1] ?? m[2]).replace(/\$\{[^}]*\}/g, ' ')
44
+ if (!/(^|\s)fixed(\s|$)/.test(cls)) continue
45
+ checked += 1
46
+ if (!TEXT_COLOUR.test(cls)) {
47
+ const line = src.slice(0, m.index).split('\n').length
48
+ offenders.push(`${relative(SRC_ROOT, file)}:${line}: ${cls.trim()}`)
49
+ }
50
+ }
51
+ }
52
+ expect(checked).toBeGreaterThan(5)
53
+ expect(offenders).toEqual([])
54
+ })
55
+ })
@@ -70,7 +70,7 @@ function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark
70
70
  else if (/error|fail/i.test(t)) color = mode === 'light' ? 'text-red-600' : 'text-red-400'
71
71
 
72
72
  const prefix = text.startsWith('[render]')
73
- ? <span className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">[render] </span>
73
+ ? <span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">[render] </span>
74
74
  : null
75
75
 
76
76
  return (
@@ -240,7 +240,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
240
240
  // Portal to document.body (see RenderModal): a transformed host ancestor
241
241
  // would otherwise trap this `fixed` overlay and center the panel off-screen.
242
242
  return createPortal(
243
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
243
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
244
244
  <div className="w-[96vw] h-[96vh] bg-[var(--editor-bg)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex overflow-hidden">
245
245
 
246
246
  {/* Left — slide gallery */}
@@ -264,15 +264,15 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
264
264
  className="block w-full h-auto"
265
265
  style={{ aspectRatio: `${resolution[0]} / ${resolution[1]}` }}
266
266
  />
267
- <div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black text-[11px] text-[var(--editor-text)] font-mono flex justify-between">
267
+ <div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black text-[11px] text-white/90 font-mono flex justify-between">
268
268
  <span>#{String(i + 1).padStart(2, '0')}</span>
269
- <span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{slide.filename}</span>
269
+ <span className="text-white/60">{slide.filename}</span>
270
270
  </div>
271
271
  </a>
272
272
  ))}
273
273
  </div>
274
274
  ) : (
275
- <p className="text-sm text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Render complete.</p>
275
+ <p className="text-sm text-white/60">Render complete.</p>
276
276
  )}
277
277
  </div>
278
278
 
@@ -311,7 +311,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
311
311
  // Poll transport carries no per-line logs, so show a spinner + status text;
312
312
  // the SSE transport (montaj-native) streams logs into the log panel.
313
313
  return createPortal(
314
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
314
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
315
315
  <div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
316
316
 
317
317
  <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
@@ -357,7 +357,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
357
357
  className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)] bg-[var(--editor-bg)] flex flex-col gap-0.5"
358
358
  >
359
359
  {logs.length === 0 && status === 'running' && (
360
- <span className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] italic">Starting render engine…</span>
360
+ <span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] italic">Starting render engine…</span>
361
361
  )}
362
362
  {logs.map((line, i) => (
363
363
  <LogLine key={i} text={line} mode={mode} />
@@ -100,7 +100,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
100
100
 
101
101
  return (
102
102
  <div
103
- className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
103
+ className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 text-[var(--editor-text)]"
104
104
  onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
105
105
  >
106
106
  <div className="bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
@@ -156,7 +156,7 @@ export function VideoSourceCropModal({
156
156
 
157
157
  return (
158
158
  <div
159
- className="fixed inset-0 z-[300] flex items-center justify-center bg-black/70 p-6"
159
+ className="fixed inset-0 z-[300] flex items-center justify-center bg-black/70 p-6 text-[var(--editor-text)]"
160
160
  onPointerDown={onClose}
161
161
  >
162
162
  <div
@@ -165,7 +165,7 @@ export function VideoSourceCropModal({
165
165
  >
166
166
  <div className="flex items-center justify-between">
167
167
  <h2 className="text-sm font-semibold text-[var(--editor-text)]">Crop source</h2>
168
- <span className="text-xs text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">Pick the part of the footage to keep — position &amp; zoom live on the canvas</span>
168
+ <span className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Pick the part of the footage to keep. Position and zoom live on the canvas</span>
169
169
  </div>
170
170
 
171
171
  {/* Source frame + crop window */}
package/src/types.ts CHANGED
@@ -561,6 +561,8 @@ export interface EditorContext {
561
561
  selectedIds: string[]
562
562
  /** Selected caption segment id, if any. */
563
563
  selectedCaptionId: string | null
564
+ /** Whether the preview is playing. Absent from hosts that do not know. */
565
+ playing?: boolean
564
566
  }
565
567
 
566
568
  /**
@@ -681,7 +681,7 @@ export default function AudioPolishModal<P extends Project = Project>({
681
681
  // ── Render ────────────────────────────────────────────────────────────────
682
682
 
683
683
  const panel = (
684
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
684
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 text-[var(--editor-text)]">
685
685
  <div
686
686
  role="dialog"
687
687
  aria-label="Polish audio"
@@ -747,7 +747,7 @@ export default function AudioPolishModal<P extends Project = Project>({
747
747
  <option key={l.id} value={l.id}>{l.label}</option>
748
748
  ))}
749
749
  </select>
750
- <span className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] max-w-[15rem] leading-snug">
750
+ <span className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] max-w-[15rem] leading-snug">
751
751
  Set this before analysing. The wrong language makes real speech look like silence.
752
752
  </span>
753
753
  </label>
@@ -841,7 +841,7 @@ export default function AudioPolishModal<P extends Project = Project>({
841
841
  <section key={clip.id} className="flex flex-col gap-2">
842
842
  <header className="flex items-baseline justify-between gap-3 border-b border-[var(--editor-border)] pb-1">
843
843
  <span className="text-xs font-semibold text-[var(--editor-text)] truncate">{clipLabel(clip)}</span>
844
- <span className="text-[10px] font-mono text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] shrink-0">
844
+ <span className="text-[10px] font-mono text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] shrink-0">
845
845
  {`${clock(clip.start)} to ${clock(clip.end)}`}
846
846
  </span>
847
847
  </header>
@@ -914,7 +914,7 @@ export default function AudioPolishModal<P extends Project = Project>({
914
914
 
915
915
  {/* Removals */}
916
916
  {rows.length === 0 ? (
917
- <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] italic">No removals proposed for this clip.</p>
917
+ <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] italic">No removals proposed for this clip.</p>
918
918
  ) : (
919
919
  <ul className="flex flex-col gap-1">
920
920
  {rows.map(row => (
@@ -144,7 +144,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
144
144
  // Portal to document.body so a transformed host ancestor can't trap this
145
145
  // `fixed` overlay and push the panel off-screen (see RenderModal).
146
146
  return createPortal(
147
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
147
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
148
148
  <div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden">
149
149
 
150
150
  {/* Header */}
@@ -199,7 +199,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
199
199
  className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] bg-[var(--editor-surface)] flex flex-col gap-0.5"
200
200
  >
201
201
  {logs.length === 0 && status === 'running' && (
202
- <span className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] italic">Starting transcription…</span>
202
+ <span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] italic">Starting transcription…</span>
203
203
  )}
204
204
  {logs.map((line, i) => (
205
205
  <LogLine key={i} text={line} mode={mode} />
@@ -94,7 +94,7 @@ export default function CommandPalette({ commands, initialMode = 'list', onGoToT
94
94
 
95
95
  return createPortal(
96
96
  <div
97
- className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/70 backdrop-blur-sm"
97
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/70 backdrop-blur-sm text-[var(--editor-text)]"
98
98
  onClick={onClose}
99
99
  >
100
100
  <div
@@ -131,7 +131,7 @@ export default function CommandPalette({ commands, initialMode = 'list', onGoToT
131
131
  </div>
132
132
  <div className="max-h-80 overflow-y-auto py-1">
133
133
  {filtered.length === 0 && (
134
- <div className="px-4 py-3 text-xs text-[color-mix(in_srgb,var(--editor-text)_35%,transparent)]">No matching commands</div>
134
+ <div className="px-4 py-3 text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">No matching commands</div>
135
135
  )}
136
136
  {filtered.map((cmd, i) => (
137
137
  <button
@@ -16,9 +16,8 @@ export interface ImageToneMenuProps {
16
16
  * For host chrome at the top of the page.
17
17
  */
18
18
  variant?: 'icon' | 'header'
19
- /** Editor theme mode — light/dark. Only affects the icon variant's active-
20
- * state hue (violet-400 is sub-AA on a light bottom toolbar). Absent ->
21
- * dark, matching every existing caller. */
19
+ /** Editor theme mode — light/dark. Kept for API compatibility; the active state now
20
+ * reads the theme's accent tokens, which already adapt to light and dark. */
22
21
  mode?: 'light' | 'dark'
23
22
  }
24
23
 
@@ -29,7 +28,7 @@ export interface ImageToneMenuProps {
29
28
  * caller gates on settings.colorSpace). Each option shows an example: the same
30
29
  * generic photo converted through the actual render pipeline under that mode.
31
30
  */
32
- export default function ImageToneMenu({ value, onChange, variant = 'icon', mode = 'dark' }: ImageToneMenuProps) {
31
+ export default function ImageToneMenu({ value, onChange, variant = 'icon' }: ImageToneMenuProps) {
33
32
  const [open, setOpen] = useState(false)
34
33
  const rootRef = useRef<HTMLDivElement>(null)
35
34
 
@@ -78,7 +77,7 @@ export default function ImageToneMenu({ value, onChange, variant = 'icon', mode
78
77
  aria-haspopup="menu"
79
78
  className={`flex items-center justify-center w-5 h-5 rounded transition-colors ${
80
79
  open
81
- ? (mode === 'light' ? 'text-violet-600 bg-violet-400/15 hover:bg-violet-400/25' : 'text-violet-400 bg-violet-400/15 hover:bg-violet-400/25')
80
+ ? 'text-[var(--editor-accent-text)] bg-[color-mix(in_srgb,var(--editor-accent)_15%,transparent)] hover:bg-[color-mix(in_srgb,var(--editor-accent)_25%,transparent)]'
82
81
  : 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] bg-transparent hover:text-[var(--editor-text)]'
83
82
  }`}
84
83
  >
@@ -89,11 +88,11 @@ export default function ImageToneMenu({ value, onChange, variant = 'icon', mode
89
88
  {open && (
90
89
  <div
91
90
  role="menu"
92
- className={`${menuPosition} z-40 w-[300px] rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1`}
91
+ className={`${menuPosition} z-40 w-[300px] rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)] shadow-2xl p-2 flex flex-col gap-1`}
93
92
  >
94
93
  <p className="px-2 pt-1 pb-1.5 text-[11px] font-semibold text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]">
95
94
  Image color mapping
96
- <span className="block font-normal text-[10px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
95
+ <span className="block font-normal text-[10px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
97
96
  How photos and logos are converted for the HDR render.
98
97
  </span>
99
98
  </p>
@@ -107,7 +106,7 @@ export default function ImageToneMenu({ value, onChange, variant = 'icon', mode
107
106
  onClick={() => { onChange(tone.id); setOpen(false) }}
108
107
  className={`flex items-start gap-2.5 rounded-lg p-2 text-left transition-colors border ${
109
108
  active
110
- ? 'border-violet-400/60 bg-violet-400/10'
109
+ ? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
111
110
  : 'border-transparent hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
112
111
  }`}
113
112
  >
@@ -115,12 +115,10 @@ interface RenderModalProps<P extends Project = Project> {
115
115
  preRenderOptions?: PreRenderOptions
116
116
  /** Editor theme mode — light/dark. The export dialog and progress/error
117
117
  * panels follow `--editor-surface`, so warning/error hues need to darken
118
- * in light mode. The render-log terminal boxes stay dark by design and so
119
- * take no mode: they are painted an OPAQUE near-black (`#0a0e17`/`#0e131f`)
120
- * rather than a translucent `bg-black/40`, which would have composited to
121
- * mid-grey over a light surface instead of staying a terminal. Those two
122
- * values are exactly what the old translucent blacks composited to over the
123
- * dark `--editor-surface`, so dark mode is unchanged. Absent -> dark,
118
+ * in light mode. The render-log terminal boxes stay dark by design: they are
119
+ * painted an OPAQUE near-black (`#0a0e17`) with fixed white text, so they
120
+ * read the same under either theme. The phase stepper and footer buttons
121
+ * follow the theme tokens instead. Absent -> dark,
124
122
  * matching every existing caller. */
125
123
  mode?: 'light' | 'dark'
126
124
  }
@@ -309,15 +307,15 @@ function PhaseStepper({ current, phases }: { current: RenderPhase; phases: Rende
309
307
 
310
308
  function LogLine({ text }: { text: string }) {
311
309
  const t = text.replace(/^\[montaj render\]\s*/, '')
312
- let color = 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]'
310
+ let color = 'text-white/60'
313
311
  if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
314
312
  else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
315
313
  else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
316
- else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
314
+ else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-white/55'
317
315
  else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
318
316
 
319
317
  const prefix = text.startsWith('[montaj render]')
320
- ? <span className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">[render] </span>
318
+ ? <span className="text-white/40">[render] </span>
321
319
  : null
322
320
 
323
321
  return (
@@ -792,16 +790,16 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
792
790
  }
793
791
  return (<>
794
792
  {createPortal(
795
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
796
- <div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
793
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 text-[var(--editor-text)]">
794
+ <div className="w-full max-w-3xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
797
795
 
798
796
  {/* Header */}
799
- <div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
797
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
800
798
  <h2 className="text-sm font-semibold text-[var(--editor-text)]">Export</h2>
801
799
  <button
802
800
  onClick={handleCancel}
803
801
  aria-label="Close"
804
- className="text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
802
+ className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
805
803
  >
806
804
  ×
807
805
  </button>
@@ -813,7 +811,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
813
811
  {/* Left — cover preview + editor */}
814
812
  <div className="md:w-64 shrink-0 flex flex-col gap-2">
815
813
  <div
816
- className="relative mx-auto max-w-full overflow-hidden rounded-lg border border-white/10 bg-black/40 flex items-center justify-center"
814
+ className="relative mx-auto max-w-full overflow-hidden rounded-lg border border-[var(--editor-border)] bg-black/40 flex items-center justify-center"
817
815
  style={coverPortrait
818
816
  ? { aspectRatio: coverAspect, height: 'min(46vh, 22rem)' }
819
817
  : { aspectRatio: coverAspect, width: '100%' }}
@@ -827,7 +825,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
827
825
  ) : coverLoading ? (
828
826
  <Loader size="md" label="Loading cover" />
829
827
  ) : (
830
- <span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">No preview</span>
828
+ <span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">No preview</span>
831
829
  )}
832
830
  </div>
833
831
  {adapter.getSampleFrame && (
@@ -857,7 +855,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
857
855
  className={`relative shrink-0 overflow-hidden rounded border transition-colors ${
858
856
  active
859
857
  ? 'border-[var(--editor-accent)] ring-1 ring-[var(--editor-accent)]'
860
- : 'border-white/10 hover:border-white/40'
858
+ : 'border-[var(--editor-border)] hover:border-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]'
861
859
  }`}
862
860
  style={{ aspectRatio: coverAspect, height: 72 }}
863
861
  >
@@ -869,7 +867,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
869
867
  })}
870
868
  </div>
871
869
  ) : (
872
- <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">No frames to pick from.</p>
870
+ <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">No frames to pick from.</p>
873
871
  )
874
872
  )}
875
873
  </>
@@ -880,7 +878,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
880
878
  <div className="flex-1 min-w-0 flex flex-col gap-4">
881
879
  {/* Name */}
882
880
  <label className="flex flex-col gap-1.5">
883
- <span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">Name</span>
881
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Name</span>
884
882
  <input
885
883
  type="text"
886
884
  value={name}
@@ -892,9 +890,9 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
892
890
 
893
891
  {/* Save to */}
894
892
  <div className="flex flex-col gap-1">
895
- <span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">Save to</span>
893
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Save to</span>
896
894
  <span className="text-xs font-mono text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] truncate">output/{outputName}.mp4</span>
897
- <span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">Download after export.</span>
895
+ <span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Download after export.</span>
898
896
  </div>
899
897
 
900
898
  {/* Resolution — source-capped tier picker. Only rendered when the
@@ -908,7 +906,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
908
906
  const currentShort = Math.min(...res.value)
909
907
  return (
910
908
  <div className="flex flex-col gap-2">
911
- <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">Resolution</p>
909
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Resolution</p>
912
910
  <div role="radiogroup" aria-label="Resolution" className="grid grid-cols-2 sm:grid-cols-4 gap-2">
913
911
  {res.available.map(([w, h]) => {
914
912
  const short = Math.min(w, h)
@@ -921,12 +919,12 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
921
919
  onClick={() => res.set([w, h])}
922
920
  className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
923
921
  active
924
- ? 'border-violet-400/60 bg-violet-400/10'
922
+ ? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
925
923
  : 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
926
924
  }`}
927
925
  >
928
926
  <span className="text-xs font-semibold text-[var(--editor-text)]">{resLabel([w, h])}</span>
929
- <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">{w} × {h}</span>
927
+ <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{w} × {h}</span>
930
928
  </button>
931
929
  )
932
930
  })}
@@ -940,7 +938,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
940
938
  const fps = preRenderOptions.fps
941
939
  return (
942
940
  <div className="flex flex-col gap-2">
943
- <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">Frame rate</p>
941
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Frame rate</p>
944
942
  <div role="radiogroup" aria-label="Frame rate" className="grid grid-cols-3 gap-2">
945
943
  {fps.available.map(f => {
946
944
  const active = f === fps.value
@@ -953,12 +951,12 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
953
951
  onClick={() => fps.set(f)}
954
952
  className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
955
953
  active
956
- ? 'border-violet-400/60 bg-violet-400/10'
954
+ ? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
957
955
  : 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
958
956
  }`}
959
957
  >
960
958
  <span className="text-xs font-semibold text-[var(--editor-text)]">{f} fps</span>
961
- <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">{hint}</span>
959
+ <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{hint}</span>
962
960
  </button>
963
961
  )
964
962
  })}
@@ -972,7 +970,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
972
970
  <>
973
971
  {/* Format */}
974
972
  <div className="flex flex-col gap-2">
975
- <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">Format</p>
973
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Format</p>
976
974
  <div role="radiogroup" aria-label="Format" className="grid grid-cols-3 gap-2">
977
975
  {EXPORT_CHOICES.map(choice => {
978
976
  const active = choice.id === exportChoice
@@ -984,12 +982,12 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
984
982
  onClick={() => setExport(choice.id)}
985
983
  className={`flex flex-col gap-1 rounded-lg border p-2.5 text-left transition-colors ${
986
984
  active
987
- ? 'border-violet-400/60 bg-violet-400/10'
985
+ ? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
988
986
  : 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
989
987
  }`}
990
988
  >
991
989
  <span className="text-xs font-semibold text-[var(--editor-text)]">{choice.label}</span>
992
- <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">{choice.blurb}</span>
990
+ <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{choice.blurb}</span>
993
991
  </button>
994
992
  )
995
993
  })}
@@ -1018,7 +1016,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1018
1016
 
1019
1017
  {imageColorOpen ? (
1020
1018
  <div className="flex flex-col gap-2">
1021
- <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
1019
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
1022
1020
  How photos and logos convert for HDR
1023
1021
  </p>
1024
1022
  <div role="radiogroup" aria-label="Image color" className="grid grid-cols-2 gap-2">
@@ -1032,7 +1030,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1032
1030
  onClick={() => it.set(t.id)}
1033
1031
  className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
1034
1032
  active
1035
- ? 'border-violet-400/60 bg-violet-400/10'
1033
+ ? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
1036
1034
  : 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
1037
1035
  }`}
1038
1036
  >
@@ -1044,7 +1042,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1044
1042
  <span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
1045
1043
  {t.label}
1046
1044
  {t.id === DEFAULT_IMAGE_TONE && (
1047
- <span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">default</span>
1045
+ <span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">default</span>
1048
1046
  )}
1049
1047
  </span>
1050
1048
  <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{t.summary}</span>
@@ -1054,7 +1052,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1054
1052
  </div>
1055
1053
  </div>
1056
1054
  ) : (
1057
- <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
1055
+ <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
1058
1056
  Photos and logos use the {toneInfo.label} conversion. Open to change.
1059
1057
  </p>
1060
1058
  )}
@@ -1075,7 +1073,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1075
1073
 
1076
1074
  {advancedOpen ? (
1077
1075
  <div className="flex flex-col gap-2">
1078
- <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
1076
+ <p className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
1079
1077
  Tone curve for SDR
1080
1078
  </p>
1081
1079
  <div role="radiogroup" aria-label="Tone curve for SDR" className="grid grid-cols-2 gap-2">
@@ -1090,7 +1088,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1090
1088
  onClick={() => setSdrCurve(curve.id)}
1091
1089
  className={`flex flex-col gap-1.5 rounded-lg border p-2 text-left transition-colors ${
1092
1090
  active
1093
- ? 'border-violet-400/60 bg-violet-400/10'
1091
+ ? 'border-[var(--editor-accent)] bg-[color-mix(in_srgb,var(--editor-accent)_12%,transparent)]'
1094
1092
  : 'border-[var(--editor-border)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]'
1095
1093
  }`}
1096
1094
  >
@@ -1127,7 +1125,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1127
1125
  <span className="text-xs font-semibold text-[var(--editor-text)] flex items-center gap-1.5">
1128
1126
  {curve.label}
1129
1127
  {curve.id === DEFAULT_SDR_CURVE && (
1130
- <span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">default</span>
1128
+ <span className="text-[9px] font-normal px-1 py-px rounded bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">default</span>
1131
1129
  )}
1132
1130
  </span>
1133
1131
  <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{curve.blurb}</span>
@@ -1139,7 +1137,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1139
1137
  data-testid="sdr-honesty-line"
1140
1138
  className={`text-[11px] leading-snug ${
1141
1139
  sdrCurve === DEFAULT_SDR_CURVE
1142
- ? 'text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
1140
+ ? 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]'
1143
1141
  : (mode === 'light' ? 'text-amber-700' : 'text-amber-400/90')
1144
1142
  }`}
1145
1143
  >
@@ -1147,7 +1145,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1147
1145
  </p>
1148
1146
  </div>
1149
1147
  ) : (
1150
- <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)]">
1148
+ <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
1151
1149
  SDR files use the {curveInfo.label} look. Open Advanced to compare curves.
1152
1150
  </p>
1153
1151
  )}
@@ -1159,7 +1157,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1159
1157
 
1160
1158
  {/* Footer — duration on the left, actions on the right. */}
1161
1159
  <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
1162
- <span className="text-xs text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)] tabular-nums">
1160
+ <span className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] tabular-nums">
1163
1161
  {durationSec > 0 ? formatTimecode(durationSec) : ''}
1164
1162
  </span>
1165
1163
  <div className="flex items-center gap-2">
@@ -1183,7 +1181,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1183
1181
  )}
1184
1182
  {expandedCurve && expandedUrl && createPortal(
1185
1183
  <div
1186
- className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 bg-black/90 p-6"
1184
+ className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-3 bg-black/90 p-6 text-white"
1187
1185
  onClick={() => setExpandedCurveId(null)}
1188
1186
  role="dialog"
1189
1187
  aria-label={`${expandedCurve.label} preview`}
@@ -1243,7 +1241,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1243
1241
  // block for this `fixed` overlay, sizing it to the scrolled page height and
1244
1242
  // centering the panel off-screen below the fold.
1245
1243
  return createPortal(
1246
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
1244
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black text-[var(--editor-text)]">
1247
1245
  <div className="w-[96vw] h-[96vh] bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex overflow-hidden">
1248
1246
 
1249
1247
  {/* Left — video */}
@@ -1257,7 +1255,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1257
1255
  className="h-full w-full object-contain"
1258
1256
  />
1259
1257
  ) : (
1260
- <p className="text-sm text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Render complete.</p>
1258
+ <p className="text-sm text-white/60">Render complete.</p>
1261
1259
  )}
1262
1260
  </div>
1263
1261
 
@@ -1271,7 +1269,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1271
1269
  <p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Saved to your library.</p>
1272
1270
  </div>
1273
1271
  </div>
1274
- <button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
1272
+ <button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
1275
1273
  </div>
1276
1274
 
1277
1275
  <div className="flex flex-col gap-3 p-5 flex-1">
@@ -1281,7 +1279,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1281
1279
  <a
1282
1280
  href={videoUrl}
1283
1281
  download={downloadName}
1284
- className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-green-800/60 border border-green-700 text-green-200 hover:bg-green-700/60 transition-colors font-medium"
1282
+ className={`w-full text-center text-sm px-4 py-2.5 rounded-lg border transition-colors font-medium ${mode === 'light' ? 'bg-green-600 border-green-700 text-white hover:bg-green-700' : 'bg-green-800/60 border-green-700 text-green-200 hover:bg-green-700/60'}`}
1285
1283
  >
1286
1284
  {extraOutputs.length > 0 ? 'Download HDR master' : 'Download'}
1287
1285
  </a>
@@ -1292,7 +1290,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1292
1290
  key={p}
1293
1291
  href={adapter.fileUrl(p)}
1294
1292
  download={basename(p)}
1295
- className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border border-green-800 text-green-200/90 hover:bg-green-900/40 transition-colors font-medium"
1293
+ className={`w-full text-center text-sm px-4 py-2.5 rounded-lg bg-[var(--editor-surface)] border transition-colors font-medium ${mode === 'light' ? 'border-green-600 text-green-700 hover:bg-green-50' : 'border-green-800 text-green-200/90 hover:bg-green-900/40'}`}
1296
1294
  >
1297
1295
  Download SDR version
1298
1296
  </a>
@@ -1312,11 +1310,11 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1312
1310
  }
1313
1311
 
1314
1312
  return createPortal(
1315
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
1316
- <div className="w-full max-w-md bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
1313
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 text-[var(--editor-text)]">
1314
+ <div className="w-full max-w-md bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
1317
1315
 
1318
1316
  {/* Header */}
1319
- <div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
1317
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
1320
1318
  <div className="flex items-center gap-2.5">
1321
1319
  {status === 'running' && <span className="w-2 h-2 rounded-full bg-[var(--editor-accent)] animate-pulse" />}
1322
1320
  {status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
@@ -1325,7 +1323,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1325
1323
  </h2>
1326
1324
  </div>
1327
1325
  {status !== 'running' && (
1328
- <button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
1326
+ <button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
1329
1327
  )}
1330
1328
  </div>
1331
1329
 
@@ -1336,7 +1334,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1336
1334
 
1337
1335
  <div className="flex flex-col items-center gap-1 text-center">
1338
1336
  <h3 className="text-base font-semibold text-[var(--editor-text)]">Exporting your video</h3>
1339
- <p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">This can take a few minutes for longer videos.</p>
1337
+ <p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">This can take a few minutes for longer videos.</p>
1340
1338
  </div>
1341
1339
 
1342
1340
  {/* Progress bar — always visible while rendering, above the stepper
@@ -1355,7 +1353,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1355
1353
 
1356
1354
  {/* Granular phase progress (poll hosts advance through phases). */}
1357
1355
  {view !== 'logs' && (
1358
- <div className="w-full max-w-xs rounded-xl border border-white/10 bg-[#0e131f] px-4 py-3">
1356
+ <div className="w-full max-w-xs rounded-xl border border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-text)_4%,var(--editor-surface))] px-4 py-3">
1359
1357
  <PhaseStepper current={phase} phases={stepperPhases(renderOpts)} />
1360
1358
  </div>
1361
1359
  )}
@@ -1366,7 +1364,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1366
1364
  <button
1367
1365
  onClick={() => setShowLog(o => !o)}
1368
1366
  aria-expanded={showLog}
1369
- className="mx-auto flex items-center gap-1 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] transition-colors"
1367
+ className="mx-auto flex items-center gap-1 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] transition-colors"
1370
1368
  >
1371
1369
  {showLog ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
1372
1370
  {showLog ? 'Hide render log' : 'Show render log'}
@@ -1375,17 +1373,17 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1375
1373
  <div className="relative mt-2">
1376
1374
  <button
1377
1375
  onClick={() => navigator.clipboard.writeText(logs.join('\n'))}
1378
- className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors"
1376
+ className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-white/60 hover:text-white transition-colors"
1379
1377
  title="Copy logs"
1380
1378
  >
1381
1379
  Copy
1382
1380
  </button>
1383
1381
  <div
1384
1382
  ref={logRef}
1385
- className="flex h-52 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]"
1383
+ className="flex h-52 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-white/80"
1386
1384
  >
1387
1385
  {logs.length === 0
1388
- ? <span className="italic text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">Starting render engine…</span>
1386
+ ? <span className="italic text-white/40">Starting render engine…</span>
1389
1387
  : logs.map((line, i) => <LogLine key={i} text={line} />)}
1390
1388
  </div>
1391
1389
  </div>
@@ -1400,14 +1398,14 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1400
1398
  <div className="relative">
1401
1399
  <button
1402
1400
  onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
1403
- className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors"
1401
+ className="absolute top-2 right-2 z-10 rounded border border-white/10 bg-[#0a0e17] px-2 py-0.5 text-[10px] text-white/60 hover:text-white transition-colors"
1404
1402
  title="Copy logs"
1405
1403
  >
1406
1404
  Copy
1407
1405
  </button>
1408
1406
  <div
1409
1407
  ref={logRef}
1410
- className="flex h-72 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]"
1408
+ className="flex h-72 flex-col gap-0.5 overflow-y-auto rounded-lg border border-white/10 bg-[#0a0e17] px-3 py-2 font-mono text-[11px] text-white/80"
1411
1409
  >
1412
1410
  {logs.map((line, i) => <LogLine key={i} text={line} />)}
1413
1411
  {errorMsg && <span className="mt-1 text-red-400">{errorMsg}</span>}
@@ -1422,18 +1420,18 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
1422
1420
  )}
1423
1421
 
1424
1422
  {/* Footer */}
1425
- <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-white/10">
1423
+ <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
1426
1424
  {status === 'running' ? (
1427
1425
  <button
1428
1426
  onClick={handleCancel}
1429
- className={`text-sm px-4 py-1.5 rounded-md bg-[#0e131f] border border-white/10 text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] transition-colors ${mode === 'light' ? 'hover:bg-red-50 hover:border-red-300 hover:text-red-700' : 'hover:bg-red-900/40 hover:border-red-700 hover:text-red-300'}`}
1427
+ className={`text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] transition-colors ${mode === 'light' ? 'hover:bg-red-50 hover:border-red-300 hover:text-red-700' : 'hover:bg-red-900/40 hover:border-red-700 hover:text-red-300'}`}
1430
1428
  >
1431
1429
  Cancel
1432
1430
  </button>
1433
1431
  ) : (
1434
1432
  <button
1435
1433
  onClick={onClose}
1436
- className="text-sm px-4 py-1.5 rounded-md bg-[#0e131f] border border-white/10 text-[var(--editor-text)] hover:opacity-90 transition-colors"
1434
+ className="text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)] hover:opacity-90 transition-colors"
1437
1435
  >
1438
1436
  Close
1439
1437
  </button>
@@ -169,11 +169,11 @@ export default function VersionCompare({
169
169
  }, [onClose])
170
170
 
171
171
  return createPortal(
172
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4">
173
- <div className="w-full max-w-5xl bg-[var(--editor-surface)] border border-white/10 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
172
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 text-[var(--editor-text)]">
173
+ <div className="w-full max-w-5xl bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-2xl shadow-2xl flex flex-col overflow-hidden">
174
174
 
175
175
  {/* Header */}
176
- <div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
176
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
177
177
  <h2 className="text-sm font-semibold text-[var(--editor-text)]">Compare versions</h2>
178
178
  <button
179
179
  onClick={onClose}
@@ -844,6 +844,9 @@ function ReviewSurface<P extends Project>({
844
844
  // one caption id in `selectedIds` — taking the first is correct, the
845
845
  // preview only ever shows one box.
846
846
  const selectedCaptionId = selectedIds.find(id => captionIdSet.has(id)) ?? null
847
+ // Transport state for the context report. PreviewPlayer pushes it on a real
848
+ // play/pause change only, so this re-renders the surface per toggle, never per frame.
849
+ const [previewPlaying, setPreviewPlaying] = useState(false)
847
850
  // Publish what we are looking at, so an agent can resolve "this section".
848
851
  // No-ops entirely on a host that does not implement reportContext.
849
852
  useReportContext({
@@ -852,6 +855,7 @@ function ReviewSurface<P extends Project>({
852
855
  clock,
853
856
  selectedIds,
854
857
  selectedCaptionId,
858
+ playing: previewPlaying,
855
859
  })
856
860
  // Selecting a caption anywhere (timeline, preview box, or the caption list —
857
861
  // all funnel through `selectedIds` → `selectedCaptionId`) jumps the left
@@ -2115,6 +2119,7 @@ function ReviewSurface<P extends Project>({
2115
2119
  onCaptionSegmentChange={handleCaptionSegmentChange}
2116
2120
  engine={engine}
2117
2121
  transportRef={transportRef}
2122
+ onPlayingChange={setPreviewPlaying}
2118
2123
  scrubHandleRef={scrubHandleRef}
2119
2124
  hoverScrub={hoverScrub}
2120
2125
  // Social-media preview chrome (mirrors CapCut's "Preview your
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
2
+ import { render, screen, within, cleanup } from '@testing-library/react'
3
+ import type { EditorAdapter, Project, ImageElement } from '../../types'
4
+ import RenderModal from '../RenderModal'
5
+
6
+ // The Export dialog portals to document.body, outside the editor root. A host
7
+ // whose page text is dark (the desktop app's `text-gray-900`) must not leak
8
+ // that colour into the dialog: the portal root sets the editor's own text
9
+ // colour, and every control carries an explicit themed colour.
10
+
11
+ afterEach(cleanup)
12
+
13
+ const HOST_DARK_TEXT = 'rgb(17, 24, 39)'
14
+ let prevColor = ''
15
+ beforeEach(() => { prevColor = document.body.style.color; document.body.style.color = HOST_DARK_TEXT })
16
+ afterEach(() => { document.body.style.color = prevColor })
17
+
18
+ function adapter(): EditorAdapter<Project> {
19
+ return {
20
+ loadProject: vi.fn(),
21
+ saveProject: vi.fn(),
22
+ subscribe: () => () => {},
23
+ render: vi.fn(async function* () {}),
24
+ renderAsync: vi.fn(async () => ({ status: 'running' })),
25
+ getRenderStatus: vi.fn(async () => ({ status: 'running' as const, phase: 'rendering' as const })),
26
+ resolveImageSrc: (el: ImageElement) => el.src,
27
+ compileOverlay: vi.fn(async () => () => null),
28
+ listGlobalOverlays: vi.fn(async () => []),
29
+ listSystemOverlays: vi.fn(async () => []),
30
+ uploadFile: vi.fn(async () => ''),
31
+ fileUrl: (p: string) => `/files?path=${p}`,
32
+ } as unknown as EditorAdapter<Project>
33
+ }
34
+
35
+ function renderExport() {
36
+ return render(
37
+ <RenderModal
38
+ adapter={adapter()}
39
+ projectId="vid-1"
40
+ onClose={vi.fn()}
41
+ preRenderOptions={{
42
+ isHdr: false,
43
+ keeps: [{ start: 0, end: 12 }],
44
+ resolution: { value: [1080, 1920], available: [[720, 1280], [1080, 1920]], set: vi.fn() },
45
+ }}
46
+ />,
47
+ )
48
+ }
49
+
50
+ const THEMED_TEXT = /(^|\s)text-\[(var\(--editor-text\)|color-mix\(in_srgb,var\(--editor-text\)_\d+%,transparent\))\]/
51
+
52
+ describe('Export dialog in a dark-text host', () => {
53
+ it('the portal root sets the editor text colour', () => {
54
+ renderExport()
55
+ const cancel = screen.getByRole('button', { name: 'Cancel' })
56
+ const root = cancel.closest('.fixed')
57
+ expect(root).not.toBeNull()
58
+ expect(root!.className).toMatch(/(^|\s)text-\[var\(--editor-text\)\](\s|$)/)
59
+ })
60
+
61
+ it('Cancel has a non-empty label and an explicit themed text colour', () => {
62
+ renderExport()
63
+ const cancel = screen.getByRole('button', { name: 'Cancel' })
64
+ expect(cancel.textContent?.trim()).toBe('Cancel')
65
+ expect(cancel.className).toMatch(THEMED_TEXT)
66
+ })
67
+
68
+ it('every resolution tile, 720p included, shows its dims in a themed colour', () => {
69
+ renderExport()
70
+ const group = screen.getByRole('radiogroup', { name: 'Resolution' })
71
+ const tile720 = within(group).getByRole('radio', { name: /720p/ })
72
+ const dims720 = within(tile720).getByText('720 × 1280')
73
+ expect(dims720.className).toMatch(THEMED_TEXT)
74
+ const tile1080 = within(group).getByRole('radio', { name: /1080p/ })
75
+ expect(within(tile1080).getByText('1080 × 1920').className).toMatch(THEMED_TEXT)
76
+ })
77
+
78
+ it('the selected tile uses the editor accent, not a hard-coded violet', () => {
79
+ renderExport()
80
+ const group = screen.getByRole('radiogroup', { name: 'Resolution' })
81
+ const active = within(group).getByRole('radio', { checked: true })
82
+ expect(active.className).toContain('border-[var(--editor-accent)]')
83
+ expect(active.className).not.toMatch(/violet|indigo/)
84
+ })
85
+ })
@@ -22,6 +22,12 @@ describe('VideoEditor context reporting', () => {
22
22
  expect(call).toMatch(/adapter/)
23
23
  })
24
24
 
25
+ it('reports the transport state PreviewPlayer pushes on play/pause', () => {
26
+ const call = src.match(/useReportContext\(\{[\s\S]*?\}\)/)?.[0] ?? ''
27
+ expect(call).toMatch(/playing: previewPlaying/)
28
+ expect(src).toMatch(/onPlayingChange=\{setPreviewPlaying\}/)
29
+ })
30
+
25
31
  // D1: captions share `selectedIds` with clips/audio now, so `selectedCaptionId`
26
32
  // must be DERIVED from that array rather than tracked as its own `useState` —
27
33
  // a second selection model is exactly the bug a future refactor could
@@ -163,8 +163,12 @@ describe('availableResolutionTiers', () => {
163
163
  })
164
164
 
165
165
  describe('availableFpsTiers', () => {
166
- it('returns [24, 30] when settings.fps is 30', () => {
167
- expect(availableFpsTiers(makeProject({ settings: { resolution: [1080, 1920], fps: 30 } }))).toEqual([24, 30])
166
+ it('returns [24, 30] when settings.fps is 30 and the project has a video item', () => {
167
+ const p = makeProject({
168
+ settings: { resolution: [1080, 1920], fps: 30 },
169
+ tracks: vtracks([videoClip()]),
170
+ })
171
+ expect(availableFpsTiers(p)).toEqual([24, 30])
168
172
  })
169
173
 
170
174
  it('returns [24, 30, 60] when settings.fps is 60', () => {
@@ -172,6 +176,27 @@ describe('availableFpsTiers', () => {
172
176
  24, 30, 60,
173
177
  ])
174
178
  })
179
+
180
+ it('offers every tier at fps 30 when the project has no video items (overlay/canvas-only)', () => {
181
+ const p = makeProject({
182
+ settings: { resolution: [1080, 1920], fps: 30 },
183
+ tracks: vtracks([{ id: 'o1', type: 'overlay', start: 0, end: 5 }]),
184
+ })
185
+ expect(availableFpsTiers(p)).toEqual([24, 30, 60])
186
+ })
187
+
188
+ it('offers every tier at fps 30 when tracks is empty (no items of any kind)', () => {
189
+ const p = makeProject({ settings: { resolution: [1080, 1920], fps: 30 } })
190
+ expect(availableFpsTiers(p)).toEqual([24, 30, 60])
191
+ })
192
+
193
+ it('still caps to [24, 30] for a video item missing source dims (has-video check ignores dims)', () => {
194
+ const p = makeProject({
195
+ settings: { resolution: [1080, 1920], fps: 30 },
196
+ tracks: vtracks([videoClip()]),
197
+ })
198
+ expect(availableFpsTiers(p)).toEqual([24, 30])
199
+ })
175
200
  })
176
201
 
177
202
  describe('currentResolutionTier', () => {
@@ -26,7 +26,7 @@ describe('useReportContext', () => {
26
26
  selectedIds: [], selectedCaptionId: null,
27
27
  }))
28
28
  expect(reportContext).toHaveBeenCalledWith('p1', {
29
- playheadSec: 4, selectedIds: [], selectedCaptionId: null,
29
+ playheadSec: 4, selectedIds: [], selectedCaptionId: null, playing: false,
30
30
  })
31
31
  })
32
32
 
@@ -71,6 +71,27 @@ describe('useReportContext', () => {
71
71
  expect(reportContext.mock.calls[0][1].selectedIds).toEqual(['c3'])
72
72
  })
73
73
 
74
+ it('reports play and pause immediately, carrying the playhead', () => {
75
+ const reportContext = vi.fn().mockResolvedValue(undefined)
76
+ const clock = createPlaybackClock()
77
+ const adapter = { reportContext } as never
78
+ const { rerender } = renderHook(
79
+ (props: { playing: boolean }) => useReportContext({
80
+ adapter, projectId: 'p1', clock,
81
+ selectedIds: [], selectedCaptionId: null, playing: props.playing,
82
+ }),
83
+ { initialProps: { playing: false } },
84
+ )
85
+ reportContext.mockClear()
86
+ rerender({ playing: true })
87
+ expect(reportContext).toHaveBeenCalledTimes(1)
88
+ expect(reportContext.mock.calls[0][1].playing).toBe(true)
89
+ act(() => { clock.set(2.5) })
90
+ rerender({ playing: false })
91
+ expect(reportContext).toHaveBeenCalledTimes(2)
92
+ expect(reportContext.mock.calls[1][1]).toMatchObject({ playing: false, playheadSec: 2.5 })
93
+ })
94
+
74
95
  it('swallows a rejected report — context sync never breaks the editor', async () => {
75
96
  const reportContext = vi.fn().mockRejectedValue(new Error('serve is down'))
76
97
  const clock = createPlaybackClock()
@@ -31,6 +31,21 @@ function maxSourceShortSide(project: Project): number | undefined {
31
31
  return max
32
32
  }
33
33
 
34
+ /** Whether the project has any video item at all (regardless of whether its
35
+ * source dims are known). Canvas/animation projects — overlays, images,
36
+ * audio, no footage — have no source fps to protect and render cleanly at
37
+ * any fps, so `availableFpsTiers` uses this (not `maxSourceShortSide`, which
38
+ * also returns undefined for a video item with unknown dims) to decide
39
+ * whether the fps cap applies at all. */
40
+ function hasVideoItem(project: Project): boolean {
41
+ for (const items of trackItems(project)) {
42
+ for (const item of items) {
43
+ if (item.type === 'video') return true
44
+ }
45
+ }
46
+ return false
47
+ }
48
+
34
49
  /** Converts a short-side tier to [w,h], preserving the project's aspect and
35
50
  * orientation. Long side is rounded to the nearest EVEN integer (encoders
36
51
  * demand even dims). */
@@ -79,8 +94,14 @@ export function availableResolutionTiers(project: Project): Array<[number, numbe
79
94
  }
80
95
 
81
96
  /** Every fps tier offerable in the export dialog, filtered to the source cap
82
- * and ordered ascending. Always non-empty. */
97
+ * and ordered ascending. Always non-empty.
98
+ *
99
+ * The cap only protects source footage, so a project with no video items at
100
+ * all — overlays/images/audio only, e.g. a canvas/animation project — has
101
+ * nothing to protect and offers every standard tier uncapped. */
83
102
  export function availableFpsTiers(project: Project): number[] {
103
+ if (!hasVideoItem(project)) return [...STANDARD_FPS_TIERS]
104
+
84
105
  const cap = maxExportFps(project)
85
106
  const tiers = STANDARD_FPS_TIERS.filter(f => f <= cap)
86
107
  return tiers.length > 0 ? tiers : [cap]
@@ -85,6 +85,11 @@ interface PreviewPlayerProps {
85
85
  * engine). Absent → no-op; nothing reads or writes it.
86
86
  */
87
87
  transportRef?: MutableRefObject<TransportHandle | null>
88
+ /**
89
+ * Called with the transport's playing state whenever it changes (and once on
90
+ * mount), from whichever playback path is active. Absent → no-op.
91
+ */
92
+ onPlayingChange?: (playing: boolean) => void
88
93
  /**
89
94
  * The audible drag-scrub source's engine seam — mirrors `transportRef`.
90
95
  * Absent → no-op; nothing reads or writes it.
@@ -314,6 +319,7 @@ function PreviewSurface({
314
319
  onCaptionSegmentChange,
315
320
  engine,
316
321
  transportRef,
322
+ onPlayingChange,
317
323
  scrubHandleRef,
318
324
  socialPreview,
319
325
  muted,
@@ -378,6 +384,14 @@ function PreviewSurface({
378
384
  return () => { transportRef.current = null }
379
385
  }, [transportRef, togglePlay, isPlaying, setRate])
380
386
 
387
+ // Push play/pause to the host. Held in a ref so a new callback identity
388
+ // never re-fires it; only a real `isPlaying` change does.
389
+ const onPlayingChangeRef = useRef(onPlayingChange)
390
+ onPlayingChangeRef.current = onPlayingChange
391
+ useEffect(() => {
392
+ onPlayingChangeRef.current?.(isPlaying)
393
+ }, [isPlaying])
394
+
381
395
  // The scrub seam: only the engine path can decode a grain, so the legacy
382
396
  // `<video>` fallback simply never fills this — that absence is the audible
383
397
  // drag-scrub's capability gate. `acquireDemux` is a stable-identity callback
@@ -145,7 +145,7 @@ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose,
145
145
  ref={popoverRef}
146
146
  role="menu"
147
147
  aria-label="Preview for social media"
148
- className="fixed z-[100] w-64 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1"
148
+ className="fixed z-[100] w-64 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1 text-[var(--editor-text)]"
149
149
  style={{
150
150
  left: position?.left ?? -9999,
151
151
  top: position?.top ?? -9999,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `onPlayingChange` — PreviewPlayer pushes the ACTIVE transport's playing
3
+ * state to the host (VideoEditor feeds it to the agent context report). Driven
4
+ * through the same `transportRef` seam the keymap uses, on a canvas project so
5
+ * play/pause is a pure state flip with no media element in the way.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
8
+ import { render, act } from '@testing-library/react'
9
+ import type { MutableRefObject } from 'react'
10
+ import PreviewPlayer, { type TransportHandle } from '../PreviewPlayer'
11
+ import { createPlaybackClock } from '../../playback-clock'
12
+ import type { EditorProject as Project } from '../../../schema'
13
+
14
+ function makeCanvasProject(): Project {
15
+ return {
16
+ id: 'p-canvas',
17
+ status: 'draft',
18
+ settings: { resolution: [1080, 1920], fps: 30 },
19
+ tracks: [[{ id: 'o1', type: 'overlay', src: 'a.jsx', start: 0, end: 4, props: {} }]],
20
+ } as unknown as Project
21
+ }
22
+
23
+ beforeEach(() => {
24
+ vi.spyOn(console, 'error').mockImplementation(() => {})
25
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ }
30
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
31
+ state = 'running'
32
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
33
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
34
+ get destination() { return {} }
35
+ resume() { return Promise.resolve() }
36
+ close() {}
37
+ }
38
+ })
39
+ afterEach(() => { vi.restoreAllMocks() })
40
+
41
+ describe('PreviewPlayer onPlayingChange', () => {
42
+ it('reports paused on mount, then each real play/pause change', () => {
43
+ const transportRef: MutableRefObject<TransportHandle | null> = { current: null }
44
+ const onPlayingChange = vi.fn()
45
+ render(
46
+ <PreviewPlayer
47
+ project={makeCanvasProject()}
48
+ clock={createPlaybackClock(0)}
49
+ compileOverlay={async () => (() => null) as never}
50
+ fileUrl={(p) => p}
51
+ transportRef={transportRef}
52
+ onPlayingChange={onPlayingChange}
53
+ />,
54
+ )
55
+ expect(onPlayingChange.mock.calls).toEqual([[false]])
56
+ act(() => { transportRef.current!.togglePlay() })
57
+ expect(onPlayingChange.mock.lastCall).toEqual([true])
58
+ act(() => { transportRef.current!.togglePlay() })
59
+ expect(onPlayingChange.mock.lastCall).toEqual([false])
60
+ expect(onPlayingChange).toHaveBeenCalledTimes(3)
61
+ })
62
+ })
@@ -174,7 +174,7 @@ export default function TrackSettingsPopover({
174
174
  ref={popoverRef}
175
175
  role="dialog"
176
176
  aria-label={title}
177
- className="fixed z-[100] w-64 rounded-lg border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-3 flex flex-col gap-3"
177
+ className="fixed z-[100] w-64 rounded-lg border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-3 flex flex-col gap-3 text-[var(--editor-text)]"
178
178
  style={{
179
179
  left: position?.left ?? -9999,
180
180
  top: position?.top ?? -9999,
@@ -23,6 +23,8 @@ interface Options {
23
23
  clock: PlaybackClock
24
24
  selectedIds: string[]
25
25
  selectedCaptionId: string | null
26
+ /** Transport state. Reported like selection: at once, on change. */
27
+ playing?: boolean
26
28
  }
27
29
 
28
30
  /**
@@ -36,12 +38,12 @@ interface Options {
36
38
  * selection.
37
39
  */
38
40
  export function useReportContext({
39
- adapter, projectId, clock, selectedIds, selectedCaptionId,
41
+ adapter, projectId, clock, selectedIds, selectedCaptionId, playing = false,
40
42
  }: Options): void {
41
43
  // Kept in a ref so the interval effect never re-subscribes on a selection
42
44
  // change — it always reads the latest values without depending on them.
43
- const latest = useRef({ selectedIds, selectedCaptionId })
44
- latest.current = { selectedIds, selectedCaptionId }
45
+ const latest = useRef({ selectedIds, selectedCaptionId, playing })
46
+ latest.current = { selectedIds, selectedCaptionId, playing }
45
47
 
46
48
  const send = useRef<(ctx: EditorContext) => void>(() => {})
47
49
  send.current = (ctx: EditorContext) => {
@@ -58,18 +60,19 @@ export function useReportContext({
58
60
  playheadSec: clock.get(),
59
61
  selectedIds: latest.current.selectedIds,
60
62
  selectedCaptionId: latest.current.selectedCaptionId,
63
+ playing: latest.current.playing,
61
64
  })
62
65
  emit()
63
66
  const id = setInterval(emit, REPORT_INTERVAL_MS)
64
67
  return () => clearInterval(id)
65
68
  }, [adapter, projectId, clock])
66
69
 
67
- // Selection, immediately on change. Skips the mount emit above by comparing
68
- // against what the interval effect already sent.
70
+ // Selection and play/pause, immediately on change. Skips the mount emit
71
+ // above by comparing against what the interval effect already sent.
69
72
  const mounted = useRef(false)
70
73
  useEffect(() => {
71
74
  if (!adapter.reportContext) return
72
75
  if (!mounted.current) { mounted.current = true; return }
73
- send.current({ playheadSec: clock.get(), selectedIds, selectedCaptionId })
74
- }, [adapter, clock, selectedIds, selectedCaptionId])
76
+ send.current({ playheadSec: clock.get(), selectedIds, selectedCaptionId, playing })
77
+ }, [adapter, clock, selectedIds, selectedCaptionId, playing])
75
78
  }