@bycrux/editor 1.2.2 → 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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/src/ControlsInfoModal.tsx +5 -5
  3. package/src/__tests__/no-opacity-modifier-on-editor-vars.test.ts +84 -0
  4. package/src/__tests__/node-shims.d.ts +19 -0
  5. package/src/__tests__/portal-roots-set-editor-text.test.ts +55 -0
  6. package/src/carousel/AddElementMenu.tsx +1 -1
  7. package/src/carousel/CarouselEditor.tsx +11 -11
  8. package/src/carousel/CarouselRenderModal.tsx +14 -14
  9. package/src/carousel/OverlayPicker.tsx +4 -4
  10. package/src/carousel/SlidePropertyPanel.tsx +6 -6
  11. package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
  12. package/src/components/FilmstripScrubber.tsx +1 -1
  13. package/src/crop/VideoSourceCropModal.tsx +5 -5
  14. package/src/state/__tests__/use-project-sync.test.tsx +76 -0
  15. package/src/state/use-project-sync.ts +21 -1
  16. package/src/text/FontPicker.tsx +3 -3
  17. package/src/text/TextFormattingToolbar.tsx +3 -3
  18. package/src/types.ts +18 -0
  19. package/src/ui/Loader.tsx +1 -1
  20. package/src/ui/NumberField.tsx +3 -3
  21. package/src/ui/SwatchInput.tsx +1 -1
  22. package/src/ui/Tooltip.tsx +1 -1
  23. package/src/ui/input.tsx +1 -1
  24. package/src/ui/label.tsx +1 -1
  25. package/src/ui/textarea.tsx +1 -1
  26. package/src/video/AudioPolishModal.tsx +22 -22
  27. package/src/video/CaptionListPanel.test.tsx +8 -7
  28. package/src/video/CaptionListPanel.tsx +17 -17
  29. package/src/video/CaptionRegenModal.tsx +8 -8
  30. package/src/video/CaptionStyleGallery.tsx +1 -1
  31. package/src/video/CommandPalette.tsx +8 -8
  32. package/src/video/ImageToneMenu.tsx +13 -14
  33. package/src/video/OverlayInspector.tsx +7 -7
  34. package/src/video/RenderModal.tsx +76 -78
  35. package/src/video/VersionCompare.tsx +10 -10
  36. package/src/video/VersionPanel.tsx +2 -2
  37. package/src/video/VideoEditor.tsx +48 -23
  38. package/src/video/__tests__/RenderModal.hostTheme.test.tsx +85 -0
  39. package/src/video/__tests__/VideoEditor.context.test.tsx +6 -0
  40. package/src/video/__tests__/VideoEditor.pendingSurface.test.tsx +233 -0
  41. package/src/video/__tests__/VideoEditor.saveError.test.tsx +105 -0
  42. package/src/video/__tests__/export-limits.test.ts +27 -2
  43. package/src/video/__tests__/use-report-context.test.tsx +22 -1
  44. package/src/video/export-limits.ts +22 -1
  45. package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
  46. package/src/video/panels/OverlayContentPanel.tsx +5 -5
  47. package/src/video/preview/OverlayItemsLayer.tsx +7 -2
  48. package/src/video/preview/PreviewPlayer.tsx +14 -0
  49. package/src/video/preview/SocialPreviewMenu.tsx +6 -6
  50. package/src/video/preview/__tests__/PreviewPlayer.playing.test.tsx +62 -0
  51. package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
  52. package/src/video/timeline/EditableSegment.tsx +1 -1
  53. package/src/video/timeline/Scrubber.tsx +2 -2
  54. package/src/video/timeline/SpeedControl.tsx +3 -3
  55. package/src/video/timeline/Timeline.tsx +3 -3
  56. package/src/video/timeline/TrackGutter.tsx +6 -6
  57. package/src/video/timeline/TrackSettingsPopover.tsx +4 -4
  58. package/src/video/timeline/VolumeControl.tsx +3 -3
  59. package/src/video/use-report-context.ts +10 -7
@@ -369,3 +369,79 @@ describe('useProjectSync — subscription lifecycle', () => {
369
369
  expect(adapter.unsub).toHaveBeenCalledTimes(1)
370
370
  })
371
371
  })
372
+
373
+ describe('useProjectSync — onLocalEdit', () => {
374
+ it('fires on the four user paths only (mutate, a real commit, undo, redo)', async () => {
375
+ const adapter = makeFakeAdapter()
376
+ const initial = makeProject()
377
+ const onLocalEdit = vi.fn()
378
+ const { result } = renderHook(() =>
379
+ useProjectSync(adapter, initial.id, initial, { onLocalEdit }),
380
+ )
381
+
382
+ await act(async () => { await result.current.mutate((p) => ({ ...p, name: 'A' })) })
383
+ expect(onLocalEdit).toHaveBeenCalledTimes(1)
384
+
385
+ act(() => {
386
+ result.current.mutateTransient((p) => ({ ...p, name: 'B1' }))
387
+ result.current.mutateTransient((p) => ({ ...p, name: 'B2' }))
388
+ result.current.mutateTransient((p) => ({ ...p, name: 'B3' }))
389
+ })
390
+ expect(onLocalEdit).toHaveBeenCalledTimes(1)
391
+
392
+ await act(async () => { await result.current.commit() })
393
+ expect(onLocalEdit).toHaveBeenCalledTimes(2)
394
+
395
+ // A commit with no transient gesture in flight changed nothing.
396
+ await act(async () => { await result.current.commit() })
397
+ expect(onLocalEdit).toHaveBeenCalledTimes(2)
398
+
399
+ await act(async () => { result.current.undo() })
400
+ expect(onLocalEdit).toHaveBeenCalledTimes(3)
401
+
402
+ await act(async () => { result.current.redo() })
403
+ expect(onLocalEdit).toHaveBeenCalledTimes(4)
404
+
405
+ act(() => { result.current.applyExternal(makeProject({ name: 'Ext' })) })
406
+ expect(onLocalEdit).toHaveBeenCalledTimes(4)
407
+
408
+ act(() => { adapter.emit(makeProject({ name: 'SSE' })) })
409
+ expect(result.current.project.name).toBe('SSE')
410
+ expect(onLocalEdit).toHaveBeenCalledTimes(4)
411
+ })
412
+
413
+ it('does not fire for undo or redo on an empty stack, or for discardTransient', async () => {
414
+ const adapter = makeFakeAdapter()
415
+ const initial = makeProject()
416
+ const onLocalEdit = vi.fn()
417
+ const { result } = renderHook(() =>
418
+ useProjectSync(adapter, initial.id, initial, { onLocalEdit }),
419
+ )
420
+
421
+ act(() => { result.current.undo() })
422
+ act(() => { result.current.redo() })
423
+ act(() => {
424
+ result.current.mutateTransient((p) => ({ ...p, name: 'X' }))
425
+ result.current.discardTransient()
426
+ })
427
+ expect(onLocalEdit).not.toHaveBeenCalled()
428
+ })
429
+
430
+ it('calls the latest callback when the option identity changes', async () => {
431
+ const adapter = makeFakeAdapter()
432
+ const initial = makeProject()
433
+ const first = vi.fn()
434
+ const second = vi.fn()
435
+ const { result, rerender } = renderHook(
436
+ ({ cb }) => useProjectSync(adapter, initial.id, initial, { onLocalEdit: cb }),
437
+ { initialProps: { cb: first } },
438
+ )
439
+ const mutateBefore = result.current.mutate
440
+ rerender({ cb: second })
441
+ expect(result.current.mutate).toBe(mutateBefore)
442
+
443
+ await act(async () => { await result.current.mutate((p) => ({ ...p, name: 'A' })) })
444
+ expect(first).not.toHaveBeenCalled()
445
+ expect(second).toHaveBeenCalledTimes(1)
446
+ })
447
+ })
@@ -41,6 +41,16 @@ export interface UseProjectSyncOptions<P extends Project> {
41
41
  * one here — it must be a pure function of its two arguments.
42
42
  */
43
43
  reconcile?: (prev: P, next: P) => P
44
+ /**
45
+ * Called on every local user edit: `mutate`, a `commit` that closes a
46
+ * gesture which actually changed something, and an `undo`/`redo` that popped
47
+ * a snapshot. Never for `mutateTransient`, `discardTransient`,
48
+ * `applyExternal`, `refetch` or SSE frames, so a host can tell its own
49
+ * user's edits from server-authored writes. Fired after the optimistic
50
+ * apply, before the save resolves. Read through a ref: identity changes are
51
+ * harmless and never re-create the returned callbacks.
52
+ */
53
+ onLocalEdit?: () => void
44
54
  }
45
55
 
46
56
  export interface UseProjectSync<P extends Project = Project> {
@@ -97,6 +107,10 @@ export function useProjectSync<P extends Project = Project>(
97
107
  const reconcileRef = useRef(options?.reconcile)
98
108
  reconcileRef.current = options?.reconcile
99
109
 
110
+ // `onLocalEdit` likewise, so the user-edit paths stay referentially stable.
111
+ const onLocalEditRef = useRef(options?.onLocalEdit)
112
+ onLocalEditRef.current = options?.onLocalEdit
113
+
100
114
  // Latest deferred external frame. Held while saves are in flight because an
101
115
  // echo for an earlier save can arrive while a later save is still mid-flight —
102
116
  // applying it would regress the optimistic state to the older value (visible
@@ -193,6 +207,7 @@ export function useProjectSync<P extends Project = Project>(
193
207
  // Non-transient mutations reset the baseline so any subsequent gesture
194
208
  // starts from the freshly committed state.
195
209
  transientBaseline.current = null
210
+ onLocalEditRef.current?.()
196
211
  return queue.current.enqueue(() =>
197
212
  save(next, snapshot).catch((err) => {
198
213
  setLastError(err instanceof Error ? err.message : String(err))
@@ -222,7 +237,10 @@ export function useProjectSync<P extends Project = Project>(
222
237
  const current = projectRef.current
223
238
  const baseline = transientBaseline.current
224
239
  transientBaseline.current = null
225
- if (baseline !== null) pushUndo(baseline)
240
+ if (baseline !== null) {
241
+ pushUndo(baseline)
242
+ onLocalEditRef.current?.()
243
+ }
226
244
  const rollbackTo = baseline ?? current
227
245
  return queue.current.enqueue(() =>
228
246
  save(current, rollbackTo).catch((err) => {
@@ -261,6 +279,7 @@ export function useProjectSync<P extends Project = Project>(
261
279
  projectRef.current = prev
262
280
  transientBaseline.current = null
263
281
  setProject(prev)
282
+ onLocalEditRef.current?.()
264
283
  void queue.current.enqueue(() =>
265
284
  save(prev, current).catch((err) => {
266
285
  setLastError(err instanceof Error ? err.message : String(err))
@@ -279,6 +298,7 @@ export function useProjectSync<P extends Project = Project>(
279
298
  projectRef.current = next
280
299
  transientBaseline.current = null
281
300
  setProject(next)
301
+ onLocalEditRef.current?.()
282
302
  void queue.current.enqueue(() =>
283
303
  save(next, current).catch((err) => {
284
304
  setLastError(err instanceof Error ? err.message : String(err))
@@ -221,14 +221,14 @@ export function FontFamilyPicker({ value, onChange, disabled, className, buttonC
221
221
  setOpen(false)
222
222
  }}
223
223
  style={{ fontFamily: opt.value }}
224
- className={`flex w-full items-center justify-between px-3 py-2 text-left text-[15px] leading-tight text-[var(--editor-text)] hover:bg-[var(--editor-accent)]/20 focus:bg-[var(--editor-accent)]/20 focus:outline-none ${
225
- isActive ? 'bg-[var(--editor-accent)]/20 font-medium' : ''
224
+ className={`flex w-full items-center justify-between px-3 py-2 text-left text-[15px] leading-tight text-[var(--editor-text)] hover:bg-[color-mix(in_srgb,var(--editor-accent)_20%,transparent)] focus:bg-[color-mix(in_srgb,var(--editor-accent)_20%,transparent)] focus:outline-none ${
225
+ isActive ? 'bg-[color-mix(in_srgb,var(--editor-accent)_20%,transparent)] font-medium' : ''
226
226
  }`}
227
227
  >
228
228
  <span className="truncate">{opt.label}</span>
229
229
  {isActive && (
230
230
  <span
231
- className="ml-2 shrink-0 text-xs text-[var(--editor-text)]/60"
231
+ className="ml-2 shrink-0 text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]"
232
232
  style={{ fontFamily: 'system-ui, sans-serif' }}
233
233
  >
234
234
  ✓
@@ -106,7 +106,7 @@ export function TextFormattingToolbar({
106
106
  // toward the surface tone. One look for B / I / case / align across the bar.
107
107
  const toolbarBtnBase = 'flex h-7 min-w-[1.75rem] items-center justify-center rounded-md border px-1.5 text-sm transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)]'
108
108
  const toolbarBtnActive = 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
109
- const toolbarBtnInactive = 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
109
+ const toolbarBtnInactive = 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
110
110
 
111
111
  // Segment visibility helpers — group toggles vs size/font vs alignment.
112
112
  const hasLeftGroup = supported.has('fontWeight') || supported.has('fontStyle') || supported.has('textTransform') || supported.has('color')
@@ -233,7 +233,7 @@ export function TextFormattingToolbar({
233
233
  )}
234
234
 
235
235
  {!hasAnyControlProp && (
236
- <span className="px-1 py-1 text-xs text-[var(--editor-text)]/55">
236
+ <span className="px-1 py-1 text-xs text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">
237
237
  Edit via property panel
238
238
  </span>
239
239
  )}
@@ -243,7 +243,7 @@ export function TextFormattingToolbar({
243
243
  type="button"
244
244
  aria-label="Delete text overlay"
245
245
  onClick={onDelete}
246
- className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/60 ${mode === 'light' ? 'hover:border-red-300 hover:bg-red-50 hover:text-red-700' : 'hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400'}`}
246
+ className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] ${mode === 'light' ? 'hover:border-red-300 hover:bg-red-50 hover:text-red-700' : 'hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400'}`}
247
247
  >
248
248
  <Trash2 className="h-3.5 w-3.5" />
249
249
  </button>
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
  /**
@@ -1038,6 +1040,22 @@ export interface VideoEditorProps<P extends Project = Project> {
1038
1040
  slots?: EditorSlots
1039
1041
  readOnly?: boolean
1040
1042
  onBackToSetup?: () => void
1043
+ /**
1044
+ * What renders while `project.status === 'pending'`:
1045
+ * - `'default'` — the built-in pending surface (agent prompt, `slots.pendingStatus`,
1046
+ * project id, back-to-setup).
1047
+ * - `'host'` — the full editor, exactly as for a draft. The editor draws no
1048
+ * pending UI of its own; any gate or overlay is the host's. `onBackToSetup`
1049
+ * and `slots.pendingStatus` are unused in this mode.
1050
+ * Defaults to `'default'`.
1051
+ */
1052
+ pendingSurface?: 'default' | 'host'
1053
+ /**
1054
+ * Called on every edit the user makes (edits, finished gestures, undo, redo),
1055
+ * never for server frames. Lets a host tell its own user's edits from an
1056
+ * agent's writes. Fires on every edit; the host dedupes.
1057
+ */
1058
+ onUserEdit?: () => void
1041
1059
  /**
1042
1060
  * Where the host's `slots.assetsPanel` is placed in the review layout:
1043
1061
  * - `'sidebar'` — stacked inside the right-hand version/run-history rail, below
package/src/ui/Loader.tsx CHANGED
@@ -56,7 +56,7 @@ export function Loader({ size = 'md', label, className = '' }: LoaderProps) {
56
56
  <path d="M43 14 l7 10 h7 l-7 -10 z" fill="#facc15" />
57
57
  </g>
58
58
  </svg>
59
- {label && <span className="text-[11px] text-[var(--editor-text)]/50">{label}</span>}
59
+ {label && <span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">{label}</span>}
60
60
  </div>
61
61
  )
62
62
  }
@@ -196,7 +196,7 @@ export function NumberField({
196
196
  return (
197
197
  <div className="flex shrink-0 items-center gap-1">
198
198
  {prefix && (
199
- <span aria-hidden="true" className="w-2 shrink-0 text-[10px] text-[var(--editor-text)]/40">
199
+ <span aria-hidden="true" className="w-2 shrink-0 text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">
200
200
  {prefix}
201
201
  </span>
202
202
  )}
@@ -218,7 +218,7 @@ export function NumberField({
218
218
  className={cn(inspectorInputClass, 'text-right disabled:opacity-50 disabled:cursor-not-allowed', className)}
219
219
  />
220
220
  {unit && (
221
- <span aria-hidden="true" className="text-[10px] text-[var(--editor-text)]/40">
221
+ <span aria-hidden="true" className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">
222
222
  {unit}
223
223
  </span>
224
224
  )}
@@ -238,7 +238,7 @@ export function NumberField({
238
238
  * than only being dimmed for show. */
239
239
  function Stepper({ name, onStep, disabled }: { name: string; onStep: (direction: 1 | -1) => void; disabled?: boolean }) {
240
240
  const btn = cn(
241
- 'flex h-3 w-4 items-center justify-center rounded-sm text-[var(--editor-text)]/45 transition-colors',
241
+ 'flex h-3 w-4 items-center justify-center rounded-sm text-[color-mix(in_srgb,var(--editor-text)_45%,transparent)] transition-colors',
242
242
  disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]',
243
243
  )
244
244
  return (
@@ -50,7 +50,7 @@ export function SwatchInput({
50
50
  />
51
51
  </label>
52
52
  {showValue && (
53
- <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
53
+ <span className="font-mono text-sm uppercase text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)]">{value}</span>
54
54
  )}
55
55
  </div>
56
56
  )
@@ -103,7 +103,7 @@ export function Tooltip({ label, keys, side = 'top', className, children }: Tool
103
103
  {keys?.map((k, i) => (
104
104
  <kbd
105
105
  key={i}
106
- className="rounded border border-[var(--editor-border)] bg-[var(--editor-text)]/10 px-1 py-0.5 font-mono text-[9px] leading-none text-[var(--editor-text)]/70"
106
+ className="rounded border border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] px-1 py-0.5 font-mono text-[9px] leading-none text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]"
107
107
  >
108
108
  {k}
109
109
  </kbd>
package/src/ui/input.tsx CHANGED
@@ -12,7 +12,7 @@ export function Input({ className, ...props }: InputProps) {
12
12
  return (
13
13
  <input
14
14
  className={cn(
15
- 'flex h-9 w-full rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-3 py-1 text-sm text-[var(--editor-text)] placeholder:text-[var(--editor-text)]/60 focus:outline-none focus:ring-2 focus:ring-[var(--editor-accent)] disabled:opacity-50',
15
+ 'flex h-9 w-full rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-3 py-1 text-sm text-[var(--editor-text)] placeholder:text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] focus:outline-none focus:ring-2 focus:ring-[var(--editor-accent)] disabled:opacity-50',
16
16
  className,
17
17
  )}
18
18
  {...props}
package/src/ui/label.tsx CHANGED
@@ -3,7 +3,7 @@ import { cn } from './utils'
3
3
  export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
4
4
  return (
5
5
  <label
6
- className={cn('text-xs font-medium text-[var(--editor-text)]/60 leading-none', className)}
6
+ className={cn('text-xs font-medium text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] leading-none', className)}
7
7
  {...props}
8
8
  />
9
9
  )
@@ -6,7 +6,7 @@ export function Textarea({ className, ...props }: TextareaProps) {
6
6
  return (
7
7
  <textarea
8
8
  className={cn(
9
- 'flex w-full rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-3 py-2 text-sm text-[var(--editor-text)] placeholder:text-[var(--editor-text)]/60 focus:outline-none focus:ring-2 focus:ring-[var(--editor-accent)] disabled:opacity-50 resize-none',
9
+ 'flex w-full rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-3 py-2 text-sm text-[var(--editor-text)] placeholder:text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] focus:outline-none focus:ring-2 focus:ring-[var(--editor-accent)] disabled:opacity-50 resize-none',
10
10
  className,
11
11
  )}
12
12
  {...props}
@@ -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"
@@ -691,7 +691,7 @@ export default function AudioPolishModal<P extends Project = Project>({
691
691
  <div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
692
692
  <div className="flex flex-col gap-0.5">
693
693
  <h2 className="text-sm font-semibold text-[var(--editor-text)]">Polish audio</h2>
694
- <p className="text-[11px] text-[var(--editor-text)]/55">
694
+ <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">
695
695
  {targets.length === 1
696
696
  ? '1 clip in scope. Nothing is saved until you press Apply.'
697
697
  : `${targets.length} clips in scope. Nothing is saved until you press Apply.`}
@@ -700,7 +700,7 @@ export default function AudioPolishModal<P extends Project = Project>({
700
700
  <button
701
701
  onClick={handleCancel}
702
702
  aria-label="Close"
703
- className="text-[var(--editor-text)]/55 hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
703
+ className="text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
704
704
  >
705
705
  ×
706
706
  </button>
@@ -712,7 +712,7 @@ export default function AudioPolishModal<P extends Project = Project>({
712
712
  {PIECES.map(p => (
713
713
  <label
714
714
  key={p.id}
715
- className="flex items-start gap-2 rounded-lg border border-[var(--editor-border)] p-2.5 cursor-pointer hover:bg-[var(--editor-text)]/5 transition-colors"
715
+ className="flex items-start gap-2 rounded-lg border border-[var(--editor-border)] p-2.5 cursor-pointer hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)] transition-colors"
716
716
  >
717
717
  <input
718
718
  type="checkbox"
@@ -723,7 +723,7 @@ export default function AudioPolishModal<P extends Project = Project>({
723
723
  />
724
724
  <span className="flex flex-col gap-0.5 min-w-0">
725
725
  <span className="text-xs font-semibold text-[var(--editor-text)]">{p.label}</span>
726
- <span className="text-[10px] leading-snug text-[var(--editor-text)]/55">{p.blurb}</span>
726
+ <span className="text-[10px] leading-snug text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">{p.blurb}</span>
727
727
  {phase === 'review' && pieces[p.id] && !analysedPieces[p.id] && (
728
728
  <span className={`text-[10px] ${mode === 'light' ? 'text-amber-700' : 'text-amber-400'}`}>Not analysed yet. Run Analyse again.</span>
729
729
  )}
@@ -734,7 +734,7 @@ export default function AudioPolishModal<P extends Project = Project>({
734
734
 
735
735
  <div className="flex flex-wrap items-end gap-4">
736
736
  <label className="flex flex-col gap-1.5">
737
- <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
737
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
738
738
  Spoken language
739
739
  </span>
740
740
  <select
@@ -747,13 +747,13 @@ 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-[var(--editor-text)]/45 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>
754
754
 
755
755
  <label className="flex flex-col gap-1.5">
756
- <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
756
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
757
757
  Loudness target
758
758
  </span>
759
759
  <select
@@ -770,7 +770,7 @@ export default function AudioPolishModal<P extends Project = Project>({
770
770
 
771
771
  {targetId === 'custom' && (
772
772
  <label className="flex flex-col gap-1.5">
773
- <span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--editor-text)]/50">
773
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
774
774
  Custom LUFS
775
775
  </span>
776
776
  <NumberField
@@ -803,7 +803,7 @@ export default function AudioPolishModal<P extends Project = Project>({
803
803
  {busy ? 'Analysing…' : phase === 'review' ? 'Analyse again' : 'Analyse'}
804
804
  </button>
805
805
  {busy && (
806
- <span className="text-[10px] text-[var(--editor-text)]/55">
806
+ <span className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">
807
807
  {`Analysing ${Math.min(progress.done + 1, progress.total)} of ${progress.total}. ${progress.label}`}
808
808
  </span>
809
809
  )}
@@ -825,7 +825,7 @@ export default function AudioPolishModal<P extends Project = Project>({
825
825
  {/* Review */}
826
826
  <div className="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-5 min-h-[12rem]">
827
827
  {phase === 'setup' && (
828
- <p className="text-xs text-[var(--editor-text)]/50 italic">
828
+ <p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)] italic">
829
829
  Pick the cleanups you want, set the language, then press Analyse. You see every proposed
830
830
  change on the timeline before anything is saved.
831
831
  </p>
@@ -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-[var(--editor-text)]/45 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>
@@ -859,7 +859,7 @@ export default function AudioPolishModal<P extends Project = Project>({
859
859
 
860
860
  {/* Loudness */}
861
861
  {pieces.loudness && a?.loudness && gainDb !== undefined && (
862
- <p className="text-[11px] text-[var(--editor-text)]/70">
862
+ <p className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]">
863
863
  {`Loudness: measured ${a.loudness.measuredI.toFixed(1)} LUFS, true peak ${a.loudness.measuredTP.toFixed(1)} dBTP. Gain ${signedDb(gainDb)} to reach ${targetLufs} LUFS.`}
864
864
  {clamped && (
865
865
  <span className={mode === 'light' ? 'text-amber-700' : 'text-amber-400'}>
@@ -885,12 +885,12 @@ export default function AudioPolishModal<P extends Project = Project>({
885
885
  {pieces.voice && supports(clip, 'voice') && (
886
886
  a?.vocalsUrl ? (
887
887
  <div className="flex flex-col gap-1.5">
888
- <span className="text-[11px] text-[var(--editor-text)]/70">
888
+ <span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]">
889
889
  Isolated voice. Compare the two before you apply.
890
890
  </span>
891
891
  <div className="flex flex-wrap items-center gap-3">
892
892
  <span className="flex items-center gap-1.5">
893
- <span className="text-[10px] text-[var(--editor-text)]/55">Original</span>
893
+ <span className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">Original</span>
894
894
  <audio
895
895
  controls
896
896
  preload="none"
@@ -899,7 +899,7 @@ export default function AudioPolishModal<P extends Project = Project>({
899
899
  />
900
900
  </span>
901
901
  <span className="flex items-center gap-1.5">
902
- <span className="text-[10px] text-[var(--editor-text)]/55">Isolated voice</span>
902
+ <span className="text-[10px] text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]">Isolated voice</span>
903
903
  <audio
904
904
  controls
905
905
  preload="none"
@@ -914,12 +914,12 @@ export default function AudioPolishModal<P extends Project = Project>({
914
914
 
915
915
  {/* Removals */}
916
916
  {rows.length === 0 ? (
917
- <p className="text-[11px] text-[var(--editor-text)]/45 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 => (
921
921
  <li key={row.key}>
922
- <label className="flex items-start gap-2 text-[11px] text-[var(--editor-text)]/80 cursor-pointer hover:bg-[var(--editor-text)]/5 rounded px-1 py-0.5">
922
+ <label className="flex items-start gap-2 text-[11px] text-[color-mix(in_srgb,var(--editor-text)_80%,transparent)] cursor-pointer hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)] rounded px-1 py-0.5">
923
923
  <input
924
924
  type="checkbox"
925
925
  checked={Boolean(approved[row.key])}
@@ -928,10 +928,10 @@ export default function AudioPolishModal<P extends Project = Project>({
928
928
  className="mt-0.5"
929
929
  />
930
930
  <span className="font-mono shrink-0">{clock(row.tlStart)}</span>
931
- <span className="font-mono text-[var(--editor-text)]/50 shrink-0">
931
+ <span className="font-mono text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)] shrink-0">
932
932
  {`${(row.tlEnd - row.tlStart).toFixed(2)}s`}
933
933
  </span>
934
- <span className="text-[var(--editor-text)]/50 shrink-0">
934
+ <span className="text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)] shrink-0">
935
935
  {row.piece === 'silence' ? 'silence' : 'filler'}
936
936
  </span>
937
937
  {row.text && <span className="italic truncate">{`"${row.text}"`}</span>}
@@ -952,7 +952,7 @@ export default function AudioPolishModal<P extends Project = Project>({
952
952
 
953
953
  {/* Footer */}
954
954
  <div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
955
- <span className="text-[11px] text-[var(--editor-text)]/50">
955
+ <span className="text-[11px] text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">
956
956
  {phase === 'review'
957
957
  ? `${totalApproved} removal${totalApproved === 1 ? '' : 's'} ticked. The timeline is showing the result.`
958
958
  : 'Nothing is saved until you press Apply.'}
@@ -960,7 +960,7 @@ export default function AudioPolishModal<P extends Project = Project>({
960
960
  <div className="flex items-center gap-2">
961
961
  <button
962
962
  onClick={handleCancel}
963
- className={`text-sm px-4 py-1.5 rounded-md bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/80 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'}`}
963
+ 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'}`}
964
964
  >
965
965
  Cancel
966
966
  </button>
@@ -145,13 +145,14 @@ describe('CaptionListPanel list', () => {
145
145
  // only a source-text check on the actual class string catches it.
146
146
  //
147
147
  // Scoped to just these two spans, not "no `/<number>` modifier anywhere in
148
- // the file": ~12 OTHER `text-[var(--editor-text)]/N` uses remain elsewhere
149
- // in this component (toolbar, buttons, search, empty states) and are
150
- // equally no-ops, but they were individually measured to render legibly in
151
- // their positions and are out of scope for this fix. A file-wide ban here
152
- // would either force touching all of them unverified or need a fragile
153
- // "count didn't grow" check that can't tell one class moving from banning
154
- // one occurrence while a new one appears elsewhere.
148
+ // the file": the other `text-[var(--editor-text)]/N` uses elsewhere in this
149
+ // component (toolbar, buttons, search, empty states) were the same no-op at
150
+ // the time this guard was written. They're since converted to the
151
+ // equivalent `color-mix(in_srgb,var(--editor-text)_N%,transparent)` form
152
+ // package-wide (see the CHANGELOG), which a repo-wide grep test now
153
+ // enforces — this test stays narrow because it predates that fix and these
154
+ // two spans specifically were fixed with a plain `opacity-N` utility
155
+ // instead, not `color-mix`.
155
156
  it('the row index and timestamp spans do not use a no-op opacity-modifier color class', () => {
156
157
  const indexSpan = src.match(/<span className="[^"]*">\{index \+ 1\}<\/span>/)?.[0] ?? ''
157
158
  const timestampSpan = src.match(/<span className="[^"]*">\{formatTime\(seg\.start\)\}<\/span>/)?.[0] ?? ''