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:
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user