@phenx-inc/ctlsurf 0.7.0 → 0.9.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 (38) hide show
  1. package/out/headless/index.mjs +169 -10
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +175 -9
  4. package/out/preload/index.js +10 -0
  5. package/out/renderer/assets/{cssMode-eTXVdAkZ.js → cssMode-BdYp0fp9.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-B5BKaiK4.js → freemarker2-Cya-9zr-.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BIdLd2wU.js → handlebars-EJs9W5Ub.js} +1 -1
  8. package/out/renderer/assets/{html-BXL4cnLS.js → html-Ex4qy2lh.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-46N3XG2c.js → htmlMode-CCm0aJ6C.js} +3 -3
  10. package/out/renderer/assets/{index-dRvutfbl.js → index-Cj7K9-Ba.js} +823 -137
  11. package/out/renderer/assets/{index-Cf-RsxoC.css → index-nvsNYP5c.css} +229 -0
  12. package/out/renderer/assets/{javascript-n_iZZzDX.js → javascript-B2niCouI.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DXDczSNu.js → jsonMode-BgCUHaFo.js} +3 -3
  14. package/out/renderer/assets/{liquid-B1QweUh7.js → liquid-DOQmSo4G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-DqzMqkRk.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
  16. package/out/renderer/assets/{mdx-BCv8lm5e.js → mdx-DuKdGNKE.js} +1 -1
  17. package/out/renderer/assets/{python-BLNzYwDv.js → python-BUa4XFG-.js} +1 -1
  18. package/out/renderer/assets/{razor-CvAww8bG.js → razor-BUw8jho0.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-C7m6Kr5E.js → tsMode-8FLRnobw.js} +1 -1
  21. package/out/renderer/assets/{typescript-DhPw4VVg.js → typescript-rdwAjCi4.js} +1 -1
  22. package/out/renderer/assets/{xml-B0WLFJ2U.js → xml-HOzT59Qg.js} +1 -1
  23. package/out/renderer/assets/{yaml-BWyn9Wd7.js → yaml-B81JfLsW.js} +1 -1
  24. package/out/renderer/index.html +64 -2
  25. package/out/repro-toggle.mjs +305 -0
  26. package/package.json +1 -1
  27. package/src/main/index.ts +9 -0
  28. package/src/main/orchestrator.ts +51 -10
  29. package/src/main/transcripts.ts +134 -0
  30. package/src/preload/index.ts +14 -0
  31. package/src/renderer/App.tsx +30 -11
  32. package/src/renderer/components/FloatingMic.tsx +48 -0
  33. package/src/renderer/components/SpeakControls.tsx +235 -0
  34. package/src/renderer/components/VoiceInput.tsx +183 -140
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +94 -6
  37. package/src/renderer/lib/speech.ts +317 -0
  38. package/src/renderer/styles.css +229 -0
@@ -0,0 +1,317 @@
1
+ // Text-to-speech for spoken agent replies (Electron desktop only). Two engines
2
+ // the user can pick between:
3
+ // - 'web' → the built-in Web Speech synthesis API (OS voices, no download)
4
+ // - 'neural' → a local neural model via transformers.js (heavier, downloads
5
+ // on first use, more natural)
6
+ // A small queue serializes utterances so overlapping replies don't talk over
7
+ // each other, and stop() flushes everything.
8
+
9
+ export type TtsEngineId = 'web' | 'neural'
10
+
11
+ const ENGINE_KEY = 'ctlsurf.tts.engine'
12
+ const VOICE_KEY = 'ctlsurf.tts.voiceURI'
13
+ const RATE_KEY = 'ctlsurf.tts.rate'
14
+
15
+ // Cap so a runaway reply doesn't narrate for minutes; split into sentence-ish
16
+ // chunks so long passages stay reliable on the Web Speech backend.
17
+ const MAX_SPEAK_CHARS = 1600
18
+ const MAX_CHUNK_CHARS = 280
19
+
20
+ // ─── Text cleaning ───────────────────────────────────
21
+
22
+ // Turn a markdown-ish assistant reply into something listenable: code blocks
23
+ // are condensed to a short spoken marker rather than read symbol-by-symbol.
24
+ export function cleanForSpeech(input: string): string {
25
+ let t = input
26
+
27
+ // Fenced code blocks → "code block, N lines."
28
+ t = t.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_m, body: string) => {
29
+ const lines = body.replace(/\n+$/, '').split('\n').filter((l) => l.trim().length).length
30
+ return lines > 0 ? ` (code block, ${lines} ${lines === 1 ? 'line' : 'lines'}) ` : ' (code) '
31
+ })
32
+ // Leftover/unterminated fence
33
+ t = t.replace(/```/g, ' (code) ')
34
+
35
+ // Inline code → spoken contents without backticks
36
+ t = t.replace(/`([^`]+)`/g, '$1')
37
+ // Markdown links [text](url) → text
38
+ t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
39
+ // Bare URLs → "link"
40
+ t = t.replace(/https?:\/\/\S+/g, 'link')
41
+ // File paths with many slashes are noise when read aloud → basename
42
+ t = t.replace(/(?:[\w.-]*\/){2,}([\w.-]+)/g, '$1')
43
+ // Strip leftover markdown emphasis / heading / list / blockquote markers
44
+ t = t.replace(/[*_#>]+/g, ' ')
45
+ t = t.replace(/^\s*[-•]\s+/gm, ', ')
46
+ // HTML tags, if any slipped through
47
+ t = t.replace(/<[^>]+>/g, ' ')
48
+ // Collapse whitespace
49
+ t = t.replace(/\s+/g, ' ').trim()
50
+
51
+ if (t.length > MAX_SPEAK_CHARS) {
52
+ t = t.slice(0, MAX_SPEAK_CHARS).replace(/\s+\S*$/, '') + '…'
53
+ }
54
+ return t
55
+ }
56
+
57
+ function splitIntoChunks(text: string): string[] {
58
+ if (text.length <= MAX_CHUNK_CHARS) return text ? [text] : []
59
+ const sentences = text.match(/[^.!?]+[.!?]+|\S[^.!?]*$/g) || [text]
60
+ const chunks: string[] = []
61
+ let buf = ''
62
+ for (const s of sentences) {
63
+ if ((buf + s).length > MAX_CHUNK_CHARS && buf) {
64
+ chunks.push(buf.trim())
65
+ buf = ''
66
+ }
67
+ // A single sentence longer than the cap is hard-split.
68
+ if (s.length > MAX_CHUNK_CHARS) {
69
+ for (let i = 0; i < s.length; i += MAX_CHUNK_CHARS) chunks.push(s.slice(i, i + MAX_CHUNK_CHARS).trim())
70
+ } else {
71
+ buf += s
72
+ }
73
+ }
74
+ if (buf.trim()) chunks.push(buf.trim())
75
+ return chunks
76
+ }
77
+
78
+ // ─── Preferences ─────────────────────────────────────
79
+
80
+ export function getEngine(): TtsEngineId {
81
+ try { return localStorage.getItem(ENGINE_KEY) === 'neural' ? 'neural' : 'web' } catch { return 'web' }
82
+ }
83
+ export function setEngine(id: TtsEngineId): void {
84
+ try { localStorage.setItem(ENGINE_KEY, id) } catch { /* ignore */ }
85
+ }
86
+ export function getVoiceURI(): string | null {
87
+ try { return localStorage.getItem(VOICE_KEY) || null } catch { return null }
88
+ }
89
+ export function setVoiceURI(uri: string | null): void {
90
+ try { uri ? localStorage.setItem(VOICE_KEY, uri) : localStorage.removeItem(VOICE_KEY) } catch { /* ignore */ }
91
+ }
92
+ export function getRate(): number {
93
+ try {
94
+ const n = Number(localStorage.getItem(RATE_KEY))
95
+ return Number.isFinite(n) && n >= 0.5 && n <= 2 ? n : 1
96
+ } catch { return 1 }
97
+ }
98
+ export function setRate(rate: number): void {
99
+ try { localStorage.setItem(RATE_KEY, String(rate)) } catch { /* ignore */ }
100
+ }
101
+
102
+ export function listWebVoices(): SpeechSynthesisVoice[] {
103
+ if (typeof speechSynthesis === 'undefined') return []
104
+ return speechSynthesis.getVoices()
105
+ }
106
+
107
+ // ─── Neural engine (transformers.js) ─────────────────
108
+
109
+ // Self-contained neural voice: a VITS model that needs no speaker-embeddings
110
+ // file and no separate vocoder (unlike SpeechT5), so first use has far fewer
111
+ // ways to fail. English; output is mono 16kHz PCM.
112
+ const NEURAL_MODEL = 'Xenova/mms-tts-eng'
113
+
114
+ type RawAudio = { audio: Float32Array; sampling_rate: number }
115
+ type Synthesizer = (text: string, options?: Record<string, unknown>) => Promise<RawAudio | RawAudio[]>
116
+ let synthPromise: Promise<Synthesizer> | null = null
117
+
118
+ export interface TtsModelProgress { status: string; progress?: number }
119
+
120
+ function loadSynthesizer(onProgress?: (p: TtsModelProgress) => void): Promise<Synthesizer> {
121
+ if (!synthPromise) {
122
+ synthPromise = (async () => {
123
+ const { pipeline, env } = await import('@huggingface/transformers')
124
+ env.allowLocalModels = false
125
+ const common = { progress_callback: onProgress as never }
126
+ // WebGPU (Metal on macOS) is far faster than the WASM CPU backend for
127
+ // inference. Fall back to WASM if it's unavailable or the model has an op
128
+ // WebGPU can't run — same pattern as the Whisper path.
129
+ const hasWebGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
130
+ if (hasWebGpu) {
131
+ try {
132
+ const s = (await pipeline('text-to-speech', NEURAL_MODEL, { ...common, device: 'webgpu' })) as unknown as Synthesizer
133
+ console.info('[tts] neural backend: webgpu')
134
+ return s
135
+ } catch (err) {
136
+ console.warn('[tts] WebGPU backend failed, falling back to WASM', err)
137
+ }
138
+ }
139
+ const s = (await pipeline('text-to-speech', NEURAL_MODEL, common)) as unknown as Synthesizer
140
+ console.info('[tts] neural backend: wasm')
141
+ return s
142
+ })()
143
+ synthPromise.catch(() => { synthPromise = null })
144
+ }
145
+ return synthPromise
146
+ }
147
+
148
+ export function isNeuralModelLoading(): boolean {
149
+ return synthPromise !== null
150
+ }
151
+
152
+ // ─── Controller ──────────────────────────────────────
153
+
154
+ class SpeechController {
155
+ private queue: string[] = []
156
+ private draining = false
157
+ private audioCtx: AudioContext | null = null
158
+ private currentSource: AudioBufferSourceNode | null = null
159
+ private generation = 0 // bumped by stop() to abort in-flight work
160
+ private active = false
161
+ onModelProgress: ((p: TtsModelProgress | null) => void) | null = null
162
+ // Fires true while a reply is being spoken/queued, false when idle — drives
163
+ // the visible Stop button.
164
+ onActivityChange: ((active: boolean) => void) | null = null
165
+ onError: ((message: string) => void) | null = null
166
+
167
+ // Multiple UI surfaces (titlebar SpeakControls + floating read button) need
168
+ // the speaking state at once, so beyond the single onActivityChange slot we
169
+ // also keep a listener set.
170
+ private activityListeners = new Set<(active: boolean) => void>()
171
+
172
+ get isActive(): boolean {
173
+ return this.active
174
+ }
175
+
176
+ // Subscribe to speaking-state changes. Fires immediately with the current
177
+ // state so a late subscriber syncs. Returns an unsubscribe function.
178
+ onActivity(fn: (active: boolean) => void): () => void {
179
+ this.activityListeners.add(fn)
180
+ fn(this.active)
181
+ return () => { this.activityListeners.delete(fn) }
182
+ }
183
+
184
+ // Prime/resume the AudioContext from a user gesture so neural playback isn't
185
+ // blocked by the browser autoplay policy (the System engine is unaffected).
186
+ unlock(): void {
187
+ try {
188
+ const ctx = this.ensureCtx()
189
+ if (ctx.state === 'suspended') void ctx.resume()
190
+ } catch { /* ignore */ }
191
+ }
192
+
193
+ // Start loading the neural model in the background (e.g. when the user picks
194
+ // the neural engine) so the first utterance doesn't pay download/compile time.
195
+ warmup(): void {
196
+ if (getEngine() !== 'neural') return
197
+ void loadSynthesizer((p) => this.onModelProgress?.(p))
198
+ .then(() => this.onModelProgress?.(null))
199
+ .catch(() => { this.onModelProgress?.(null) })
200
+ }
201
+
202
+ enqueue(rawText: string): void {
203
+ const text = cleanForSpeech(rawText)
204
+ if (!text) return
205
+ this.queue.push(...splitIntoChunks(text))
206
+ void this.drain()
207
+ }
208
+
209
+ stop(): void {
210
+ this.generation++
211
+ this.queue = []
212
+ this.draining = false
213
+ if (typeof speechSynthesis !== 'undefined') {
214
+ try { speechSynthesis.cancel() } catch { /* ignore */ }
215
+ }
216
+ if (this.currentSource) {
217
+ try { this.currentSource.stop() } catch { /* ignore */ }
218
+ this.currentSource = null
219
+ }
220
+ this.onModelProgress?.(null)
221
+ this.setActive(false)
222
+ }
223
+
224
+ private ensureCtx(): AudioContext {
225
+ if (!this.audioCtx) {
226
+ const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
227
+ this.audioCtx = new Ctx()
228
+ }
229
+ return this.audioCtx
230
+ }
231
+
232
+ private setActive(a: boolean): void {
233
+ if (this.active === a) return
234
+ this.active = a
235
+ this.onActivityChange?.(a)
236
+ this.activityListeners.forEach((fn) => fn(a))
237
+ }
238
+
239
+ private async drain(): Promise<void> {
240
+ if (this.draining) return
241
+ this.draining = true
242
+ const gen = this.generation
243
+ this.setActive(true)
244
+ while (this.queue.length && gen === this.generation) {
245
+ const chunk = this.queue.shift() as string
246
+ try {
247
+ if (getEngine() === 'neural') await this.speakNeural(chunk, gen)
248
+ else await this.speakWeb(chunk, gen)
249
+ } catch (err) {
250
+ console.error('[tts] speak failed', err)
251
+ this.onModelProgress?.(null)
252
+ const detail = err instanceof Error ? err.message : String(err)
253
+ this.onError?.(getEngine() === 'neural' ? `Neural voice failed: ${detail}` : 'Speech failed')
254
+ }
255
+ }
256
+ if (gen === this.generation) {
257
+ this.draining = false
258
+ this.setActive(false)
259
+ }
260
+ }
261
+
262
+ private speakWeb(text: string, gen: number): Promise<void> {
263
+ return new Promise((resolve) => {
264
+ if (typeof speechSynthesis === 'undefined' || gen !== this.generation) return resolve()
265
+ const u = new SpeechSynthesisUtterance(text)
266
+ u.rate = getRate()
267
+ const wantUri = getVoiceURI()
268
+ if (wantUri) {
269
+ const v = speechSynthesis.getVoices().find((vv) => vv.voiceURI === wantUri)
270
+ if (v) u.voice = v
271
+ }
272
+ u.onend = () => resolve()
273
+ u.onerror = () => resolve()
274
+ speechSynthesis.speak(u)
275
+ })
276
+ }
277
+
278
+ private async speakNeural(text: string, gen: number): Promise<void> {
279
+ console.info('[tts] neural: loading model…')
280
+ this.onModelProgress?.({ status: 'loading' })
281
+ const synth = await loadSynthesizer((p) => this.onModelProgress?.(p))
282
+ if (gen !== this.generation) return
283
+ console.info('[tts] neural: synthesizing', JSON.stringify(text.slice(0, 60)))
284
+ const out = await synth(text)
285
+ const raw = Array.isArray(out) ? out[0] : out
286
+ this.onModelProgress?.(null)
287
+ if (gen !== this.generation) return
288
+ if (!raw?.audio?.length) throw new Error('neural synth returned no audio')
289
+ console.info(`[tts] neural: playing ${raw.audio.length} samples @ ${raw.sampling_rate}Hz`)
290
+ await this.playPcm(raw.audio, raw.sampling_rate, gen)
291
+ }
292
+
293
+ private async playPcm(pcm: Float32Array, sampleRate: number, gen: number): Promise<void> {
294
+ if (gen !== this.generation) return
295
+ const ctx = this.ensureCtx()
296
+ // Autoplay policy can leave the context suspended; resume before playing.
297
+ if (ctx.state === 'suspended') {
298
+ try { await ctx.resume() } catch { /* ignore */ }
299
+ }
300
+ if (gen !== this.generation) return
301
+ return new Promise((resolve) => {
302
+ const buffer = ctx.createBuffer(1, pcm.length, sampleRate)
303
+ buffer.getChannelData(0).set(pcm)
304
+ const source = ctx.createBufferSource()
305
+ source.buffer = buffer
306
+ source.connect(ctx.destination)
307
+ source.onended = () => {
308
+ if (this.currentSource === source) this.currentSource = null
309
+ resolve()
310
+ }
311
+ this.currentSource = source
312
+ source.start()
313
+ })
314
+ }
315
+ }
316
+
317
+ export const speech = new SpeechController()
@@ -701,6 +701,40 @@ html, body, #root {
701
701
  }
702
702
  .floating-mic-hide:hover { color: #f7768e; }
703
703
 
704
+ /* Smaller sibling of the round mic: read the last agent reply aloud. */
705
+ .floating-mic-read {
706
+ width: 30px;
707
+ height: 30px;
708
+ border-radius: 50%;
709
+ display: inline-flex;
710
+ align-items: center;
711
+ justify-content: center;
712
+ background: #2a2b3d;
713
+ border: 1px solid #3b3d57;
714
+ color: #a9b1d6;
715
+ cursor: pointer;
716
+ transition: all 0.15s;
717
+ }
718
+ .floating-mic-read:hover { border-color: #565f89; color: #c0caf5; }
719
+ .floating-mic-read.speaking {
720
+ color: #7aa2f7;
721
+ border-color: #7aa2f7;
722
+ }
723
+ .floating-mic-note {
724
+ position: absolute;
725
+ bottom: -22px;
726
+ left: 50%;
727
+ transform: translateX(-50%);
728
+ white-space: nowrap;
729
+ font-size: 11px;
730
+ color: #565f89;
731
+ background: #16161e;
732
+ border: 1px solid #3b3d57;
733
+ border-radius: 6px;
734
+ padding: 2px 6px;
735
+ pointer-events: none;
736
+ }
737
+
704
738
  .voice-btn-floating {
705
739
  width: 48px;
706
740
  height: 48px;
@@ -733,6 +767,201 @@ html, body, #root {
733
767
  margin-bottom: 8px;
734
768
  }
735
769
 
770
+ /* Mic source picker (small caret badge on the round mic + dropdown) */
771
+ .voice-source-btn {
772
+ position: absolute;
773
+ bottom: -3px;
774
+ right: -3px;
775
+ width: 18px;
776
+ height: 18px;
777
+ border-radius: 50%;
778
+ display: inline-flex;
779
+ align-items: center;
780
+ justify-content: center;
781
+ padding: 0;
782
+ font-size: 10px;
783
+ line-height: 1;
784
+ background: #2a2b3d;
785
+ border: 1px solid #3b3d57;
786
+ color: #a9b1d6;
787
+ cursor: pointer;
788
+ z-index: 60;
789
+ }
790
+ .voice-source-btn:hover {
791
+ border-color: #7aa2f7;
792
+ color: #c0caf5;
793
+ }
794
+ .voice-source-menu {
795
+ position: absolute;
796
+ bottom: 100%;
797
+ right: 0;
798
+ margin-bottom: 10px;
799
+ min-width: 200px;
800
+ max-width: 280px;
801
+ padding: 4px;
802
+ background: #16161e;
803
+ border: 1px solid #3b3d57;
804
+ border-radius: 8px;
805
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
806
+ z-index: 300;
807
+ }
808
+ .voice-source-head {
809
+ font-size: 10px;
810
+ text-transform: uppercase;
811
+ letter-spacing: 0.04em;
812
+ color: #565f89;
813
+ padding: 4px 8px 6px;
814
+ }
815
+ .voice-source-item {
816
+ display: flex;
817
+ align-items: center;
818
+ gap: 6px;
819
+ width: 100%;
820
+ padding: 5px 8px;
821
+ border: none;
822
+ border-radius: 5px;
823
+ background: transparent;
824
+ color: #a9b1d6;
825
+ font-size: 12px;
826
+ text-align: left;
827
+ cursor: pointer;
828
+ }
829
+ .voice-source-item:hover { background: #1f2335; }
830
+ .voice-source-item.active { color: #7aa2f7; }
831
+ .voice-source-check {
832
+ flex: 0 0 12px;
833
+ width: 12px;
834
+ color: #7aa2f7;
835
+ font-size: 11px;
836
+ }
837
+ .voice-source-label {
838
+ overflow: hidden;
839
+ text-overflow: ellipsis;
840
+ white-space: nowrap;
841
+ }
842
+ .voice-source-empty {
843
+ padding: 6px 8px;
844
+ font-size: 11px;
845
+ color: #565f89;
846
+ }
847
+
848
+ /* Spoken-replies titlebar control */
849
+ .speak-controls {
850
+ position: relative;
851
+ display: inline-flex;
852
+ align-items: center;
853
+ }
854
+ .speak-btn.active {
855
+ color: #7aa2f7;
856
+ border-color: #7aa2f7;
857
+ }
858
+ .speak-pct {
859
+ font-size: 9px;
860
+ margin-left: 3px;
861
+ color: #e0af68;
862
+ }
863
+ .speak-caret {
864
+ padding: 0 3px;
865
+ font-size: 10px;
866
+ min-width: 0;
867
+ }
868
+ .speak-stop {
869
+ color: #f7768e;
870
+ border-color: #f7768e;
871
+ }
872
+ .speak-stop:hover { background: #2d2030; }
873
+ .speak-error {
874
+ position: absolute;
875
+ top: 100%;
876
+ right: 0;
877
+ margin-top: 6px;
878
+ max-width: 340px;
879
+ padding: 4px 9px;
880
+ border-radius: 5px;
881
+ font-size: 11px;
882
+ line-height: 1.3;
883
+ white-space: normal;
884
+ word-break: break-word;
885
+ background: #2d2030;
886
+ color: #f7768e;
887
+ border: 1px solid #f7768e;
888
+ z-index: 60;
889
+ }
890
+ .speak-menu {
891
+ position: absolute;
892
+ top: 100%;
893
+ right: 0;
894
+ margin-top: 6px;
895
+ min-width: 220px;
896
+ max-width: 280px;
897
+ padding: 6px;
898
+ background: #16161e;
899
+ border: 1px solid #3b3d57;
900
+ border-radius: 8px;
901
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
902
+ z-index: 300;
903
+ }
904
+ .speak-menu-head {
905
+ font-size: 10px;
906
+ text-transform: uppercase;
907
+ letter-spacing: 0.04em;
908
+ color: #565f89;
909
+ padding: 6px 6px 4px;
910
+ }
911
+ .speak-menu-item {
912
+ display: flex;
913
+ align-items: center;
914
+ gap: 6px;
915
+ width: 100%;
916
+ padding: 5px 6px;
917
+ border: none;
918
+ border-radius: 5px;
919
+ background: transparent;
920
+ color: #a9b1d6;
921
+ font-size: 12px;
922
+ text-align: left;
923
+ cursor: pointer;
924
+ }
925
+ .speak-menu-item:hover { background: #1f2335; }
926
+ .speak-menu-item.active { color: #7aa2f7; }
927
+ .speak-menu-check {
928
+ flex: 0 0 12px;
929
+ width: 12px;
930
+ color: #7aa2f7;
931
+ font-size: 11px;
932
+ }
933
+ .speak-select {
934
+ width: 100%;
935
+ margin: 2px 0 4px;
936
+ padding: 4px 6px;
937
+ background: #1f2335;
938
+ color: #a9b1d6;
939
+ border: 1px solid #3b3d57;
940
+ border-radius: 5px;
941
+ font-size: 12px;
942
+ }
943
+ .speak-rate {
944
+ width: 100%;
945
+ margin: 2px 0 6px;
946
+ accent-color: #7aa2f7;
947
+ }
948
+ .speak-menu-row {
949
+ display: flex;
950
+ gap: 6px;
951
+ padding: 2px 0 0;
952
+ }
953
+ .speak-menu-btn {
954
+ flex: 1;
955
+ padding: 5px 6px;
956
+ background: #2a2b3d;
957
+ color: #a9b1d6;
958
+ border: 1px solid #3b3d57;
959
+ border-radius: 5px;
960
+ font-size: 12px;
961
+ cursor: pointer;
962
+ }
963
+ .speak-menu-btn:hover { border-color: #7aa2f7; color: #c0caf5; }
964
+
736
965
  /* Editor panel */
737
966
  .editor-panel {
738
967
  display: flex;