@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "1.2.2",
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
@@ -140,7 +140,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
140
140
  <div className="relative flex items-start justify-between gap-4">
141
141
  <div className="flex items-center gap-3">
142
142
  <span
143
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border bg-[var(--editor-text)]/[0.06]"
143
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border bg-[color-mix(in_srgb,var(--editor-text)_6%,transparent)]"
144
144
  style={{ borderColor: 'var(--editor-border)' }}
145
145
  >
146
146
  <Command size={17} style={{ color: 'var(--editor-accent)' }} />
@@ -156,7 +156,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
156
156
  type="button"
157
157
  onClick={onClose}
158
158
  aria-label="Close"
159
- className="-mr-1 -mt-1 shrink-0 cursor-pointer rounded-md p-1.5 opacity-55 transition-all hover:bg-[var(--editor-text)]/10 hover:opacity-100"
159
+ className="-mr-1 -mt-1 shrink-0 cursor-pointer rounded-md p-1.5 opacity-55 transition-all hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] hover:opacity-100"
160
160
  >
161
161
  <X size={18} />
162
162
  </button>
@@ -182,7 +182,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
182
182
  style={{ borderColor: 'var(--editor-border)' }}
183
183
  >
184
184
  <span
185
- className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[var(--editor-text)]/[0.06]"
185
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[color-mix(in_srgb,var(--editor-text)_6%,transparent)]"
186
186
  >
187
187
  <Icon size={14} style={{ color: 'var(--editor-accent)' }} />
188
188
  </span>
@@ -194,7 +194,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
194
194
  {section.entries.map((entry, i) => (
195
195
  <li
196
196
  key={i}
197
- className="flex items-center justify-between gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-[var(--editor-text)]/[0.06]"
197
+ className="flex items-center justify-between gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-[color-mix(in_srgb,var(--editor-text)_6%,transparent)]"
198
198
  >
199
199
  <span className="flex items-start gap-2.5 opacity-90">
200
200
  {/* A toolbar row shows that button's real glyph;
@@ -0,0 +1,84 @@
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: this package has no @types/node, so the `node:*`
6
+ // specifiers above have no type declarations under this package's tsconfig.
7
+ // Vitest itself runs on Node and provides the real modules at runtime.
8
+
9
+ // Tailwind cannot generate a rule for a `/NN` opacity modifier on an
10
+ // arbitrary CSS-variable color — `text-[var(--editor-text)]/60` and friends
11
+ // — because it can't parse an opaque `var(...)` reference to inject an alpha
12
+ // channel. It silently emits NO rule (measured under a host app's Tailwind
13
+ // v3.4), so the class falls back to an inherited/default color, which is how
14
+ // toolbar and timeline icons went invisible against the editor's dark
15
+ // surface. The fix is `color-mix(in_srgb,var(--editor-X)_NN%,transparent)`
16
+ // baked directly into the arbitrary value, so Tailwind never has to parse
17
+ // the color itself (see CHANGELOG.md "Unreleased", and LeftPanelTabs.tsx for
18
+ // the original instance of this fix).
19
+ //
20
+ // This guard fails the build if that dead pattern comes back anywhere in
21
+ // this package's source, from a revert, a bad merge, or a new component
22
+ // copy-pasting an old snippet.
23
+
24
+ const SRC_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
25
+
26
+ // The one deliberate exception: this file quotes the OLD broken class with a
27
+ // real digit (`text-[var(--editor-text)]/40`) inside a comment, as a
28
+ // historical example for a narrower regression test that predates this
29
+ // repo-wide fix. It is not live code and this guard would otherwise treat
30
+ // that quote as a violation.
31
+ const ALLOWLIST = new Set(['video/CaptionListPanel.test.tsx'])
32
+
33
+ // Requires a digit or `[` right after the slash, matching how the modifier
34
+ // actually looks in real Tailwind classes (`/60`, `/[0.06]`) — this keeps
35
+ // prose like "`text-[var(--editor-text)]/N`" (an `N` placeholder, not a real
36
+ // modifier) out of the check without needing it on the allowlist below.
37
+ const DEAD_OPACITY_MODIFIER = /var\(--editor-[a-z-]+\)\]\/[\d[]/
38
+
39
+ function collectSourceFiles(dir: string, out: string[] = []): string[] {
40
+ for (const entry of readdirSync(dir)) {
41
+ if (entry === 'node_modules') continue
42
+ const full = join(dir, entry)
43
+ const stat = statSync(full)
44
+ if (stat.isDirectory()) {
45
+ collectSourceFiles(full, out)
46
+ } else if (/\.(ts|tsx)$/.test(entry)) {
47
+ out.push(full)
48
+ }
49
+ }
50
+ return out
51
+ }
52
+
53
+ describe('no dead Tailwind opacity modifier on --editor-* vars', () => {
54
+ it('finds zero `[var(--editor-*)]/NN`-style classes anywhere in src', () => {
55
+ const thisFile = fileURLToPath(import.meta.url)
56
+ const offenders: { file: string; line: number; text: string }[] = []
57
+
58
+ for (const file of collectSourceFiles(SRC_ROOT)) {
59
+ if (file === thisFile) continue
60
+ const rel = relative(SRC_ROOT, file)
61
+ if (ALLOWLIST.has(rel)) continue
62
+
63
+ const lines = readFileSync(file, 'utf8').split('\n')
64
+ lines.forEach((line, i) => {
65
+ if (DEAD_OPACITY_MODIFIER.test(line)) {
66
+ offenders.push({ file: rel, line: i + 1, text: line.trim() })
67
+ }
68
+ })
69
+ }
70
+
71
+ if (offenders.length > 0) {
72
+ const report = offenders
73
+ .map((o) => ` ${o.file}:${o.line}: ${o.text}`)
74
+ .join('\n')
75
+ throw new Error(
76
+ `Found ${offenders.length} dead Tailwind opacity-modifier class(es) on an --editor-* var ` +
77
+ `(Tailwind emits no rule for these — see this test's header comment).\n` +
78
+ `Replace each with color-mix(in_srgb,var(--editor-X)_NN%,transparent):\n${report}`,
79
+ )
80
+ }
81
+
82
+ expect(offenders).toEqual([])
83
+ })
84
+ })
@@ -0,0 +1,19 @@
1
+ // This package has no @types/node (it's a browser-facing library with no
2
+ // Node-touching runtime code otherwise), so plain TypeScript can't resolve
3
+ // `node:*` specifiers on its own. Vitest runs on Node and provides the real
4
+ // modules at runtime regardless — these ambient shapes cover only the
5
+ // handful of calls `no-opacity-modifier-on-editor-vars.test.ts` makes,
6
+ // purely so `tsc --noEmit` can typecheck that file.
7
+ declare module 'node:fs' {
8
+ export function readFileSync(path: string, encoding: 'utf8'): string
9
+ export function readdirSync(path: string): string[]
10
+ export function statSync(path: string): { isDirectory(): boolean }
11
+ }
12
+ declare module 'node:path' {
13
+ export function dirname(path: string): string
14
+ export function join(...parts: string[]): string
15
+ export function relative(from: string, to: string): string
16
+ }
17
+ declare module 'node:url' {
18
+ export function fileURLToPath(url: string): string
19
+ }
@@ -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
+ })
@@ -182,7 +182,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
182
182
  {showPrompt && !disabled && (
183
183
  <div className="flex flex-col gap-2 p-3 bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-lg">
184
184
  <textarea
185
- className="w-full bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded px-2 py-1.5 text-xs text-[var(--editor-text)] placeholder-[var(--editor-text)]/60 resize-none focus:outline-none focus:border-[var(--editor-accent)]"
185
+ className="w-full bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded px-2 py-1.5 text-xs text-[var(--editor-text)] placeholder-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] resize-none focus:outline-none focus:border-[var(--editor-accent)]"
186
186
  rows={3}
187
187
  placeholder="Describe the image to generate…"
188
188
  value={prompt}
@@ -73,7 +73,7 @@ function SlideGrid({
73
73
  return (
74
74
  <div className="w-56 flex-shrink-0 flex flex-col border-r border-[var(--editor-border)] bg-[var(--editor-bg)] overflow-y-auto min-h-0 h-full">
75
75
  <div className="px-3 py-2 border-b border-[var(--editor-border)]">
76
- <span className="text-xs font-semibold text-[var(--editor-text)]/60 uppercase tracking-wider">Slides</span>
76
+ <span className="text-xs font-semibold text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] uppercase tracking-wider">Slides</span>
77
77
  </div>
78
78
  <div className="flex-1 overflow-y-auto py-2 flex flex-col gap-2 px-2">
79
79
  {slides.map((slide, idx) => (
@@ -392,7 +392,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
392
392
  const canvasScale = Math.min(availW / w, availH / h, 1)
393
393
 
394
394
  return (
395
- <div ref={containerRef} className="flex flex-col h-full overflow-y-auto bg-[var(--editor-bg)]">
395
+ <div ref={containerRef} className="flex flex-col h-full overflow-y-auto bg-[var(--editor-bg)] text-[var(--editor-text)]">
396
396
  {/* TOP: slide rail | canvas | editing panel (right). Fixed viewport-relative
397
397
  height with min-h-0 so each of the three columns establishes its own
398
398
  independent scroll context; the project-media region flows beneath and
@@ -424,7 +424,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
424
424
  className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
425
425
  refreshState === 'err'
426
426
  ? (mode === 'light' ? 'text-red-700 border-red-300 bg-red-50 hover:bg-red-100' : 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70')
427
- : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
427
+ : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-surface)_80%,transparent)] hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
428
428
  }`}
429
429
  title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
430
430
  >
@@ -434,7 +434,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
434
434
  <button
435
435
  onClick={() => state.undo()}
436
436
  disabled={!state.canUndo}
437
- className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
437
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-surface)_80%,transparent)] text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
438
438
  title="Undo (Cmd/Ctrl+Z)"
439
439
  aria-label="Undo"
440
440
  >
@@ -443,7 +443,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
443
443
  <button
444
444
  onClick={() => state.redo()}
445
445
  disabled={!state.canRedo}
446
- className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]/80 text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
446
+ className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-surface)_80%,transparent)] text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
447
447
  title="Redo (Cmd/Ctrl+Shift+Z)"
448
448
  aria-label="Redo"
449
449
  >
@@ -479,11 +479,11 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
479
479
  {slots?.pendingStatus ?? (
480
480
  <div className="flex flex-col items-center gap-2">
481
481
  <p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
482
- <p className="text-[var(--editor-text)]/60 text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
482
+ <p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
483
483
  </div>
484
484
  )}
485
485
  {!slots?.pendingStatus && skillPath && (
486
- <div className="w-full rounded-xl border-2 border-[var(--editor-accent)] bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]/10">
486
+ <div className="w-full rounded-xl border-2 border-[var(--editor-accent)] bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[color-mix(in_srgb,var(--editor-accent)_10%,transparent)]">
487
487
  {/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
488
488
  `--editor-surface`, and 12px bold is still NORMAL text under WCAG
489
489
  (large starts at 18.66px bold), so it needs the 4.5:1 floor. The
@@ -527,7 +527,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
527
527
  </div>
528
528
  </div>
529
529
  )}
530
- <p className="text-[var(--editor-text)]/40 text-xs font-mono">project id: {project.id}</p>
530
+ <p className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] text-xs font-mono">project id: {project.id}</p>
531
531
  </div>
532
532
  ) : selectedSlide ? (
533
533
  <>
@@ -538,7 +538,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
538
538
  instead, the same "tint with the foreground colour" idiom the rest
539
539
  of the chrome uses, so it is a light ring on dark and a dark ring
540
540
  on light at the same subtle strength. */}
541
- <div className="flex-shrink-0 ring-1 ring-[var(--editor-text)]/10">
541
+ <div className="flex-shrink-0 ring-1 ring-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]">
542
542
  <SlideCanvas
543
543
  slide={selectedSlide}
544
544
  slideId={selectedSlide.id}
@@ -562,7 +562,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
562
562
  hiddenElementIds={hiddenElementIds}
563
563
  />
564
564
  </div>
565
- <div className="flex-shrink-0 flex items-center justify-center gap-1.5 text-xs text-[var(--editor-text)]/60 max-w-md">
565
+ <div className="flex-shrink-0 flex items-center justify-center gap-1.5 text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] max-w-md">
566
566
  <span className="text-center">
567
567
  Drag to reposition, resize/rotate via handles, double-click text to edit. Cmd/Ctrl+Z to undo.
568
568
  </span>
@@ -578,7 +578,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
578
578
  </div>
579
579
  </>
580
580
  ) : (
581
- <div className="text-[var(--editor-text)]/40 text-sm">No slides yet. Add one in the left panel.</div>
581
+ <div className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] text-sm">No slides yet. Add one in the left panel.</div>
582
582
  )}
583
583
 
584
584
  {state.lastError && (
@@ -64,13 +64,13 @@ function slidesFromMedia(media: RenderMedia[]): SlideView[] {
64
64
 
65
65
  function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark' }) {
66
66
  const t = text.replace(/^\[render\]\s*/, '')
67
- let color = 'text-[var(--editor-text)]/60'
67
+ let color = 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]'
68
68
  if (/done|complete|→/i.test(t)) color = mode === 'light' ? 'text-green-700' : 'text-green-400'
69
69
  else if (/rendering|launching|bundling/i.test(t)) color = mode === 'light' ? 'text-sky-700' : 'text-sky-400'
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-[var(--editor-text)]/40">[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-[var(--editor-text)]/60">{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-[var(--editor-text)]/60">Render complete.</p>
275
+ <p className="text-sm text-white/60">Render complete.</p>
276
276
  )}
277
277
  </div>
278
278
 
@@ -283,12 +283,12 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
283
283
  <span className="w-2 h-2 rounded-full bg-green-400" />
284
284
  <div>
285
285
  <p className="text-sm font-semibold text-[var(--editor-text)]">Render complete</p>
286
- <p className="text-xs text-[var(--editor-text)]/60">
286
+ <p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
287
287
  {slides.length || slidesCount} slide{(slides.length || slidesCount) === 1 ? '' : 's'} ready.
288
288
  </p>
289
289
  </div>
290
290
  </div>
291
- <button onClick={onClose} className="text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
291
+ <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>
292
292
  </div>
293
293
 
294
294
  <div className="flex flex-col gap-3 p-5 flex-1">
@@ -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)]">
@@ -325,7 +325,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
325
325
  </div>
326
326
  </div>
327
327
  {status !== 'running' && (
328
- <button onClick={onClose} className="text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
328
+ <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>
329
329
  )}
330
330
  </div>
331
331
 
@@ -334,8 +334,8 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
334
334
  {status === 'running' ? (
335
335
  <>
336
336
  <Loader size="lg" />
337
- <p className="text-sm text-[var(--editor-text)]/70">Rendering slides…</p>
338
- <p className="text-xs text-[var(--editor-text)]/50">This can take a moment.</p>
337
+ <p className="text-sm text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]">Rendering slides…</p>
338
+ <p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">This can take a moment.</p>
339
339
  </>
340
340
  ) : (
341
341
  <p className={`text-sm whitespace-pre-wrap break-words text-center ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>
@@ -347,7 +347,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
347
347
  <div className="relative">
348
348
  <button
349
349
  onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
350
- className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] transition-colors"
350
+ className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] transition-colors"
351
351
  title="Copy logs"
352
352
  >
353
353
  Copy
@@ -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-[var(--editor-text)]/40 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">
@@ -108,7 +108,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
108
108
  <h2 className="text-sm font-semibold text-[var(--editor-text)]">Add Overlay</h2>
109
109
  <button
110
110
  onClick={onClose}
111
- className="text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
111
+ className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
112
112
  >
113
113
  ×
114
114
  </button>
@@ -124,7 +124,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
124
124
  <div className={`text-center text-sm py-8 ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{error}</div>
125
125
  )}
126
126
  {!loading && !error && overlays.length === 0 && (
127
- <div className="text-center text-[var(--editor-text)]/60 text-sm py-8">No overlays available</div>
127
+ <div className="text-center text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm py-8">No overlays available</div>
128
128
  )}
129
129
  {!loading && !error && overlays.length > 0 && (
130
130
  <div className="grid grid-cols-3 gap-3">
@@ -139,7 +139,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
139
139
  <div className="text-xs text-[var(--editor-accent)] mt-0.5 truncate">{overlay.group}</div>
140
140
  )}
141
141
  {overlay.description && (
142
- <div className="text-xs text-[var(--editor-text)]/60 mt-1 line-clamp-2">{overlay.description}</div>
142
+ <div className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] mt-1 line-clamp-2">{overlay.description}</div>
143
143
  )}
144
144
  </button>
145
145
  ))}
@@ -14,11 +14,11 @@ import { Loader } from '../ui/Loader'
14
14
  import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
15
15
 
16
16
  // Shared small muted label that sits just above an inspector control.
17
- const fieldLabelClass = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55'
17
+ const fieldLabelClass = 'text-[11px] uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
18
18
 
19
19
  // Section header: SLIDE / OVERLAY / IMAGE.
20
20
  const sectionHeaderClass =
21
- 'text-xs font-semibold uppercase tracking-wider text-[var(--editor-text)]/70'
21
+ 'text-xs font-semibold uppercase tracking-wider text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]'
22
22
 
23
23
  interface Props {
24
24
  project: Project
@@ -71,7 +71,7 @@ function HideToggle({
71
71
  title={isHidden ? 'Show in editor' : 'Hide from editor'}
72
72
  aria-label={isHidden ? 'Show in editor' : 'Hide from editor'}
73
73
  aria-pressed={isHidden}
74
- className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
74
+ className="flex h-7 w-7 items-center justify-center rounded text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
75
75
  >
76
76
  {isHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
77
77
  </button>
@@ -238,7 +238,7 @@ export default function SlidePropertyPanel({
238
238
 
239
239
  if (!slide) {
240
240
  return (
241
- <div className={cn('w-80 flex-shrink-0 flex items-center justify-center text-[var(--editor-text)]/40 text-xs p-4', className)}>
241
+ <div className={cn('w-80 flex-shrink-0 flex items-center justify-center text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] text-xs p-4', className)}>
242
242
  Select a slide
243
243
  </div>
244
244
  )
@@ -295,14 +295,14 @@ export default function SlidePropertyPanel({
295
295
  />
296
296
  <button
297
297
  onClick={() => onReorderElement(slide.id, element.id, 'forward')}
298
- className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
298
+ className="flex h-7 w-7 items-center justify-center rounded text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
299
299
  title="Bring forward"
300
300
  >
301
301
  ↑
302
302
  </button>
303
303
  <button
304
304
  onClick={() => onReorderElement(slide.id, element.id, 'backward')}
305
- className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
305
+ className="flex h-7 w-7 items-center justify-center rounded text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
306
306
  title="Send backward"
307
307
  >
308
308
  ↓
@@ -105,6 +105,18 @@ describe('CarouselEditor — editor-core integration', () => {
105
105
  expect(wrapper?.className).not.toContain('w-80')
106
106
  })
107
107
 
108
+ // The root sets the editor text colour (as VideoEditor's root does), so chrome
109
+ // that inherits colour reads --editor-text instead of the host page's colour.
110
+ it('sets the editor text colour on its root', async () => {
111
+ const adapter = makeFakeAdapter()
112
+ const { container } = render(
113
+ <CarouselEditor project={makeProject()} adapter={adapter} onProjectChange={vi.fn()} />,
114
+ )
115
+ const root = container.firstElementChild as HTMLElement
116
+ expect(root.className).toContain('bg-[var(--editor-bg)]')
117
+ expect(root.className).toContain('text-[var(--editor-text)]')
118
+ })
119
+
108
120
  // Regression: SlideGrid thumbnails must receive `compileOverlay` so overlay
109
121
  // elements render in the left rail. Before the fix the thumbnail used a
110
122
  // noopCompiler that always rejected → a red "overlay error" badge on every
@@ -266,7 +266,7 @@ export default function FilmstripScrubber({
266
266
  )}
267
267
  {phase === 'error' && (
268
268
  <div
269
- className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--editor-surface)] text-[10px] text-[var(--editor-text)]/40"
269
+ className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--editor-surface)] text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]"
270
270
  role="status"
271
271
  >
272
272
  No preview
@@ -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-[var(--editor-text)]/50">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 */}
@@ -243,8 +243,8 @@ export function VideoSourceCropModal({
243
243
  title={key === 'free' ? 'Free-form crop' : `Lock crop to ${label}`}
244
244
  className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
245
245
  active
246
- ? 'border-[var(--editor-selection)] bg-[var(--editor-selection)]/15 text-[var(--editor-text)]'
247
- : 'border-[var(--editor-border)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)]'
246
+ ? 'border-[var(--editor-selection)] bg-[color-mix(in_srgb,var(--editor-selection)_15%,transparent)] text-[var(--editor-text)]'
247
+ : 'border-[var(--editor-border)] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] hover:text-[var(--editor-text)]'
248
248
  }`}
249
249
  >
250
250
  <Icon size={14} />
@@ -258,7 +258,7 @@ export function VideoSourceCropModal({
258
258
  <div className="flex items-center justify-end gap-2 pt-1">
259
259
  <button
260
260
  onClick={onClose}
261
- className="rounded-md px-3 py-1.5 text-xs font-medium text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
261
+ className="rounded-md px-3 py-1.5 text-xs font-medium text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] hover:text-[var(--editor-text)] transition-colors"
262
262
  >
263
263
  Cancel
264
264
  </button>