",
+ "css": ".ds-git { font-family: monospace; background: #1a1b26; padding: 8px 12px; display: flex; flex-direction: column; gap: 4px; border: 1px solid #414868; } .ds-g { font-weight: 600; } .ds-add { color: #9ece6a; } .ds-mod { color: #e0af68; } .ds-rem { color: #f7768e; } .ds-unt { color: #7aa2f7; }"
+ }
+ ],
+ "narrative": {
+ "northStar": "The Operator's Console",
+ "overview": "RightClick is a terminal UI — a focused cockpit an operator sits in for hours. Everything present, nothing competing, the pilot in control. The aesthetic is the medium: character cells, contrast, and density, never transplanted web effects. There is no CSS here, no drop-shadows, no blur, no gradient text. Depth comes from tone and border, hierarchy comes from weight and position, and the single accent arrives rarely enough that when it does, it means something.\n\nThe operator is an expert. The screen is read at a glance and trusted under long sessions, so information density is a feature, not a problem — but density is disciplined: one clear focus per surface, supporting context receding through the muted token, never shouting.\n\nThis system explicitly rejects noisy, glossy SaaS dashboards: no gradients, no glassmorphism, no marketing polish. Anything that exists to look impressive rather than to convey state or afford action is a defect.",
+ "keyCharacteristics": [
+ "Terminal-native. Monospace grid, box-drawing chrome, no web effects.",
+ "Dark, low-glare. Deep blue-black canvas with soft off-white ink.",
+ "One accent, used sparingly. Soft blue carries focus.",
+ "Flat and tonal. No shadows; depth via background tone and plain borders.",
+ "Semantic, never decorative. Color encodes state and is reinforced by glyph."
+ ],
+ "rules": [
+ { "name": "The One Console Rule", "body": "The primary accent covers a small fraction of any screen. If two equally-bright accents compete, demote one to muted or neutral.", "section": "colors" },
+ { "name": "The Never-Color-Only Rule", "body": "Git status and semantic state are never encoded by color alone. Always pair with a glyph or position.", "section": "colors" },
+ { "name": "The Chrome Recedes Rule", "body": "Status bars and sidebars use a background darker than the content canvas; inputs use a background lighter than it. Depth is tonal, never shadowed.", "section": "elevation" },
+ { "name": "The Weight-Is-Hierarchy Rule","body": "For emphasis, change weight or apply the muted modifier. Do not invent size scales or a second typeface.", "section": "typography" },
+ { "name": "The Flat-By-Default Rule", "body": "Never simulate shadows, glow, or blur. If a surface needs elevation, raise its background tone one step or border it.", "section": "elevation" },
+ { "name": "The Plain-Border Rule", "body": "Borders are single-line and plain (Borders::ALL, BorderType::Plain). No rounded, double, or thick borders.", "section": "elevation" }
+ ],
+ "dos": [
+ "Do design against the Default theme tokens first; treat the other three themes as palette swaps of the same roles.",
+ "Do reinforce every semantic color with a glyph or position (The Never-Color-Only Rule).",
+ "Do convey depth through tone: recessed chrome (#16161e), raised inputs (#24283b), plain borders (#414868).",
+ "Do keep body and placeholder text contrast-checked against the canvas.",
+ "Do use weight and the muted modifier for hierarchy; preserve monospace column alignment.",
+ "Do provide a calm or static fallback for every spinner or transition under reduced-motion."
+ ],
+ "donts": [
+ "Don't introduce gradients, glassmorphism, drop-shadows, glow, or any marketing polish transplanted from the web.",
+ "Don't encode git status or semantic state by color alone.",
+ "Don't use rounded, double, or thick borders — single-line plain borders only.",
+ "Don't let the primary accent cover more than a small fraction of a screen, and never let two equally-bright accents compete.",
+ "Don't invent pixel font sizes, a second typeface, or proportional spacing.",
+ "Don't ship a spinner or animation without a reduced-motion fallback."
+ ]
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
index 8bd18cb..4e8c39c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,25 @@
RightClick is a TUI dashboard for AI coding agents. This document provides context for AI agents working on the codebase.
+## Design Context
+
+Before touching anything user-visible (`src/ui/`, `src/theme/`, `src/plugins/`, `src/modal/`, `src/palette/`), read these two files first:
+
+- **`PRODUCT.md`** — register, users, purpose, brand personality, anti-references, accessibility commitments. The *why*.
+- **`DESIGN.md`** — the design system: canonical color tokens, typography, elevation, components, and the six named rules that govern all UI decisions. The *how*. Machine-readable tokens also live in `.impeccable/design.json`.
+
+The design north star is **"The Operator's Console"**: a focused cockpit — everything present, nothing competing, the pilot in control. Terminal-native by medium (character cells, monospace, plain borders); dark and low-glare; one accent used sparingly; depth through tone, never shadows.
+
+**Non-negotiable UI rules** (full detail in `DESIGN.md`):
+1. *The One Console Rule* — the primary accent covers a small fraction of any screen.
+2. *The Never-Color-Only Rule* — git/semantic state is always color **+** glyph/position.
+3. *The Chrome Recedes Rule* — chrome darker than canvas; inputs lighter. Depth is tonal.
+4. *The Weight-Is-Hierarchy Rule* — emphasize via weight or the muted modifier, never new sizes/typefaces.
+5. *The Flat-By-Default Rule* — no simulated shadows, glow, or blur.
+6. *The Plain-Border Rule* — `Borders::ALL` + `BorderType::Plain` only.
+
+Token discipline: route colors through `src/theme/styles.rs` (`style_for_ui_element`, `style_for_token`, `style_for_git_status`). Do not hardcode `Color::Rgb`/hex literals in UI code — they bypass theming and break the four shipped themes.
+
## Architecture: Functional Core & Imperative Shell
This project follows the **Functional Core & Imperative Shell** (FC&IS) architecture pattern:
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..f138b45
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,315 @@
+---
+name: RightClick
+description: Terminal UI design system for a multi-agent developer cockpit — dark, dense, terminal-native.
+colors:
+ # Canonical palette = Default theme (Theme::default()). All four shipped themes
+ # share this token structure; alternate themes are documented in §2.
+ primary: "#7aa2f7" # Soft blue — the single accent
+ secondary: "#bb9af7" # Soft purple — rare secondary accent
+ success: "#9ece6a" # Soft green — added / ok
+ warning: "#e0af68" # Soft amber — modified / caution
+ error: "#f7768e" # Soft red — removed / failure
+ info: "#7dcfff" # Cyan — info / untracked-alt
+ background: "#1a1b26" # Canvas — deep blue-black
+ foreground: "#c0caf5" # Ink — soft off-white
+ muted: "#565f89" # Receded text / placeholders
+ border: "#414868" # Chrome — plain box-drawing borders
+ highlight: "#283457" # Selection / active-item fill
+ cursor: "#c0caf5" # Cursor (matches ink)
+ git-added: "#9ece6a"
+ git-removed: "#f7768e"
+ git-modified: "#e0af68"
+ git-untracked: "#7aa2f7"
+ status-bar-bg: "#16161e" # Receded chrome (darker than canvas)
+ status-bar-fg: "#a9b1d6"
+ popup-bg: "#1a1b26"
+ popup-border: "#414868"
+ input-bg: "#24283b" # Raised fill (lighter than canvas)
+ input-placeholder: "#565f89"
+ button-bg: "#7aa2f7"
+ button-fg: "#1a1b26"
+typography:
+ display:
+ fontFamily: "monospace, Menlo, SF Mono, Consolas"
+ fontWeight: 700
+ headline:
+ fontFamily: "monospace, Menlo, SF Mono, Consolas"
+ fontWeight: 700
+ title:
+ fontFamily: "monospace, Menlo, SF Mono, Consolas"
+ fontWeight: 600
+ body:
+ fontFamily: "monospace, Menlo, SF Mono, Consolas"
+ fontWeight: 400
+ label:
+ fontFamily: "monospace, Menlo, SF Mono, Consolas"
+ fontWeight: 600
+components:
+ list-item-active:
+ backgroundColor: "{colors.highlight}"
+ textColor: "{colors.foreground}"
+ padding: "0 1 cell"
+ list-item-inactive:
+ backgroundColor: "{colors.status-bar-bg}"
+ textColor: "{colors.muted}"
+ padding: "0 1 cell"
+ popup:
+ backgroundColor: "{colors.popup-bg}"
+ textColor: "{colors.foreground}"
+ padding: "1 cell"
+ input:
+ backgroundColor: "{colors.input-bg}"
+ textColor: "{colors.foreground}"
+ padding: "0 1 cell"
+ status-bar:
+ backgroundColor: "{colors.status-bar-bg}"
+ textColor: "{colors.status-bar-fg}"
+ padding: "0 1 cell"
+ button-primary:
+ backgroundColor: "{colors.button-bg}"
+ textColor: "{colors.button-fg}"
+ padding: "0 2 cells"
+---
+
+# Design System: RightClick
+
+## 1. Overview
+
+**Creative North Star: "The Operator's Console"**
+
+RightClick is a terminal UI — a focused cockpit an operator sits in for hours.
+Everything present, nothing competing, the pilot in control. The aesthetic is
+the medium: character cells, contrast, and density, never transplanted web
+effects. There is no CSS here, no drop-shadows, no blur, no gradient text. Depth
+comes from tone and border, hierarchy comes from weight and position, and the
+single accent arrives rarely enough that when it does, it means something.
+
+The operator is an expert. The screen is read at a glance and trusted under
+long sessions, so information density is a feature, not a problem — but density
+is disciplined: one clear focus per surface, supporting context receding through
+the muted token, never shouting. Motion is sparse and only ever conveys state
+(a spinner, a transition), never decoration.
+
+This system explicitly rejects noisy, glossy SaaS dashboards: no gradients, no
+glassmorphism, no marketing polish. Anything that exists to look impressive
+rather than to convey state or afford action is a defect. RightClick should
+look like a serious instrument, not a consumer app.
+
+**Key Characteristics:**
+
+- **Terminal-native.** Monospace grid, box-drawing chrome, no web effects.
+- **Dark, low-glare.** A deep blue-black canvas with a soft off-white ink,
+ tuned for extended focus.
+- **One accent, used sparingly.** Soft blue carries focus; everything else is
+ neutral or semantic.
+- **Flat and tonal.** No shadows; depth is conveyed by background tone and
+ plain borders.
+- **Semantic, never decorative.** Every color encodes state (added/removed/
+ modified, success/warning/error) and is reinforced by glyph so color is
+ never the sole signal.
+
+## 2. Colors: The Console Palette
+
+Four dark themes ship (`default`, `dracula`, `nord`, `tokyo-night`), all sharing
+one token structure. The **Default** theme is canonical — the values above and
+below — and is what new screens are designed against. Alternate themes are
+palette swaps of the same roles, not different systems.
+
+### Primary
+
+- **Soft Blue** (`#7aa2f7`): the single accent. Used for focus, the primary
+ action, untracked files, and key interactive emphasis. Its rarity is the
+ point — it should cover a small fraction of any screen.
+
+### Secondary
+
+- **Soft Purple** (`#bb9af7`): a rare secondary accent for hover states and a
+ second tier of emphasis. Used even more sparingly than Primary.
+
+### Tertiary (semantic state)
+
+- **Soft Green / Added** (`#9ece6a`): success and git additions.
+- **Soft Amber / Modified** (`#e0af68`): caution and git modifications.
+- **Soft Red / Removed** (`#f7768e`): errors and git deletions.
+- **Cyan / Info** (`#7dcfff`): informational accent and syntax type names.
+
+### Neutral
+
+- **Canvas** (`#1a1b26`): the deep blue-black background. Lowest layer.
+- **Ink** (`#c0caf5`): soft off-white body text and default foreground.
+- **Muted** (`#565f89`): receded text, placeholders, comments. Anything that
+ must remain legible but should not compete for attention.
+- **Chrome** (`#414868`): plain single-line borders and popup borders.
+- **Selection** (`#283457`): the active/selected item fill.
+- **Recessed Chrome** (`#16161e`): status bar and sidebar backgrounds — darker
+ than the canvas so chrome recedes behind content.
+- **Raised Fill** (`#24283b`): input fields — lighter than the canvas so they
+ read as an interactive surface without needing a border glow.
+
+### Named Rules
+
+**The One Console Rule.** The primary accent covers a small fraction of any
+screen. If two equally-bright accents compete, the screen is no longer a
+cockpit — demote one to muted or neutral.
+
+**The Never-Color-Only Rule.** Git status and semantic state are never encoded
+by color alone. Added / modified / removed / untracked are always paired with a
+glyph (or position), so the screen stays readable for every form of color
+vision. This is non-negotiable for accessibility.
+
+**The Chrome Recedes Rule.** Status bars and sidebars use a background darker
+than the content canvas; inputs use a background lighter than it. Depth is
+tonal, never shadowed.
+
+## 3. Typography
+
+**Font:** monospace — whatever the operator's terminal renders (fallbacks:
+Menlo, SF Mono, Consolas). RightClick does not choose a typeface; it commits to
+the grid the terminal gives it.
+
+**Character:** A single monospace family at multiple weights. Hierarchy comes
+from **weight** (bold for headings and emphasis), **modifier** (dim for
+muted/receded content, italic for secondary metadata), and **Unicode
+box-drawing** for structure — never from pixel sizes. There is no `clamp()`, no
+display-vs-body family pairing, no tracking tweaks: the cell grid is fixed, and
+that constraint is the aesthetic.
+
+### Hierarchy
+
+- **Display** (bold): top-level surface titles and the rare large label. The
+ heaviest weight on screen; used at most once per surface.
+- **Headline** (bold): section and pane titles within a surface.
+- **Title** (semibold): list headings, dialog titles, column headers.
+- **Body** (regular): the default. Lists, file names, conversation lines,
+ diff content. Line length is governed by pane width, not a measure.
+- **Label** (semibold): keybindings, status-bar segments, short affordances.
+ Often paired with a muted key hint (e.g. `q` quit).
+
+### Named Rules
+
+**The Weight-Is-Hierarchy Rule.** When you need emphasis, change weight or
+apply the muted modifier. Do not invent size scales the terminal cannot honor
+or introduce a second typeface.
+
+**The Monospace-Discipline Rule.** Alignment is part of the design: columns of
+status glyphs, file modes, and line numbers align vertically because the font
+is monospace. Preserve that alignment; never pad with proportional spaces or
+mix in a proportional font.
+
+## 4. Elevation
+
+RightClick is **flat by default**. The terminal cannot render drop-shadows, and
+this system does not simulate them. Depth is conveyed three ways, all tonal:
+
+1. **Recession** — chrome (status bar, sidebar) sits on a background darker
+ than the content canvas, so it falls back visually.
+2. **Raise** — interactive surfaces (inputs) sit on a background lighter than
+ the canvas, so they come forward.
+3. **Border** — plain single-line box-drawing (`Borders::ALL`, default
+ `BorderType::Plain`: `┌┐└┘─│`) defines boundaries. No rounded, double, or
+ thick borders are used anywhere in the codebase.
+
+Popups and overlays clear the cells behind them to the popup background and
+enclose themselves with the chrome border; that is the only "lift" the system
+expresses, and it is enough.
+
+### Named Rules
+
+**The Flat-By-Default Rule.** Never simulate shadows, glow, or blur. If a
+surface needs to feel elevated, raise its background tone one step or border
+it — nothing more.
+
+**The Plain-Border Rule.** Borders are single-line and plain. Rounded, double,
+and thick borders are not part of this system; introducing them breaks the
+visual consistency across panes.
+
+## 5. Components
+
+### Borders & Chrome
+
+- **Shape:** plain single-line box-drawing on all sides (`Borders::ALL`).
+- **Color:** `{colors.border}` (`#414868`).
+- **Use:** pane boundaries, popup frames, input outlines, notification frames.
+
+### Lists (the primary surface)
+
+- **Active item:** `{colors.highlight}` fill, `{colors.foreground}` text,
+ 1-cell horizontal padding. This is the focus indicator.
+- **Inactive item:** recessed background, `{colors.muted}` text, same padding.
+- **Selection marker:** an inline glyph (`▶` / `>` / `*`) in the accent color,
+ so focus is visible even where highlight fill is not drawn.
+
+### Popups / Overlays
+
+- **Background:** `{colors.popup-bg}` (matches canvas); cells behind are
+ cleared so the popup reads as a distinct layer.
+- **Border:** `{colors.popup-border}`.
+- **Padding:** 1 cell.
+- **Centered** over the viewport; `Esc` closes.
+
+### Inputs / Fields
+
+- **Background:** `{colors.input-bg}` (raised fill, lighter than canvas).
+- **Text:** `{colors.foreground}`; **placeholder:** `{colors.input-placeholder}`
+ (muted — still contrast-checked, never the failing default gray).
+- **Border:** plain chrome border; focus is conveyed by the cursor and by
+ selection/highlight tone, not by a glow.
+
+### Status Bar & Sidebar
+
+- **Background:** `{colors.status-bar-bg}` (recessed, darker than canvas).
+- **Text:** `{colors.status-bar-fg}`; key hints use the label weight with a
+ muted modifier. The status bar is the lowest visual layer.
+
+### Buttons (rare in a TUI)
+
+- **Primary:** `{colors.button-bg}` fill with `{colors.button-fg}` (dark) text.
+- **Hover:** swaps to `{colors.secondary}` (`#bb9af7`) - the rare secondary
+ accent reserved for hover states.
+- **Note:** most "actions" in RightClick are keybindings, not buttons; reach
+ for a button only when no keybinding exists.
+
+### Git Status Indicators (signature)
+
+File status is encoded by **color + glyph together**, never color alone:
+
+- **Added** — `{colors.git-added}` (`#9ece6a`) with `+` / `A`.
+- **Modified** — `{colors.git-modified}` (`#e0af68`) with `~` / `M`.
+- **Removed** — `{colors.git-removed}` (`#f7768e`) with `-` / `D`.
+- **Untracked** — `{colors.git-untracked}` (`#7aa2f7`) with `?`.
+
+### Spinner / Progress
+
+- A Unicode glyph sequence (e.g. `⠋⠙⠹`) cycled in the accent or info color to
+ signal active work. Must respect reduced-motion (see §6).
+
+## 6. Do's and Don'ts
+
+### Do:
+
+- **Do** design against the Default theme tokens first; treat the other three
+ themes as palette swaps of the same roles.
+- **Do** reinforce every semantic color with a glyph or position, so state is
+ readable without color (The Never-Color-Only Rule).
+- **Do** convey depth through tone: recessed chrome (`#16161e`), raised inputs
+ (`#24283b`), plain borders (`#414868`).
+- **Do** keep body and placeholder text contrast-checked against the canvas —
+ muted is a tone, not a failure.
+- **Do** use weight and the muted modifier for hierarchy; preserve monospace
+ column alignment.
+- **Do** provide a calm or static fallback for every spinner or transition
+ under reduced-motion.
+
+### Don't:
+
+- **Don't** introduce gradients, glassmorphism, drop-shadows, glow, or any
+ marketing polish transplanted from the web — RightClick is terminal-native.
+- **Don't** encode git status or semantic state by color alone.
+- **Don't** use rounded, double, or thick borders. Single-line plain borders
+ only (`Borders::ALL`, `BorderType::Plain`).
+- **Don't** let the primary accent cover more than a small fraction of a
+ screen, and never let two equally-bright accents compete (The One Console
+ Rule).
+- **Don't** invent pixel font sizes, a second typeface, or proportional
+ spacing — the terminal grid is fixed.
+- **Don't** ship a spinner or animation without a reduced-motion fallback.
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..62e6325
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,72 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+Developers who work with AI coding agents (Claude, Cursor, Codex, and others) and
+currently juggle a dozen terminal tabs, editor windows, and agent UIs. They live in the
+terminal for long, focused sessions and treat context-switching as a tax on attention.
+
+The job to be done: keep the entire development workflow — planning tasks, talking to
+agents, reviewing diffs, staging commits, browsing past conversations, navigating files,
+and switching worktrees — in one calm, keyboard-driven surface, so they never leave the
+shell to do work the shell does better.
+
+## Product Purpose
+
+RightClick is a terminal UI dashboard that unifies the AI-agent development workflow in a
+single shell. It exists because wrangling multiple agents across scattered tools fragments
+attention and slows experts down.
+
+Success looks like this: a developer opens RightClick at the start of a session and does
+not close it. They plan, delegate to agents, inspect output, diff, commit, and hop between
+worktrees with a handful of keystrokes — faster than reaching for the mouse, and with the
+whole context visible at once.
+
+## Brand Personality
+
+Sharp. Precise. Power-user.
+
+Voice and tone are direct, terse, and expert — like a well-tuned instrument an operator
+trusts. No hand-holding, no cheerleading, no decoration that doesn't earn its place. The
+emotional goal is the feeling of a focused cockpit: everything present, nothing competing,
+the operator in control.
+
+## Anti-references
+
+- **Noisy, glossy SaaS dashboards.** No gradients, glassmorphism, soft drop-shadows, or
+ marketing polish transplanted from the web. RightClick is terminal-native; effects that
+ read as "consumer web app" are a failure signal, not a feature.
+- **Decorative-for-its-own-sake chrome.** Anything that exists to look impressive rather
+ than to convey state or afford action does not belong.
+
+## Design Principles
+
+1. **Terminal-native, not transplanted web.** Every visual choice respects the medium:
+ character cells, contrast, and density — not CSS effects, shadows, or blur. The
+ constraint is the aesthetic.
+2. **Density without noise.** Show a lot, but never compete for attention. One clear focus
+ at a time; supporting information recedes until it is needed.
+3. **The keyboard is the interface.** Every action is reachable, fast, and discoverable.
+ The mouse is never required, and the design never assumes it.
+4. **Show, don't decorate.** Information and state carry the design. An expert operator
+ should be able to read the screen at a glance; ornament gets in the way.
+5. **Practice what you preach.** RightClick orchestrates AI agents from a model-driven,
+ testable core (Functional Core & Imperative Shell). Its own interface must model the
+ same discipline — explicit state, predictable transitions, nothing accidental.
+
+## Accessibility & Inclusion
+
+- **Git status is never color-only.** Added / removed / modified / untracked states must be
+ distinguishable by glyph, position, or label as well as hue, so color vision never gates
+ understanding.
+- **Reduced motion is respected.** Any spinner, transition, or animation has a calm or
+ static fallback; motion conveys state, never decoration.
+- **Strong terminal contrast everywhere.** Body text, muted text, and placeholder text meet
+ contrast expectations across every shipped theme; muted grays that fail against the
+ background are treated as bugs.
+- **Color-blind-safe palettes.** Themes must remain legible and unambiguous under common
+ color vision deficiencies (protanopia, deuteranopia, tritanopia).
diff --git a/src/core/models/config.rs b/src/core/models/config.rs
index 725cc3a..b7140b8 100644
--- a/src/core/models/config.rs
+++ b/src/core/models/config.rs
@@ -264,6 +264,12 @@ pub struct UIConfig {
/// Compact mode (reduces padding).
#[serde(default)]
pub compact_mode: bool,
+ /// Reduce motion: freeze frame-based animations (spinners) at a stable
+ /// glyph instead of advancing frames. Accessibility affordance - see
+ /// PRODUCT.md "Full inclusion" and DESIGN.md "Do ... provide a calm or
+ /// static fallback for every spinner or transition under reduced-motion."
+ #[serde(default)]
+ pub reduced_motion: bool,
}
impl Default for UIConfig {
@@ -274,6 +280,7 @@ impl Default for UIConfig {
nerd_fonts_enabled: true,
accent_color: "blue".to_string(),
compact_mode: false,
+ reduced_motion: false,
}
}
}
@@ -388,5 +395,14 @@ mod tests {
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 1);
assert!(config.ui.show_clock);
+ // Backward compatibility: a config serialized before `reduced_motion`
+ // existed must still deserialize, defaulting the field to false.
+ assert!(!config.ui.reduced_motion);
+ }
+
+ #[test]
+ fn test_default_ui_config_reduced_motion_is_false() {
+ let config = Config::default();
+ assert!(!config.ui.reduced_motion);
}
}
diff --git a/src/ui/spinner.rs b/src/ui/spinner.rs
index 0f14522..b41b1ff 100644
--- a/src/ui/spinner.rs
+++ b/src/ui/spinner.rs
@@ -29,6 +29,9 @@ pub struct Spinner {
frame_duration: Duration,
/// Optional label to display next to the spinner
label: Option,
+ /// When true, the spinner never advances frames (reduced-motion).
+ /// `tick()` and `next_frame()` become no-ops; render draws a frozen glyph.
+ reduced_motion: bool,
}
impl Spinner {
@@ -65,6 +68,7 @@ impl Spinner {
last_update: Instant::now(),
frame_duration: Duration::from_millis(80),
label: None,
+ reduced_motion: false,
}
}
@@ -86,6 +90,7 @@ impl Spinner {
last_update: Instant::now(),
frame_duration: Duration::from_millis(100),
label: None,
+ reduced_motion: false,
}
}
@@ -107,6 +112,7 @@ impl Spinner {
last_update: Instant::now(),
frame_duration: Duration::from_millis(80),
label: None,
+ reduced_motion: false,
}
}
@@ -128,6 +134,7 @@ impl Spinner {
last_update: Instant::now(),
frame_duration: Duration::from_millis(100),
label: None,
+ reduced_motion: false,
}
}
@@ -158,6 +165,7 @@ impl Spinner {
last_update: Instant::now(),
frame_duration: Duration::from_millis(100),
label: None,
+ reduced_motion: false,
}
}
@@ -179,6 +187,7 @@ impl Spinner {
last_update: Instant::now(),
frame_duration: Duration::from_millis(150),
label: None,
+ reduced_motion: false,
}
}
@@ -202,6 +211,7 @@ impl Spinner {
last_update: Instant::now(),
frame_duration: Duration::from_millis(100),
label: None,
+ reduced_motion: false,
}
}
@@ -242,6 +252,50 @@ impl Spinner {
self
}
+ /// Enable or disable reduced-motion mode.
+ ///
+ /// When enabled, `tick()` and `next_frame()` become no-ops (the frame is
+ /// frozen at its current index) and `is_running()` returns `false`, so
+ /// render loops stop driving animation. The rendered glyph stays stable —
+ /// an accessibility affordance, not a different visual identity.
+ ///
+ /// Call sites should thread this from `UIConfig.reduced_motion`:
+ ///
+ /// ```rust
+ /// use rightclick::ui::Spinner;
+ ///
+ /// let spinner = Spinner::new().with_reduced_motion(true);
+ /// assert!(!spinner.is_running());
+ /// ```
+ pub fn with_reduced_motion(mut self, reduced: bool) -> Self {
+ self.reduced_motion = reduced;
+ self
+ }
+
+ /// Build a spinner from the application's UI config, automatically
+ /// honoring the user's `reduced_motion` preference.
+ ///
+ /// This is the recommended constructor for live surfaces that consume a
+ /// loaded [`Config`](crate::core::models::Config): it threads the
+ /// accessibility preference in one place so call sites cannot forget it.
+ /// For custom styles or test code, use a named constructor (e.g.
+ /// [`Spinner::line()`](Self::line)) and chain
+ /// [`.with_reduced_motion()`](Self::with_reduced_motion) explicitly.
+ ///
+ /// # Example
+ ///
+ /// ```rust
+ /// use rightclick::core::models::UIConfig;
+ /// use rightclick::ui::Spinner;
+ ///
+ /// let ui = UIConfig { reduced_motion: true, ..UIConfig::default() };
+ /// let spinner = Spinner::from_ui_config(&ui);
+ /// assert!(!spinner.is_running());
+ /// ```
+ pub fn from_ui_config(ui_config: &crate::core::models::UIConfig) -> Self {
+ Self::new().with_reduced_motion(ui_config.reduced_motion)
+ }
+
/// Advance to the next frame if enough time has elapsed
///
/// Returns the current frame symbol.
@@ -255,6 +309,9 @@ impl Spinner {
/// let frame = spinner.tick();
/// ```
pub fn tick(&mut self) -> &str {
+ if self.reduced_motion {
+ return self.current_frame();
+ }
let now = Instant::now();
if now.duration_since(self.last_update) >= self.frame_duration {
self.frame_index = (self.frame_index + 1) % self.frames.len();
@@ -276,6 +333,9 @@ impl Spinner {
/// let frame = spinner.next_frame();
/// ```
pub fn next_frame(&mut self) -> &str {
+ if self.reduced_motion {
+ return self.current_frame();
+ }
self.frame_index = (self.frame_index + 1) % self.frames.len();
self.last_update = Instant::now();
self.current_frame()
@@ -295,7 +355,10 @@ impl Spinner {
self.frames.get(self.frame_index).copied().unwrap_or(" ")
}
- /// Check if the spinner is running (has multiple frames)
+ /// Check if the spinner is running (actively animating)
+ ///
+ /// Returns true only when the spinner has multiple frames AND
+ /// reduced-motion mode is disabled.
///
/// # Example
///
@@ -303,10 +366,10 @@ impl Spinner {
/// use rightclick::ui::Spinner;
///
/// let spinner = Spinner::new();
- /// assert!(spinner.is_running());
+ /// assert!(spinner.is_running()); // new spinner: multi-frame, reduced_motion=false
/// ```
pub fn is_running(&self) -> bool {
- self.frames.len() > 1
+ self.frames.len() > 1 && !self.reduced_motion
}
/// Reset the spinner to the first frame
@@ -555,6 +618,116 @@ mod tests {
assert!(spinner.is_running());
}
+ #[test]
+ fn test_reduced_motion_builder_sets_field() {
+ let spinner = Spinner::line();
+ assert!(!spinner.reduced_motion);
+
+ let spinner = Spinner::line().with_reduced_motion(true);
+ assert!(spinner.reduced_motion);
+
+ let spinner = Spinner::line().with_reduced_motion(false);
+ assert!(!spinner.reduced_motion);
+ }
+
+ #[test]
+ fn test_reduced_motion_is_running_returns_false() {
+ let spinner = Spinner::new().with_reduced_motion(true);
+ assert!(
+ !spinner.is_running(),
+ "reduced-motion spinner must not report as running"
+ );
+ }
+
+ #[test]
+ fn test_reduced_motion_tick_is_noop() {
+ let mut spinner = Spinner::line().with_reduced_motion(true);
+ // Force enough elapsed time that a normal spinner would advance.
+ spinner.last_update = Instant::now() - Duration::from_millis(500);
+ let before = spinner.frame_index;
+ let frame = spinner.tick().to_string();
+ assert_eq!(
+ spinner.frame_index, before,
+ "frame_index must not advance under reduced-motion"
+ );
+ assert_eq!(
+ frame,
+ spinner.current_frame(),
+ "tick must still return the frozen frame"
+ );
+ }
+
+ #[test]
+ fn test_reduced_motion_next_frame_is_noop() {
+ let mut spinner = Spinner::line().with_reduced_motion(true);
+ let before = spinner.frame_index;
+ let frame = spinner.next_frame().to_string();
+ assert_eq!(
+ spinner.frame_index, before,
+ "next_frame must not advance under reduced-motion"
+ );
+ assert_eq!(frame, spinner.current_frame());
+ }
+
+ #[test]
+ fn test_reduced_motion_render_is_stable_across_ticks() {
+ let mut spinner = Spinner::dots().with_reduced_motion(true);
+ let theme = Theme::default();
+ let area = Rect::new(0, 0, 5, 1);
+ let mut buf_a = Buffer::empty(area);
+ let mut buf_b = Buffer::empty(area);
+
+ // Simulate several render cycles with elapsed time between them.
+ spinner.last_update = Instant::now() - Duration::from_millis(500);
+ let _ = spinner.tick();
+ spinner.render(area, &mut buf_a, &theme);
+
+ spinner.last_update = Instant::now() - Duration::from_millis(500);
+ let _ = spinner.tick();
+ spinner.render(area, &mut buf_b, &theme);
+
+ let glyph_a = buf_a.cell((0, 0)).unwrap().symbol();
+ let glyph_b = buf_b.cell((0, 0)).unwrap().symbol();
+ assert_eq!(
+ glyph_a, glyph_b,
+ "rendered glyph must be identical across ticks under reduced-motion"
+ );
+ }
+
+ #[test]
+ fn test_default_spinner_still_animates_regression() {
+ // Regression contract: reduced_motion defaults to false and the
+ // nominal animation path is unchanged.
+ let mut spinner = Spinner::line();
+ assert!(!spinner.reduced_motion);
+ assert!(spinner.is_running());
+ spinner.last_update = Instant::now() - Duration::from_millis(200);
+ let _ = spinner.tick();
+ assert_ne!(spinner.frame_index, 0, "nominal spinner must still advance");
+ }
+
+ #[test]
+ fn test_from_ui_config_honors_reduced_motion() {
+ use crate::core::models::UIConfig;
+
+ let ui = UIConfig::default();
+ assert!(!ui.reduced_motion);
+ let spinner = Spinner::from_ui_config(&ui);
+ assert!(
+ !spinner.reduced_motion,
+ "default config must produce an animating spinner"
+ );
+ assert!(spinner.is_running());
+
+ let ui = UIConfig {
+ reduced_motion: true,
+ ..UIConfig::default()
+ };
+ let spinner = Spinner::from_ui_config(&ui);
+ assert!(spinner.reduced_motion);
+ assert!(!spinner.is_running());
+ }
+
#[test]
fn test_spinner_reset() {
let mut spinner = Spinner::line();