feat(a11y): NO_ANIMATIONS env override + accessibility docs (#450)

`fancy_animations: false` and `low_motion: true` already exist on
the settings struct, but the flag was undocumented and the only
ways to opt in were the `/settings` slash command or hand-editing
`~/.config/deepseek/settings.toml` — there was no environment-
level signal that platform a11y tooling could carry forward.

* `NO_ANIMATIONS=1` env var now forces `low_motion = true` and
  `fancy_animations = false` at startup, regardless of what's on
  disk. Recognises `1`, `true`, `yes`, `on` (case-insensitive);
  any other value is treated as unset.
* `Settings::apply_env_overrides()` is now called at the end of
  `Settings::load()`, so every consumer (App::new, /config, the
  doctor surface) sees the override applied uniformly. The
  override is a startup-time overlay — changing the env var
  mid-session has no effect.
* New `docs/ACCESSIBILITY.md` documents the existing `low_motion`,
  `fancy_animations`, `calm_mode`, `show_thinking`, and
  `show_tool_details` toggles plus the `NO_ANIMATIONS` startup
  override. Includes guidance for screen-reader users and a link
  back to this issue for follow-up motion regressions.

Tests:
  3 new tests in `settings.rs` (force-low-motion-on, override-
  user-opt-in, truthy-spelling-recognition). All three serialise
  through a static Mutex so the cargo parallel runner doesn't
  observe interleaved env mutations.
This commit is contained in:
Hunter Bown
2026-05-03 05:09:17 -05:00
parent 3625b887fa
commit 6dfb10f321
3 changed files with 207 additions and 21 deletions
+8
View File
@@ -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.
+125 -21
View File
@@ -102,30 +102,39 @@ impl Settings {
/// Load settings from disk, or return defaults if not found
pub fn load() -> Result<Self> {
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");
}
}
}
+74
View File
@@ -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.