diff --git a/package.json b/package.json index ec70221..6f65c88 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "flashspan", "private": true, - "version": "2.1.0", + "version": "2.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e60e73e..95f4aa3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Flashspan" -version = "2.2.1" +version = "2.3.0" dependencies = [ "getrandom 0.4.2", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 935ea11..c126ac6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Flashspan" -version = "2.2.1" +version = "2.3.0" description = "Mental Maths training simulator" authors = ["you"] license = "" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 9ba4f9a..235ffc2 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,7 +1,7 @@ use log::{error, info, warn}; -use std::sync::OnceLock; use rodio::{Decoder, DeviceSinkBuilder, Player}; use std::io::Cursor; +use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Sender, channel}; diff --git a/src-tauri/src/core/engine.rs b/src-tauri/src/core/engine.rs index 2c1b00c..7dc50d5 100644 --- a/src-tauri/src/core/engine.rs +++ b/src-tauri/src/core/engine.rs @@ -1,7 +1,7 @@ use crate::core::generate::random_number_with_constraints; use crate::core::types::{SessionConfig, SessionConfigEffective, SessionPlan, SessionStep}; -use rand::rngs::StdRng; use rand::SeedableRng; +use rand::rngs::StdRng; /// Build a deterministic session plan from configuration and an optional seed. /// diff --git a/src-tauri/src/core/generate.rs b/src-tauri/src/core/generate.rs index f1b228a..bb866c6 100644 --- a/src-tauri/src/core/generate.rs +++ b/src-tauri/src/core/generate.rs @@ -6,6 +6,7 @@ pub(crate) fn random_fixed_digits_no_leading_zero(rng: &mut impl Rng, digits: u3 return rng.random_range(1u32..=9u32).to_string(); } + debug_assert!(digits <= 18, "digits {} exceeds u64 range", digits); let min = 10u64.pow(digits - 1); let max_exclusive = 10u64.pow(digits); rng.random_range(min..max_exclusive).to_string() diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 640350f..a048a40 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -12,7 +12,8 @@ mod native_app { types::{AutoRepeatPlan, SessionConfigEffective, SessionConfigInput}, validate::normalize_session_config, }; - use crate::session::SessionManager; + use crate::session::{recover_lock, SessionManager}; + use log::warn; use serde::Deserialize; use std::sync::{Arc, Mutex}; use std::thread; @@ -108,7 +109,7 @@ mod native_app { #[tauri::command] fn get_app_settings(settings: tauri::State<'_, SettingsState>) -> AppSettings { - settings.0.lock().expect("settings lock poisoned").clone() + recover_lock(&settings.0, "settings").clone() } #[tauri::command] @@ -128,7 +129,7 @@ mod native_app { color_scheme: ColorScheme, ) -> Result { let updated = { - let mut guard = settings.0.lock().expect("settings lock poisoned"); + let mut guard = recover_lock(&settings.0, "settings"); guard.color_scheme = color_scheme; guard.clone() }; @@ -143,7 +144,7 @@ mod native_app { theme_mode: ThemeMode, ) -> Result { let updated = { - let mut guard = settings.0.lock().expect("settings lock poisoned"); + let mut guard = recover_lock(&settings.0, "settings"); guard.theme_mode = theme_mode; guard.clone() }; @@ -233,54 +234,65 @@ mod native_app { let manager_arc = Arc::clone(&manager); let app_for_thread = app.clone(); let remaining_repeats = remaining; - thread::spawn(move || { - let end_at = Instant::now() + std::time::Duration::from_millis(delay_ms); - let mut last_sent: Option = None; + if let Err(e) = std::thread::Builder::new() + .name("auto-repeat".into()) + .spawn(move || { + let end_at = Instant::now() + std::time::Duration::from_millis(delay_ms); + let mut last_sent: Option = None; + + loop { + if manager_arc.auto_repeat_generation() != generation { + return; + } + + let now = Instant::now(); + if now >= end_at { + break; + } + + let remaining_duration = end_at.saturating_duration_since(now); + let seconds_left = (remaining_duration.as_millis() as u64).div_ceil(1000); + + if last_sent != Some(seconds_left) { + last_sent = Some(seconds_left); + let _ = app_for_thread.emit( + "auto_repeat_tick", + AutoRepeatTickPayload { + session_id, + seconds_left, + remaining: remaining_repeats, + }, + ); + } + + let step = remaining_duration.min(std::time::Duration::from_millis(120)); + thread::sleep(step); + } - loop { if manager_arc.auto_repeat_generation() != generation { return; } - let now = Instant::now(); - if now >= end_at { - break; - } + let _ = app_for_thread.emit( + "auto_repeat_tick", + AutoRepeatTickPayload { + session_id, + seconds_left: 0, + remaining: remaining_repeats, + }, + ); - let remaining_duration = end_at.saturating_duration_since(now); - let seconds_left = (remaining_duration.as_millis() as u64).div_ceil(1000); - - if last_sent != Some(seconds_left) { - last_sent = Some(seconds_left); - let _ = app_for_thread.emit( - "auto_repeat_tick", - AutoRepeatTickPayload { - session_id, - seconds_left, - remaining: remaining_repeats, - }, - ); + // TOCTOU guard: re-check generation right before start() + if manager_arc.auto_repeat_generation() != generation { + return; } - - let step = remaining_duration.min(std::time::Duration::from_millis(120)); - thread::sleep(step); - } - - if manager_arc.auto_repeat_generation() != generation { - return; - } - - let _ = app_for_thread.emit( - "auto_repeat_tick", - AutoRepeatTickPayload { - session_id, - seconds_left: 0, - remaining: remaining_repeats, - }, - ); - - let _ = manager_arc.start(app_for_thread, config); - }); + if let Err(e) = manager_arc.start(app_for_thread, config) { + warn!("auto-repeat start failed: {}", e); + } + }) + { + warn!("auto-repeat thread spawn failed: {}", e); + } Ok(Some(payload)) } diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 6577c45..77e5074 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -2,9 +2,8 @@ use crate::core::types::{ AutoRepeatPlan, ClearScreen, SessionComplete, SessionConfig, SessionConfigEffective, SessionPlan, SessionStep, ShowNumber, }; -use crate::core::{ - engine::build_session_plan, validate::validate_config, -}; +use crate::core::{engine::build_session_plan, validate::validate_config}; +use log::warn; use std::time::{SystemTime, UNIX_EPOCH}; use std::{ collections::VecDeque, @@ -90,17 +89,24 @@ impl Default for SessionManager { } } +pub(crate) fn recover_lock<'a, T>(mutex: &'a Mutex, name: &str) -> std::sync::MutexGuard<'a, T> { + mutex.lock().unwrap_or_else(|e| { + warn!("mutex {} poisoned, recovering", name); + e.into_inner() + }) +} + impl SessionManager { const MAX_RECENT_RESULTS: usize = 8; fn cleanup_finished_worker(&self) { - let mut worker = self.worker.lock().expect("worker lock poisoned"); + let mut worker = recover_lock(&self.worker, "worker"); if let Some(handle) = worker.as_ref() && handle.is_finished() { let handle = worker.take().expect("just checked Some"); let _ = handle.join(); - *self.stop.lock().expect("stop lock poisoned") = None; + *recover_lock(&self.stop, "stop") = None; } } @@ -110,7 +116,7 @@ impl SessionManager { validate_config(&config)?; { - let worker = self.worker.lock().expect("worker lock poisoned"); + let worker = recover_lock(&self.worker, "worker"); if let Some(handle) = worker.as_ref() && !handle.is_finished() { @@ -119,44 +125,42 @@ impl SessionManager { } let stop_flag = Arc::new(AtomicBool::new(false)); - *self.stop.lock().expect("stop lock poisoned") = Some(stop_flag.clone()); + *recover_lock(&self.stop, "stop") = Some(stop_flag.clone()); { - let mut state = self.state.lock().expect("state lock poisoned"); + let mut state = recover_lock(&self.state, "state"); *state = SessionState::ShowingNumbers { current: 0, total: config.total_numbers, }; } - // New session id for this run. let session_id = self.next_session_id.fetch_add(1, Ordering::SeqCst); let state_arc = Arc::clone(&self.state); let recent_results_arc = Arc::clone(&self.recent_results); let plan_arc = Arc::clone(&self.auto_repeat_plan); - let handle = thread::spawn(move || { - run_session_loop( - app, - config, - state_arc, - stop_flag, - session_id, - recent_results_arc, - plan_arc, - ); - }); - - *self.worker.lock().expect("worker lock poisoned") = Some(handle); + let handle = std::thread::Builder::new() + .name("session-worker".into()) + .spawn(move || { + run_session_loop( + app, + config, + state_arc, + stop_flag, + session_id, + recent_results_arc, + plan_arc, + ); + }) + .map_err(|e| format!("failed to spawn session worker: {}", e))?; + + *recover_lock(&self.worker, "worker") = Some(handle); Ok(session_id) } pub fn configure_auto_repeat(&self, plan: Option) { - *self - .auto_repeat_plan - .lock() - .expect("auto_repeat_plan lock poisoned") = plan; - // Bump generation so any previously scheduled starts become no-ops. + *recover_lock(&self.auto_repeat_plan, "auto_repeat_plan") = plan; self.auto_repeat_generation.fetch_add(1, Ordering::SeqCst); } @@ -165,10 +169,7 @@ impl SessionManager { } pub fn result_for(&self, session_id: u64) -> Result { - let guard = self - .recent_results - .lock() - .expect("recent_results lock poisoned"); + let guard = recover_lock(&self.recent_results, "recent_results"); for result in guard.iter().rev() { if result.session_id == session_id { @@ -186,10 +187,7 @@ impl SessionManager { let generation = self.auto_repeat_generation.load(Ordering::SeqCst); let (delay_ms, config, remaining_after_decrement) = { - let mut plan_guard = self - .auto_repeat_plan - .lock() - .expect("auto_repeat_plan lock poisoned"); + let mut plan_guard = recover_lock(&self.auto_repeat_plan, "auto_repeat_plan"); let Some(plan) = plan_guard.as_mut() else { return Ok(None); }; @@ -219,23 +217,19 @@ impl SessionManager { pub fn stop(&self) { self.cleanup_finished_worker(); - // Cancel any pending auto-repeat and forget last result. self.configure_auto_repeat(None); - self.recent_results - .lock() - .expect("recent_results lock poisoned") - .clear(); + recover_lock(&self.recent_results, "recent_results").clear(); - let stop_flag = self.stop.lock().expect("stop lock poisoned").take(); + let stop_flag = recover_lock(&self.stop, "stop").take(); if let Some(flag) = stop_flag { flag.store(true, Ordering::SeqCst); } - if let Some(handle) = self.worker.lock().expect("worker lock poisoned").take() { + if let Some(handle) = recover_lock(&self.worker, "worker").take() { let _ = handle.join(); } - let mut state = self.state.lock().expect("state lock poisoned"); + let mut state = recover_lock(&self.state, "state"); *state = SessionState::Idle; } } @@ -278,8 +272,6 @@ fn run_session_loop( ); } - - fn sleep_until_interruptible(deadline: Instant, stop: &AtomicBool) { while Instant::now() < deadline { if stop.load(Ordering::SeqCst) { @@ -315,7 +307,7 @@ fn run_session_plan( index: None, emitted_at_ms: now_epoch_ms(), }); - let mut st = state.lock().expect("state lock poisoned"); + let mut st = recover_lock(&*state, "state"); *st = SessionState::Idle; return; } @@ -360,8 +352,7 @@ fn run_session_plan( let delay = Duration::from_millis(*delay_ms_before_next) + grace; sleep_until_interruptible(Instant::now() + delay, &stop); - // Update state to reflect which number is showing - let mut st = state.lock().expect("state lock poisoned"); + let mut st = recover_lock(&*state, "state"); *st = SessionState::ShowingNumbers { current: *index, total: *total, @@ -397,7 +388,7 @@ fn run_session_plan( }; { - let mut guard = recent_results.lock().expect("recent_results lock poisoned"); + let mut guard = recover_lock(&*recent_results, "recent_results"); guard.push_back(result.clone()); while guard.len() > SessionManager::MAX_RECENT_RESULTS { guard.pop_front(); @@ -405,11 +396,8 @@ fn run_session_plan( } emitter.session_complete(result); - // Arm auto-repeat if configured { - let mut plan_guard = auto_repeat_plan - .lock() - .expect("auto_repeat_plan lock poisoned"); + let mut plan_guard = recover_lock(&*auto_repeat_plan, "auto_repeat_plan"); if let Some(ar_plan) = plan_guard.as_mut() && ar_plan.remaining > 0 { @@ -426,14 +414,13 @@ fn run_session_plan( index: None, emitted_at_ms: now_epoch_ms(), }); - let mut st = state.lock().expect("state lock poisoned"); + let mut st = recover_lock(&*state, "state"); *st = SessionState::Idle; return; } } - // Final state transition - let mut st = state.lock().expect("state lock poisoned"); + let mut st = recover_lock(&*state, "state"); *st = SessionState::Complete; } @@ -448,7 +435,6 @@ mod tests { use rand::rng; use std::sync::Arc; - #[test] fn generator_respects_invariants() { let mut rng = rng(); @@ -852,6 +838,4 @@ mod tests { let elapsed = Instant::now() - start; assert!(elapsed >= Duration::from_millis(25)); } - - } diff --git a/src-tauri/src/wasm_bridge_tests.rs b/src-tauri/src/wasm_bridge_tests.rs index 7b4c799..363bb50 100644 --- a/src-tauri/src/wasm_bridge_tests.rs +++ b/src-tauri/src/wasm_bridge_tests.rs @@ -1,9 +1,9 @@ #[cfg(all(test, target_arch = "wasm32"))] mod wasm_tests { - use wasm_bindgen_test::*; - use crate::{ping, wasm_version, normalize_session_config_wasm, build_session_plan_wasm}; use crate::core::types::SessionConfigInput; - use serde_wasm_bindgen::{to_value, from_value}; + use crate::{build_session_plan_wasm, normalize_session_config_wasm, ping, wasm_version}; + use serde_wasm_bindgen::{from_value, to_value}; + use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); @@ -18,7 +18,10 @@ mod wasm_tests { let version = wasm_version(); // Version should be in format "major.minor.patch" let parts: Vec<&str> = version.split('.').collect(); - assert!(parts.len() >= 2, "Version should have at least major.minor format"); + assert!( + parts.len() >= 2, + "Version should have at least major.minor format" + ); // All parts should be numeric or pre-release identifiers assert!(!version.is_empty(), "Version should not be empty"); @@ -113,7 +116,10 @@ mod wasm_tests { let plan1_str = format!("{:?}", result1.unwrap()); let plan2_str = format!("{:?}", result2.unwrap()); - assert_eq!(plan1_str, plan2_str, "Same seed should produce identical plans"); + assert_eq!( + plan1_str, plan2_str, + "Same seed should produce identical plans" + ); } #[wasm_bindgen_test] @@ -137,15 +143,19 @@ mod wasm_tests { assert!(result2.is_ok()); assert!(result3.is_ok()); + let plan1 = result1.unwrap(); + let plan2 = result2.unwrap(); + let plan3 = result3.unwrap(); + // All three should produce identical results assert_eq!( - format!("{:?}", result1.unwrap()), - format!("{:?}", result2.unwrap()), + format!("{:?}", plan1), + format!("{:?}", plan2), "Determinism check 1" ); assert_eq!( - format!("{:?}", result2.unwrap()), - format!("{:?}", result3.unwrap()), + format!("{:?}", plan2), + format!("{:?}", plan3), "Determinism check 2" ); } @@ -171,7 +181,10 @@ mod wasm_tests { let plan1_str = format!("{:?}", result_seed1.unwrap()); let plan2_str = format!("{:?}", result_seed2.unwrap()); - assert_ne!(plan1_str, plan2_str, "Different seeds should produce different plans"); + assert_ne!( + plan1_str, plan2_str, + "Different seeds should produce different plans" + ); } #[wasm_bindgen_test] diff --git a/src/App.css b/src/App.css index 479f536..274f8f8 100644 --- a/src/App.css +++ b/src/App.css @@ -57,7 +57,7 @@ --space: calc(clamp(8px, 1.6vw, 28px) * var(--ui-scale)); /* Inline gap used between label and input and other small inline controls. Increase viewport-driven growth so wide screens receive more spacing. */ - --inline-gap: calc(clamp(12px, 6vw, 160px) * var(--ui-scale)); + --inline-gap: clamp(12px, 6vw, 160px); } /* Bottom-corner branding and info */ @@ -323,19 +323,59 @@ padding: 24px; } -.endCard { - width: min(560px, 92vw); - border-radius: 16px; - padding: 22px; - border: 1px solid rgba(var(--app-fg-rgb), 0.22); - background: rgba(var(--app-bg-rgb), 0.65); - cursor: pointer; - user-select: none; +.answerNumbers { + display: grid; + width: 100%; + max-width: 720px; + margin: 0; + padding: 8px 12px; + background: rgba(var(--app-bg-rgb), 0.55); + border-radius: 12px; + border: 1px solid rgba(var(--app-fg-rgb), 0.12); + box-sizing: border-box; + gap: 6px; + max-height: 280px; + /* confine height so only this list scrolls */ + overflow-y: auto; + -webkit-overflow-scrolling: touch; } -.endCard:focus-visible { - outline: 2px solid rgba(var(--app-fg-rgb), 0.28); - outline-offset: 3px; +.answerRow { + display: grid; + grid-template-columns: calc(48px * var(--ui-scale)) 1fr; + /* fixed index column, flexible value column */ + align-items: center; + gap: calc(8px * var(--ui-scale)); + padding: calc(4px * var(--ui-scale)) calc(6px * var(--ui-scale)); + border-radius: calc(8px * var(--ui-scale)); +} + +.answerIndex { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + color: rgba(var(--app-fg-rgb), 0.7); + text-align: right; + padding-right: 8px; +} + +.answerValue { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 20px; + line-height: 1; + padding: 6px 8px; + border-radius: 8px; + background: rgba(var(--app-fg-rgb), 0.04); + border: 1px solid rgba(var(--app-fg-rgb), 0.06); + color: var(--app-fg); +} + +.endHeaderCenter { + text-align: center; + width: 100%; + max-width: min(560px, 92vw); } .endTitle { @@ -404,93 +444,6 @@ letter-spacing: 0.04em; } -.answerHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.answerActions { - display: flex; - gap: 10px; -} - -.answerText { - flex: 1; - overflow: auto; - width: 100%; - max-width: 720px; - text-align: left; - margin: 0; - padding: 14px; - border-radius: 12px; - border: 1px solid rgba(var(--app-fg-rgb), 0.16); - background: rgba(var(--app-bg-rgb), 0.55); - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 20px; - line-height: 1.4; - white-space: pre-wrap; - word-break: break-word; -} - -.answerNumbers { - display: grid; - width: 100%; - max-width: 720px; - margin: 0; - padding: 8px 12px; - background: rgba(var(--app-bg-rgb), 0.55); - border-radius: 12px; - border: 1px solid rgba(var(--app-fg-rgb), 0.12); - box-sizing: border-box; - gap: 6px; - max-height: 280px; - /* confine height so only this list scrolls */ - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} - -.answerRow { - display: grid; - grid-template-columns: calc(48px * var(--ui-scale)) 1fr; - /* fixed index column, flexible value column */ - align-items: center; - gap: calc(8px * var(--ui-scale)); - padding: calc(4px * var(--ui-scale)) calc(6px * var(--ui-scale)); - border-radius: calc(8px * var(--ui-scale)); -} - -.answerIndex { - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - color: rgba(var(--app-fg-rgb), 0.7); - text-align: right; - padding-right: 8px; -} - -.answerValue { - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 20px; - line-height: 1; - padding: 6px 8px; - border-radius: 8px; - background: rgba(var(--app-fg-rgb), 0.04); - border: 1px solid rgba(var(--app-fg-rgb), 0.06); - color: var(--app-fg); -} - -.endHeaderCenter { - text-align: center; - width: 100%; - max-width: min(560px, 92vw); -} - .actionField { width: 100%; max-width: 560px; @@ -565,30 +518,6 @@ color: rgba(var(--app-fg-rgb), 0.96); } -.answerInput { - flex: 1; - resize: none; - width: 100%; - margin: 0; - padding: 14px; - border-radius: 12px; - border: 1px solid rgba(var(--app-fg-rgb), 0.16); - background: rgba(var(--app-bg-rgb), 0.55); - color: var(--app-fg); - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 18px; - line-height: 1.35; - text-align: center; - outline: none; -} - -.answerInput:focus-visible { - outline: 2px solid rgba(var(--app-fg-rgb), 0.22); - outline-offset: 2px; -} - .validationText { margin: 0; width: 100%; @@ -678,17 +607,6 @@ gap: calc(16px * var(--ui-scale)); } -.advancedTitle { - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 13px; - letter-spacing: 0.08em; - text-transform: uppercase; - opacity: 0.85; - margin: 0; -} - .advancedDivider { height: 1px; background: rgba(var(--app-fg-rgb), 0.14); @@ -736,14 +654,6 @@ opacity: 0.9; } -.headerLogo { - width: calc(20px * var(--ui-scale)); - height: calc(20px * var(--ui-scale)); - object-fit: contain; - vertical-align: middle; - margin-right: calc(var(--space) * 0.25); -} - .grid { display: grid; grid-template-columns: 1fr; @@ -875,32 +785,6 @@ user-select: none; } -.modeControl { - display: inline-flex; - gap: 6px; - margin-left: 10px; - align-items: center; -} - -.modeVertical { - display: flex; - flex-direction: column; - gap: 6px; - margin-top: 8px; - align-items: flex-start; -} - -.smallOption { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.smallLabel { - font-size: 11px; - color: rgba(var(--app-fg-rgb), 0.85); -} - .colorSwatch { width: calc(52px * var(--ui-scale)); height: calc(36px * var(--ui-scale)); @@ -980,9 +864,6 @@ } /* Light-mode adjustments: swap bg/fg for lighter palettes */ -.theme-light { - --app-bg-opacity-panel: 0.98; -} .segmentedOption { position: relative; @@ -1014,6 +895,14 @@ min-width: calc(54px * var(--ui-scale)); } +.modeVertical { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + align-items: flex-start; +} + .modeVertical .segmentedLabel { min-width: 44px; padding: 6px 8px; @@ -1062,19 +951,6 @@ cursor: default; } -/* Sound toggle styling */ -.soundToggle { - background: transparent; - color: var(--app-fg); - border: 1px solid rgba(var(--app-fg-rgb), 0.18); - padding: 6px 12px; -} - -.soundToggle.enabled { - background: var(--app-fg); - color: var(--app-bg); -} - .error { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", diff --git a/src/App.tsx b/src/App.tsx index c9e28df..88b44e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -129,7 +129,6 @@ export default function App() { const [errorText, setErrorText] = createSignal(""); const [showAnswer, setShowAnswer] = createSignal(false); - const [, setAnswerText] = createSignal(""); const [answerMode, setAnswerMode] = createSignal<"reveal" | "type">("reveal"); const [typedAnswer, setTypedAnswer] = createSignal(""); const [validationSummary, setValidationSummary] = createSignal(""); @@ -140,10 +139,7 @@ export default function App() { const [sessionId, setSessionId] = createSignal(null); const [numbers, setNumbers] = createSignal([]); - // Interactive logo/title transform based on window size while preserving - // proportions relative to the initial app startup dimensions. This keeps - // the starting layout identical but makes subsequent resizes animate - // the logo gently toward the top-left. + // Scale UI elements proportionally to window size relative to startup dimensions. onMount(() => { const root = document.documentElement; const baselineW = Math.max(800, window.innerWidth); @@ -151,25 +147,14 @@ export default function App() { let raf = 0; const update = () => { - const w = window.innerWidth; - const h = window.innerHeight; - const ratio = Math.min(w / baselineW, h / baselineH); - const clamped = Math.max(0.75, Math.min(ratio, 1.5)); - - // Shift amounts are proportional to baseline dims, capped to sensible px. - const shiftX = Math.min(220, Math.max(48, baselineW * 0.06)); - const shiftY = Math.min(120, Math.max(20, baselineH * 0.035)); - - const offsetX = (clamped - 1) * -shiftX; // negative -> move left when larger - const offsetY = (clamped - 1) * -shiftY; // negative -> move up when larger - const logoScale = 1 + (clamped - 1) * 0.22; // modest scale up - const titleOffset = (clamped - 1) * -8; // small title lift - - root.style.setProperty("--ui-scale", `${clamped}`); - root.style.setProperty("--home-logo-translate-x", `${offsetX}px`); - root.style.setProperty("--home-logo-translate-y", `${offsetY}px`); - root.style.setProperty("--home-logo-scale", `${logoScale}`); - root.style.setProperty("--home-title-translate-y", `${titleOffset}px`); + const ratio = Math.min( + window.innerWidth / baselineW, + window.innerHeight / baselineH, + ); + root.style.setProperty( + "--ui-scale", + `${Math.max(0.75, Math.min(ratio, 1.5))}`, + ); }; const handler = () => { @@ -177,7 +162,6 @@ export default function App() { raf = requestAnimationFrame(update); }; - // initialize and listen update(); window.addEventListener("resize", handler, { passive: true }); @@ -212,6 +196,7 @@ export default function App() { createSignal(false); let sumInputRef: HTMLInputElement | undefined; + let overlayRef: HTMLDivElement | undefined; const isRunning = (): boolean => phase() === "starting" || phase() === "flashing"; @@ -219,7 +204,6 @@ export default function App() { const resetForIncomingSessionIfComplete = () => { if (phase() !== "complete") return; setShowAnswer(false); - setAnswerText(""); setAnswerSum(0); setTypedAnswer(""); setValidationSummary(""); @@ -240,6 +224,36 @@ export default function App() { requestAnimationFrame(() => sumInputRef?.focus?.()); }); + createEffect(() => { + if (!showAdvanced() || !overlayRef) return; + + const panel = overlayRef.querySelector(".advancedPanel"); + if (!panel) return; + + const focusable = panel.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + first.focus(); + + const handler = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + panel.addEventListener("keydown", handler); + onCleanup(() => panel.removeEventListener("keydown", handler)); + }); + const applySubmitAnswerResponse = (resp: SubmitAnswerResponse) => { const { validation } = resp; const ok = validation.correct; @@ -395,7 +409,6 @@ export default function App() { setCurrentShown(null); setShowAnswer(false); setNumbers(payload.numbers); - setAnswerText(payload.numbers.join("\n")); setAnswerSum(payload.sum); setTypedAnswer(""); setValidationSummary(""); @@ -452,7 +465,6 @@ export default function App() { setShowAdvanced(false); setShowAnswer(false); - setAnswerText(""); setAnswerSum(0); setTypedAnswer(""); setValidationSummary(""); @@ -507,7 +519,6 @@ export default function App() { setDisplayText(""); setCurrentShown(null); setShowAnswer(false); - setAnswerText(""); setAnswerSum(0); setTypedAnswer(""); setValidationSummary(""); @@ -553,6 +564,7 @@ export default function App() { class="iconButton" type="button" aria-label="Additional settings" + aria-expanded={showAdvanced()} disabled={isRunning()} onClick={() => setShowAdvanced((v) => !v)} > @@ -560,7 +572,7 @@ export default function App() { {showAdvanced() ? ( -
+
) : (
{ // Initial render should be fast (< 500ms) if (renderTime >= 500) { - console.warn(`Render time ${renderTime.toFixed(1)}ms exceeded 500ms threshold`); + console.warn( + `Render time ${renderTime.toFixed(1)}ms exceeded 500ms threshold`, + ); } }); @@ -28,7 +30,9 @@ describe("App performance tests", () => { // Runtime init should be instant (< 50ms) if (initTime >= 50) { - console.warn(`Init time ${initTime.toFixed(1)}ms exceeded 50ms threshold`); + console.warn( + `Init time ${initTime.toFixed(1)}ms exceeded 50ms threshold`, + ); } }); @@ -53,7 +57,9 @@ describe("App performance tests", () => { // All 7 listeners should register in < 10ms if (registerTime >= 10) { - console.warn(`Listener registration time ${registerTime.toFixed(2)}ms exceeded 10ms threshold`); + console.warn( + `Listener registration time ${registerTime.toFixed(2)}ms exceeded 10ms threshold`, + ); } expect(unlisteners.length).toBe(7); @@ -79,7 +85,9 @@ describe("App performance tests", () => { // 100 emissions should complete in < 100ms if (emitTime >= 100) { - console.warn(`Emission time ${emitTime.toFixed(1)}ms exceeded 100ms threshold for 100 events`); + console.warn( + `Emission time ${emitTime.toFixed(1)}ms exceeded 100ms threshold for 100 events`, + ); } expect(emissions.length).toBe(100); }); @@ -125,14 +133,9 @@ describe("App performance tests", () => { // Queries should complete in < 100ms total if (queryTime >= 100) { - console.warn(`Query time ${queryTime.toFixed(1)}ms exceeded 100ms threshold for 10 queries`); + console.warn( + `Query time ${queryTime.toFixed(1)}ms exceeded 100ms threshold for 10 queries`, + ); } }); - - it("note: for large numbers list (500+), consider virtualization if render time > 2000ms", () => { - // This is a reminder test - not an actual test - // Real app should monitor performance with actual data - // If rendering 500+ numbers takes > 2 seconds, implement virtualization - expect(true).toBe(true); - }); }); diff --git a/src/runtime/native.ts b/src/runtime/native.ts index 565a0e5..fdbf24e 100644 --- a/src/runtime/native.ts +++ b/src/runtime/native.ts @@ -11,6 +11,9 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import applauseUrl from "../assets/applause.wav?url"; +import beepUrl from "../assets/beep.wav?url"; +import buzzerUrl from "../assets/buzzer.wav?url"; import type { Runtime, UnlistenFn } from "./index"; import type { AppSettings, @@ -27,10 +30,6 @@ import type { ThemeMode, } from "./types"; -import applauseUrl from "../assets/applause.wav?url"; -import beepUrl from "../assets/beep.wav?url"; -import buzzerUrl from "../assets/buzzer.wav?url"; - // Audio fallback (in case Tauri audio fails or is not available) const audioFallback = { beep: new Audio(beepUrl), diff --git a/src/tauri.ts b/src/tauri.ts deleted file mode 100644 index 3ce4f56..0000000 --- a/src/tauri.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; - -const fallback = { - beep: new Audio("/src/assets/beep.wav"), - applause: new Audio("/src/assets/applause.wav"), - buzzer: new Audio("/src/assets/buzzer.wav"), -}; -Object.values(fallback).forEach((a) => { - a.preload = "auto"; - a.volume = 0.9; -}); - -export async function playSound(kind: "beep" | "applause" | "buzzer") { - try { - await invoke("play_sound_kind", { kind }); - } catch (_e) { - // native playback failed or not available — fallback to HTML Audio - const a = fallback[kind]; - try { - a.currentTime = 0; - await a.play(); - } catch { - /* ignore */ - } - } -} - -export default playSound; - -export type Phase = "idle" | "starting" | "countdown" | "flashing" | "complete"; - -export type ColorScheme = - | "midnight" - | "ivory" - | "crimson" - | "aqua" - | "violet" - | "amber"; - -export type ThemeMode = "dark" | "light"; - -export interface AppSettings { - color_scheme: ColorScheme; - theme_mode: ThemeMode; -} - -export interface SessionConfigInput { - digits_per_number: number; - number_duration_s: number; - delay_between_numbers_s: number; - total_numbers: number; - allow_negative_numbers: boolean; -} - -export interface SessionConfigEffective { - digits_per_number: number; - number_duration_s: number; - delay_between_numbers_s: number; - total_numbers: number; - allow_negative_numbers: boolean; -} - -export interface AutoRepeatConfig { - enabled: boolean; - repeats: number; - delay_s: number; -} - -export interface AutoRepeatEffective { - enabled: boolean; - repeats: number; - delay_s: number; -} - -export interface StartSessionResponse { - session_id: number; - effective_config: SessionConfigEffective; - effective_auto_repeat: AutoRepeatEffective | null; -} - -export interface ShowNumber { - session_id: number; - index: number; - total: number; - value: number; - running_sum: number; - emitted_at_ms: number; -} - -export interface ClearScreen { - session_id: number; - index: number | null; - emitted_at_ms: number; -} - -export interface SessionComplete { - session_id: number; - numbers: number[]; - sum: number; -} - -export interface AutoRepeatWaitingPayload { - session_id: number; - next_start_at_ms: number; - remaining: number; -} - -export interface AutoRepeatTickPayload { - session_id: number; - seconds_left: number; - remaining: number; -} - -export interface ValidationResult { - expected_sum: number; - provided_sum: number; - correct: boolean; - delta: number; -} - -export interface SubmitAnswerResponse { - validation: ValidationResult; - auto_repeat_waiting: AutoRepeatWaitingPayload | null; -} - -export async function ping(): Promise { - return invoke("ping"); -} - -export async function getAppSettings(): Promise { - return invoke("get_app_settings"); -} - -export async function setColorScheme( - color_scheme: ColorScheme, -): Promise { - return invoke("set_color_scheme", { - color_scheme: color_scheme, - }); -} - -export async function setThemeMode( - theme_mode: ThemeMode, -): Promise { - return invoke("set_theme_mode", { theme_mode: theme_mode }); -} - -export async function startSession( - config: SessionConfigInput, - autoRepeat?: AutoRepeatConfig | null, -): Promise { - return invoke("start_session", { - config, - auto_repeat: autoRepeat ?? null, - }); -} - -export async function stopSession(): Promise { - return invoke("stop_session"); -} - -export async function cancelAutoRepeat(): Promise { - return invoke("cancel_auto_repeat"); -} - -export async function markValidated( - session_id: number, -): Promise { - return invoke("mark_validated", { - session_id: session_id, - }); -} - -export async function acknowledgeComplete( - session_id: number, -): Promise { - return invoke("acknowledge_complete", { - session_id: session_id, - }); -} - -export async function submitAnswer( - session_id: number, - provided_sum: number, -): Promise { - return invoke("submit_answer", { - args: { - session_id: session_id, - provided_sum: provided_sum, - }, - }); -} - -export async function submitAnswerText( - session_id: number, - provided_text: string, -): Promise { - return invoke("submit_answer_text", { - args: { - session_id: session_id, - provided_text: provided_text, - }, - }); -} - -export async function getSoundEnabled(): Promise { - return invoke("get_sound_enabled"); -} - -export async function setSoundEnabled(enabled: boolean): Promise { - return invoke("set_sound_enabled", { enabled }); -} - -// --- Events (typed) --- -export function onCountdownTick( - handler: (value: string) => void, -): Promise { - return listen("countdown_tick", (event) => - handler(String(event.payload ?? "")), - ); -} - -export function onShowNumber( - handler: (payload: ShowNumber) => void, -): Promise { - return listen("show_number", (event) => handler(event.payload)); -} - -export function onClearScreen( - handler: (payload: ClearScreen) => void, -): Promise { - return listen("clear_screen", (event) => handler(event.payload)); -} - -export function onAutoRepeatWaiting( - handler: (payload: AutoRepeatWaitingPayload) => void, -): Promise { - return listen("auto_repeat_waiting", (event) => - handler(event.payload), - ); -} - -export function onAutoRepeatTick( - handler: (payload: AutoRepeatTickPayload) => void, -): Promise { - return listen("auto_repeat_tick", (event) => - handler(event.payload), - ); -} - -export function onAppSettingsChanged( - handler: (payload: AppSettings) => void, -): Promise { - return listen("app_settings_changed", (event) => - handler(event.payload), - ); -} - -export function onSessionComplete( - handler: (payload: SessionComplete) => void, -): Promise { - return listen("session_complete", (event) => - handler(event.payload), - ); -} diff --git a/vitest.config.ts b/vitest.config.ts index cad9d3b..ca76f1a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,11 +12,7 @@ export default defineConfig({ enabled: true, reporter: ["text", "json"], include: ["src/**"], - exclude: [ - "src/wasm/pkg/**", - "src/__tests__/**", - "src/tauri.ts", - ], + exclude: ["src/wasm/pkg/**", "src/__tests__/**"], }, }, -}); +});