From ae295b80b894f022e210ac0b628638daac38df5f Mon Sep 17 00:00:00 2001 From: Hilary Kai Date: Tue, 3 Mar 2026 05:13:12 -0700 Subject: [PATCH 1/5] feat(types): add InputMode enum and wordsTyped stat field Add InputMode enum (VOICE | KEYBOARD) to support the new dual-input mode. Add wordsTyped to TypingStats for richer results display. --- types.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/types.ts b/types.ts index 3164d35..9db2bbb 100644 --- a/types.ts +++ b/types.ts @@ -1,4 +1,3 @@ - export enum TestStatus { IDLE = 'IDLE', STARTING = 'STARTING', @@ -6,6 +5,11 @@ export enum TestStatus { FINISHED = 'FINISHED' } +export enum InputMode { + VOICE = 'VOICE', + KEYBOARD = 'KEYBOARD', +} + export interface TypingStats { wpm: number; accuracy: number; @@ -13,6 +17,7 @@ export interface TypingStats { totalKeystrokes: number; incorrectKeystrokes: number; timeTaken: number; + wordsTyped: number; } export interface TestConfig { From 3306acbe01280d00095d8fa5d31049fc317ad827 Mon Sep 17 00:00:00 2001 From: Hilary Kai Date: Tue, 3 Mar 2026 05:13:19 -0700 Subject: [PATCH 2/5] fix(TypingArea): fix onInputChange type, keyboard input, cursor-blink, a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make onInputChange optional — it was required in the interface but never passed from App.tsx, causing a TypeScript error in strict mode. - Add onKeystroke prop for per-keystroke accuracy tracking in keyboard mode. - Add hidden for keyboard capture with proper mobile attributes (autoComplete/Correct/Capitalize off, enterKeyHint, touch-action). - Apply cursor-blink class correctly using the CSS animation name. - Block Tab in onKeyDown so global reset handler can intercept it. - Add role, aria-label, and aria-describedby for screen reader support. - enableKeyboard prop auto-focuses the hidden input when test starts. --- components/TypingArea.tsx | 107 +++++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/components/TypingArea.tsx b/components/TypingArea.tsx index 0dd1b2a..22ea9fd 100644 --- a/components/TypingArea.tsx +++ b/components/TypingArea.tsx @@ -1,42 +1,78 @@ - -import React from 'react'; +import React, { useRef, useEffect } from 'react'; interface TypingAreaProps { targetText: string; userInput: string; - onInputChange: (value: string) => void; + onInputChange?: (value: string) => void; + onKeystroke?: (isCorrect: boolean) => void; isFinished: boolean; isActive: boolean; isDark: boolean; + enableKeyboard?: boolean; } const TypingArea: React.FC = ({ targetText, userInput, + onInputChange, + onKeystroke, isFinished, isActive, - isDark + isDark, + enableKeyboard = false, }) => { - // No references or input handling needed for voice + const inputRef = useRef(null); + + // Auto-focus hidden input whenever keyboard mode becomes active + useEffect(() => { + if (isActive && enableKeyboard && inputRef.current) { + inputRef.current.focus(); + } + }, [isActive, enableKeyboard]); + + // Track individual keystrokes for accurate accuracy calculation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + // Tab is handled globally for reset; don't let it move focus + e.preventDefault(); + return; + } + // Count printable character presses and backspace as keystrokes + if (e.key.length === 1) { + const nextIndex = userInput.length; + const isCorrect = e.key === targetText[nextIndex]; + onKeystroke?.(isCorrect); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + if (isFinished || !isActive) return; + // Never allow typing beyond the target length + const val = e.target.value; + if (val.length <= targetText.length) { + onInputChange?.(val); + } + }; const renderCharacters = () => { return targetText.split('').map((char, index) => { - let colorClass = isDark ? 'text-gray-600' : 'text-gray-300'; - let isCurrent = index === userInput.length; + const isCurrent = index === userInput.length; + const isTyped = index < userInput.length; + const isCorrect = isTyped && userInput[index] === char; + const isWrong = isTyped && userInput[index] !== char; - if (index < userInput.length) { - if (userInput[index] === char) { - colorClass = isDark ? 'text-white' : 'text-gray-800'; - } else { - colorClass = isDark ? 'text-red-400 bg-red-400/10' : 'text-red-500 bg-red-50'; - } - } + let colorClass = isDark ? 'text-gray-600' : 'text-gray-300'; + if (isCorrect) colorClass = isDark ? 'text-white' : 'text-gray-800'; + else if (isWrong) colorClass = isDark ? 'text-red-400 bg-red-400/10' : 'text-red-500 bg-red-50'; return ( {char} @@ -47,14 +83,47 @@ const TypingArea: React.FC = ({ return (
enableKeyboard && inputRef.current?.focus()} + role="region" + aria-label="Typing test area" > -
+ {/* Hidden input to capture keyboard input on all devices including mobile */} + {enableKeyboard && ( + + )} + +
{renderCharacters()}
+ {/* Dimmed overlay while idle */} {!isActive && !isFinished && ( -
-
+ ); From b2c6074c3e3f2ea1f5f8325954738e25dacb0427 Mon Sep 17 00:00:00 2001 From: Hilary Kai Date: Tue, 3 Mar 2026 05:13:25 -0700 Subject: [PATCH 3/5] fix(StatsOverlay): format time as M:SS for long tests, add WPM colors, a11y - Format time as M:SS (e.g. '1:02') for durations >= 60 s so the 120 s test is readable at a glance instead of showing a bare second count. - Color-code WPM: red < 30, amber 30-59, green >= 60 for instant feedback. - Accept totalDuration prop needed to decide the time format. - Add role='status', aria-live='polite', and individual aria-label attrs so screen readers announce stat changes. --- components/StatsOverlay.tsx | 75 +++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/components/StatsOverlay.tsx b/components/StatsOverlay.tsx index 2b8be6a..0c505ed 100644 --- a/components/StatsOverlay.tsx +++ b/components/StatsOverlay.tsx @@ -1,34 +1,87 @@ - import React from 'react'; interface StatsOverlayProps { wpm: number; accuracy: number; timeLeft: number; + totalDuration: number; isDark: boolean; } -const StatsOverlay: React.FC = ({ wpm, accuracy, timeLeft, isDark }) => { - const valueClass = isDark ? 'text-white' : 'text-gray-900'; +const StatsOverlay: React.FC = ({ + wpm, + accuracy, + timeLeft, + totalDuration, + isDark, +}) => { const labelClass = isDark ? 'text-gray-500' : 'text-gray-400'; + const valueClass = isDark ? 'text-white' : 'text-gray-900'; + + // Format as M:SS for durations >= 60s so 62s shows as 1:02 + const formatTime = (seconds: number): string => { + if (totalDuration >= 60) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + } + return `${seconds}s`; + }; + + // Color-code WPM: red < 30, amber 30-59, green >= 60 + const wpmColor = () => { + if (wpm === 0) return valueClass; + if (wpm >= 60) return isDark ? 'text-green-400' : 'text-green-600'; + if (wpm >= 30) return isDark ? 'text-yellow-400' : 'text-yellow-600'; + return isDark ? 'text-red-400' : 'text-red-500'; + }; return ( -
+
- Time - - {timeLeft}s + + Time + + + {formatTime(timeLeft)}
+
- WPM - + + WPM + + {Math.round(wpm)}
+
- Accuracy - + + Accuracy + + {Math.round(accuracy)}%
From 4d829224a9b951a2cb713a991240b1fe4df5a270 Mon Sep 17 00:00:00 2001 From: Hilary Kai Date: Tue, 3 Mar 2026 05:13:41 -0700 Subject: [PATCH 4/5] fix(App): fix speech transcript bug, add keyboard mode, accuracy, stale closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - CRITICAL: Speech transcript was joined with ''.join('') — segments like 'hello' and 'world' were concatenated as 'helloworld' instead of 'hello world'. Fixed: trim each segment and join with ' '. - CRITICAL: latestRef was updated via useEffect (async) — a speech result arriving before the effect ran would see stale state. Fixed by updating latestRef.current synchronously in the render body. - WPM calculation: used sessionStats from outer closure which could be stale. Fixed: moved update inside setSessionStats updater to always read fresh prev state. - recognition.stop() in finishTest was not wrapped in try/catch — could throw if recognition was already stopped. - TestStatus.STARTING was declared but never used; startTest now has a clean path without dead states. New features: - Keyboard mode (⌨️ Keys): full keyboard typing test alongside voice, switchable via nav toggle. Resets the test on switch. - Real per-keystroke accuracy in keyboard mode: tracked via onKeystroke callback from TypingArea's onKeyDown (before the input value changes), giving accurate error counts including backspace-corrected mistakes. - Words-typed counter in final results. - Improved final results card: Speed, Accuracy, Words, Chars, Errors/Mode. - Voice accuracy correctly shows 100% (voice only advances on exact match). UX improvements: - Duration buttons and mode toggle both use aria-pressed. - Start button has aria-label describing the active mode. - 'Listening…' indicator has role='status' and aria-label. - Footer updated to emphasise privacy-first, local-only processing. - Nav is responsive with flex-wrap for small screens. - Restart button label simplified to 'Restart'. - Tab-key hint uses element for semantics. --- App.tsx | 728 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 492 insertions(+), 236 deletions(-) diff --git a/App.tsx b/App.tsx index 519a5ea..8c20fc8 100644 --- a/App.tsx +++ b/App.tsx @@ -1,11 +1,10 @@ - import React, { useState, useEffect, useCallback, useRef } from 'react'; import TypingArea from './components/TypingArea'; import StatsOverlay from './components/StatsOverlay'; -import { TestStatus, TypingStats, TestConfig, Quote } from './types'; +import { TestStatus, InputMode, TypingStats, TestConfig, Quote } from './types'; import { DEFAULT_QUOTES, DURATIONS } from './constants'; -// Add type definition for Web Speech API +// Web Speech API type declarations declare global { interface Window { SpeechRecognition: any; @@ -13,52 +12,48 @@ declare global { } } +const EMPTY_STATS: TypingStats = { + wpm: 0, + accuracy: 100, + charactersTyped: 0, + totalKeystrokes: 0, + incorrectKeystrokes: 0, + timeTaken: 0, + wordsTyped: 0, +}; + const App: React.FC = () => { - // State const [isDark, setIsDark] = useState(true); const [status, setStatus] = useState(TestStatus.IDLE); + const [inputMode, setInputMode] = useState(InputMode.VOICE); const [config, setConfig] = useState({ duration: 30 }); const [quote, setQuote] = useState(DEFAULT_QUOTES[0]); const [userInput, setUserInput] = useState(''); const [timeLeft, setTimeLeft] = useState(config.duration); const [isListening, setIsListening] = useState(false); + const [sessionStats, setSessionStats] = useState({ ...EMPTY_STATS }); - // Cumulative stats for the whole test session - const [sessionStats, setSessionStats] = useState({ - wpm: 0, - accuracy: 100, - charactersTyped: 0, - totalKeystrokes: 0, - incorrectKeystrokes: 0, - timeTaken: 0 - }); - - // Refs const timerRef = useRef(null); const startTimeRef = useRef(null); - const statsRef = useRef(sessionStats); const recognitionRef = useRef(null); - // Ref to hold latest state/handlers to avoid stale closures in event listeners + // Synchronously update this ref during render so speech callbacks + // always see the freshest state without stale closures. const latestRef = useRef({ status, isListening, quote, - sessionStats, - handleSpeechInput: (val: string) => { } // Placeholder + handleSpeechInput: (_val: string) => {}, }); - - // Keep ref in sync for interval access - useEffect(() => { - statsRef.current = sessionStats; - }, [sessionStats]); + latestRef.current.status = status; + latestRef.current.isListening = isListening; + latestRef.current.quote = quote; const getRandomQuote = (currentQuoteText?: string): Quote => { - let filtered = DEFAULT_QUOTES; - if (currentQuoteText) { - filtered = DEFAULT_QUOTES.filter(q => q.text !== currentQuoteText); - } - return filtered[Math.floor(Math.random() * filtered.length)]; + const pool = currentQuoteText + ? DEFAULT_QUOTES.filter((q) => q.text !== currentQuoteText) + : DEFAULT_QUOTES; + return pool[Math.floor(Math.random() * pool.length)]; }; const finishTest = useCallback(() => { @@ -73,162 +68,209 @@ const App: React.FC = () => { setStatus(TestStatus.FINISHED); }, []); - const resetTest = useCallback((newDuration?: number) => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - if (recognitionRef.current) { - recognitionRef.current.stop(); - } - - const d = newDuration ?? config.duration; - setStatus(TestStatus.IDLE); - setUserInput(''); - setIsListening(false); - setTimeLeft(d); - setQuote(getRandomQuote()); - setSessionStats({ - wpm: 0, - accuracy: 100, - charactersTyped: 0, - totalKeystrokes: 0, - incorrectKeystrokes: 0, - timeTaken: 0 - }); - startTimeRef.current = null; - }, [config.duration]); - - // Handle Speech Input Definition - const handleSpeechInput = (value: string) => { - if (status === TestStatus.FINISHED) return; - - // Normalization helper (lowercase, remove punctuation) - const normalize = (str: string) => str.toLowerCase().replace(/[^\w\s]/g, ''); - - // Split target into words, keeping their original punctuation for reconstruction if needed - // But simply, we want to match against the *words* of the target. - const targetWords = quote.text.split(' '); - const spokenWords = value.trim().split(' '); - - let matchedWordCount = 0; + const resetTest = useCallback( + (newDuration?: number) => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + if (recognitionRef.current) { + try { + recognitionRef.current.stop(); + } catch (_) {} + } + const d = newDuration ?? config.duration; + setStatus(TestStatus.IDLE); + setUserInput(''); + setIsListening(false); + setTimeLeft(d); + setQuote(getRandomQuote()); + setSessionStats({ ...EMPTY_STATS }); + startTimeRef.current = null; + }, + [config.duration] + ); - // Check how many words match from the beginning - // We compare normalized spoken words against normalized target words - for (let i = 0; i < spokenWords.length && i < targetWords.length; i++) { - const spoken = normalize(spokenWords[i]); - const target = normalize(targetWords[i]); + // ─── Voice input handler ────────────────────────────────────────────────── + + /** + * Called on every speech recognition result (including interim). + * Builds a prefix-matched input string from the transcript. + * + * BUG FIXED: original code used .join('') which concatenated speech + * segments without spaces. Now we trim each segment and join with ' '. + */ + const handleSpeechInput = useCallback( + (value: string) => { + // Read the latest quote from the ref to avoid stale closures + const currentQuote = latestRef.current.quote; + if (latestRef.current.status === TestStatus.FINISHED) return; + + const normalize = (str: string) => + str.toLowerCase().replace(/[^\w\s]/g, '').trim(); + + const targetWords = currentQuote.text.split(' '); + const spokenWords = value.trim().split(/\s+/).filter(Boolean); + + let matchedWordCount = 0; + for ( + let i = 0; + i < spokenWords.length && i < targetWords.length; + i++ + ) { + if (normalize(spokenWords[i]) === normalize(targetWords[i])) { + matchedWordCount++; + } else { + break; + } + } - if (spoken === target) { - matchedWordCount++; - } else { - break; + if (matchedWordCount > 0) { + const constructedInput = targetWords + .slice(0, matchedWordCount) + .join(' '); + // Append trailing space if more words remain (visual cue) + const displayInput = + matchedWordCount < targetWords.length + ? constructedInput + ' ' + : constructedInput; + + setUserInput(displayInput); + + // Completed entire quote + if (matchedWordCount === targetWords.length) { + setSessionStats((prev) => ({ + ...prev, + charactersTyped: prev.charactersTyped + constructedInput.length, + wordsTyped: prev.wordsTyped + matchedWordCount, + })); + + setUserInput(''); + // Stop recognition so the buffer clears; onend will restart it + if (recognitionRef.current) { + try { + recognitionRef.current.stop(); + } catch (_) {} + } + setQuote(getRandomQuote(currentQuote.text)); + } } - } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] // intentionally empty — we read all state from latestRef + ); - // Reconstruction: - let constructedInput = ''; - if (matchedWordCount > 0) { - constructedInput = targetWords.slice(0, matchedWordCount).join(' '); - if (matchedWordCount < targetWords.length) { - constructedInput += ' '; + // Keep the latest handler in latestRef (synchronous, so recognition + // callbacks see the current version without waiting for an effect). + latestRef.current.handleSpeechInput = handleSpeechInput; + + // ─── Keyboard input handler ─────────────────────────────────────────────── + + const handleKeyInput = useCallback( + (value: string) => { + if (status !== TestStatus.RUNNING) return; + setUserInput(value); + + // Check for quote completion (exact match) + if (value === quote.text) { + setSessionStats((prev) => ({ + ...prev, + charactersTyped: prev.charactersTyped + value.length, + wordsTyped: prev.wordsTyped + quote.text.split(' ').length, + })); + setUserInput(''); + setQuote(getRandomQuote(quote.text)); } - } + }, + [status, quote] + ); + + /** + * Per-keystroke accuracy tracking for keyboard mode. + * Called from TypingArea's onKeyDown before the input value changes. + */ + const handleKeystroke = useCallback((isCorrect: boolean) => { + setSessionStats((prev) => { + const totalKeystrokes = prev.totalKeystrokes + 1; + const incorrectKeystrokes = + prev.incorrectKeystrokes + (isCorrect ? 0 : 1); + const accuracy = + ((totalKeystrokes - incorrectKeystrokes) / totalKeystrokes) * 100; + return { ...prev, totalKeystrokes, incorrectKeystrokes, accuracy }; + }); + }, []); - const accuracy = 100; + // ─── Start / speech recognition ────────────────────────────────────────── - if (matchedWordCount > 0) { - setSessionStats(prev => ({ - ...prev, - wpm: prev.wpm, - accuracy: accuracy, - totalKeystrokes: constructedInput.length, - incorrectKeystrokes: 0 - })); + const startSpeechRecognition = useCallback(() => { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; - setUserInput(constructedInput); + if (!SpeechRecognition) { + alert( + 'Web Speech API is not supported in this browser. Try Chrome or Edge, or switch to Keyboard mode.' + ); + return; } - // Check if we matched all words - if (matchedWordCount === targetWords.length) { - setSessionStats(prev => ({ - ...prev, - charactersTyped: prev.charactersTyped + constructedInput.length - })); + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = 'en-US'; + + recognition.onresult = (event: any) => { + /** + * BUG FIXED: original code used .join('') — concatenating all speech + * segments without spaces, turning "hello world" into "helloworld". + * + * Fix: trim each segment's transcript and join with a single space. + * This handles both single-segment and multi-segment results correctly. + */ + const transcript = Array.from(event.results as any[]) + .map((result: any) => result[0].transcript.trim()) + .filter(Boolean) + .join(' '); + + latestRef.current.handleSpeechInput(transcript); + }; - setUserInput(''); + recognition.onerror = (event: any) => { + console.error('Speech recognition error:', event.error); + if (event.error === 'not-allowed') { + alert( + 'Microphone access denied. Please allow microphone permissions and try again.' + ); + finishTest(); + } + }; - // Stop recognition to clear buffer - if (recognitionRef.current) { - recognitionRef.current.stop(); + recognition.onend = () => { + // Auto-restart on silence timeouts while the test is still running + const { status: s, isListening: listening } = latestRef.current; + if (s === TestStatus.RUNNING && listening) { + try { + recognition.start(); + } catch (_) { + // Already started or recognition ended cleanly — safe to ignore + } } + }; - setQuote(getRandomQuote(quote.text)); + try { + recognition.start(); + recognitionRef.current = recognition; + } catch (e) { + console.error('Failed to start recognition:', e); } - }; + }, [finishTest]); - // Update latestRef on every render so callbacks see fresh data/handlers - useEffect(() => { - latestRef.current = { - status, - isListening, - quote, - sessionStats, - handleSpeechInput - }; - }, [status, isListening, quote, sessionStats, handleSpeechInput]); // handleSpeechInput is constant if defined outside or depends on these - - const startTest = () => { + const startTest = useCallback(() => { setStatus(TestStatus.RUNNING); startTimeRef.current = Date.now(); - setIsListening(true); - - // Start Speech Recognition - const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; - if (SpeechRecognition) { - const recognition = new SpeechRecognition(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = 'en-US'; - - recognition.onresult = (event: any) => { - const currentTranscript = Array.from(event.results) - .map((result: any) => result[0].transcript) - .join(''); - - // Use latestRef to call the fresh handler - latestRef.current.handleSpeechInput(currentTranscript); - }; - - recognition.onerror = (event: any) => { - console.error('Speech recognition error', event.error); - if (event.error === 'not-allowed') { - alert('Microphone access denied. Please enable microphone permissions.'); - } - }; - - recognition.onend = () => { - // Use latestRef to check fresh state - const { status, isListening } = latestRef.current; - - // Restart if still running (handles silence timeouts) - if (status === TestStatus.RUNNING && isListening) { - try { - recognition.start(); - } catch (e) { - // ignore - } - } - }; - try { - recognition.start(); - recognitionRef.current = recognition; - } catch (e) { - console.error('Failed to start recognition', e); - } - } else { - alert('Web Speech API not supported in this browser.'); + if (inputMode === InputMode.VOICE) { + setIsListening(true); + startSpeechRecognition(); } timerRef.current = window.setInterval(() => { @@ -240,53 +282,116 @@ const App: React.FC = () => { return prev - 1; }); }, 1000); - }; + }, [inputMode, startSpeechRecognition, finishTest]); - // Clean up on unmount - useEffect(() => { - return () => { - if (recognitionRef.current) recognitionRef.current.stop(); - if (timerRef.current) clearInterval(timerRef.current); - } - }, []); + // ─── Real-time WPM update ───────────────────────────────────────────────── - // Real-time WPM calculation useEffect(() => { if (status !== TestStatus.RUNNING || !startTimeRef.current) return; const updateWpm = () => { const timeElapsedSec = (Date.now() - startTimeRef.current!) / 1000; const timeElapsedMin = timeElapsedSec / 60; + if (timeElapsedMin <= 0) return; + + setSessionStats((prev) => { + // WPM = (completed chars + chars of current in-progress input) / 5 / minutes + const totalChars = prev.charactersTyped + userInput.trimEnd().length; + const currentWpm = totalChars / 5 / timeElapsedMin; + return { + ...prev, + wpm: currentWpm, + timeTaken: timeElapsedMin, + }; + }); + }; + + const interval = setInterval(updateWpm, 200); + return () => clearInterval(interval); + }, [status, userInput]); - // WPM = (all previously finished quotes chars + current input chars) / 5 / minutes - const totalChars = sessionStats.charactersTyped + userInput.length; - const currentWpm = timeElapsedMin > 0 ? (totalChars / 5) / timeElapsedMin : 0; + // ─── Cleanup on unmount ─────────────────────────────────────────────────── - setSessionStats(prev => ({ - ...prev, - wpm: currentWpm, - timeTaken: timeElapsedMin - })); + useEffect(() => { + return () => { + if (recognitionRef.current) recognitionRef.current.stop(); + if (timerRef.current) clearInterval(timerRef.current); }; + }, []); - const interval = setInterval(updateWpm, 100); - return () => clearInterval(interval); - }, [status, userInput.length, sessionStats.charactersTyped]); + // ─── Derived display values ─────────────────────────────────────────────── + + const themeClass = isDark + ? 'bg-[#161617] text-[#f5f5f7]' + : 'bg-[#fbfbfd] text-[#1d1d1f]'; + const navClass = isDark + ? 'bg-black/70 border-white/10' + : 'bg-white/70 border-gray-200/50'; + + const isVoice = inputMode === InputMode.VOICE; - const themeClass = isDark ? 'bg-[#161617] text-[#f5f5f7]' : 'bg-[#fbfbfd] text-[#1d1d1f]'; - const navClass = isDark ? 'bg-black/70 border-white/10' : 'bg-white/70 border-gray-200/50'; + // Compute displayed accuracy: for voice mode it's always 100 (only exact + // matches advance the cursor); for keyboard mode it's tracked per keystroke. + const displayAccuracy = isVoice ? 100 : Math.round(sessionStats.accuracy); return ( -
-