@bycrux/editor 1.2.1 → 1.3.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 (56) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +4 -4
  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/carousel/AddElementMenu.tsx +1 -1
  6. package/src/carousel/CarouselEditor.tsx +11 -11
  7. package/src/carousel/CarouselRenderModal.tsx +11 -11
  8. package/src/carousel/OverlayPicker.tsx +3 -3
  9. package/src/carousel/SlidePropertyPanel.tsx +6 -6
  10. package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
  11. package/src/components/FilmstripScrubber.tsx +1 -1
  12. package/src/crop/VideoSourceCropModal.tsx +4 -4
  13. package/src/index.ts +1 -0
  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 +84 -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 +21 -21
  27. package/src/video/CaptionListPanel.test.tsx +8 -7
  28. package/src/video/CaptionListPanel.tsx +17 -17
  29. package/src/video/CaptionRegenModal.tsx +41 -15
  30. package/src/video/CaptionStyleGallery.tsx +1 -1
  31. package/src/video/CommandPalette.tsx +7 -7
  32. package/src/video/ImageToneMenu.tsx +7 -7
  33. package/src/video/OverlayInspector.tsx +7 -7
  34. package/src/video/RenderModal.tsx +52 -52
  35. package/src/video/VersionCompare.tsx +7 -7
  36. package/src/video/VersionPanel.tsx +2 -2
  37. package/src/video/VideoEditor.tsx +55 -25
  38. package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
  39. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -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__/captionProfileDefaults.test.ts +121 -0
  43. package/src/video/captionProfileDefaults.ts +86 -0
  44. package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
  45. package/src/video/panels/LeftPanelTabs.tsx +20 -2
  46. package/src/video/panels/OverlayContentPanel.tsx +5 -5
  47. package/src/video/preview/OverlayItemsLayer.tsx +7 -2
  48. package/src/video/preview/SocialPreviewMenu.tsx +5 -5
  49. package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
  50. package/src/video/timeline/EditableSegment.tsx +1 -1
  51. package/src/video/timeline/Scrubber.tsx +2 -2
  52. package/src/video/timeline/SpeedControl.tsx +3 -3
  53. package/src/video/timeline/Timeline.tsx +3 -3
  54. package/src/video/timeline/TrackGutter.tsx +6 -6
  55. package/src/video/timeline/TrackSettingsPopover.tsx +3 -3
  56. package/src/video/timeline/VolumeControl.tsx +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "lint": "eslint src"
18
18
  },
19
19
  "dependencies": {
20
- "@bycrux/timeline-core": "^0.2.1",
20
+ "@bycrux/timeline-core": "^0.2.2",
21
21
  "class-variance-authority": "^0.7",
22
22
  "clsx": "^2",
23
23
  "lucide-react": "^0.400",
@@ -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
+ }
@@ -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)_40%,transparent)]">[render] </span>
74
74
  : null
75
75
 
76
76
  return (
@@ -266,13 +266,13 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
266
266
  />
267
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">
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-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{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-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">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">
@@ -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)_40%,transparent)] italic">Starting render engine…</span>
361
361
  )}
362
362
  {logs.map((line, i) => (
363
363
  <LogLine key={i} text={line} mode={mode} />
@@ -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
@@ -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)_50%,transparent)]">Pick the part of the footage to keep — position &amp; 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>
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export type {
37
37
  RenderPhase,
38
38
  CaptionEvent,
39
39
  GenerateCaptionsOptions,
40
+ CaptionProfileDefaults,
40
41
  MediaScope,
41
42
  MediaItem,
42
43
  GlobalOverlay,
@@ -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))