Files
Lumotia/src/lib/pages/DictationPage.svelte
Jake aecb191705 refactor(theme): tokenise hard-coded amber shadows and reconcile duplicate @font-face declarations
Hard-coded rgba(214,132,80,X) shadows were tied to the dark-theme accent
and stayed off-hue in light mode (where the accent is a darker #a3683a).
Six tokens added to app.css @theme and mirrored in the light-theme block
plus design-system/colors_and_type.css:

  --shadow-accent-sm     0 0 8px   0.25 alpha  Toggle on-state
  --shadow-accent-md     0 4px 16px 0.3       ModelDownloader card
  --shadow-accent-lg     0 4px 20px 0.3       DictationPage record button
  --shadow-accent-glow   0 0 8px   0.4        progress-bar glow
  --shadow-accent-pill   0 1px 4px 0.3        SegmentedButton pill
  --shadow-accent-raised 0 2px 8px 0.2        FilesPage browse tile

Migrated seven sites: Toggle, SegmentedButton, ModelDownloader (x2),
FilesPage (x2), DictationPage record button, SettingsPage locale pill.
Focus-ring shadows (0_0_0_3px_rgba(...,0.1)) left alone — handled in
parallel via the --accent-shadow-focus token.

@font-face: app.css and colors_and_type.css both declare the same five
fonts. Duplication is unavoidable because preview/*.html loads the
design-system file directly without Vite, so it cannot share the
runtime declarations. font-weight ranges and font-style reconciled to
match exactly (Atkinson 200-800 not 400-700, JetBrains gains font-style:
normal); src URLs intentionally differ (root-absolute vs relative).
Comments added to both copies explaining the contract.
2026-05-07 11:31:14 +01:00

1101 lines
40 KiB
Svelte

<script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte";
import { Channel, invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
import { markGenerating, markGenerationDone } from "$lib/stores/llmStatus.svelte.js";
import { playStartCue, playStopCue, playCompleteCue } from "$lib/utils/sounds.js";
import { profilesStore } from "$lib/stores/profiles.svelte.ts";
import { toasts } from "$lib/stores/toasts.svelte.js";
import Card from "$lib/components/Card.svelte";
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
import { exportTranscript } from "$lib/utils/export.js";
import { extractTasks } from "$lib/utils/taskExtractor.js";
import { pad } from "$lib/utils/time.js";
import { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import { bionicReading } from '$lib/actions/bionicReading.js';
import { measurePreWrap } from '$lib/utils/textMeasure.js';
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
import { hasTauriRuntime } from '$lib/utils/runtime.js';
const prefs = getPreferences();
const tauriRuntimeAvailable = hasTauriRuntime();
const browserPreviewMessage = "You're viewing Magnotia in a normal browser. Local transcription only works in the Tauri desktop app window.";
let transcript = $state("");
let segments = $state([]);
let timerInterval = $state(null);
let startTime = $state(0);
let modelReady = $state(false);
let modelLoading = $state(false);
let needsDownload = $state(false);
let error = $state("");
let showExportMenu = $state(false);
let transcribing = $state(false);
let saved = $state(false);
let extractedCount = $state(0);
let aiProcessing = $state(false);
let aiStatus = $state("");
let runtimeCapabilities = $state(null);
let sessionId = $state(null);
let drainingSessionId = $state(null);
let liveWarning = $state("");
let lastResultAt = $state(0);
let lastLiveActivityAt = $state(0);
let resultChannel = null;
let statusChannel = null;
// Cursor-based insertion
let textareaEl = $state(null);
let insertPos = $state(-1); // -1 = append mode, >= 0 = insert at position
// Template
let activeTemplate = $state("");
let showTemplateMenu = $state(false);
// Deduplication: track which chunk IDs have been processed
let processedChunks = new Set();
// Global hotkey listener
let hotkeyHandler = () => toggleRecording();
onMount(async () => {
window.addEventListener("magnotia:toggle-recording", hotkeyHandler);
if (!tauriRuntimeAvailable) {
error = browserPreviewMessage;
return;
}
await checkModelState();
await ensureLlmModelLoaded();
});
onDestroy(() => {
window.removeEventListener("magnotia:toggle-recording", hotkeyHandler);
clearInterval(timerInterval);
if (page.recording) {
page.recording = false;
page.status = "Ready";
page.statusColor = "#7ec89a";
}
cleanup();
});
$effect(() => {
settings.engine;
settings.modelSize;
if (!tauriRuntimeAvailable) return;
queueMicrotask(() => {
if (!page.recording) {
void checkModelState();
}
});
});
function whisperModelId(size) {
const map = {
Tiny: "whisper-tiny-en",
Base: "whisper-base-en",
Small: "whisper-small-en",
Medium: "whisper-medium-en",
};
return map[size] || "whisper-base-en";
}
function selectedModelId() {
return settings.engine === "parakeet"
? "parakeet-ctc-0.6b-int8"
: whisperModelId(settings.modelSize);
}
function currentEngineCapabilities() {
return runtimeCapabilities?.engines?.find((engine) => engine.id === settings.engine) || null;
}
function currentModelCapabilities() {
return currentEngineCapabilities()?.models?.find((model) => model.id === selectedModelId()) || null;
}
function currentModelIsEnglishOnly() {
return currentModelCapabilities()?.languageSupport?.kind === "english-only";
}
function effectiveLanguage() {
return currentModelIsEnglishOnly() ? "en" : settings.language;
}
async function refreshRuntimeCapabilities() {
runtimeCapabilities = await invoke("get_runtime_capabilities");
}
function matchesLiveSession(candidateSessionId) {
return candidateSessionId != null
&& (candidateSessionId === sessionId || candidateSessionId === drainingSessionId);
}
function handleLiveResult(result) {
if (!result || !matchesLiveSession(result.sessionId) || !result.segments) {
return;
}
lastResultAt = Date.now();
lastLiveActivityAt = lastResultAt;
if (textareaEl) {
if (page.recording && userScrolledUp) {
shouldPreserveScrollAnchor = true;
} else if (page.recording) {
shouldStickToBottom = true;
}
}
if (result.chunkId != null && processedChunks.has(result.chunkId)) {
return;
}
if (result.chunkId != null) processedChunks.add(result.chunkId);
// Forward raw Whisper output to the preview overlay (if it's enabled
// and opened). `raw_text` was captured in live.rs before post-processing.
if (result.rawText && settings.transcriptionPreview) {
emit("preview-append", { text: result.rawText }).catch(() => {});
}
const text = result.segments.map((segment) => segment.text).join(" ").trim();
if (text) {
if (insertPos >= 0) {
const before = transcript.slice(0, insertPos);
const after = transcript.slice(insertPos);
const spaceBefore = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
const spaceAfter = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : "";
transcript = before + spaceBefore + text + spaceAfter + after;
insertPos += spaceBefore.length + text.length + spaceAfter.length;
requestAnimationFrame(() => {
if (textareaEl) {
textareaEl.selectionStart = insertPos;
textareaEl.selectionEnd = insertPos;
}
});
} else {
transcript = transcript ? `${transcript} ${text}` : text;
}
}
const absoluteSegments = result.segments.map((segment) => ({
...segment,
start: segment.start + result.chunkStartSecs,
end: segment.end + result.chunkStartSecs,
}));
segments = [...segments, ...absoluteSegments];
}
function handleLiveStatus(status) {
if (!status || !matchesLiveSession(status.sessionId)) return;
lastLiveActivityAt = Date.now();
if (status.type === "overload" || status.type === "warning") {
liveWarning = status.message || "Magnotia is dropping older audio to stay responsive.";
return;
}
if (status.type === "error") {
error = status.message || "Live transcription failed";
transcriptionFailed = true;
page.status = "Error";
page.statusColor = "#e87171";
return;
}
if (status.type === "finished" && status.droppedAudioMs > 0) {
liveWarning = `Dropped ${Math.round(status.droppedAudioMs / 1000)}s of older audio to keep up in real time.`;
}
}
async function checkModelState() {
if (!tauriRuntimeAvailable) {
modelReady = false;
needsDownload = false;
return;
}
try {
await refreshRuntimeCapabilities();
const currentModel = currentModelCapabilities();
modelReady = !!currentModel?.loaded;
needsDownload = currentModel ? !currentModel.downloaded : true;
if (currentModelIsEnglishOnly() && settings.language !== "en") {
settings.language = "en";
}
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to check model";
}
}
async function ensureLlmModelLoaded() {
if (!tauriRuntimeAvailable || settings.aiTier === "off" || !settings.llmModelId) return;
try {
const status = await invoke("check_llm_model", { modelId: settings.llmModelId });
if (status?.downloaded && !status.loaded) {
await invoke("load_llm_model", {
modelId: settings.llmModelId,
// Sequential-GPU guard (brief item A.1 #28): frees whisper
// before loading the LLM on tight-VRAM setups. "parallel"
// keeps both resident (default, safe on multi-GB cards).
concurrent: settings.aiGpuConcurrency === "parallel",
});
}
} catch (err) {
console.warn("ensureLlmModelLoaded failed", err);
}
}
async function loadModel() {
if (!tauriRuntimeAvailable) {
error = browserPreviewMessage;
return;
}
modelLoading = true;
page.status = "Loading model...";
page.statusColor = "#e8c86e";
error = "";
try {
// concurrent flag mirrors settings.aiGpuConcurrency (brief A.1 #28).
const concurrent = settings.aiGpuConcurrency === "parallel";
if (settings.engine === "parakeet") {
await invoke("load_parakeet_model", { name: "ctc-int8", concurrent });
} else {
await invoke("load_model", {
size: settings.modelSize.toLowerCase(),
concurrent,
});
}
await refreshRuntimeCapabilities();
modelReady = true;
modelLoading = false;
page.status = "Ready";
page.statusColor = "#7ec89a";
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to load model";
modelLoading = false;
page.status = "Error";
page.statusColor = "#e87171";
}
}
function onModelDownloaded() {
needsDownload = false;
void loadModel();
}
async function toggleRecording() {
if (page.recording) {
await stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
error = "";
liveWarning = "";
saved = false;
transcriptionFailed = false;
if (!tauriRuntimeAvailable) {
error = browserPreviewMessage;
page.status = "Desktop app required";
page.statusColor = "#e8c86e";
return;
}
if (!modelReady) {
if (needsDownload) return;
await loadModel();
if (!modelReady) return;
}
// Capture cursor position for insert mode
if (textareaEl && transcript.length > 0) {
insertPos = textareaEl.selectionStart ?? transcript.length;
} else {
insertPos = -1; // Append mode for empty textarea
}
try {
sessionId = null;
drainingSessionId = null;
lastResultAt = 0;
lastLiveActivityAt = 0;
processedChunks.clear();
// Only clear transcript if not in insert mode (fresh recording)
if (insertPos === -1) {
transcript = "";
segments = [];
previousMeasuredContentHeight = 0;
}
resultChannel = new Channel((message) => handleLiveResult(message));
statusChannel = new Channel((message) => handleLiveStatus(message));
const response = await invoke("start_live_transcription_session", {
config: {
engine: settings.engine,
modelId: selectedModelId(),
language: effectiveLanguage(),
// Backend (Task 13) falls back to profile.initial_prompt when this
// is empty. Vocabulary now lives on the active profile, not on the
// old page.activeProfile localStorage bag.
initialPrompt: "",
profileId: profilesStore.activeProfileId,
saveAudio: settings.saveAudio,
outputFolder: settings.outputFolder || null,
removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination,
formatMode: settings.formatMode,
// Honour Settings → Audio → Microphone choice. Empty = auto-select.
microphoneDevice: settings.microphoneDevice || null,
},
resultChannel,
statusChannel,
});
sessionId = response.sessionId;
startTime = Date.now();
page.recording = true;
page.status = "Recording...";
page.statusColor = "#e87171";
timerInterval = setInterval(updateTimer, 1000);
// Start cue (brief item #20). Off by default; volume user-set.
playStartCue(settings.soundCueVolume, settings.soundCues);
// Preview overlay: if the user opted in, reset any leftover state
// from a prior run and open the window when the main window is not
// focused (i.e. the user is dictating into some other app).
if (settings.transcriptionPreview && tauriRuntimeAvailable) {
emit("preview-listening").catch(() => {});
try {
const focused = await getCurrentWindow().isFocused();
if (!focused) {
invoke("open_preview_window").catch(() => {});
}
} catch { /* best effort */ }
}
} catch (err) {
error = typeof err === "string" ? err : err?.message || "Microphone error";
// Surface the failure as a sticky error toast so it does not get
// swallowed if the inline `error` panel is not visible.
// (Day 3 of the upgrade plan)
toasts.error("Could not start recording", error);
page.status = "Error";
page.statusColor = "#e87171";
cleanup();
}
}
async function stopRecording() {
clearInterval(timerInterval);
page.recording = false;
transcribing = true;
page.status = "Finalising...";
page.statusColor = "#e8c86e";
// Stop cue (brief item #20). "Recording over, finalising now."
playStopCue(settings.soundCueVolume, settings.soundCues);
try {
const activityBeforeStop = lastLiveActivityAt;
drainingSessionId = sessionId;
const response = sessionId
? await invoke("stop_live_transcription_session", { sessionId })
: null;
await waitForResultDrain(activityBeforeStop);
cleanup();
if (processedChunks.size === 0 && !transcript.trim()) {
page.status = "Ready";
page.statusColor = "#7ec89a";
} else {
await finaliseTranscription(response?.audioPath || null);
}
} catch (err) {
error = typeof err === "string" ? err : err?.message || "Failed to stop live transcription";
page.status = "Error";
page.statusColor = "#e87171";
cleanup();
} finally {
transcribing = false;
}
}
function cleanup() {
sessionId = null;
drainingSessionId = null;
resultChannel = null;
statusChannel = null;
}
function replaceSegmentsWithCleanedText(cleanedText) {
if (!cleanedText.trim()) return;
if (segments.length === 0) {
segments = [{
start: 0,
end: Math.max((Date.now() - startTime) / 1000, 0),
text: cleanedText,
}];
return;
}
segments = [{
start: segments[0].start,
end: segments[segments.length - 1].end,
text: cleanedText,
}];
}
async function cleanupTranscriptIfEnabled(text) {
if (!text.trim()) return text;
if (settings.aiTier === "off" || settings.formatMode === "Raw") return text;
const llmLoaded = await invoke("get_llm_status").catch(() => false);
if (!llmLoaded) return text;
// Flip the sidebar LLM status chip to "generating" so the user can
// see the cleanup pass in flight (brief item #31). Always paired
// with markGenerationDone below — success or failure.
markGenerating("Cleaning up");
try {
const cleaned = await invoke("cleanup_transcript_text_cmd", {
transcript: text,
profileId: profilesStore.activeProfileId,
preset: settings.llmPromptPreset,
});
markGenerationDone(true);
return cleaned?.trim() ? cleaned.trim() : text;
} catch (err) {
console.warn("LLM cleanup failed, keeping existing transcript", err);
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
return text;
}
}
async function extractTasksForTranscript(text) {
const llmLoaded = await invoke("get_llm_status").catch(() => false);
if (settings.aiTier === "tasks" && llmLoaded) {
markGenerating("Extracting tasks");
try {
const items = await invoke("extract_tasks_from_transcript_cmd", {
transcript: text,
profileId: profilesStore.activeProfileId,
});
markGenerationDone(true);
return items.map((taskText) => ({ text: taskText }));
} catch (err) {
console.warn("LLM extract_tasks failed, falling back to regex", err);
markGenerationDone(false, typeof err === "string" ? err : err?.message ?? null);
}
}
return extractTasks(text);
}
async function waitForResultDrain(previousActivityAt = 0) {
const firstMessageDeadline = Date.now() + 400;
while (Date.now() < firstMessageDeadline) {
if (lastLiveActivityAt > previousActivityAt) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
if (lastLiveActivityAt <= previousActivityAt) {
return;
}
const idleDeadline = Date.now() + 1500;
while (Date.now() < idleDeadline) {
if (Date.now() - lastLiveActivityAt >= 200) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
async function finaliseTranscription(audioPath = null) {
if (transcript.trim()) {
if (settings.transcriptionPreview && settings.aiTier !== "off" && settings.formatMode !== "Raw") {
emit("preview-cleanup").catch(() => {});
}
const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript);
if (cleanedTranscript !== transcript) {
transcript = cleanedTranscript;
replaceSegmentsWithCleanedText(cleanedTranscript);
}
if (settings.transcriptionPreview) {
emit("preview-final", { text: transcript }).catch(() => {});
}
if (settings.autoPaste) {
try {
const outcome = await invoke("paste_text", { text: transcript });
if (!outcome?.pasted && outcome?.message) {
toasts.warn(
"Auto-paste unavailable",
`${outcome.message}${outcome.copied ? ". Transcript is on your clipboard." : ""}`,
);
}
} catch (err) {
await navigator.clipboard
.writeText(transcript)
.catch(() => invoke("copy_to_clipboard", { text: transcript }).catch(() => {}));
toasts.warn("Auto-paste failed", String(err));
}
} else if (settings.autoCopy) {
navigator.clipboard.writeText(transcript).catch(() => {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
});
}
const historyId = crypto.randomUUID();
addToHistory({
id: historyId,
date: new Date().toLocaleString("en-GB"),
source: "live",
profileId: profilesStore.activeProfileId,
preview: transcript.slice(0, 120),
text: transcript,
segments: segments,
duration: (Date.now() - startTime) / 1000,
engine: settings.engine,
modelId: selectedModelId(),
language: effectiveLanguage(),
template: activeTemplate || undefined,
audioPath,
});
// Extract tasks from transcript
const extracted = await extractTasksForTranscript(transcript);
extractedCount = extracted.length;
for (const item of extracted) {
addTask({
text: item.text,
bucket: "inbox",
sourceTranscriptId: historyId,
});
}
// Auto-open task sidebar if tasks were extracted
if (extracted.length > 0) {
page.taskSidebarOpen = true;
}
// Complete cue (brief item #20) — major-third arpeggio. Plays
// after the full finalise flow so the user hears "done"
// aligned with the history entry + paste landing.
playCompleteCue(settings.soundCueVolume, settings.soundCues);
saved = true;
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
} else if (settings.transcriptionPreview) {
// No transcript to surface — dismiss the overlay rather than leaving
// it stuck in "listening".
emit("preview-hide").catch(() => {});
}
page.status = "Ready";
page.statusColor = "#7ec89a";
insertPos = -1;
}
function updateTimer() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
page.timerText = `${pad(Math.floor(elapsed / 60))}:${pad(elapsed % 60)}`;
}
function copyAll() {
if (transcript) {
navigator.clipboard.writeText(transcript).catch(() => {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
});
}
}
function clearTranscript() {
transcript = "";
segments = [];
previousMeasuredContentHeight = 0;
shouldPreserveScrollAnchor = false;
shouldStickToBottom = false;
page.timerText = "00:00";
error = "";
liveWarning = "";
saved = false;
insertPos = -1;
activeTemplate = "";
}
function handleExport(format) {
if (!transcript) return;
showExportMenu = false;
const content = exportTranscript(transcript, segments, format);
const extMap = { vtt: "vtt", srt: "srt", md: "md", csv: "csv", html: "html" };
const ext = extMap[format] || "txt";
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `magnotia-${new Date().toISOString().slice(0, 10)}.${ext}`;
a.click();
URL.revokeObjectURL(url);
}
function applyTemplate(template) {
activeTemplate = template.name;
showTemplateMenu = false;
transcript = template.sections.map((s) => `## ${s}\n\n`).join("\n");
segments = [];
previousMeasuredContentHeight = 0;
// Position cursor at first section body
requestAnimationFrame(() => {
if (textareaEl) {
const firstNewline = transcript.indexOf("\n\n") + 2;
textareaEl.selectionStart = firstNewline;
textareaEl.selectionEnd = firstNewline;
textareaEl.focus();
}
});
}
async function manualExtractTasks() {
if (!transcript.trim() || aiProcessing) return;
aiProcessing = true;
aiStatus = "Extracting tasks...";
try {
const extracted = await extractTasksForTranscript(transcript);
for (const item of extracted) {
addTask({ text: item.text, bucket: "inbox" });
}
aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`;
} catch (err) {
aiStatus = typeof err === "string" ? err : "Extraction failed";
} finally {
aiProcessing = false;
setTimeout(() => { aiStatus = ""; }, 4000);
}
}
function saveTypedText() {
if (!transcript.trim()) return;
addToHistory({
id: crypto.randomUUID(),
date: new Date().toLocaleString("en-GB"),
source: "typed",
profileId: profilesStore.activeProfileId,
preview: transcript.slice(0, 120),
text: transcript,
segments: [],
duration: 0,
language: settings.language,
});
saved = true;
setTimeout(() => { saved = false; }, FEEDBACK_TIMEOUT_MS);
}
let taskCount = $derived(tasks.filter((t) => !t.done).length);
let hasTranscript = $derived(transcript.trim().length > 0);
let wordCount = $derived.by(() => {
const trimmed = transcript.trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
});
// Pretext content height measurement (DOM-free).
// Used for scroll-to-bottom affordance during live transcription.
let textareaWidth = $state(0);
let userScrolledUp = $state(false);
let previousMeasuredContentHeight = $state(0);
let shouldPreserveScrollAnchor = $state(false);
let shouldStickToBottom = $state(false);
// Build font string matching .font-transcript CSS
let transcriptFont = $derived(transcriptPretextFont(prefs.accessibility));
let transcriptLineHeight = $derived(transcriptPretextLineHeight(prefs.accessibility));
let contentHeight = $derived.by(() => {
if (!transcript.trim() || textareaWidth <= 0) return 0;
// Subtract padding (p-6 = 24px each side)
const innerWidth = textareaWidth - 48;
if (innerWidth <= 0) return 0;
return measurePreWrap(transcript, transcriptFont, innerWidth, transcriptLineHeight).height;
});
function onTextareaScroll(e) {
const el = e.target;
// "scrolled up" = more than 1 line from the bottom
userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > transcriptLineHeight;
}
function scrollToBottom() {
if (textareaEl) {
textareaEl.scrollTop = textareaEl.scrollHeight;
userScrolledUp = false;
}
}
// Track textarea width for Pretext layout calculations
$effect(() => {
if (!textareaEl) return;
const ro = new ResizeObserver(entries => {
for (const entry of entries) textareaWidth = entry.contentRect.width;
});
ro.observe(textareaEl);
return () => ro.disconnect();
});
$effect(() => {
const measuredHeight = contentHeight;
const previousHeight = previousMeasuredContentHeight;
if (!textareaEl) {
previousMeasuredContentHeight = measuredHeight;
return;
}
if (shouldPreserveScrollAnchor && measuredHeight > previousHeight) {
const delta = measuredHeight - previousHeight;
requestAnimationFrame(() => {
if (textareaEl) {
textareaEl.scrollTop += delta;
}
});
shouldPreserveScrollAnchor = false;
} else if (shouldStickToBottom) {
requestAnimationFrame(() => {
if (textareaEl) {
textareaEl.scrollTop = textareaEl.scrollHeight;
}
});
shouldStickToBottom = false;
}
previousMeasuredContentHeight = measuredHeight;
});
let showScrollHint = $derived(page.recording && userScrolledUp && contentHeight > 0);
let reduceMotion = $derived(
prefs.accessibility.reduceMotion === 'on'
|| (prefs.accessibility.reduceMotion === 'system'
&& typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches)
);
let transcriptionFailed = $state(false);
// Screen-reader announcer for recording state transitions.
// Announces both start and stop, then clears so the live region does not
// hold stale text. prevRecording is a plain let (not $state) because it is
// bookkeeping and must not re-trigger the effect.
let recordingAnnouncement = $state("");
let prevRecording = false;
$effect(() => {
if (page.recording && !prevRecording) {
recordingAnnouncement = "Recording started";
} else if (!page.recording && prevRecording) {
recordingAnnouncement = "Recording stopped";
setTimeout(() => { recordingAnnouncement = ""; }, 1200);
}
prevRecording = page.recording;
});
</script>
<div class="flex flex-col h-full animate-fade-in">
<div aria-live="assertive" aria-atomic="true" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);">
{recordingAnnouncement}
</div>
{#if needsDownload}
<ModelDownloader
engine={settings.engine}
modelSize={settings.modelSize.toLowerCase()}
onComplete={onModelDownloaded}
/>
{:else}
<!-- Control strip -->
<div class="flex items-center gap-3 px-5 h-[56px] border-b border-border-subtle flex-shrink-0">
<!-- Record button -->
<button
class="relative flex items-center justify-center w-[48px] h-[48px] min-w-[48px] flex-shrink-0 rounded-full text-white
{page.recording
? 'bg-danger animate-pulse-warm'
: modelLoading
? 'bg-warning opacity-60 cursor-wait'
: !tauriRuntimeAvailable
? 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
: 'bg-accent hover:bg-accent-hover shadow-[var(--shadow-accent-lg)]'}"
onclick={toggleRecording}
disabled={modelLoading || !tauriRuntimeAvailable}
aria-label={page.recording ? "Stop recording" : "Start recording"}
>
{#if page.recording}
<span class="w-[16px] h-[16px] rounded-[3px] bg-white"></span>
{:else if modelLoading}
<Loader2 size={18} class="animate-spin" aria-hidden="true" />
{:else}
<span class="w-[16px] h-[16px] rounded-full bg-white/90"></span>
{/if}
</button>
<span class="text-[12px] font-medium flex-shrink-0 {page.recording ? 'text-danger' : 'text-text-secondary'}">
{page.recording ? 'Stop' : modelLoading ? 'Loading' : 'Record'}
</span>
<!-- Waveform placeholder (Phase 2: live visualiser) -->
<div class="flex-1 min-h-[32px] rounded-md border border-border-subtle bg-bg-elevated/40 flex items-center px-3">
{#if page.recording}
<span class="flex items-end gap-[3px] h-[18px]">
{#each [0.4, 0.8, 0.5, 1, 0.6, 0.9, 0.4, 0.7, 0.5, 0.8, 0.3, 0.6] as h, i}
{#if reduceMotion}
<span
class="w-[2px] rounded-full bg-danger"
style="height: 50%"
></span>
{:else}
<span
class="w-[2px] rounded-full bg-danger/70 animate-pulse-soft"
style="height: {h * 100}%; animation-delay: {i * 60}ms"
></span>
{/if}
{/each}
</span>
{:else}
<span class="text-[12px] text-text-secondary">
{#if modelLoading}
Loading model...
{:else if !tauriRuntimeAvailable}
Desktop app required for local transcription
{:else if saved}
<span class="text-success animate-fade-in">
Saved{#if extractedCount > 0} · {extractedCount} task{extractedCount === 1 ? '' : 's'} extracted{/if}
</span>
{:else}
Press record or <kbd class="px-1 py-0.5 rounded bg-bg-elevated text-[12px] text-text-secondary border border-border-subtle">{settings.globalHotkey}</kbd>
{/if}
</span>
{/if}
</div>
<!-- Timer -->
<span class="text-[20px] font-bold text-text tabular-nums tracking-tight leading-none flex-shrink-0" style="font-variant-numeric: tabular-nums;">
{page.timerText}
</span>
<!-- Status indicator -->
<span class="text-[12px] text-text-secondary flex-shrink-0 min-w-[60px] text-right">
{#if page.recording}
<span class="inline-flex items-center gap-1.5">
<span class="w-[6px] h-[6px] rounded-full bg-danger animate-pulse-soft"></span>
<span class="font-medium text-danger tracking-wide">REC</span>
</span>
{:else if modelLoading}
<span class="text-warning">Loading</span>
{:else}
<span class="text-success">Ready</span>
{/if}
</span>
<!-- Separator -->
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
<!-- Task sidebar toggle -->
<button
class="relative px-2 py-1.5 rounded-lg flex-shrink-0
{page.taskSidebarOpen ? 'bg-accent/10' : 'hover:bg-hover'}"
onclick={() => page.taskSidebarOpen = !page.taskSidebarOpen}
aria-label="Toggle task sidebar"
>
<span class="flex items-center gap-1.5">
<SquareCheck size={16} class={page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'} aria-hidden="true" />
<span class="text-[12px] {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}">Tasks</span>
</span>
{#if taskCount > 0}
<span class="absolute -top-0.5 -right-0.5 text-[12px] px-1 py-0 rounded-full bg-accent text-white font-medium min-w-[14px] text-center leading-[14px]">
{taskCount}
</span>
{/if}
</button>
</div>
<!-- Toolbar -->
<div class="flex items-center justify-end gap-1 px-5 py-2 border-b border-border-subtle flex-shrink-0">
<!-- Template selector -->
{#if templates.length > 0}
<div class="relative">
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={() => { showTemplateMenu = !showTemplateMenu; showExportMenu = false; }}
>Template</button>
{#if showTemplateMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[160px]">
{#each templates as template}
<button
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text
{activeTemplate === template.name ? 'text-accent' : ''}"
onclick={() => applyTemplate(template)}
>{template.name}</button>
{/each}
</div>
{/if}
</div>
{/if}
<button
class="btn-md rounded-lg flex-shrink-0 whitespace-nowrap {aiProcessing ? 'text-warning' : 'text-accent hover:bg-accent/10 hover:text-accent font-medium'}"
onclick={manualExtractTasks}
disabled={aiProcessing || !transcript.trim()}
>{aiProcessing ? "Extracting..." : "Extract Tasks"}</button>
{#if hasTranscript || page.recording}
<div class="flex items-center gap-1 animate-fade-in">
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap
{!transcript.trim() ? 'opacity-40 cursor-default' : ''}"
onclick={saveTypedText}
disabled={!transcript.trim()}
>Save</button>
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={copyAll}
>Copy</button>
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={clearTranscript}
>Clear</button>
<div class="relative flex-shrink-0">
<button
class="btn-md rounded-lg text-text-secondary hover:bg-hover hover:text-text whitespace-nowrap"
onclick={() => { showExportMenu = !showExportMenu; showTemplateMenu = false; }}
>Export</button>
{#if showExportMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
<button
class="w-full text-left btn-md text-text-secondary hover:bg-hover hover:text-text"
onclick={() => handleExport(fmt)}
>{label}</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Template indicator -->
{#if activeTemplate}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-accent-subtle border border-accent/20">
<span class="text-[12px] text-accent font-medium">Template: {activeTemplate}</span>
<span class="text-[12px] text-text-secondary">Click a section, then record to fill it</span>
<div class="flex-1"></div>
<button class="text-[12px] text-text-secondary hover:text-text" onclick={() => activeTemplate = ""}>Remove</button>
</div>
</div>
{/if}
<!-- Error -->
{#if error}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
{error}
</div>
</div>
{/if}
{#if liveWarning && !error}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="px-4 py-2 rounded-lg bg-warning/10 border border-warning/20 text-[12px] text-warning">
{liveWarning}
</div>
</div>
{/if}
<!-- Transcript area -->
<div class="flex-1 px-5 pt-3 pb-3 min-h-0" style="--text-transcript: {prefs.accessibility.transcriptSize}px; font-size: var(--text-transcript)">
<Card classes="h-full flex flex-col">
{#if transcriptionFailed && !transcript.trim() && !page.recording}
<div class="flex-1 flex items-center justify-center">
<EmptyState icon={AlertTriangle} message="Something went wrong with that transcription. Your audio is saved, try again when you're ready." />
</div>
{:else if !transcript.trim() && !page.recording && !transcribing}
<div class="flex-1 flex items-center justify-center">
<EmptyState icon={Mic} headline="Talk now, think later." message={`Press record, or ${settings.globalHotkey}.`} />
</div>
<textarea
bind:this={textareaEl}
class="font-transcript flex-1 w-full bg-transparent text-text p-6
resize-none focus:outline-none placeholder:text-text-tertiary min-h-0 hidden"
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
bind:value={transcript}
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
data-no-transition
aria-label="Transcript"
aria-live="polite"
></textarea>
{:else}
<div class="relative flex-1 min-h-0">
<textarea
bind:this={textareaEl}
class="font-transcript h-full w-full bg-transparent text-text p-6
resize-none focus:outline-none placeholder:text-text-tertiary"
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
bind:value={transcript}
onscroll={onTextareaScroll}
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
data-no-transition
aria-label="Transcript"
aria-live="polite"
></textarea>
{#if showScrollHint}
<button
class="absolute bottom-3 right-6 px-3 py-1.5 rounded-full bg-accent text-white text-[12px] font-medium shadow-md hover:bg-accent-hover animate-fade-in"
onclick={scrollToBottom}
aria-label="Scroll to latest"
style="transition-duration: var(--duration-ui)"
>
↓ New text
</button>
{/if}
</div>
{/if}
<!-- Status footer (inside transcript card) -->
<div class="flex items-center justify-between px-6 pb-3 pt-1 flex-shrink-0">
<span class="text-[12px] text-text-secondary">
{#if wordCount > 0}
{wordCount} {wordCount === 1 ? 'word' : 'words'}
{:else}
0 words
{/if}
{#if insertPos >= 0 && page.recording}
· <span class="text-accent">inserting at cursor</span>
{/if}
</span>
<div class="flex items-center gap-2">
{#if aiStatus}
<span class="text-[12px] text-accent animate-fade-in">{aiStatus}</span>
<span class="text-[12px] text-text-secondary">·</span>
{/if}
<span class="text-[12px] text-text-secondary">
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
</span>
{#if transcript.trim()}
<SpeakerButton text={transcript} label="Read transcript aloud" size={12} />
{/if}
</div>
</div>
</Card>
</div>
{/if}
</div>