feat: noise gate, PDF search, sidebar active highlight, stats collapse, tooltip + dialogue fixes

- Conversation: adjustable noise gate slider (RMS threshold + 300 ms
  minimum burst duration) prevents short noise spikes from triggering
  STT; level meter shows gate position as a blue marker
- Conversation stats panel now collapsible (chevron button) to free
  chat width; floating expand button restores it; state persists
- Read Aloud: text search input in PDF toolbar (Enter = next hit,
  Shift+Enter = previous, Esc = clear)
- Sidebar: tooltip now works for all item types including sub-items
  that had no .nav-label span (text extracted by stripping icon/badge)
- Sidebar active section indicator added (.nav-tree-item.active was
  previously unstyled — active section now has bg + right accent bar)
- Casting audiobook: prompt instructs LLM to handle ?« / !« endings
  and unclosed » at passage end as dialogue; deterministic fallback
  also handles unclosed opening quote

Bumps to v1.12.2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 16:48:48 +02:00
parent 41f747a7b0
commit 4f795e820c
9 changed files with 185 additions and 19 deletions

View File

@ -9,6 +9,20 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.2] — 2026-06-27
### Added
- **PDF Search**: search input in the Read Aloud toolbar — press Enter to jump to the first page containing the term, Shift+Enter to go back.
- **Noise gate slider**: adjustable minimum-amplitude threshold in the Conversation input bar (the blue marker on the level meter shows the current gate). Short noise spikes below the gate or bursts shorter than 300 ms are silenced before STT. Persists across sessions.
- **Conversation stats panel** now has a collapse button (→) to hide the latency sidebar and give more chat space; a floating icon button restores it.
### Changed
- **Sidebar tooltip** now extracts text correctly for all item types (sub-items that had no `.nav-label` span showed nothing before).
- **Active section** in the sidebar now gets a blue background highlight + right-border accent (`.nav-tree-item.active` was previously unstyled, making it impossible to tell which section you were in).
- **Casting audiobook**: LLM prompt now explicitly handles `?«` and `!«` as valid German quote endings, and instructs the model to treat an unclosed `»` at end-of-passage as dialogue. Deterministic fallback also handles the unclosed-quote edge case.
---
## [1.12.1] — 2026-06-27 ## [1.12.1] — 2026-06-27
### Changed ### Changed

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.1"> <link rel="stylesheet" href="/static/style.css?v=1.12.2">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -336,7 +336,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.1"></script> <script src="/static/loader.js?v=1.12.2"></script>
</body> </body>
</html> </html>

View File

@ -70,8 +70,19 @@ function audiobookSplitByQuotes(text) {
} }
last = sp.end; last = sp.end;
} }
const tail = text.slice(last); const tail = text.slice(last).trimEnd();
if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' }); if (tail) {
// If the tail starts with an unclosed opening quote (PDF line-break cut it off),
// treat the quoted portion as dialogue rather than narrator.
const unclosed = tail.match(/^([\s\S]*?)(»)([\s\S]+)$/);
if (unclosed && !tail.includes('«')) {
if (unclosed[1].trim()) out.push({ speaker: 'Narrator', type: 'narration', text: unclosed[1].trim(), emotion: '' });
const speaker = audiobookGuessSpeaker('', unclosed[1]) || 'Unknown';
out.push({ speaker, type: 'dialogue', text: unclosed[3].trim(), emotion: '' });
} else if (tail.trim()) {
out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' });
}
}
return audiobookTurnTaking(out); return audiobookTurnTaking(out);
} }
@ -269,6 +280,8 @@ FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS:
STRIKTE FORMAT- UND TEXTREGELN: STRIKTE FORMAT- UND TEXTREGELN:
- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt."). - Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt.").
- »Text?« und »Text!« sind vollständige Dialoge das ?« bzw. !« schließt das Zitat ab, auch wenn es ungewohnt aussieht.
- Wenn ein Auszug mit einem offenen »-Zitat endet (kein schließendes «), behandle den Text ab » bis Textende als 'dialogue'.
- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!`; - Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!`;
const globalPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT; const globalPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT;

View File

@ -194,11 +194,14 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let vadRafId = null; let vadRafId = null;
let liveInterimText = ''; let liveInterimText = '';
let speechRec = null; let speechRec = null;
const VAD_THRESHOLD = 0.02; // raised to ignore background noise
const VAD_MIN_REC_MS = 650; // wait before VAD starts checking (avoids click/noise at start) const VAD_MIN_REC_MS = 650; // wait before VAD starts checking (avoids click/noise at start)
const VAD_SILENCE_MS = 1000; const VAD_SILENCE_MS = 1000;
const INTERRUPT_THRESHOLD = 0.04; // higher than VAD to avoid echo triggering interruption const INTERRUPT_THRESHOLD = 0.04; // higher than VAD to avoid echo triggering interruption
const INTERRUPT_HOLD_MS = 350; // speech must persist this long to interrupt const INTERRUPT_HOLD_MS = 350; // speech must persist this long to interrupt
const MIN_SPEECH_FRAMES_MS = 300; // speech must exceed threshold for at least this long
const gateSlider = () => document.getElementById('conv-gate-slider');
const vadThreshold = () => { const s = gateSlider(); return s ? (parseInt(s.value, 10) / 1000) : 0.02; };
let vadSpeechStartMs = 0; // time when speech level first crossed threshold in this recording
const liveAgentOn = () => liveAgentToggle ? liveAgentToggle.checked : true; const liveAgentOn = () => liveAgentToggle ? liveAgentToggle.checked : true;
const vadSilenceMs = () => liveAgentOn() ? 650 : VAD_SILENCE_MS; const vadSilenceMs = () => liveAgentOn() ? 650 : VAD_SILENCE_MS;
const interruptHoldMs = () => liveAgentOn() ? 120 : INTERRUPT_HOLD_MS; const interruptHoldMs = () => liveAgentOn() ? 120 : INTERRUPT_HOLD_MS;
@ -225,6 +228,23 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let vadHadSpeech = false; // true once RMS crossed threshold during this recording let vadHadSpeech = false; // true once RMS crossed threshold during this recording
let vadLastVoiceMs = 0; // last timestamp speech was detected (for preview gate) let vadLastVoiceMs = 0; // last timestamp speech was detected (for preview gate)
let cancelNextBlob = false; // set by VAD when no speech was detected → skip STT let cancelNextBlob = false; // set by VAD when no speech was detected → skip STT
// Update gate marker position and persist on slider change
function updateGateMarker() {
const s = gateSlider(); if (!s) return;
const gate = document.getElementById('conv-level-gate');
if (gate) gate.style.left = s.value + '%';
try { localStorage.setItem('ttsvc_conv_gate', s.value); } catch (_) {}
}
document.addEventListener('change', function (e) {
if (e.target.id === 'conv-gate-slider') updateGateMarker();
});
// Restore persisted gate
(function () {
const s = gateSlider(); if (!s) return;
try { const v = localStorage.getItem('ttsvc_conv_gate'); if (v) s.value = v; } catch (_) {}
updateGateMarker();
})();
let convCurrentSentenceBubble = null; // assistant bubble to update with current TTS sentence let convCurrentSentenceBubble = null; // assistant bubble to update with current TTS sentence
// ── Populate STT backends ──────────────────────────────────────────────── // ── Populate STT backends ────────────────────────────────────────────────
@ -650,6 +670,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
previewTranscribing = false; previewTranscribing = false;
vadHadSpeech = false; vadHadSpeech = false;
vadLastVoiceMs = 0; vadLastVoiceMs = 0;
vadSpeechStartMs = 0;
cancelNextBlob = false; cancelNextBlob = false;
recChunks = []; recChunks = [];
mediaRecorder = new MediaRecorder(stream); mediaRecorder = new MediaRecorder(stream);
@ -738,11 +759,17 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let rms = 0; let rms = 0;
for (const s of vadBuf) rms += s * s; for (const s of vadBuf) rms += s * s;
rms = Math.sqrt(rms / vadBuf.length); rms = Math.sqrt(rms / vadBuf.length);
if (levelFill) levelFill.style.width = Math.min(100, rms * 5000) + '%'; const thr = vadThreshold();
if (rms > VAD_THRESHOLD) { if (levelFill) levelFill.style.width = Math.min(100, rms / thr * 40) + '%';
vadLastVoice = Date.now(); if (rms > thr) {
vadLastVoiceMs = Date.now(); const now = Date.now();
vadHadSpeech = true; if (!vadSpeechStartMs) vadSpeechStartMs = now;
vadLastVoice = now;
vadLastVoiceMs = now;
// Only mark as valid speech once it persists long enough
if (!vadHadSpeech && (now - vadSpeechStartMs) >= MIN_SPEECH_FRAMES_MS) vadHadSpeech = true;
} else {
vadSpeechStartMs = 0; // reset burst timer on silence
} }
const elapsed = Date.now() - recStart; const elapsed = Date.now() - recStart;
const silence = Date.now() - vadLastVoice; const silence = Date.now() - vadLastVoice;
@ -1178,4 +1205,21 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// Keep TTS backend select in sync after global backend refresh // Keep TTS backend select in sync after global backend refresh
window._ttsRefreshHooks = window._ttsRefreshHooks || []; window._ttsRefreshHooks = window._ttsRefreshHooks || [];
window._ttsRefreshHooks.push(populateConvTtsBackends); window._ttsRefreshHooks.push(populateConvTtsBackends);
// ── Stats panel collapse ─────────────────────────────────────────────────
(function () {
const panel = document.getElementById('conv-stats-panel');
const colBtn = document.getElementById('conv-stats-collapse-btn');
const expBtn = document.getElementById('conv-stats-expand-btn');
const KEY = 'ttsvc_conv_stats_collapsed';
function setCollapsed(on) {
if (!panel) return;
panel.classList.toggle('collapsed', on);
if (expBtn) expBtn.style.display = on ? 'flex' : 'none';
try { localStorage.setItem(KEY, on ? '1' : '0'); } catch (_) {}
}
if (colBtn) colBtn.addEventListener('click', function () { setCollapsed(true); });
if (expBtn) expBtn.addEventListener('click', function () { setCollapsed(false); });
try { if (localStorage.getItem(KEY) === '1') setCollapsed(true); } catch (_) {}
})();
})(); })();

View File

@ -1398,6 +1398,43 @@ $('reader-zoom-two') ?.addEventListener('click', () => readerApplyZoom('two'));
$('reader-zoom-in') ?.addEventListener('click', () => { readerState.scale = Math.min(4, readerState.scale * 1.2); readerApplyZoom('custom'); }); $('reader-zoom-in') ?.addEventListener('click', () => { readerState.scale = Math.min(4, readerState.scale * 1.2); readerApplyZoom('custom'); });
$('reader-zoom-out') ?.addEventListener('click', () => { readerState.scale = Math.max(0.2, readerState.scale / 1.2); readerApplyZoom('custom'); }); $('reader-zoom-out') ?.addEventListener('click', () => { readerState.scale = Math.max(0.2, readerState.scale / 1.2); readerApplyZoom('custom'); });
// ── Document search ──────────────────────────────────────────────────────────
let _readerSearchIdx = -1; // last hit sentence index
function readerSearchGo(term, direction) {
if (!term) { const st = $('reader-search-status'); if (st) st.textContent = ''; return; }
const lc = term.toLowerCase();
const sents = readerState.sentences;
if (!sents || !sents.length) { const st = $('reader-search-status'); if (st) st.textContent = 'No text loaded'; return; }
const dir = direction === -1 ? -1 : 1;
const start = _readerSearchIdx < 0 ? 0 : (_readerSearchIdx + dir + sents.length) % sents.length;
for (let i = 0; i < sents.length; i++) {
const idx = (start + i * dir + sents.length * sents.length) % sents.length;
const s = sents[idx];
const txt = (s.words || []).map(w => w.text).join(' ').toLowerCase();
if (txt.includes(lc)) {
_readerSearchIdx = idx;
const pageIdx = s.words?.[0]?.page ?? 0;
const pgDiv = readerState.pages[pageIdx]?.pageDiv;
if (pgDiv) pgDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
const st = $('reader-search-status');
const total = sents.filter(s2 => (s2.words||[]).map(w=>w.text).join(' ').toLowerCase().includes(lc)).length;
if (st) st.textContent = total ? `p.${pageIdx + 1}` : '';
return;
}
}
const st = $('reader-search-status');
if (st) st.textContent = 'Not found';
}
$('reader-search')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); readerSearchGo(e.target.value.trim(), e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { e.target.value = ''; _readerSearchIdx = -1; const st = $('reader-search-status'); if (st) st.textContent = ''; }
});
$('reader-search')?.addEventListener('input', e => {
_readerSearchIdx = -1;
if (!e.target.value) { const st = $('reader-search-status'); if (st) st.textContent = ''; }
});
// Library + export // Library + export
$('reader-save-lib')?.addEventListener('click', readerSaveLibrary); $('reader-save-lib')?.addEventListener('click', readerSaveLibrary);
$('reader-export-btn')?.addEventListener('click', () => readerExport($('reader-export-mode')?.value || 'page')); $('reader-export-btn')?.addEventListener('click', () => readerExport($('reader-export-mode')?.value || 'page'));

View File

@ -393,8 +393,14 @@
var item = e.target.closest('.nav-item, .nav-tree-item, .nav-tree-head'); var item = e.target.closest('.nav-item, .nav-tree-item, .nav-tree-head');
if (!item || item === _railTipActive) return; if (!item || item === _railTipActive) return;
var label = item.querySelector('.nav-label'); var label = item.querySelector('.nav-label');
if (!label) return; var text = '';
var text = label.textContent.trim(); if (label) {
text = label.textContent.trim();
} else {
var clone = item.cloneNode(true);
clone.querySelectorAll('.mdi, .ntc, .nav-badge, .nav-chevron').forEach(function (el) { el.remove(); });
text = clone.textContent.replace(/\s+/g, ' ').trim();
}
if (!text) return; if (!text) return;
var r = item.getBoundingClientRect(); var r = item.getBoundingClientRect();
_railTip.textContent = text; _railTip.textContent = text;

View File

@ -59,7 +59,14 @@
<!-- Input bar: text field + send + mic --> <!-- Input bar: text field + send + mic -->
<div class="conv-input-bar"> <div class="conv-input-bar">
<div class="conv-mic-status" id="conv-mic-status">Ready</div> <div class="conv-mic-status" id="conv-mic-status">Ready</div>
<div class="conv-level-wrap" id="conv-level-wrap"><div class="conv-level-fill" id="conv-level-fill"></div></div> <div class="conv-gate-row">
<div class="conv-level-wrap" id="conv-level-wrap">
<div class="conv-level-fill" id="conv-level-fill"></div>
<div class="conv-level-gate" id="conv-level-gate"></div>
</div>
<span class="conv-gate-label" title="Noise gate — raise to ignore background noise"><span class="mdi mdi-tune-variant"></span></span>
<input type="range" id="conv-gate-slider" class="conv-gate-slider" min="1" max="80" value="20" title="Noise gate threshold (raise to ignore background noise)">
</div>
<div class="conv-text-row"> <div class="conv-text-row">
<input id="conv-text-input" class="conv-text-inp" <input id="conv-text-input" class="conv-text-inp"
type="text" placeholder="Type a message and press Enter or →" type="text" placeholder="Type a message and press Enter or →"
@ -88,9 +95,12 @@
</div> </div>
</div> </div>
<!-- Stats panel --> <!-- Stats panel (collapsible) -->
<div class="conv-stats-panel"> <div class="conv-stats-panel" id="conv-stats-panel">
<div class="conv-stats-head">Latency</div> <div class="conv-stats-toggle-row">
<span class="conv-stats-head">Latency</span>
<button class="conv-stats-collapse-btn" id="conv-stats-collapse-btn" title="Hide stats panel"><span class="mdi mdi-chevron-right"></span></button>
</div>
<div class="conv-pipeline"> <div class="conv-pipeline">
<div class="conv-pipe-step" id="cps-stt"> <div class="conv-pipe-step" id="cps-stt">
@ -125,4 +135,8 @@
<div class="conv-history-empty">No turns yet.</div> <div class="conv-history-empty">No turns yet.</div>
</div> </div>
</div> </div>
<!-- Expand button shown when stats panel is collapsed -->
<button class="conv-stats-expand-btn" id="conv-stats-expand-btn" title="Show stats panel" style="display:none">
<span class="mdi mdi-chart-timeline-variant"></span>
</button>
</div> </div>

View File

@ -69,7 +69,7 @@
</div> </div>
</div> </div>
<!-- ③ Zoom toolbar + status legend (PDF) — above the document ───── --> <!-- ③ Zoom toolbar + search + status legend (PDF) — above the document ───── -->
<div class="reader-toolbar" id="reader-toolbar" hidden> <div class="reader-toolbar" id="reader-toolbar" hidden>
<div class="reader-zoom" id="reader-zoom"> <div class="reader-zoom" id="reader-zoom">
<button class="btn-secondary btn-sm" id="reader-zoom-fitw" title="Fit width"><span class="mdi mdi-arrow-expand-horizontal"></span> Fit width</button> <button class="btn-secondary btn-sm" id="reader-zoom-fitw" title="Fit width"><span class="mdi mdi-arrow-expand-horizontal"></span> Fit width</button>
@ -80,6 +80,11 @@
<span class="reader-zoom-pct" id="reader-zoom-pct">100%</span> <span class="reader-zoom-pct" id="reader-zoom-pct">100%</span>
<button class="btn-secondary btn-sm" id="reader-zoom-in" title="Zoom in"><span class="mdi mdi-plus"></span></button> <button class="btn-secondary btn-sm" id="reader-zoom-in" title="Zoom in"><span class="mdi mdi-plus"></span></button>
</div> </div>
<div class="reader-search-wrap">
<span class="mdi mdi-magnify reader-search-icon"></span>
<input type="search" id="reader-search" class="reader-search-inp" placeholder="Search…" autocomplete="off" aria-label="Search document text">
<span class="reader-search-status" id="reader-search-status"></span>
</div>
<div class="reader-legend"> <div class="reader-legend">
<span class="reader-leg reader-leg-pending">Not synthesised</span> <span class="reader-leg reader-leg-pending">Not synthesised</span>
<span class="reader-leg reader-leg-synth">Synthesising</span> <span class="reader-leg reader-leg-synth">Synthesising</span>

View File

@ -187,6 +187,11 @@ body {
.nav-tree-item .mdi { font-size: 14px; flex-shrink: 0; } .nav-tree-item .mdi { font-size: 14px; flex-shrink: 0; }
.nav-tree-item:hover { background: var(--panel); color: var(--text); } .nav-tree-item:hover { background: var(--panel); color: var(--text); }
.nav-tree-item.is-active { color: var(--accent); font-weight: 700; } .nav-tree-item.is-active { color: var(--accent); font-weight: 700; }
/* Current page/section within its parent group */
.nav-tree-item.active {
background: rgba(37,99,235,0.08); color: var(--accent); font-weight: 700;
border-right: 3px solid var(--accent);
}
/* Virtual tag subfolders under My Voices */ /* Virtual tag subfolders under My Voices */
.nav-tree-tag-label { padding: 7px 16px 3px 32px; font-size: 9.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--subtext); pointer-events: none; } .nav-tree-tag-label { padding: 7px 16px 3px 32px; font-size: 9.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--subtext); pointer-events: none; }
.nav-tree-item.nav-tree-tag { padding-left: 38px; } .nav-tree-item.nav-tree-tag { padding-left: 38px; }
@ -3170,11 +3175,29 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; } .conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; }
.conv-vad-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--subtext); cursor: pointer; white-space: nowrap; user-select: none; } .conv-vad-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--subtext); cursor: pointer; white-space: nowrap; user-select: none; }
.conv-vad-label input { cursor: pointer; accent-color: var(--accent); } .conv-vad-label input { cursor: pointer; accent-color: var(--accent); }
.conv-level-wrap { height: 2px; background: var(--panel); border-radius: 1px; margin: 3px 0; overflow: hidden; display: none; } /* Noise gate row */
.conv-gate-row { display: flex; align-items: center; gap: 8px; }
.conv-gate-label { font-size: 13px; color: var(--subtext); flex-shrink: 0; line-height: 1; }
.conv-gate-slider { flex: 1; max-width: 100px; height: 3px; cursor: pointer; accent-color: var(--accent); }
.conv-level-wrap { position: relative; flex: 1; height: 6px; background: var(--panel); border-radius: 3px; margin: 0; overflow: visible; display: none; }
.conv-level-wrap.active { display: block; } .conv-level-wrap.active { display: block; }
.conv-level-fill { height: 100%; background: var(--red); border-radius: 1px; width: 0%; } .conv-level-fill { height: 100%; background: var(--red); border-radius: 3px; width: 0%; transition: width .07s linear; }
.conv-level-gate { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--accent); border-radius: 1px; pointer-events: none; }
.conv-text-inp.listening { border-color: var(--red); font-style: italic; color: var(--subtext); } .conv-text-inp.listening { border-color: var(--red); font-style: italic; color: var(--subtext); }
/* Stats panel collapsible */
.conv-stats-toggle-row { display: flex; align-items: center; justify-content: space-between; }
.conv-stats-collapse-btn { background: none; border: none; cursor: pointer; color: var(--subtext); font-size: 16px; line-height: 1; padding: 2px; border-radius: 4px; }
.conv-stats-collapse-btn:hover { color: var(--text); background: var(--panel); }
.conv-stats-panel.collapsed { width: 0 !important; padding: 0; overflow: hidden; border: none; min-width: 0; }
.conv-stats-expand-btn {
align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 50%; background: var(--surface);
border: 1px solid var(--border); cursor: pointer; color: var(--subtext); font-size: 16px;
flex-shrink: 0; align-self: flex-start; margin-top: 4px;
}
.conv-stats-expand-btn:hover { color: var(--text); background: var(--panel); }
/* Stats panel */ /* Stats panel */
.conv-stats-head { font-size: 10px; font-weight: 800; color: var(--subtext); text-transform: uppercase; letter-spacing: .08em; } .conv-stats-head { font-size: 10px; font-weight: 800; color: var(--subtext); text-transform: uppercase; letter-spacing: .08em; }
.conv-pipeline { display: flex; flex-direction: column; gap: 8px; } .conv-pipeline { display: flex; flex-direction: column; gap: 8px; }
@ -4517,6 +4540,16 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.reader-zoom { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .reader-zoom { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.reader-zoom-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; } .reader-zoom-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.reader-zoom-pct { font-size: 12px; font-weight: 700; min-width: 44px; text-align: center; color: var(--subtext); } .reader-zoom-pct { font-size: 12px; font-weight: 700; min-width: 44px; text-align: center; color: var(--subtext); }
/* PDF Search */
.reader-search-wrap { display: flex; align-items: center; gap: 6px; }
.reader-search-icon { font-size: 14px; color: var(--subtext); }
.reader-search-inp {
width: 160px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 20px;
background: var(--panel); color: var(--text); font-size: 13px; outline: none;
transition: border-color .15s, width .2s;
}
.reader-search-inp:focus { border-color: var(--accent); width: 220px; }
.reader-search-status { font-size: 11px; color: var(--subtext); white-space: nowrap; min-width: 32px; }
.reader-legend { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 11px; color: var(--subtext); } .reader-legend { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 11px; color: var(--subtext); }
.reader-leg { display: inline-flex; align-items: center; gap: 5px; } .reader-leg { display: inline-flex; align-items: center; gap: 5px; }
.reader-leg::before { content: ''; width: 11px; height: 11px; border-radius: 3px; } .reader-leg::before { content: ''; width: 11px; height: 11px; border-radius: 3px; }