@bycrux/editor 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/types.ts +68 -0
- package/src/video/CaptionRegenModal.tsx +34 -8
- package/src/video/VideoEditor.tsx +12 -2
- package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -0
- package/src/video/__tests__/captionProfileDefaults.test.ts +121 -0
- package/src/video/captionProfileDefaults.ts +86 -0
- package/src/video/panels/LeftPanelTabs.tsx +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bycrux/editor",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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.
|
|
20
|
+
"@bycrux/timeline-core": "^0.2.2",
|
|
21
21
|
"class-variance-authority": "^0.7",
|
|
22
22
|
"clsx": "^2",
|
|
23
23
|
"lucide-react": "^0.400",
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -186,6 +186,50 @@ export interface GenerateCaptionsOptions {
|
|
|
186
186
|
style?: string
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The caption look a host has on file for one of its named profiles —
|
|
191
|
+
* everything this package can seed a freshly transcribed track with.
|
|
192
|
+
*
|
|
193
|
+
* This package owns NO notion of what a profile is beyond `Project.profile`'s
|
|
194
|
+
* bare name: whether profiles are files on disk, rows in the host's database,
|
|
195
|
+
* or nothing at all is the host's business, and asking the host through
|
|
196
|
+
* `getCaptionProfileDefaults` is how the editor stays ignorant of it. Every
|
|
197
|
+
* field is optional and the whole thing is nullable, so "this host has no
|
|
198
|
+
* profiles", "this profile has no styling" and "this profile sets only a
|
|
199
|
+
* color" are all expressible without the editor knowing which it got.
|
|
200
|
+
*
|
|
201
|
+
* Field names are the *editor's* vocabulary, not any host's — each one is
|
|
202
|
+
* named after the field it seeds, so the mapping from a host's own schema
|
|
203
|
+
* happens once, in that host's adapter, rather than leaking a host's column
|
|
204
|
+
* names into this package.
|
|
205
|
+
*/
|
|
206
|
+
export interface CaptionProfileDefaults {
|
|
207
|
+
/**
|
|
208
|
+
* Seeds `GenerateCaptionsOptions.style` on the regeneration request — the
|
|
209
|
+
* style the new track is transcribed INTO. Deliberately `string` rather
|
|
210
|
+
* than `Captions['style']`, matching the field it feeds: the host is what
|
|
211
|
+
* validates a style name, and a host storing one in a free-text column
|
|
212
|
+
* should not have to narrow before it can answer.
|
|
213
|
+
*
|
|
214
|
+
* Never written onto the returned track: the host reports the style it
|
|
215
|
+
* actually used, and that report wins (see `mergeCaptionProfileDefaults`).
|
|
216
|
+
*/
|
|
217
|
+
style?: string
|
|
218
|
+
/** Seeds `Captions.fontFamily` — a CSS font-family stack. */
|
|
219
|
+
fontFamily?: string
|
|
220
|
+
/**
|
|
221
|
+
* Seeds `Captions.googleFonts` alongside `fontFamily`, and only alongside
|
|
222
|
+
* it. The two travel together (see `Captions.fontFamily`'s own note): a
|
|
223
|
+
* family whose font file is not also fetched renders as the fallback face,
|
|
224
|
+
* in the editor preview and the export alike. A host that seeds a Google
|
|
225
|
+
* family without its spec here gets a silent half-application — the right
|
|
226
|
+
* stack, the wrong glyphs.
|
|
227
|
+
*/
|
|
228
|
+
googleFonts?: string[]
|
|
229
|
+
/** Seeds `Captions.color` — the base caption text color. */
|
|
230
|
+
color?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
189
233
|
// ── Overlay library types ─────────────────────────────────────────────────────
|
|
190
234
|
// Copied verbatim from Montaj's `ui/src/lib/api.ts` so the package owns the
|
|
191
235
|
// shape the editor consumes. A host's overlay-listing endpoints return these;
|
|
@@ -794,6 +838,30 @@ export interface EditorAdapter<P extends Project = Project> {
|
|
|
794
838
|
*/
|
|
795
839
|
generateCaptions?(id: string, opts?: GenerateCaptionsOptions): AsyncIterable<CaptionEvent>
|
|
796
840
|
|
|
841
|
+
/**
|
|
842
|
+
* Optional: resolve the caption look the host has on file for `profile` —
|
|
843
|
+
* the value of `Project.profile`, which this package treats as an opaque
|
|
844
|
+
* name and nothing more. Used to seed a freshly transcribed caption track
|
|
845
|
+
* with the style, font and color the profile already implies, instead of
|
|
846
|
+
* leaving the user to re-pick all three every regeneration.
|
|
847
|
+
*
|
|
848
|
+
* THE PROFILE CONCEPT ITSELF STAYS ON THE HOST SIDE, which is the whole
|
|
849
|
+
* reason this is a seam rather than a lookup. `Project.profile` is a bare
|
|
850
|
+
* string here; what it resolves to is the host's — a local file for the
|
|
851
|
+
* OSS `serve` UI, an account-scoped database row for a hosted app, nothing
|
|
852
|
+
* at all for a host with no profile concept. A host that cannot answer
|
|
853
|
+
* omits this method; the editor feature-detects its absence and generates
|
|
854
|
+
* captions exactly as it did before this existed, with no second argument
|
|
855
|
+
* on `generateCaptions` and no merge on the result.
|
|
856
|
+
*
|
|
857
|
+
* Best-effort on the editor's side: a rejection is swallowed and treated as
|
|
858
|
+
* `null`. Caption regeneration is the user's actual request and must never
|
|
859
|
+
* fail because a styling convenience could not be looked up.
|
|
860
|
+
*
|
|
861
|
+
* Returns `null` when the host has no defaults for that name.
|
|
862
|
+
*/
|
|
863
|
+
getCaptionProfileDefaults?(profile: string): Promise<CaptionProfileDefaults | null>
|
|
864
|
+
|
|
797
865
|
/**
|
|
798
866
|
* Optional: report the editor's live playhead and selection to the host.
|
|
799
867
|
*
|
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import type { EditorAdapter, Project } from '../types'
|
|
3
|
+
import type { CaptionProfileDefaults, EditorAdapter, Project } from '../types'
|
|
4
4
|
import type { Captions } from '../schema'
|
|
5
|
+
import { resolveCaptionProfileDefaults } from './captionProfileDefaults'
|
|
5
6
|
|
|
6
7
|
interface CaptionRegenModalProps<P extends Project = Project> {
|
|
7
8
|
projectId: string
|
|
8
9
|
/** Adapter driving the caption-regeneration stream. Must implement
|
|
9
10
|
* `generateCaptions` — callers gate rendering on its presence. */
|
|
10
11
|
adapter: EditorAdapter<P>
|
|
12
|
+
/** The project's attached profile name (`Project.profile`), if it has one.
|
|
13
|
+
* Passed to `adapter.getCaptionProfileDefaults` to seed the new track's
|
|
14
|
+
* style/font/color. Absent — or a host with no such seam — and the run is
|
|
15
|
+
* byte-identical to what it was before profile seeding existed. */
|
|
16
|
+
profile?: string
|
|
11
17
|
/** Caption rows the project has right now (`maxCaptionLane(segments) + 1`,
|
|
12
18
|
* so 1 for a lane-less or empty track). Regeneration replaces the whole
|
|
13
19
|
* track with a single fresh row, so this is the count the warning banner
|
|
14
20
|
* below reports as about to be discarded. */
|
|
15
21
|
existingRowCount: number
|
|
16
|
-
/** Fired on terminal success with the freshly transcribed caption track
|
|
17
|
-
*
|
|
18
|
-
|
|
22
|
+
/** Fired on terminal success with the freshly transcribed caption track,
|
|
23
|
+
* plus whatever the host answered for `profile` (null when it answered
|
|
24
|
+
* nothing). The caller patches `project.captions` from the first and folds
|
|
25
|
+
* the second onto it — see `mergeCaptionProfileDefaults`. The modal then
|
|
26
|
+
* closes.
|
|
27
|
+
*
|
|
28
|
+
* The defaults are handed OVER rather than re-fetched by the caller so the
|
|
29
|
+
* profile is resolved exactly once per run: the modal already had to ask
|
|
30
|
+
* before the stream started (it needs the style for the request), and a
|
|
31
|
+
* second lookup at apply time could answer differently. */
|
|
32
|
+
onDone: (captions: Captions, profileDefaults: CaptionProfileDefaults | null) => void
|
|
19
33
|
/** Fired when the modal closes (cancel, error dismiss, or post-done). */
|
|
20
34
|
onClose: () => void
|
|
21
35
|
/** Editor theme mode — light/dark. The panel and log box follow
|
|
@@ -38,7 +52,7 @@ function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark
|
|
|
38
52
|
)
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
export default function CaptionRegenModal<P extends Project = Project>({ projectId, adapter, existingRowCount, onDone, onClose, mode = 'dark' }: CaptionRegenModalProps<P>) {
|
|
55
|
+
export default function CaptionRegenModal<P extends Project = Project>({ projectId, adapter, profile, existingRowCount, onDone, onClose, mode = 'dark' }: CaptionRegenModalProps<P>) {
|
|
42
56
|
const [logs, setLogs] = useState<string[]>([])
|
|
43
57
|
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
44
58
|
const [errorMsg, setError] = useState<string | null>(null)
|
|
@@ -66,13 +80,25 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
66
80
|
|
|
67
81
|
void (async () => {
|
|
68
82
|
try {
|
|
69
|
-
|
|
83
|
+
// Seeding, before anything is spawned: the style has to be on the
|
|
84
|
+
// request, so this one await sits in front of the stream. It cannot
|
|
85
|
+
// reject (see `resolveCaptionProfileDefaults`) and it cannot stop the
|
|
86
|
+
// run — a host with no profile seam, or a lookup that failed, simply
|
|
87
|
+
// produces `null` and everything below behaves exactly as it did
|
|
88
|
+
// before this existed, down to the arity of the call.
|
|
89
|
+
const profileDefaults = await resolveCaptionProfileDefaults(adapter, profile)
|
|
90
|
+
if (unmountedRef.current || cancelledRef.current) return
|
|
91
|
+
const stream = profileDefaults?.style
|
|
92
|
+
? adapter.generateCaptions!(projectId, { style: profileDefaults.style })
|
|
93
|
+
: adapter.generateCaptions!(projectId)
|
|
94
|
+
|
|
95
|
+
for await (const ev of stream) {
|
|
70
96
|
if (unmountedRef.current || cancelledRef.current) break
|
|
71
97
|
if (ev.type === 'log') {
|
|
72
98
|
setLogs(l => [...l, ev.message])
|
|
73
99
|
} else if (ev.type === 'done') {
|
|
74
100
|
setStatus('done')
|
|
75
|
-
onDone(ev.captions)
|
|
101
|
+
onDone(ev.captions, profileDefaults)
|
|
76
102
|
} else {
|
|
77
103
|
setError(ev.message)
|
|
78
104
|
setStatus('error')
|
|
@@ -94,7 +120,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
|
|
|
94
120
|
unmountedRef.current = true
|
|
95
121
|
}, 0)
|
|
96
122
|
}
|
|
97
|
-
}, [projectId, adapter, onDone])
|
|
123
|
+
}, [projectId, adapter, profile, onDone])
|
|
98
124
|
|
|
99
125
|
// Auto-scroll logs
|
|
100
126
|
useEffect(() => {
|
|
@@ -14,6 +14,7 @@ import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
|
|
|
14
14
|
import { addMarker } from './timeline/markers'
|
|
15
15
|
import { repairCaptionWords } from './captionRepair'
|
|
16
16
|
import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
|
|
17
|
+
import { mergeCaptionProfileDefaults } from './captionProfileDefaults'
|
|
17
18
|
import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
|
|
18
19
|
import { visualDuration } from '@bycrux/timeline-core'
|
|
19
20
|
import { audioEnd, computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
|
|
@@ -2954,10 +2955,19 @@ function ReviewSurface<P extends Project>({
|
|
|
2954
2955
|
<CaptionRegenModal
|
|
2955
2956
|
adapter={adapter}
|
|
2956
2957
|
projectId={project.id}
|
|
2958
|
+
profile={project.profile}
|
|
2957
2959
|
existingRowCount={maxCaptionLane(project.captions?.segments ?? []) + 1}
|
|
2958
2960
|
onClose={() => setRegenCaptionsOpen(false)}
|
|
2959
|
-
onDone={(captions) => {
|
|
2960
|
-
|
|
2961
|
+
onDone={(captions, profileDefaults) => {
|
|
2962
|
+
// The modal resolved the profile once, before the stream; we fold
|
|
2963
|
+
// its font/color onto the fresh track here rather than there
|
|
2964
|
+
// because this is the seam that decides what lands on the project
|
|
2965
|
+
// — and the merge only ever fills fields the host left unset, so
|
|
2966
|
+
// a host that starts returning them keeps authoring them.
|
|
2967
|
+
// `mergeCaptionProfileDefaults` returns `captions` itself when
|
|
2968
|
+
// there is nothing to seed, which is every host without the seam.
|
|
2969
|
+
const seeded = mergeCaptionProfileDefaults(captions, profileDefaults)
|
|
2970
|
+
sync.applyExternal({ ...syncProjectRef.current, captions: seeded } as P)
|
|
2961
2971
|
setRegenCaptionsOpen(false)
|
|
2962
2972
|
}}
|
|
2963
2973
|
mode={timelineMode}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
2
|
import { render, screen, waitFor, cleanup } from '@testing-library/react'
|
|
3
|
-
import type { CaptionEvent, EditorAdapter, ImageElement, Project } from '../../types'
|
|
3
|
+
import type { CaptionEvent, CaptionProfileDefaults, EditorAdapter, ImageElement, Project } from '../../types'
|
|
4
4
|
import type { Captions } from '../../schema'
|
|
5
5
|
import CaptionRegenModal from '../CaptionRegenModal'
|
|
6
6
|
|
|
@@ -45,7 +45,9 @@ describe('CaptionRegenModal', () => {
|
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
await waitFor(() => expect(screen.getByText(/transcribing audio/i)).toBeTruthy())
|
|
48
|
-
|
|
48
|
+
// Second argument is the profile defaults the host resolved — `null` on
|
|
49
|
+
// this adapter, which implements no `getCaptionProfileDefaults`.
|
|
50
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, null))
|
|
49
51
|
})
|
|
50
52
|
|
|
51
53
|
it('shows an error message verbatim on error', async () => {
|
|
@@ -92,3 +94,160 @@ describe('CaptionRegenModal', () => {
|
|
|
92
94
|
expect(screen.queryByText(/caption rows/i)).toBeNull()
|
|
93
95
|
})
|
|
94
96
|
})
|
|
97
|
+
|
|
98
|
+
// ── Profile-seeded caption defaults ──────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// `adapter.getCaptionProfileDefaults` is an OPTIONAL host seam: this package
|
|
101
|
+
// owns no notion of what a profile is beyond `Project.profile`'s bare name, so
|
|
102
|
+
// a host that can resolve one answers, and a host that cannot omits the method
|
|
103
|
+
// entirely. Every assertion below therefore comes in a pair — the seam present
|
|
104
|
+
// and the seam absent — because the absent case is the one every existing host
|
|
105
|
+
// (Hub, Los Parceros, the OSS `serve` UI) is in today, and it must keep
|
|
106
|
+
// producing the byte-identical call it produced before this seam existed.
|
|
107
|
+
|
|
108
|
+
/** Adapter whose `generateCaptions` is a spy, so the opts argument is readable. */
|
|
109
|
+
function makeSpyAdapter() {
|
|
110
|
+
const adapter = makeAdapter()
|
|
111
|
+
const generateCaptions = vi.fn(async function* (): AsyncIterable<CaptionEvent> {
|
|
112
|
+
yield { type: 'done', captions: doneCaptions }
|
|
113
|
+
})
|
|
114
|
+
adapter.generateCaptions = generateCaptions
|
|
115
|
+
return { adapter, generateCaptions }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PROFILE_DEFAULTS: CaptionProfileDefaults = {
|
|
119
|
+
style: 'karaoke',
|
|
120
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
121
|
+
color: '#112233',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe('CaptionRegenModal — profile-seeded caption defaults', () => {
|
|
125
|
+
it('seeds the generation style from the defaults the host resolves for the project profile', async () => {
|
|
126
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
127
|
+
const getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
|
|
128
|
+
adapter.getCaptionProfileDefaults = getCaptionProfileDefaults
|
|
129
|
+
|
|
130
|
+
render(
|
|
131
|
+
<CaptionRegenModal
|
|
132
|
+
adapter={adapter}
|
|
133
|
+
projectId="vid-1"
|
|
134
|
+
profile="sam"
|
|
135
|
+
existingRowCount={1}
|
|
136
|
+
onDone={vi.fn()}
|
|
137
|
+
onClose={vi.fn()}
|
|
138
|
+
/>,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1', { style: 'karaoke' }))
|
|
142
|
+
expect(getCaptionProfileDefaults).toHaveBeenCalledWith('sam')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('hands the resolved defaults to onDone alongside the fresh captions', async () => {
|
|
146
|
+
// The host merges font/color onto the track at its own apply seam; the
|
|
147
|
+
// modal resolves the profile ONCE and passes what it got, so the host
|
|
148
|
+
// never repeats the round trip (and can never race a second answer).
|
|
149
|
+
const { adapter } = makeSpyAdapter()
|
|
150
|
+
adapter.getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
|
|
151
|
+
const onDone = vi.fn()
|
|
152
|
+
|
|
153
|
+
render(
|
|
154
|
+
<CaptionRegenModal
|
|
155
|
+
adapter={adapter}
|
|
156
|
+
projectId="vid-1"
|
|
157
|
+
profile="sam"
|
|
158
|
+
existingRowCount={1}
|
|
159
|
+
onDone={onDone}
|
|
160
|
+
onClose={vi.fn()}
|
|
161
|
+
/>,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, PROFILE_DEFAULTS))
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('passes no opts at all when the host implements no caption-defaults seam', async () => {
|
|
168
|
+
// Today's behaviour for every existing host, pinned: a bare one-argument
|
|
169
|
+
// call, not `(id, undefined)` and not `(id, {})`.
|
|
170
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
171
|
+
const onDone = vi.fn()
|
|
172
|
+
|
|
173
|
+
render(
|
|
174
|
+
<CaptionRegenModal
|
|
175
|
+
adapter={adapter}
|
|
176
|
+
projectId="vid-1"
|
|
177
|
+
profile="sam"
|
|
178
|
+
existingRowCount={1}
|
|
179
|
+
onDone={onDone}
|
|
180
|
+
onClose={vi.fn()}
|
|
181
|
+
/>,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
|
185
|
+
expect(generateCaptions).toHaveBeenCalledWith('vid-1')
|
|
186
|
+
expect(onDone).toHaveBeenCalledWith(doneCaptions, null)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('never asks for defaults when the project has no profile', async () => {
|
|
190
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
191
|
+
const getCaptionProfileDefaults = vi.fn(async () => PROFILE_DEFAULTS)
|
|
192
|
+
adapter.getCaptionProfileDefaults = getCaptionProfileDefaults
|
|
193
|
+
|
|
194
|
+
render(
|
|
195
|
+
<CaptionRegenModal
|
|
196
|
+
adapter={adapter}
|
|
197
|
+
projectId="vid-1"
|
|
198
|
+
existingRowCount={1}
|
|
199
|
+
onDone={vi.fn()}
|
|
200
|
+
onClose={vi.fn()}
|
|
201
|
+
/>,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
|
|
205
|
+
expect(getCaptionProfileDefaults).not.toHaveBeenCalled()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('still generates when the caption-defaults seam rejects', async () => {
|
|
209
|
+
// Seeding is a convenience. A profile lookup that 500s must never be the
|
|
210
|
+
// reason a user cannot regenerate their captions.
|
|
211
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
212
|
+
adapter.getCaptionProfileDefaults = vi.fn(async () => { throw new Error('boom') })
|
|
213
|
+
const onDone = vi.fn()
|
|
214
|
+
|
|
215
|
+
render(
|
|
216
|
+
<CaptionRegenModal
|
|
217
|
+
adapter={adapter}
|
|
218
|
+
projectId="vid-1"
|
|
219
|
+
profile="sam"
|
|
220
|
+
existingRowCount={1}
|
|
221
|
+
onDone={onDone}
|
|
222
|
+
onClose={vi.fn()}
|
|
223
|
+
/>,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
|
|
227
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, null))
|
|
228
|
+
expect(screen.queryByText('boom')).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('passes no style when the profile resolves without one', async () => {
|
|
232
|
+
// A profile that sets only a font and a color must not send `style:
|
|
233
|
+
// undefined` — the host route reads presence, not value.
|
|
234
|
+
const { adapter, generateCaptions } = makeSpyAdapter()
|
|
235
|
+
const fontOnly: CaptionProfileDefaults = { fontFamily: '"Inter", system-ui, sans-serif' }
|
|
236
|
+
adapter.getCaptionProfileDefaults = vi.fn(async () => fontOnly)
|
|
237
|
+
const onDone = vi.fn()
|
|
238
|
+
|
|
239
|
+
render(
|
|
240
|
+
<CaptionRegenModal
|
|
241
|
+
adapter={adapter}
|
|
242
|
+
projectId="vid-1"
|
|
243
|
+
profile="sam"
|
|
244
|
+
existingRowCount={1}
|
|
245
|
+
onDone={onDone}
|
|
246
|
+
onClose={vi.fn()}
|
|
247
|
+
/>,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
await waitFor(() => expect(generateCaptions).toHaveBeenCalledWith('vid-1'))
|
|
251
|
+
await waitFor(() => expect(onDone).toHaveBeenCalledWith(doneCaptions, fontOnly))
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
|
3
3
|
import type { CaptionEvent, EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import type { Captions } from '../../schema'
|
|
4
5
|
import VideoEditor from '../VideoEditor'
|
|
5
6
|
|
|
6
7
|
// ── T1 — host-driven caption trigger seam ────────────────────────────────────
|
|
@@ -132,3 +133,104 @@ describe('VideoEditor — host-driven caption trigger seam', () => {
|
|
|
132
133
|
await waitFor(() => expect(screen.getByText('Regenerating captions…')).toBeTruthy())
|
|
133
134
|
})
|
|
134
135
|
})
|
|
136
|
+
|
|
137
|
+
// ── Profile-seeded caption defaults, through the real apply seam ─────────────
|
|
138
|
+
//
|
|
139
|
+
// The unit-level rules live in `captionProfileDefaults.test.ts`; what these
|
|
140
|
+
// two prove is the WIRING — that `project.profile` reaches the host seam, and
|
|
141
|
+
// that the merged track is what `applyExternal` lands on the project. There is
|
|
142
|
+
// no spy for `applyExternal` (it is internal to `useProjectSync`, and it
|
|
143
|
+
// deliberately calls neither `onProjectChange` nor `adapter.saveProject` — see
|
|
144
|
+
// VideoEditor.captionDelete.test.tsx's note), so the assertion reads the
|
|
145
|
+
// caption color back out of the Format tab's swatch, which renders
|
|
146
|
+
// `project.captions.color` and falls back to '#ffffff' when unset.
|
|
147
|
+
|
|
148
|
+
function readCaptionColor(): string {
|
|
149
|
+
fireEvent.click(screen.getByRole('button', { name: 'Format' }))
|
|
150
|
+
return (screen.getByLabelText('Caption text color') as HTMLInputElement).value
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Adapter whose regen stream terminates with the given track. */
|
|
154
|
+
function makeRegenAdapter(captions: Captions) {
|
|
155
|
+
const adapter = makeFakeAdapter()
|
|
156
|
+
adapter.generateCaptions = vi.fn(async function* (): AsyncIterable<CaptionEvent> {
|
|
157
|
+
yield { type: 'done', captions }
|
|
158
|
+
})
|
|
159
|
+
return adapter
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe('VideoEditor — profile-seeded caption defaults', () => {
|
|
163
|
+
it('merges the profile font/color onto a server track that set neither', async () => {
|
|
164
|
+
const adapter = makeRegenAdapter({
|
|
165
|
+
style: 'pop',
|
|
166
|
+
segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
|
|
167
|
+
})
|
|
168
|
+
const getCaptionProfileDefaults = vi.fn(async () => ({
|
|
169
|
+
style: 'karaoke',
|
|
170
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
171
|
+
color: '#112233',
|
|
172
|
+
}))
|
|
173
|
+
;(adapter as unknown as { getCaptionProfileDefaults: unknown }).getCaptionProfileDefaults = getCaptionProfileDefaults
|
|
174
|
+
|
|
175
|
+
render(
|
|
176
|
+
<VideoEditor
|
|
177
|
+
project={makeVideoProject({ profile: 'sam' })}
|
|
178
|
+
adapter={adapter}
|
|
179
|
+
onProjectChange={vi.fn()}
|
|
180
|
+
/>,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
fireEvent.click(await screen.findByText('Regenerate captions'))
|
|
184
|
+
|
|
185
|
+
// The modal closes itself once the host's onDone runs, which is also when
|
|
186
|
+
// the merged track has landed.
|
|
187
|
+
await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
|
|
188
|
+
expect(getCaptionProfileDefaults).toHaveBeenCalledWith('sam')
|
|
189
|
+
expect(adapter.generateCaptions).toHaveBeenCalledWith('vid-1', { style: 'karaoke' })
|
|
190
|
+
expect(readCaptionColor()).toBe('#112233')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('leaves a color the server response already set alone', async () => {
|
|
194
|
+
const adapter = makeRegenAdapter({
|
|
195
|
+
style: 'pop',
|
|
196
|
+
color: '#ff0000',
|
|
197
|
+
segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
|
|
198
|
+
})
|
|
199
|
+
;(adapter as unknown as { getCaptionProfileDefaults: unknown }).getCaptionProfileDefaults =
|
|
200
|
+
vi.fn(async () => ({ color: '#112233' }))
|
|
201
|
+
|
|
202
|
+
render(
|
|
203
|
+
<VideoEditor
|
|
204
|
+
project={makeVideoProject({ profile: 'sam' })}
|
|
205
|
+
adapter={adapter}
|
|
206
|
+
onProjectChange={vi.fn()}
|
|
207
|
+
/>,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
fireEvent.click(await screen.findByText('Regenerate captions'))
|
|
211
|
+
await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
|
|
212
|
+
expect(readCaptionColor()).toBe('#ff0000')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('applies the server track untouched on a host with no caption-defaults seam', async () => {
|
|
216
|
+
// Every host shipping today is in this case; the regen call must stay the
|
|
217
|
+
// bare one-argument call it has always been.
|
|
218
|
+
const adapter = makeRegenAdapter({
|
|
219
|
+
style: 'pop',
|
|
220
|
+
segments: [{ id: 'cap-new', text: 'fresh', start: 0, end: 1, words: [{ word: 'fresh', start: 0, end: 1 }] }],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
render(
|
|
224
|
+
<VideoEditor
|
|
225
|
+
project={makeVideoProject({ profile: 'sam' })}
|
|
226
|
+
adapter={adapter}
|
|
227
|
+
onProjectChange={vi.fn()}
|
|
228
|
+
/>,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
fireEvent.click(await screen.findByText('Regenerate captions'))
|
|
232
|
+
await waitFor(() => expect(screen.queryByText('Regenerating captions…')).toBeNull())
|
|
233
|
+
expect(adapter.generateCaptions).toHaveBeenCalledWith('vid-1')
|
|
234
|
+
expect(readCaptionColor()).toBe('#ffffff')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type { EditorAdapter, Project } from '../../types'
|
|
3
|
+
import type { Captions } from '../../schema'
|
|
4
|
+
import { mergeCaptionProfileDefaults, resolveCaptionProfileDefaults } from '../captionProfileDefaults'
|
|
5
|
+
|
|
6
|
+
// A freshly transcribed track as the host routes actually return one: a bare
|
|
7
|
+
// `{ style, segments }` with no text styling at all (montaj's own
|
|
8
|
+
// `steps/caption/caption.py` builds exactly this shape). Every field the
|
|
9
|
+
// profile can seed is therefore absent here, which is the whole reason seeding
|
|
10
|
+
// is worth doing.
|
|
11
|
+
const FRESH: Captions = {
|
|
12
|
+
style: 'pop',
|
|
13
|
+
segments: [{ text: 'hola', start: 0, end: 1, words: [] }],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function adapterWith(
|
|
17
|
+
getCaptionProfileDefaults?: EditorAdapter<Project>['getCaptionProfileDefaults'],
|
|
18
|
+
): EditorAdapter<Project> {
|
|
19
|
+
return { getCaptionProfileDefaults } as unknown as EditorAdapter<Project>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('mergeCaptionProfileDefaults', () => {
|
|
23
|
+
it('fills the fields the server left unset', () => {
|
|
24
|
+
const merged = mergeCaptionProfileDefaults(FRESH, {
|
|
25
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
26
|
+
color: '#112233',
|
|
27
|
+
})
|
|
28
|
+
expect(merged.fontFamily).toBe('"Inter", system-ui, sans-serif')
|
|
29
|
+
expect(merged.color).toBe('#112233')
|
|
30
|
+
expect(merged.segments).toBe(FRESH.segments)
|
|
31
|
+
expect(merged.style).toBe('pop')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('never overwrites a color the server response already set', () => {
|
|
35
|
+
// The route returns a bare track today. If it ever starts carrying a
|
|
36
|
+
// color, that value is authored downstream of the profile and wins.
|
|
37
|
+
const withColor: Captions = { ...FRESH, color: '#ff0000' }
|
|
38
|
+
const merged = mergeCaptionProfileDefaults(withColor, { color: '#112233' })
|
|
39
|
+
expect(merged.color).toBe('#ff0000')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('never overwrites a fontFamily the server response already set', () => {
|
|
43
|
+
const withFont: Captions = { ...FRESH, fontFamily: '"Figtree", system-ui, sans-serif' }
|
|
44
|
+
const merged = mergeCaptionProfileDefaults(withFont, {
|
|
45
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
46
|
+
googleFonts: ['Inter:wght@700'],
|
|
47
|
+
})
|
|
48
|
+
expect(merged.fontFamily).toBe('"Figtree", system-ui, sans-serif')
|
|
49
|
+
expect(merged.googleFonts).toBeUndefined()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('carries googleFonts along with a seeded fontFamily', () => {
|
|
53
|
+
// `fontFamily` and `googleFonts` travel together (see the `Captions` doc
|
|
54
|
+
// comment): a family whose file is not also fetched renders as the
|
|
55
|
+
// fallback face, in the preview AND the export. Seeding one without the
|
|
56
|
+
// other is the silent half-failure this pair exists to avoid.
|
|
57
|
+
const merged = mergeCaptionProfileDefaults(FRESH, {
|
|
58
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
59
|
+
googleFonts: ['Inter:wght@700'],
|
|
60
|
+
})
|
|
61
|
+
expect(merged.googleFonts).toEqual(['Inter:wght@700'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not inject googleFonts when the family was not seeded', () => {
|
|
65
|
+
const withFont: Captions = { ...FRESH, fontFamily: '"Figtree", system-ui, sans-serif' }
|
|
66
|
+
const merged = mergeCaptionProfileDefaults(withFont, { googleFonts: ['Inter:wght@700'] })
|
|
67
|
+
expect(merged.googleFonts).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns the very same object when there is nothing to seed', () => {
|
|
71
|
+
// Reference identity, not deep equality: the caller feeds the result
|
|
72
|
+
// straight to `applyExternal`, and a fresh object there is a state change
|
|
73
|
+
// the editor has to reconcile for no reason.
|
|
74
|
+
expect(mergeCaptionProfileDefaults(FRESH, null)).toBe(FRESH)
|
|
75
|
+
expect(mergeCaptionProfileDefaults(FRESH, undefined)).toBe(FRESH)
|
|
76
|
+
expect(mergeCaptionProfileDefaults(FRESH, {})).toBe(FRESH)
|
|
77
|
+
expect(mergeCaptionProfileDefaults(FRESH, { style: 'karaoke' })).toBe(FRESH)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('never writes the profile style onto the track', () => {
|
|
81
|
+
// `style` seeds the GENERATION request, not the returned track: the host
|
|
82
|
+
// reports the style it actually transcribed with, and that report wins.
|
|
83
|
+
const merged = mergeCaptionProfileDefaults(FRESH, { style: 'karaoke', color: '#112233' })
|
|
84
|
+
expect(merged.style).toBe('pop')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('treats an empty-string default as nothing to seed', () => {
|
|
88
|
+
// A host backed by a nullable column can hand back `''` for "unset".
|
|
89
|
+
expect(mergeCaptionProfileDefaults(FRESH, { fontFamily: '', color: '' })).toBe(FRESH)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('resolveCaptionProfileDefaults', () => {
|
|
94
|
+
it('returns null when the host implements no seam', async () => {
|
|
95
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(), 'sam')).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns null when the project has no profile', async () => {
|
|
99
|
+
const seam = vi.fn(async () => ({ color: '#112233' }))
|
|
100
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(seam), undefined)).toBeNull()
|
|
101
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(seam), '')).toBeNull()
|
|
102
|
+
expect(seam).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns null rather than throwing when the seam rejects', async () => {
|
|
106
|
+
const seam = vi.fn(async () => { throw new Error('boom') })
|
|
107
|
+
await expect(resolveCaptionProfileDefaults(adapterWith(seam), 'sam')).resolves.toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns null rather than throwing when the seam throws synchronously', async () => {
|
|
111
|
+
const seam = vi.fn(() => { throw new Error('boom') }) as unknown as EditorAdapter<Project>['getCaptionProfileDefaults']
|
|
112
|
+
await expect(resolveCaptionProfileDefaults(adapterWith(seam), 'sam')).resolves.toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('passes the profile name through and returns what the host answered', async () => {
|
|
116
|
+
const answer = { style: 'karaoke', color: '#112233' }
|
|
117
|
+
const seam = vi.fn(async () => answer)
|
|
118
|
+
expect(await resolveCaptionProfileDefaults(adapterWith(seam), 'sam')).toBe(answer)
|
|
119
|
+
expect(seam).toHaveBeenCalledWith('sam')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two halves of profile-seeded caption defaults: ask the host what a
|
|
3
|
+
* profile looks like, and fold the answer onto a freshly transcribed track.
|
|
4
|
+
*
|
|
5
|
+
* Both live here rather than inside the two components that use them because
|
|
6
|
+
* they are used at two different moments of one flow — `CaptionRegenModal`
|
|
7
|
+
* resolves the defaults *before* the stream starts (it needs the style to put
|
|
8
|
+
* on the request), and `VideoEditor` merges them *after* it ends (at the one
|
|
9
|
+
* seam that applies server-authored captions outside the undo stack). The
|
|
10
|
+
* resolution happens once, in the modal, and rides along to the merge on the
|
|
11
|
+
* `onDone` callback; neither side repeats the round trip, so they cannot
|
|
12
|
+
* disagree about what the profile said.
|
|
13
|
+
*
|
|
14
|
+
* NOT to be confused with `captionStyleDefaults.ts` next door, which is a
|
|
15
|
+
* hand-copied mirror of each render TEMPLATE's own parameter defaults. These
|
|
16
|
+
* are the HOST's defaults for one named profile, and the editor knows nothing
|
|
17
|
+
* about where they come from.
|
|
18
|
+
*/
|
|
19
|
+
import type { CaptionProfileDefaults, EditorAdapter, Project } from '../types'
|
|
20
|
+
import type { Captions } from '../schema'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ask the host for `profile`'s caption defaults, best-effort.
|
|
24
|
+
*
|
|
25
|
+
* Returns `null` — never throws, never rejects — when the project has no
|
|
26
|
+
* profile, when the host implements no `getCaptionProfileDefaults`, or when
|
|
27
|
+
* that call fails in any way. Seeding is a convenience laid over the user's
|
|
28
|
+
* actual request (regenerate my captions); a profile lookup that 500s must not
|
|
29
|
+
* be the reason that request cannot be made. The synchronous `try` matters as
|
|
30
|
+
* much as the `.catch`: an adapter method that throws before returning its
|
|
31
|
+
* promise would otherwise escape past the await.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveCaptionProfileDefaults<P extends Project = Project>(
|
|
34
|
+
adapter: EditorAdapter<P>,
|
|
35
|
+
profile: string | undefined | null,
|
|
36
|
+
): Promise<CaptionProfileDefaults | null> {
|
|
37
|
+
if (!profile || !adapter.getCaptionProfileDefaults) return null
|
|
38
|
+
try {
|
|
39
|
+
return (await adapter.getCaptionProfileDefaults(profile)) ?? null
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fold `defaults` onto a freshly transcribed `captions` track, filling only
|
|
47
|
+
* the fields the host's response left unset.
|
|
48
|
+
*
|
|
49
|
+
* Three rules, each load-bearing:
|
|
50
|
+
*
|
|
51
|
+
* - **The server's value always wins.** The caption route returns a bare
|
|
52
|
+
* `{ style, segments }` today, so in practice every seedable field is
|
|
53
|
+
* absent — but a profile default is the weakest possible authority on a
|
|
54
|
+
* track's look, below anything the host chose to state, and the day the
|
|
55
|
+
* route starts carrying a color is not the day this should start
|
|
56
|
+
* clobbering it.
|
|
57
|
+
* - **`style` is never written onto the track.** It seeds the generation
|
|
58
|
+
* REQUEST (`GenerateCaptionsOptions.style`); what comes back reports the
|
|
59
|
+
* style actually transcribed with, and that report is the truth.
|
|
60
|
+
* - **`googleFonts` rides with `fontFamily` or not at all.** Seeding a
|
|
61
|
+
* family without its font spec renders the fallback face with nothing on
|
|
62
|
+
* screen to say so (see `Captions.fontFamily`), and seeding a spec for a
|
|
63
|
+
* family we did not apply just fetches bytes no glyph uses.
|
|
64
|
+
*
|
|
65
|
+
* Returns the ORIGINAL object by reference when there is nothing to seed. The
|
|
66
|
+
* result goes straight to `applyExternal`, and a fresh object there is a state
|
|
67
|
+
* change the editor reconciles — and, downstream, a project that differs from
|
|
68
|
+
* one produced before this feature existed, for no reason a user could name.
|
|
69
|
+
*/
|
|
70
|
+
export function mergeCaptionProfileDefaults(
|
|
71
|
+
captions: Captions,
|
|
72
|
+
defaults: CaptionProfileDefaults | null | undefined,
|
|
73
|
+
): Captions {
|
|
74
|
+
if (!defaults) return captions
|
|
75
|
+
|
|
76
|
+
const patch: Partial<Captions> = {}
|
|
77
|
+
if (captions.fontFamily == null && defaults.fontFamily) {
|
|
78
|
+
patch.fontFamily = defaults.fontFamily
|
|
79
|
+
if (captions.googleFonts == null && defaults.googleFonts?.length) {
|
|
80
|
+
patch.googleFonts = defaults.googleFonts
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (captions.color == null && defaults.color) patch.color = defaults.color
|
|
84
|
+
|
|
85
|
+
return Object.keys(patch).length === 0 ? captions : { ...captions, ...patch }
|
|
86
|
+
}
|
|
@@ -148,8 +148,26 @@ export default function LeftPanelTabs({ tabs, defaultTabId, storageKey = DEFAULT
|
|
|
148
148
|
selected
|
|
149
149
|
// Label is ~10px accent TEXT → the AA-safe accent-text token
|
|
150
150
|
// (indigo-600 in light); the tint fill keeps the plain accent.
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
//
|
|
152
|
+
// The tint uses `color-mix(...)` baked directly into the
|
|
153
|
+
// arbitrary value, NOT Tailwind's `/NN` opacity modifier —
|
|
154
|
+
// that modifier only works when Tailwind itself can parse
|
|
155
|
+
// the color to inject an alpha channel, which it cannot do
|
|
156
|
+
// for an arbitrary `var(...)` reference. `text-[var(--x)]/60`
|
|
157
|
+
// silently emits NO utility at all (no build error, no
|
|
158
|
+
// fallback), in this package's own Tailwind build and in a
|
|
159
|
+
// host's. Measured: building this component under
|
|
160
|
+
// montaj-app/desktop/ui's Tailwind (v3.4) produced zero
|
|
161
|
+
// `editor-text`/`editor-accent` rules for any `/NN`-modified
|
|
162
|
+
// class here, while the identical `color-mix(in_srgb,
|
|
163
|
+
// var(--editor-text)_60%,transparent)` arbitrary value
|
|
164
|
+
// compiled correctly in both builds. That silent failure is
|
|
165
|
+
// exactly what made the rail's inactive items (all but the
|
|
166
|
+
// selected tab) unreadable — no color rule at all, so the
|
|
167
|
+
// button fell back to inherited/default text color against
|
|
168
|
+
// the editor's dark background.
|
|
169
|
+
? 'text-[var(--editor-accent-text)] bg-[color-mix(in_srgb,var(--editor-accent)_10%,transparent)]'
|
|
170
|
+
: 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] hover:bg-[color-mix(in_srgb,var(--editor-text)_5%,transparent)]',
|
|
153
171
|
)}
|
|
154
172
|
>
|
|
155
173
|
{selected && (
|