diff --git a/CHANGELOG.md b/CHANGELOG.md index 13cbbb4d..b2c5bb00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and the previous "192m 30s" cycle output becomes `3h 12m`. The `/goal` status line picks up the same formatter so multi-day goal-elapsed times stay readable. +- **Accessibility flag** (#450) — `NO_ANIMATIONS=1` env var now + forces `low_motion = true` and `fancy_animations = false` at + startup, regardless of the saved `settings.toml`. Recognises + the standard truthy spellings (`1`, `true`, `yes`, `on`). + Documented end-to-end in the new `docs/ACCESSIBILITY.md`, + including the existing `low_motion` / `calm_mode` / + `show_thinking` / `show_tool_details` toggles for + screen-reader users. - **RLM tool family** (#512) — `rlm` tool cards map to `ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm" wording cleaned out of docs / comments / tests. diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 173ad991..0282fa40 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -102,30 +102,39 @@ impl Settings { /// Load settings from disk, or return defaults if not found pub fn load() -> Result { let path = Self::path()?; - if !path.exists() { - return Ok(Self::default()); - } - - let content = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read settings from {}", path.display()))?; - let mut settings: Settings = toml::from_str(&content) - .with_context(|| format!("Failed to parse settings from {}", path.display()))?; - settings.default_mode = normalize_mode(&settings.default_mode).to_string(); - settings.composer_density = - normalize_composer_density(&settings.composer_density).to_string(); - settings.transcript_spacing = - normalize_transcript_spacing(&settings.transcript_spacing).to_string(); - settings.sidebar_focus = normalize_sidebar_focus(&settings.sidebar_focus).to_string(); - settings.locale = normalize_configured_locale(&settings.locale) - .unwrap_or("en") - .to_string(); - settings.default_model = settings - .default_model - .as_deref() - .and_then(normalize_model_name); + let mut settings = if !path.exists() { + Self::default() + } else { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read settings from {}", path.display()))?; + let mut s: Settings = toml::from_str(&content) + .with_context(|| format!("Failed to parse settings from {}", path.display()))?; + s.default_mode = normalize_mode(&s.default_mode).to_string(); + s.composer_density = normalize_composer_density(&s.composer_density).to_string(); + s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string(); + s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string(); + s.locale = normalize_configured_locale(&s.locale) + .unwrap_or("en") + .to_string(); + s.default_model = s.default_model.as_deref().and_then(normalize_model_name); + s + }; + settings.apply_env_overrides(); Ok(settings) } + /// Apply environment-driven overlays after disk load. Used for + /// platform a11y signals that should ignore the user's saved + /// preference (#450). The env values are consulted at startup; + /// changing them mid-session has no effect because settings are + /// only re-read on `Settings::load()`. + pub fn apply_env_overrides(&mut self) { + if env_truthy("NO_ANIMATIONS") { + self.low_motion = true; + self.fancy_animations = false; + } + } + /// Save settings to disk pub fn save(&self) -> Result<()> { let path = Self::path()?; @@ -418,6 +427,20 @@ fn normalize_sidebar_focus(value: &str) -> &str { } } +/// Resolve an environment variable as a boolean. Recognises the +/// common truthy spellings (`1`, `true`, `yes`, `on`) case- +/// insensitively. Used by [`Settings::apply_env_overrides`] for +/// platform a11y signals like `NO_ANIMATIONS`. +fn env_truthy(name: &str) -> bool { + match std::env::var(name) { + Ok(v) => matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -490,4 +513,85 @@ mod tests { "chinese config label missing:\n{zh}" ); } + + /// Tests that mutate process-global `NO_ANIMATIONS` serialise + /// through this guard so the cargo parallel runner doesn't + /// observe interleaved overrides. + fn no_animations_test_guard() -> std::sync::MutexGuard<'static, ()> { + static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + GUARD.lock().unwrap_or_else(|e| e.into_inner()) + } + + #[test] + fn no_animations_env_forces_low_motion_on() { + let _g = no_animations_test_guard(); + // SAFETY: tests in this group serialise through the guard. + unsafe { + std::env::set_var("NO_ANIMATIONS", "1"); + } + let mut settings = Settings::default(); + assert!(!settings.low_motion, "default is animated"); + assert!(!settings.fancy_animations, "default is animated"); + settings.apply_env_overrides(); + assert!(settings.low_motion, "NO_ANIMATIONS=1 forces low_motion"); + assert!( + !settings.fancy_animations, + "NO_ANIMATIONS=1 keeps fancy off" + ); + // SAFETY: cleanup under the guard. + unsafe { + std::env::remove_var("NO_ANIMATIONS"); + } + } + + #[test] + fn no_animations_env_overrides_user_opt_in() { + let _g = no_animations_test_guard(); + // SAFETY: serialised by the guard. + unsafe { + std::env::set_var("NO_ANIMATIONS", "true"); + } + // User had explicitly opted into fancy animations on disk. + let mut settings = Settings { + fancy_animations: true, + ..Settings::default() + }; + settings.apply_env_overrides(); + assert!( + !settings.fancy_animations, + "platform NO_ANIMATIONS overrides user-opt-in fancy_animations" + ); + assert!(settings.low_motion); + // SAFETY: cleanup under the guard. + unsafe { + std::env::remove_var("NO_ANIMATIONS"); + } + } + + #[test] + fn no_animations_env_recognises_truthy_spellings_only() { + let _g = no_animations_test_guard(); + for truthy in ["1", "true", "True", "YES", "on"] { + // SAFETY: serialised by the guard. + unsafe { + std::env::set_var("NO_ANIMATIONS", truthy); + } + let mut s = Settings::default(); + s.apply_env_overrides(); + assert!(s.low_motion, "{truthy:?} should be truthy"); + } + for falsy in ["0", "false", "no", "off", ""] { + // SAFETY: serialised by the guard. + unsafe { + std::env::set_var("NO_ANIMATIONS", falsy); + } + let mut s = Settings::default(); + s.apply_env_overrides(); + assert!(!s.low_motion, "{falsy:?} should be falsy"); + } + // SAFETY: cleanup under the guard. + unsafe { + std::env::remove_var("NO_ANIMATIONS"); + } + } } diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md new file mode 100644 index 00000000..5fb89f73 --- /dev/null +++ b/docs/ACCESSIBILITY.md @@ -0,0 +1,74 @@ +# Accessibility + +DeepSeek-TUI runs in a terminal, so the platform's own accessibility +stack (screen readers, magnifiers, terminal-level themes) does most +of the work. The TUI provides a small set of toggles that reduce +visual motion and density for screen-reader and low-motion users. + +## Quick reference + +| Toggle | Default | Effect | +| --- | --- | --- | +| `NO_ANIMATIONS=1` env var | unset | At startup, forces `low_motion = true` and `fancy_animations = false`. Overrides whatever's saved in `settings.toml`. | +| `low_motion` setting | `false` | Suppresses spinners' motion, transcript fade-ins, footer drift, and the active-cell pulse. The frame-rate limiter also slows down idle redraws so the cursor doesn't blink as aggressively. | +| `fancy_animations` setting | `false` | Footer water-spout strip and pulsing sub-agent counter. Off by default. | +| `calm_mode` setting | `false` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. | +| `show_thinking` setting | `true` | Set to `false` to hide model `reasoning_content` blocks entirely. | +| `show_tool_details` setting | `true` | Set to `false` to render tool calls as one-liners without expanded payloads. | + +## Standard env-var surface + +Set these in your shell profile so they apply to every session: + +```bash +# Force low-motion + no fancy animations. +export NO_ANIMATIONS=1 + +# Optional: respect the wider terminal-color convention. +export NO_COLOR=1 # honored by the underlying ratatui backend +``` + +`NO_ANIMATIONS` accepts any of `1`, `true`, `yes`, or `on` +(case-insensitive). Any other value (including `0`, `false`, empty, +or unset) leaves your saved settings alone. + +The override is applied once at startup. Changing the env var +mid-session has no effect — settings are only re-read on the next +launch. + +## Configuring via `/settings` + +The same toggles are reachable from the command palette: + +* `/settings set low_motion on` +* `/settings set fancy_animations off` +* `/settings set calm_mode on` + +Settings written this way persist to `~/.config/deepseek/settings.toml`. +The `NO_ANIMATIONS` env var still wins at startup if it's set, so +unsetting the env var is the way to honor your saved choice. + +## Notes for screen-reader users + +* `low_motion` slows the idle redraw loop to ~120ms per frame so + the cursor isn't constantly repositioned. Combined with + `calm_mode`, the redraw rate stays low enough that VoiceOver / + Orca announcements track linearly with model output instead of + re-reading the whole screen on each tick. +* The transcript is pure text — no images or canvas rendering — so + any terminal that integrates with the platform's accessibility + service (e.g. macOS Terminal.app, iTerm2, Ghostty, Windows + Terminal) will pass the rendered content straight through. +* If you find a UI surface that still produces motion when + `low_motion = true`, please file an issue against + [`PRIOR: Screen-reader / accessibility flag`](https://github.com/Hmbown/DeepSeek-TUI/issues/450) + with a screenshot or terminal recording. + +## Related issues / history + +* [#450](https://github.com/Hmbown/DeepSeek-TUI/issues/450) — + documenting the existing flag, adding the `NO_ANIMATIONS` + startup overlay, and writing this page. +* [#449](https://github.com/Hmbown/DeepSeek-TUI/issues/449) — + footer statusline now uses the active theme's contrast pair + instead of a bespoke palette.