From 227fd5e9da867782d4f03816a5f5d37dc9fc30bf Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:30:54 +0100 Subject: [PATCH 1/7] Add bounded switch practice using the real scan engine (#826) --- src-tauri/build.rs | 3 + src-tauri/capabilities/main.json | 3 + .../autogenerated/begin_switch_practice.toml | 11 + .../autogenerated/end_switch_practice.toml | 11 + .../autogenerated/get_switch_practice.toml | 11 + src-tauri/src/lib.rs | 52 ++- src-tauri/src/remote_scan.rs | 8 + src-tauri/src/scanning_runtime.rs | 3 + src-tauri/src/switch_practice.rs | 396 ++++++++++++++++++ src/scanning/SwitchPractice.test.tsx | 53 +++ src/scanning/SwitchPractice.tsx | 83 ++++ src/settings/SwitchesSection.tsx | 2 + src/styles.css | 7 + 13 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src-tauri/permissions/autogenerated/begin_switch_practice.toml create mode 100644 src-tauri/permissions/autogenerated/end_switch_practice.toml create mode 100644 src-tauri/permissions/autogenerated/get_switch_practice.toml create mode 100644 src-tauri/src/switch_practice.rs create mode 100644 src/scanning/SwitchPractice.test.tsx create mode 100644 src/scanning/SwitchPractice.tsx diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 72f2c25a..089908e9 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -6,6 +6,9 @@ fn main() { let app_manifest = tauri_build::AppManifest::new().commands(&[ "get_app_state", "get_switches", + "begin_switch_practice", + "get_switch_practice", + "end_switch_practice", "get_remote_switches", "save_remote_switches", "save_switches", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 474820ed..893c2bee 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -7,6 +7,9 @@ "core:default", "allow-get-app-state", "allow-get-switches", + "allow-begin-switch-practice", + "allow-get-switch-practice", + "allow-end-switch-practice", "allow-get-remote-switches", "allow-save-remote-switches", "allow-save-switches", diff --git a/src-tauri/permissions/autogenerated/begin_switch_practice.toml b/src-tauri/permissions/autogenerated/begin_switch_practice.toml new file mode 100644 index 00000000..747cea9b --- /dev/null +++ b/src-tauri/permissions/autogenerated/begin_switch_practice.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-begin-switch-practice" +description = "Enables the begin_switch_practice command without any pre-configured scope." +commands.allow = ["begin_switch_practice"] + +[[permission]] +identifier = "deny-begin-switch-practice" +description = "Denies the begin_switch_practice command without any pre-configured scope." +commands.deny = ["begin_switch_practice"] diff --git a/src-tauri/permissions/autogenerated/end_switch_practice.toml b/src-tauri/permissions/autogenerated/end_switch_practice.toml new file mode 100644 index 00000000..304a4662 --- /dev/null +++ b/src-tauri/permissions/autogenerated/end_switch_practice.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-end-switch-practice" +description = "Enables the end_switch_practice command without any pre-configured scope." +commands.allow = ["end_switch_practice"] + +[[permission]] +identifier = "deny-end-switch-practice" +description = "Denies the end_switch_practice command without any pre-configured scope." +commands.deny = ["end_switch_practice"] diff --git a/src-tauri/permissions/autogenerated/get_switch_practice.toml b/src-tauri/permissions/autogenerated/get_switch_practice.toml new file mode 100644 index 00000000..bb7fe632 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_switch_practice.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-switch-practice" +description = "Enables the get_switch_practice command without any pre-configured scope." +commands.allow = ["get_switch_practice"] + +[[permission]] +identifier = "deny-get-switch-practice" +description = "Denies the get_switch_practice command without any pre-configured scope." +commands.deny = ["get_switch_practice"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 132f3ed9..ebadb099 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ mod state; mod storage; mod switch_gestures; mod switch_input; +mod switch_practice; mod switch_runtime; mod switches; mod telemetry; @@ -1272,8 +1273,38 @@ fn save_remote_switches( config: remote_scan::Config, ) -> Result { require_main(&window)?; + if switch_practice::active(&app) { + return Err("Finish practice before changing switches.".into()); + } remote_scan::save(&app, config) } +#[tauri::command] +fn begin_switch_practice( + window: tauri::WebviewWindow, + app: AppHandle, + remote: bool, +) -> Result { + require_main(&window)?; + if !main_window_focused(&window)? { + return Err("Focus Switchify before testing switches.".into()); + } + switch_practice::begin(&app, remote) +} +#[tauri::command] +fn get_switch_practice( + window: tauri::WebviewWindow, + app: AppHandle, +) -> Result { + require_main(&window)?; + Ok(switch_practice::view(&app)) +} +#[tauri::command] +fn end_switch_practice(window: tauri::WebviewWindow, app: AppHandle) -> Result<(), String> { + require_main(&window)?; + switch_practice::end(&app); + Ok(()) +} + #[tauri::command] fn get_switches( window: tauri::WebviewWindow, @@ -1289,6 +1320,9 @@ fn save_switches( settings: switches::Settings, ) -> Result { require_main(&window)?; + if switch_practice::active(&app) { + return Err("Finish practice before changing switches.".into()); + } // Sync commands run on the main thread, which pausing the overlay needs. point_scan_runtime::pause(&app); let view = app @@ -1331,6 +1365,9 @@ fn set_switch_keyboard_entry( require_main(&window)?; let switches = app.state::(); if active { + if switch_practice::active(&app) { + return Err("Finish practice before typing with the keyboard.".into()); + } if !main_window_focused(&window)? { return Err("Focus Switchify PC before typing with the keyboard.".into()); } @@ -1362,7 +1399,12 @@ async fn configure_point_scan( let (tx, rx) = tokio::sync::oneshot::channel(); let handle = app.clone(); app.run_on_main_thread(move || { - let _ = tx.send(point_scan_runtime::configure(&handle, config)); + let result = if switch_practice::active(&handle) { + Err("Finish practice before changing scanning settings.".into()) + } else { + point_scan_runtime::configure(&handle, config) + }; + let _ = tx.send(result); }) .map_err(|e| e.to_string())?; rx.await @@ -1376,6 +1418,9 @@ fn point_scan_ready(app: &AppHandle) -> Result<(), String> { "Typing with the keyboard. Resume switch control in Switches when finished.".into(), ); } + if switch_practice::active(app) { + return Err("Finish switch practice before scanning or editing switches.".into()); + } let state = app.state::().snapshot(); if state.bluetooth == state::BluetoothState::Connected && !remote_scan::active(app) { return Err("Local scanning pauses while Android is connected.".into()); @@ -1439,6 +1484,7 @@ pub fn run() { .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) .setup(move |app| { + app.manage(switch_practice::Controller::default()); switch_runtime::install(app.handle()); remote_scan::install(app.handle()); point_scan_runtime::install(app.handle()); @@ -1526,12 +1572,16 @@ pub fn run() { } } if let tauri::WindowEvent::CloseRequested { api, .. } = event { + switch_practice::end(window.app_handle()); api.prevent_close(); request_profile_exit(window.app_handle(), ProfileExitAction::Hide); } }) .invoke_handler(tauri::generate_handler![ get_switches, + begin_switch_practice, + get_switch_practice, + end_switch_practice, get_remote_switches, save_remote_switches, save_switches, diff --git a/src-tauri/src/remote_scan.rs b/src-tauri/src/remote_scan.rs index 001d2d3d..035382b8 100644 --- a/src-tauri/src/remote_scan.rs +++ b/src-tauri/src/remote_scan.rs @@ -378,6 +378,9 @@ pub fn input_available(session_active: bool, cleanup_required: bool) -> bool { !session_active && !cleanup_required } pub fn allow_direct(app: &AppHandle) -> Result<(), String> { + if crate::switch_practice::active(app) { + return Err("Finish switch practice before using other PC controls.".into()); + } let c = app.state::(); let d = c.data.lock().unwrap_or_else(|p| p.into_inner()); if !input_available(d.session.is_some(), d.cleanup_required) { @@ -418,6 +421,11 @@ pub fn route( command: &str, payload: &Value, ) -> Option> { + if command == "switch.session.start" && crate::switch_practice::active(app) { + return Some(Err( + "Exit switch practice before starting forwarding.".into() + )); + } let scanning_start = command == "switch.session.start" && payload["profileId"] == PROFILE_ID; if command == "switch.session.start" { crate::point_scan_runtime::pause(app); diff --git a/src-tauri/src/scanning_runtime.rs b/src-tauri/src/scanning_runtime.rs index 6d4fa9c0..46c55afc 100644 --- a/src-tauri/src/scanning_runtime.rs +++ b/src-tauri/src/scanning_runtime.rs @@ -621,6 +621,9 @@ fn render( Ok(()) } fn tick(app: &AppHandle) { + if crate::switch_practice::tick(app) { + return; + } let c = app.state::>(); let remote = crate::remote_scan::poll(app); let (mut events, local_now, _) = app.state::().poll(app); diff --git a/src-tauri/src/switch_practice.rs b/src-tauri/src/switch_practice.rs new file mode 100644 index 00000000..bd060fe5 --- /dev/null +++ b/src-tauri/src/switch_practice.rs @@ -0,0 +1,396 @@ +//! Bounded switch practice: real gestures and scan engine, no native execution. +use crate::{ + point_scan, + point_workflow::Workflow, + scanning::{Action, Rect, Session, Technique}, + switch_gestures::Gestures, + switch_input::Event, + switch_runtime, + switches::Settings, +}; +use serde::Serialize; +use std::{sync::Mutex, time::Instant}; +use tauri::{AppHandle, Manager}; + +#[derive(Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct View { + pub active: bool, + pub source: String, + pub message: String, + pub input: String, + pub action: Option, + pub completed: u32, + pub rectangles: Vec<[f64; 4]>, + pub tiles: Vec, + pub label: String, +} +#[derive(Clone, Serialize)] +pub struct Tile { + pub rect: [f64; 4], + pub text: String, + pub selected: bool, +} +fn rect(r: Rect) -> [f64; 4] { + [r.x, r.y, r.width, r.height] +} +struct Practice { + view: View, + engine: Session, + gestures: Gestures, + settings: Settings, + generation: u64, + remote: bool, + started: Instant, + last_tick: Instant, + held_since: Option, +} +impl Practice { + fn new( + config: point_scan::Config, + settings: Settings, + generation: u64, + remote: bool, + ) -> Result { + let screen = Rect { + x: 0., + y: 0., + width: 1280., + height: 720., + }; + Ok(Self { + view: View { active: true, source: if remote { "Remote" } else { "Local keyboard" }.into(), message: "Press and release Select to start. Next/Previous move an active scan, not keyboard focus.".into(), ..View::default() }, + engine: Session::new(Workflow::new(config.point(), screen, 1.)?, config.automatic), + gestures: Gestures::default(), settings, generation, remote, + started: Instant::now(), last_tick: Instant::now(), held_since: None, + }) + } + fn edge(&mut self, id: &str, pressed: bool, now: u64) { + let Some(binding) = self.settings.bindings.iter().find(|b| b.id == id) else { + return; + }; + self.view.input = binding.name.clone(); + if pressed { + self.held_since.get_or_insert(now); + self.gestures.pressed_for_scan( + id, + now, + &self.settings, + self.engine.technique.auto_selecting(), + ); + self.view.message = format!( + "{} received. Release for {} or hold for another action.", + binding.name, + binding.press_action.label() + ); + } else { + if let Some(action) = self.gestures.released(id, now) { + self.action(action); + } + if !self.gestures.held() { + self.held_since = None; + } + } + } + fn action(&mut self, action: Action) { + self.view.action = Some(action); + if matches!(action, Action::Stop | Action::Cancel) { + self.view.active = false; + return; + } + let was_idle = !self.engine.active(); + if self.engine.action(action).is_some() { + self.complete(); + } else if was_idle && matches!(action, Action::Next | Action::Back) { + self.view.message = + "Input received. Select starts a scan; Next/Previous only move an active scan." + .into(); + } else { + self.view.message = format!( + "{} received. This practice cannot click or type outside this box.", + action.label() + ); + } + } + fn complete(&mut self) { + // Intentionally discard every request, including keyboard, drag and settings requests. + self.view.completed += 1; + self.view.message = "Practice action completed safely. Nothing was sent to another application. Select starts again.".into(); + self.engine.reset(); + } + fn tick(&mut self, now: u64) { + if self.started.elapsed().as_secs() >= 120 + || self + .held_since + .is_some_and(|s| now.saturating_sub(s) >= self.settings.escape_ms()) + { + self.view.active = false; + return; + } + if let Some(prompt) = self.gestures.prompt(now) { + self.view.message = format!( + "{}: release for {}", + prompt.switch_name, + prompt.action.label() + ); + } + self.engine.tick( + self.last_tick.elapsed().as_millis().min(250) as u64, + self.gestures.held(), + ); + self.last_tick = Instant::now(); + if self.engine.take_selection().is_some() { + self.complete(); + } + let frame = self.engine.frame(); + self.view.rectangles = frame + .rectangles() + .into_iter() + .map(|r| rect(r.rect)) + .collect(); + self.view.tiles = frame + .tiles + .into_iter() + .map(|t| Tile { + rect: rect(t.rect), + text: t.text, + selected: t.selected, + }) + .collect(); + self.view.label = frame.label.map_or_else(String::new, |l| l.text); + } +} +#[derive(Default)] +pub struct Controller { + session: Mutex>, + last: Mutex, +} +pub fn active(app: &AppHandle) -> bool { + app.try_state::().is_some_and(|c| { + c.session + .lock() + .unwrap_or_else(|p| p.into_inner()) + .is_some() + }) +} +pub fn view(app: &AppHandle) -> View { + let c = app.state::(); + let session = c.session.lock().unwrap_or_else(|p| p.into_inner()); + session.as_ref().map_or_else( + || c.last.lock().unwrap_or_else(|p| p.into_inner()).clone(), + |s| s.view.clone(), + ) +} +pub fn stop(app: &AppHandle, message: &str) { + let Some(c) = app.try_state::() else { + return; + }; + let mut lock = c.session.lock().unwrap_or_else(|p| p.into_inner()); + let Some(s) = lock.as_mut() else { + return; + }; + s.gestures.cancel(); + s.view.active = false; + s.view.message = message.into(); + drop(lock); + crate::remote_scan::cancel(app); + crate::point_scan_runtime::pause(app); +} +pub fn end(app: &AppHandle) { + stop(app, "Practice finished."); + if let Some(c) = app.try_state::() { + if let Some(s) = c.session.lock().unwrap_or_else(|p| p.into_inner()).take() { + *c.last.lock().unwrap_or_else(|p| p.into_inner()) = s.view; + } + } +} +pub fn begin(app: &AppHandle, remote: bool) -> Result { + if active(app) { + if view(app).active { + return Err("Finish the current practice first.".into()); + } + end(app); + } + let switches = app.state::(); + if switches.view().capture.active { + return Err("Finish learning before testing switches.".into()); + } + crate::point_scan_prepare(app)?; + let config = app + .state::() + .view() + .config; + // Clear stale edges before the practice owns input. + let remote_state = crate::remote_scan::poll(app); + let (settings, generation) = if remote { + let Some((g, s, _, _)) = remote_state else { + return Err( + "No remote forwarding session. Connect Android and start switch forwarding first." + .into(), + ); + }; + (s, g) + } else { + if remote_state.is_some() { + return Err("Stop remote forwarding before testing local switches.".into()); + } + (switches.settings(), 0) + }; + let mut session = Practice::new(config, settings, generation, remote)?; + crate::point_scan_runtime::pause(app); + let result = if remote { + switches.enable_escape() + } else { + switches.enable(true) + }; + result?; + if !remote { + session.generation = switches.generation(); + } + *app.state::() + .session + .lock() + .unwrap_or_else(|p| p.into_inner()) = Some(session); + Ok(view(app)) +} +/// Own the whole tick while testing, so no test edge reaches the native executor. +pub fn tick(app: &AppHandle) -> bool { + if !active(app) { + return false; + } + if !view(app).active { + return true; + } + if !app + .get_webview_window("main") + .is_some_and(|w| w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false)) + { + stop(app, "Practice stopped because Switchify lost focus."); + return true; + } + let remote = crate::remote_scan::poll(app); + let (events, local_now, _) = app.state::().poll(app); + let c = app.state::(); + let mut lock = c.session.lock().unwrap_or_else(|p| p.into_inner()); + let Some(s) = lock.as_mut() else { + return true; + }; + let mut ended = false; + for event in events { + match event { + Event::Stopped { .. } => ended = true, + Event::Switch { + generation, + switch_id, + action, + monotonic_ms, + } if !s.remote && generation == s.generation => s.edge( + &switch_id, + action == crate::switch_input::Action::Pressed, + monotonic_ms, + ), + _ => {} + } + } + let now = if s.remote { + if let Some((generation, _, edges, now)) = remote { + if generation != s.generation { + ended = true; + } else { + for edge in edges { + match edge { + crate::remote_scan::Edge::Down(id) => s.edge(&id.to_string(), true, now), + crate::remote_scan::Edge::Up(id) => s.edge(&id.to_string(), false, now), + crate::remote_scan::Edge::Reset => ended = true, + } + } + } + now + } else { + ended = true; + local_now + } + } else { + if remote.is_some() { + ended = true; + } + local_now + }; + if !ended { + s.tick(now); + } + ended |= !s.view.active; + drop(lock); + if ended { + stop(app, "Practice stopped. Exit or start another test. Remote forwarding must be started again after testing."); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + fn practice(mode: point_scan::Mode) -> Practice { + let config = point_scan::Config { + mode, + automatic: false, + ..Default::default() + }; + let settings = Settings { + bindings: vec![crate::switches::Binding { + id: "one".into(), + name: "Blue".into(), + key: "Space".into(), + press_action: Action::Select, + hold_actions: vec![Action::Next], + }], + ..Default::default() + }; + Practice::new(config, settings, 7, false).unwrap() + } + #[test] + fn input_feedback_and_hold_actions_use_saved_binding() { + let mut p = practice(point_scan::Mode::Line); + p.edge("one", true, 0); + assert_eq!(p.view.input, "Blue"); + p.edge("one", false, 1001); + assert_eq!(p.view.action, Some(Action::Next)); + assert!(!p.engine.active()); + assert!(p.view.message.contains("Select starts")); + p.edge("one", true, 1200); + p.edge("one", false, 1300); + assert!(p.engine.active()); + } + #[test] + fn line_and_grid_selections_are_discarded_not_executed() { + for mode in [point_scan::Mode::Line, point_scan::Mode::Grid] { + let mut p = practice(mode); + for _ in 0..12 { + p.action(Action::Select); + if p.view.completed > 0 { + break; + } + } + assert!(p.view.completed > 0); + assert!(!p.engine.active()); + } + } + #[test] + fn stop_and_emergency_hold_end_practice() { + let mut p = practice(point_scan::Mode::Line); + p.action(Action::Stop); + assert!(!p.view.active); + let mut p = practice(point_scan::Mode::Line); + p.edge("one", true, 0); + p.tick(p.settings.escape_ms()); + assert!(!p.view.active); + } + #[test] + fn unassigned_input_and_release_without_press_do_nothing() { + let mut p = practice(point_scan::Mode::Line); + p.edge("unknown", true, 0); + p.edge("one", false, 1); + assert!(p.view.action.is_none()); + assert!(!p.engine.active()); + } +} diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx new file mode 100644 index 00000000..7271daab --- /dev/null +++ b/src/scanning/SwitchPractice.test.tsx @@ -0,0 +1,53 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SwitchPractice } from "./SwitchPractice"; +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); +const view = { active: true, source: "Local keyboard", message: "Press Select", input: "", action: null, completed: 0, rectangles: [], tiles: [], label: "" }; +beforeEach(() => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: {} }); + invoke.mockReset(); invoke.mockImplementation(async (command: string) => command === "end_switch_practice" ? undefined : view); +}); +afterEach(() => { cleanup(); delete (window as unknown as Record).__TAURI_INTERNALS__; }); +describe("safe switch practice", () => { + it("uses saved physical input via the backend, then explicitly releases ownership on exit", async () => { + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + const dialog = await screen.findByRole("dialog", { name: "Safe switch practice" }); + expect(invoke).toHaveBeenCalledWith("begin_switch_practice", { remote: false }); + expect(dialog).toHaveFocus(); + expect(screen.getByRole("button", { name: "Start another test" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(invoke).toHaveBeenCalledWith("end_switch_practice"); + expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus(); + }); + it("shows disconnected remote errors rather than reporting successful input", async () => { + invoke.mockRejectedValueOnce(new Error("No remote forwarding session")); + render(); fireEvent.change(screen.getByLabelText("Test input"), { target: { value: "remote" } }); + fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + expect(await screen.findByRole("alert")).toHaveTextContent("No remote forwarding session"); + expect(invoke).toHaveBeenCalledWith("begin_switch_practice", { remote: true }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("retains the dialog and recovery when releasing capture fails", async () => { + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + invoke.mockImplementation(async (command: string) => { if (command === "end_switch_practice") throw new Error("Release failed"); return view; }); + fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Release failed"); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + it("ends a late successful start after its component has unmounted", async () => { + let resolve!: (v: typeof view) => void; + invoke.mockImplementation((command: string) => command === "begin_switch_practice" ? new Promise(r => { resolve = r; }) : Promise.resolve()); + const result = render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); result.unmount(); + await act(async () => { resolve(view); }); + expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(2); + }); + it("does not allow starting with unsaved settings or without a native backend", () => { + const { rerender } = render(); + expect(screen.getByRole("button", { name: "Test switches" })).toBeDisabled(); + delete (window as unknown as Record).__TAURI_INTERNALS__; + rerender(); expect(screen.getByRole("button", { name: "Test switches" })).toBeDisabled(); + }); +}); diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx new file mode 100644 index 00000000..90efa2ac --- /dev/null +++ b/src/scanning/SwitchPractice.tsx @@ -0,0 +1,83 @@ +import { createPortal } from "react-dom"; +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { actions, type SwitchAction } from "./useSwitches"; + +type View = { active: boolean; source: string; message: string; input: string; action: SwitchAction | null; completed: number; rectangles: number[][]; tiles: { rect: number[]; text: string; selected: boolean }[]; label: string }; +export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { + const [open, setOpen] = useState(false); + const [view, setView] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [remote, setRemote] = useState(false); + const dialog = useRef(null); + const trigger = useRef(null); + const request = useRef(0); + const restoreFocus = useRef(false); + useEffect(() => { if (!open && !pending && restoreFocus.current) { restoreFocus.current = false; trigger.current?.focus(); } }, [open, pending]); + useEffect(() => () => { request.current++; if ("__TAURI_INTERNALS__" in window) void invoke("end_switch_practice").catch(() => {}); }, []); + useEffect(() => { + if (!open) return; + const background = [...document.body.children].filter((element) => !element.contains(dialog.current)); + const previous = background.map(element => element.hasAttribute("inert")); + background.forEach(element => element.setAttribute("inert", "")); + dialog.current?.focus(); + let alive = true; + let polling = false; + const timer = window.setInterval(() => { + if (polling) return; + polling = true; + void invoke("get_switch_practice").then(v => { if (alive) setView(v); }).catch(e => { + if (alive) setError(String(e)); + void invoke("end_switch_practice").catch(() => {}); + }).finally(() => { polling = false; }); + }, 100); + return () => { alive = false; window.clearInterval(timer); background.forEach((element, i) => { if (!previous[i]) element.removeAttribute("inert"); }); }; + }, [open]); + const begin = async () => { + const id = ++request.current; + setError(null); setPending(true); + try { + const next = await invoke("begin_switch_practice", { remote }); + if (id !== request.current) { await invoke("end_switch_practice"); return; } + setView(next); setOpen(true); + } catch (e) { if (id === request.current) setError(String(e)); } + finally { if (id === request.current) setPending(false); } + }; + const close = async () => { + request.current++; setPending(true); + try { await invoke("end_switch_practice"); restoreFocus.current = true; setOpen(false); setView(null); } + catch (e) { setError(String(e)); } + finally { setPending(false); } + }; + return
+

Test your saved switches safely. Select starts a scan; Next and Previous move an active scan, not Tab or Shift+Tab focus. Automatic scanning moves for you; auto-selection can choose a point after a delay. Adjust these in Scanning.

+ + + {!open && error &&

{error}

} + {open && createPortal(
{ + e.stopPropagation(); + if (e.key === "Escape") { e.preventDefault(); void close(); } + if (e.key === "Tab") { + const buttons = [...(dialog.current?.querySelectorAll("button:not(:disabled)") ?? [])]; + if (buttons.length) { e.preventDefault(); const i = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(i + (e.shiftKey ? buttons.length - 1 : 1)) % buttons.length].focus(); } + } + }}> +

Safe switch practice

+

Press Select to start, use Next/Previous to move, then Select to choose a point and an action. Nothing here clicks, types, or changes settings. Practice uses your saved line/grid and timing settings.

+

Press Escape, use Stop scanning, or hold any switch through its emergency stop to finish. Practice also stops on loss of focus, disconnect, or after two minutes. Remote forwarding stops when practice ends.

+

{view?.message ?? "Starting practice…"}

+

{view?.source} · {view?.input ? `Last input: ${view.input}` : "No input received yet"}{view?.action && ` · ${actions[view.action]}`} · Completed: {view?.completed ?? 0}

+ + + + Choose any point to practise + {view?.rectangles.map(([x,y,width,height],i) => )} + {view?.tiles.map(({rect:[x,y,width,height],text,selected},i) => {text})} + +

{view?.label}

+ {error &&

{error}

} +
+
, document.body)} +
; +} diff --git a/src/settings/SwitchesSection.tsx b/src/settings/SwitchesSection.tsx index 930a4325..d21f8bc3 100644 --- a/src/settings/SwitchesSection.tsx +++ b/src/settings/SwitchesSection.tsx @@ -1,3 +1,4 @@ +import { SwitchPractice } from "../scanning/SwitchPractice"; import { useEffect, useId, useRef, useState } from "react"; import { ChevronDown, ChevronUp, Keyboard, Smartphone, Trash2, X } from "lucide-react"; import { useRemoteSwitches, type RemoteSlot } from "../scanning/useRemoteSwitches"; @@ -616,6 +617,7 @@ export function SwitchesSection({ controller, onDraftChange, suspended = false } title="Switches" description="Add keyboard switches or switches forwarded from Switchify Remote, and choose what each one does." > + Date: Mon, 21 Sep 2026 15:33:10 +0100 Subject: [PATCH 2/7] Retain practice ownership when feedback polling fails --- src/scanning/SwitchPractice.test.tsx | 30 ++++++++++++++++++++++++++++ src/scanning/SwitchPractice.tsx | 6 ++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx index 7271daab..5d1407dc 100644 --- a/src/scanning/SwitchPractice.test.tsx +++ b/src/scanning/SwitchPractice.test.tsx @@ -44,6 +44,36 @@ describe("safe switch practice", () => { await act(async () => { resolve(view); }); expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(2); }); + it("retains safe ownership when a feedback poll fails until explicit exit", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "get_switch_practice") throw new Error("Feedback failed"); + return command === "end_switch_practice" ? undefined : view; + }); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + expect(await screen.findByRole("alert")).toHaveTextContent("Use Exit practice"); + expect(invoke).not.toHaveBeenCalledWith("end_switch_practice"); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(invoke).toHaveBeenCalledWith("end_switch_practice"); + }); + it("ignores a stale failed poll after exit and reopening", async () => { + let reject!: (reason: Error) => void; + let first = true; + invoke.mockImplementation((command: string) => { + if (command === "get_switch_practice" && first) { first = false; return new Promise((_, r) => { reject = r; }); } + return Promise.resolve(command === "end_switch_practice" ? undefined : view); + }); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); await waitFor(() => expect(reject).toBeDefined()); + fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Test switches" })); await screen.findByRole("dialog"); + await act(async () => { reject(new Error("Old feedback failure")); }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(1); + }); it("does not allow starting with unsaved settings or without a native backend", () => { const { rerender } = render(); expect(screen.getByRole("button", { name: "Test switches" })).toBeDisabled(); diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx index 90efa2ac..c62ceb29 100644 --- a/src/scanning/SwitchPractice.tsx +++ b/src/scanning/SwitchPractice.tsx @@ -28,8 +28,10 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { if (polling) return; polling = true; void invoke("get_switch_practice").then(v => { if (alive) setView(v); }).catch(e => { - if (alive) setError(String(e)); - void invoke("end_switch_practice").catch(() => {}); + if (alive) { + window.clearInterval(timer); + setError(`Practice feedback is unavailable. Use Exit practice before retrying. ${String(e)}`); + } }).finally(() => { polling = false; }); }, 100); return () => { alive = false; window.clearInterval(timer); background.forEach((element, i) => { if (!previous[i]) element.removeAttribute("inert"); }); }; From c9d3b7e0f778810fe91dd5a74aebc9dfd3520975 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:34:01 +0100 Subject: [PATCH 3/7] Acknowledge practice stops before closing the safe modal --- src/scanning/SwitchPractice.test.tsx | 8 ++++++++ src/scanning/SwitchPractice.tsx | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx index 5d1407dc..4e60231f 100644 --- a/src/scanning/SwitchPractice.test.tsx +++ b/src/scanning/SwitchPractice.test.tsx @@ -44,6 +44,14 @@ describe("safe switch practice", () => { await act(async () => { resolve(view); }); expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(2); }); + it("acknowledges a switch emergency stop and closes without requiring mouse assistance", async () => { + invoke.mockImplementation(async (command: string) => command === "get_switch_practice" ? { ...view, active: false, message: "Practice stopped" } : command === "end_switch_practice" ? undefined : view); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(invoke).toHaveBeenCalledWith("end_switch_practice"); + expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus(); + }); it("retains safe ownership when a feedback poll fails until explicit exit", async () => { invoke.mockImplementation(async (command: string) => { if (command === "get_switch_practice") throw new Error("Feedback failed"); diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx index c62ceb29..67c77628 100644 --- a/src/scanning/SwitchPractice.tsx +++ b/src/scanning/SwitchPractice.tsx @@ -27,7 +27,7 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { const timer = window.setInterval(() => { if (polling) return; polling = true; - void invoke("get_switch_practice").then(v => { if (alive) setView(v); }).catch(e => { + void invoke("get_switch_practice").then(v => { if (alive) { setView(v); if (!v.active) { window.clearInterval(timer); void close(); } } }).catch(e => { if (alive) { window.clearInterval(timer); setError(`Practice feedback is unavailable. Use Exit practice before retrying. ${String(e)}`); @@ -67,7 +67,7 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { }}>

Safe switch practice

Press Select to start, use Next/Previous to move, then Select to choose a point and an action. Nothing here clicks, types, or changes settings. Practice uses your saved line/grid and timing settings.

-

Press Escape, use Stop scanning, or hold any switch through its emergency stop to finish. Practice also stops on loss of focus, disconnect, or after two minutes. Remote forwarding stops when practice ends.

+

Press Escape, use Stop scanning, or hold any switch through its emergency stop to finish. The practice window closes when testing stops, including on loss of focus, disconnect, or after two minutes. Remote forwarding stops when practice ends.

{view?.message ?? "Starting practice…"}

{view?.source} · {view?.input ? `Last input: ${view.input}` : "No input received yet"}{view?.action && ` · ${actions[view.action]}`} · Completed: {view?.completed ?? 0}

From bfef22ebf0d40a0d910b314d1f868f2b10993875 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:35:07 +0100 Subject: [PATCH 4/7] Clarify remote practice prerequisites and exit flow --- src-tauri/src/switch_practice.rs | 6 ++++++ src/scanning/SwitchPractice.test.tsx | 2 +- src/scanning/SwitchPractice.tsx | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/switch_practice.rs b/src-tauri/src/switch_practice.rs index bd060fe5..0afbc50b 100644 --- a/src-tauri/src/switch_practice.rs +++ b/src-tauri/src/switch_practice.rs @@ -215,6 +215,12 @@ pub fn begin(app: &AppHandle, remote: bool) -> Result { if switches.view().capture.active { return Err("Finish learning before testing switches.".into()); } + if remote && !crate::remote_scan::active(app) { + return Err( + "No remote forwarding session. Connect Android and start switch forwarding first." + .into(), + ); + } crate::point_scan_prepare(app)?; let config = app .state::() diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx index 4e60231f..c6b3ede7 100644 --- a/src/scanning/SwitchPractice.test.tsx +++ b/src/scanning/SwitchPractice.test.tsx @@ -15,7 +15,7 @@ describe("safe switch practice", () => { const dialog = await screen.findByRole("dialog", { name: "Safe switch practice" }); expect(invoke).toHaveBeenCalledWith("begin_switch_practice", { remote: false }); expect(dialog).toHaveFocus(); - expect(screen.getByRole("button", { name: "Start another test" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Start another test" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(invoke).toHaveBeenCalledWith("end_switch_practice"); diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx index 67c77628..f3a04423 100644 --- a/src/scanning/SwitchPractice.tsx +++ b/src/scanning/SwitchPractice.tsx @@ -79,7 +79,7 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) {

{view?.label}

{error &&

{error}

} -
+
, document.body)} ; } From a356844a0fb63e0d582b4fd62711da51237d5358 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:36:44 +0100 Subject: [PATCH 5/7] Close safely on feedback failure without stranding switch users --- src/scanning/SwitchPractice.test.tsx | 7 ++----- src/scanning/SwitchPractice.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx index c6b3ede7..dcb7b5cb 100644 --- a/src/scanning/SwitchPractice.test.tsx +++ b/src/scanning/SwitchPractice.test.tsx @@ -52,19 +52,16 @@ describe("safe switch practice", () => { expect(invoke).toHaveBeenCalledWith("end_switch_practice"); expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus(); }); - it("retains safe ownership when a feedback poll fails until explicit exit", async () => { + it("closes after confirmed end when feedback fails, without requiring mouse assistance", async () => { invoke.mockImplementation(async (command: string) => { if (command === "get_switch_practice") throw new Error("Feedback failed"); return command === "end_switch_practice" ? undefined : view; }); render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); await screen.findByRole("dialog"); - expect(await screen.findByRole("alert")).toHaveTextContent("Use Exit practice"); - expect(invoke).not.toHaveBeenCalledWith("end_switch_practice"); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Exit practice" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(invoke).toHaveBeenCalledWith("end_switch_practice"); + expect(screen.getByRole("button", { name: "Test switches" })).toHaveFocus(); }); it("ignores a stale failed poll after exit and reopening", async () => { let reject!: (reason: Error) => void; diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx index f3a04423..65563c5f 100644 --- a/src/scanning/SwitchPractice.tsx +++ b/src/scanning/SwitchPractice.tsx @@ -27,10 +27,10 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { const timer = window.setInterval(() => { if (polling) return; polling = true; - void invoke("get_switch_practice").then(v => { if (alive) { setView(v); if (!v.active) { window.clearInterval(timer); void close(); } } }).catch(e => { + void invoke("get_switch_practice").then(async v => { if (alive) { setView(v); if (!v.active) { await close(); } } }).catch(async e => { if (alive) { - window.clearInterval(timer); - setError(`Practice feedback is unavailable. Use Exit practice before retrying. ${String(e)}`); + setError(`Practice feedback is unavailable. Ending the test safely. ${String(e)}`); + await close(); } }).finally(() => { polling = false; }); }, 100); @@ -47,10 +47,10 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) { finally { if (id === request.current) setPending(false); } }; const close = async () => { - request.current++; setPending(true); - try { await invoke("end_switch_practice"); restoreFocus.current = true; setOpen(false); setView(null); } - catch (e) { setError(String(e)); } - finally { setPending(false); } + const id = ++request.current; setPending(true); + try { await invoke("end_switch_practice"); if (id === request.current) { restoreFocus.current = true; setOpen(false); setView(null); } } + catch (e) { if (id === request.current) setError(String(e)); } + finally { if (id === request.current) setPending(false); } }; return

Test your saved switches safely. Select starts a scan; Next and Previous move an active scan, not Tab or Shift+Tab focus. Automatic scanning moves for you; auto-selection can choose a point after a delay. Adjust these in Scanning.

From ed094f483ca956c665938777690dc9d199de4369 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:40:06 +0100 Subject: [PATCH 6/7] Keep practice menu labels readable at small window sizes --- src/scanning/SwitchPractice.test.tsx | 8 ++++++++ src/scanning/SwitchPractice.tsx | 4 ++-- src/styles.css | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx index dcb7b5cb..2c9df9f3 100644 --- a/src/scanning/SwitchPractice.test.tsx +++ b/src/scanning/SwitchPractice.test.tsx @@ -79,6 +79,14 @@ describe("safe switch practice", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(1); }); + it("exposes readable menu labels outside the scaled preview", async () => { + invoke.mockResolvedValue({ ...view, label: "Choose an action", tiles: [ { rect: [0,0,100,50], text: "Left click", selected: true }, { rect: [100,0,100,50], text: "Right click", selected: false } ] }); + render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); + await screen.findByRole("dialog"); + expect(screen.getByRole("list", { name: "Practice action menu" })).toHaveTextContent("Left clickRight click"); + expect(screen.getByText("Highlighted: Left click")).toBeInTheDocument(); + expect(screen.queryByText("Choose any point to practise")).not.toBeInTheDocument(); + }); it("does not allow starting with unsaved settings or without a native backend", () => { const { rerender } = render(); expect(screen.getByRole("button", { name: "Test switches" })).toBeDisabled(); diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx index 65563c5f..6bab5c57 100644 --- a/src/scanning/SwitchPractice.tsx +++ b/src/scanning/SwitchPractice.tsx @@ -72,12 +72,12 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) {

{view?.source} · {view?.input ? `Last input: ${view.input}` : "No input received yet"}{view?.action && ` · ${actions[view.action]}`} · Completed: {view?.completed ?? 0}

- - Choose any point to practise + {!view?.tiles.length && <>Choose any point to practise} {view?.rectangles.map(([x,y,width,height],i) => )} {view?.tiles.map(({rect:[x,y,width,height],text,selected},i) => {text})}

{view?.label}

+ {!!view?.tiles.length && <>

Highlighted: {view.tiles.filter(tile => tile.selected).map(tile => tile.text).join(", ") || "No action selected"}

    {view.tiles.map((tile,i) =>
  • {tile.text}
  • )}
} {error &&

{error}

}
, document.body)} diff --git a/src/styles.css b/src/styles.css index 8dc42a40..3574c44e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -448,3 +448,7 @@ button:disabled { cursor: default; opacity: 0.55; } .switch-practice-dialog { background: var(--surface); color: var(--text); border-radius: 12px; padding: 24px; width: min(900px, 95vw); max-height: 92vh; overflow-y: auto; } .switch-practice-preview { display: block; width: 100%; max-height: 45vh; } .switch-practice-buttons { display: flex; gap: 12px; justify-content: flex-end; } + +.switch-practice-menu { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; font-size: 15px; } +.switch-practice-menu li { padding: 8px; border: 1px solid var(--border); border-radius: 6px; } +.switch-practice-menu li[data-selected="true"] { background: var(--segment-selected); color: var(--on-brand-container); font-weight: 700; border-color: var(--brand); } From 106a4d8a54c576e13612905e9a0831889249c6f9 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 21 Sep 2026 15:41:05 +0100 Subject: [PATCH 7/7] Use practice-area naming compatible with identity checks --- src/scanning/SwitchPractice.test.tsx | 2 +- src/scanning/SwitchPractice.tsx | 2 +- src/styles.css | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scanning/SwitchPractice.test.tsx b/src/scanning/SwitchPractice.test.tsx index 2c9df9f3..5ffe0ec9 100644 --- a/src/scanning/SwitchPractice.test.tsx +++ b/src/scanning/SwitchPractice.test.tsx @@ -79,7 +79,7 @@ describe("safe switch practice", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(invoke.mock.calls.filter(c => c[0] === "end_switch_practice")).toHaveLength(1); }); - it("exposes readable menu labels outside the scaled preview", async () => { + it("exposes readable menu labels outside the scaled scan area", async () => { invoke.mockResolvedValue({ ...view, label: "Choose an action", tiles: [ { rect: [0,0,100,50], text: "Left click", selected: true }, { rect: [100,0,100,50], text: "Right click", selected: false } ] }); render(); fireEvent.click(screen.getByRole("button", { name: "Test switches" })); await screen.findByRole("dialog"); diff --git a/src/scanning/SwitchPractice.tsx b/src/scanning/SwitchPractice.tsx index 6bab5c57..bb12fa6f 100644 --- a/src/scanning/SwitchPractice.tsx +++ b/src/scanning/SwitchPractice.tsx @@ -70,7 +70,7 @@ export function SwitchPractice({ disabled = false }: { disabled?: boolean }) {

Press Escape, use Stop scanning, or hold any switch through its emergency stop to finish. The practice window closes when testing stops, including on loss of focus, disconnect, or after two minutes. Remote forwarding stops when practice ends.

{view?.message ?? "Starting practice…"}

{view?.source} · {view?.input ? `Last input: ${view.input}` : "No input received yet"}{view?.action && ` · ${actions[view.action]}`} · Completed: {view?.completed ?? 0}

- + {!view?.tiles.length && <>Choose any point to practise} {view?.rectangles.map(([x,y,width,height],i) => )} diff --git a/src/styles.css b/src/styles.css index 3574c44e..28876baf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -446,7 +446,7 @@ button:disabled { cursor: default; opacity: 0.55; } .switch-practice-entry p { width: 100%; } .switch-practice-entry label { display: flex; align-items: center; gap: 8px; } .switch-practice-dialog { background: var(--surface); color: var(--text); border-radius: 12px; padding: 24px; width: min(900px, 95vw); max-height: 92vh; overflow-y: auto; } -.switch-practice-preview { display: block; width: 100%; max-height: 45vh; } +.switch-practice-area { display: block; width: 100%; max-height: 45vh; } .switch-practice-buttons { display: flex; gap: 12px; justify-content: flex-end; } .switch-practice-menu { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; font-size: 15px; }