diff --git a/.impeccable/critique/2026-07-28T11-22-39Z__src-ui.md b/.impeccable/critique/2026-07-28T11-22-39Z__src-ui.md new file mode 100644 index 0000000..37b20ae --- /dev/null +++ b/.impeccable/critique/2026-07-28T11-22-39Z__src-ui.md @@ -0,0 +1,86 @@ +--- +timestamp: 2026-07-28T11-22-39Z +slug: src-ui +--- +--- +slug: src-ui +target: src/ui +method: dual-agent (A: critique-a-design · B: critique-b-detector) +generatedAt: 2026-07-28T13:20:00+02:00 +healthScore: 33/40 +ratingBand: Good — address weak dimensions +--- + +# Critique: RightClick UI (src/ui) + +## ERRATUM (corrected 2026-07-29) +The original contrast figures in this snapshot were inaccurate. All values have been re-computed using the WCAG 2.x relative-luminance formula. Key corrections for Default (Tokyo Night) theme: muted #565f89 on canvas #1a1b26 is 2.76:1 (FAIL, needs >=4.5:1), placeholder #565f89 on input #24283b is 2.35:1 (FAIL), and border #414868 on canvas #1a1b26 is 1.91:1 (FAIL for 3:1 non-text). Contrast is NOT a pass. The health score remains 33/40 (no change) as reduced-motion was already the P0 blocker. + +## Method +Dual-agent. Assessment A (design review, general-purpose) + Assessment B (detector evidence, task) ran isolated. Browser visualization N/A (Rust TUI). + +## Assessment B: Detector Evidence (CLI) +- Targets: src/ui src/theme src/plugins, then full src tree. +- Exit code: 0 on both. Findings: 0. Rule breakdown: none. +- Verdict: clean. Expected for a Rust TUI — the detector targets web markup/style patterns that don't apply here. No false positives to triage. + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | Spinner has no reduced-motion fallback | +| 2 | Match System / Real World | 4 | Git glyphs (S/M/?/D) match git convention | +| 3 | User Control and Freedom | 3 | No undo mechanism visible in ui layer | +| 4 | Consistency and Standards | 4 | All chrome routes through same token helpers | +| 5 | Error Prevention | 3 | No validation/confirmation for destructive actions at ui layer | +| 6 | Recognition Rather Than Recall | 4 | Footer key hints, header status, self-documenting glyphs | +| 7 | Flexibility and Efficiency | 3 | Global search hint exists; palette not in ui/ | +| 8 | Aesthetic and Minimalist Design | 4 | Terse, recessed chrome, zero decorative effects | +| 9 | Help Users Recognize/Recover from Errors | 3 | Error text below field, but no recovery guidance | +| 10 | Help and Documentation | 4 | `?` help toggle + global search hints discoverable | +| **Total** | | **33/40** | **Good — weak dimensions: 1, 3, 5, 7, 9 (all 3)** | + +## Anti-Patterns / AI Slop Verdict +Pass. No tells. Terminal-native discipline throughout: every color routes through `style_for_ui_element` / `style_for_token` / `style_for_git_status`; Unicode box-drawing borders; full-width character handling; saturating arithmetic. An expert would not suspect AI generation. + +## Named Rules Audit (all 6) +| Rule | Status | Evidence | +|---|---|---| +| The One Console Rule | obeyed | Accent (#7aa2f7) only on header title, active tab, key hints, spinner icon, overlay title. Never competes. | +| The Never-Color-Only Rule | obeyed | Git status pairs glyph+color (gitstatus/render/lines.rs:30-76); notifications pair icon+level (notifications.rs:35-42). | +| The Chrome Recedes Rule | obeyed | StatusBar bg #16161e (darker than canvas #1a1b26); input bg #24283b (lighter). | +| The Weight-Is-Hierarchy Rule | obeyed | BOLD for emphasis (header, tabs, hints, spinner, overlay title). No size scales. | +| The Flat-By-Default Rule | obeyed | Zero shadows/glow/blur. Overlay dims via tone only. | +| The Plain-Border Rule | obeyed | Borders::ALL + single-line box-drawing throughout. No rounded/double/thick. | + +## Token Discipline +- Hardcoded colors: 4 instances — defensive `unwrap_or(Color::Cyan)` fallbacks in notifications.rs:234-241 and progress.rs:63-65. All pull from `theme.colors.*` first. Not bypasses. +- Bypasses of `style_for_*`: none. Full token-system compliance. + +## Cognitive Load +Low. Single pane per surface with supporting chrome. Footer shows 4-5 hints with overflow count when narrow; header tabs collapse around active tab. Clear focus: active tab underlined+bold, active item highlighted, no competing accents. + +## Accessibility +- **Reduced-motion: FAIL (P0).** Spinner cycles frames unconditionally (spinner.rs:257); no preference detection in ui module. ProgressBar static (pass). Notifications static (pass). +- **Contrast: FAIL (P1).** Muted #565f89 on canvas #1a1b26 = 2.76:1 (FAIL, needs >=4.5:1 for text). Placeholder #565f89 on input #24283b = 2.35:1 (FAIL). Border #414868 on canvas #1a1b26 = 1.91:1 (FAIL for 3:1 non-text). Dracula / Nord placeholder contrast also below 4.5:1 (reviewer-cited: ~1.94:1 Dracula, ~1.96:1 Nord; requires formal verification). +- **Color-blind safety: pass.** Every semantic color paired with glyph or position. Never-Color-Only is a reference implementation here. + +## Strengths +1. Complete token discipline — every color flows through the theme system; not one `Color::Rgb(...)` hardcoded outside defensive fallbacks. +2. Git status is a reference implementation of The Never-Color-Only Rule. +3. Graceful narrow-width degradation — tabs collapse around active, hints truncate with overflow count, stays operational at 40 columns. + +## Priority Issues +1. **[P0] Spinner has no reduced-motion fallback** — spinner.rs:257. PRODUCT.md requires a calm/static fallback. Add `prefers_reduced_motion` detection; render static glyph when true. +2. **[P1] TextInput cursor invisible on empty field** — text_input.rs:104 renders space with cursor bg; poor-cursor-contrast terminals can't see focus. Render `█` when active+empty. +3. **[P2] Overlay.render hardcodes Theme::default()** — overlay.rs:162-164 bypasses active theme. `render_with_theme` exists (line 237) but the default `render` ignores it. Migrate callers, deprecate default. + +## Minor Observations +- NotificationManager auto-expires with no pause API for users who need more time. +- ProgressBar chars (█/░) hardcoded but exposed via `with_chars()` — good for legacy-terminal fallback. +- Footer hint separator (` | `) could be a const/token for dense-layout users. + +## Provocative Questions +1. Spinner has 7 style presets (dots, line, arrow, moon, clock…) — how does an operator choose? Should the style encode *what kind* of work (network vs CPU)? +2. TextInputWidget duplicates single vs multi-line rendering — candidate for delegating to ratatui's built-in TextArea? +3. Overlay keeps both `render()` and `render_with_theme()` — migration in progress, or oversight? diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..d868d1b --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-07-28T13:04:02+02:00", + "title": "Design System: RightClick", + "extensions": { + "colorMeta": { + "primary": { "role": "primary", "displayName": "Soft Blue", "canonical": "#7aa2f7", "oklch": "oklch(71.9% 0.132 264.2)", "tonalRamp": ["oklch(20.0% 0.132 264.2)","oklch(30.7% 0.124 264.2)","oklch(41.4% 0.115 264.2)","oklch(52.1% 0.107 264.2)","oklch(62.9% 0.098 264.2)","oklch(73.6% 0.090 264.2)","oklch(84.3% 0.081 264.2)","oklch(95.0% 0.073 264.2)"] }, + "secondary": { "role": "secondary", "displayName": "Soft Purple", "canonical": "#bb9af7", "oklch": "oklch(75.1% 0.134 299.5)", "tonalRamp": ["oklch(20.0% 0.134 299.5)","oklch(30.7% 0.126 299.5)","oklch(41.4% 0.117 299.5)","oklch(52.1% 0.109 299.5)","oklch(62.9% 0.100 299.5)","oklch(73.6% 0.091 299.5)","oklch(84.3% 0.083 299.5)","oklch(95.0% 0.074 299.5)"] }, + "success": { "role": "tertiary", "displayName": "Soft Green / Added", "canonical": "#9ece6a", "oklch": "oklch(79.5% 0.139 130.1)", "tonalRamp": ["oklch(20.0% 0.139 130.1)","oklch(30.7% 0.131 130.1)","oklch(41.4% 0.122 130.1)","oklch(52.1% 0.113 130.1)","oklch(62.9% 0.104 130.1)","oklch(73.6% 0.095 130.1)","oklch(84.3% 0.086 130.1)","oklch(95.0% 0.077 130.1)"] }, + "warning": { "role": "tertiary", "displayName": "Soft Amber / Modified","canonical": "#e0af68", "oklch": "oklch(78.4% 0.106 75.4)", "tonalRamp": ["oklch(20.0% 0.106 75.4)","oklch(30.7% 0.099 75.4)","oklch(41.4% 0.092 75.4)","oklch(52.1% 0.085 75.4)","oklch(62.9% 0.079 75.4)","oklch(73.6% 0.072 75.4)","oklch(84.3% 0.065 75.4)","oklch(95.0% 0.058 75.4)"] }, + "error": { "role": "tertiary", "displayName": "Soft Red / Removed", "canonical": "#f7768e", "oklch": "oklch(72.3% 0.159 10.3)", "tonalRamp": ["oklch(20.0% 0.159 10.3)","oklch(30.7% 0.149 10.3)","oklch(41.4% 0.138 10.3)","oklch(52.1% 0.128 10.3)","oklch(62.9% 0.118 10.3)","oklch(73.6% 0.108 10.3)","oklch(84.3% 0.098 10.3)","oklch(95.0% 0.087 10.3)"] }, + "info": { "role": "tertiary", "displayName": "Cyan", "canonical": "#7dcfff", "oklch": "oklch(82.0% 0.105 235.7)", "tonalRamp": ["oklch(20.0% 0.105 235.7)","oklch(30.7% 0.098 235.7)","oklch(41.4% 0.092 235.7)","oklch(52.1% 0.085 235.7)","oklch(62.9% 0.078 235.7)","oklch(73.6% 0.071 235.7)","oklch(84.3% 0.065 235.7)","oklch(95.0% 0.058 235.7)"] }, + "background": { "role": "neutral", "displayName": "Canvas", "canonical": "#1a1b26", "oklch": "oklch(22.6% 0.021 280.5)", "tonalRamp": ["oklch(20.0% 0.021 280.5)","oklch(30.7% 0.020 280.5)","oklch(41.4% 0.019 280.5)","oklch(52.1% 0.017 280.5)","oklch(62.9% 0.016 280.5)","oklch(73.6% 0.015 280.5)","oklch(84.3% 0.013 280.5)","oklch(95.0% 0.012 280.5)"] }, + "foreground": { "role": "neutral", "displayName": "Ink", "canonical": "#c0caf5", "oklch": "oklch(84.6% 0.061 274.8)", "tonalRamp": ["oklch(20.0% 0.061 274.8)","oklch(30.7% 0.057 274.8)","oklch(41.4% 0.053 274.8)","oklch(52.1% 0.049 274.8)","oklch(62.9% 0.045 274.8)","oklch(73.6% 0.041 274.8)","oklch(84.3% 0.038 274.8)","oklch(95.0% 0.034 274.8)"] }, + "muted": { "role": "neutral", "displayName": "Muted", "canonical": "#565f89", "oklch": "oklch(49.6% 0.068 274.4)", "tonalRamp": ["oklch(20.0% 0.068 274.4)","oklch(30.7% 0.064 274.4)","oklch(41.4% 0.059 274.4)","oklch(52.1% 0.055 274.4)","oklch(62.9% 0.051 274.4)","oklch(73.6% 0.046 274.4)","oklch(84.3% 0.042 274.4)","oklch(95.0% 0.038 274.4)"] }, + "border": { "role": "neutral", "displayName": "Chrome", "canonical": "#414868", "oklch": "oklch(40.9% 0.055 274.3)", "tonalRamp": ["oklch(20.0% 0.055 274.3)","oklch(30.7% 0.051 274.3)","oklch(41.4% 0.048 274.3)","oklch(52.1% 0.044 274.3)","oklch(62.9% 0.041 274.3)","oklch(73.6% 0.037 274.3)","oklch(84.3% 0.034 274.3)","oklch(95.0% 0.030 274.3)"] }, + "highlight": { "role": "neutral", "displayName": "Selection", "canonical": "#283457", "oklch": "oklch(33.2% 0.064 268.4)", "tonalRamp": ["oklch(20.0% 0.064 268.4)","oklch(30.7% 0.060 268.4)","oklch(41.4% 0.056 268.4)","oklch(52.1% 0.052 268.4)","oklch(62.9% 0.048 268.4)","oklch(73.6% 0.044 268.4)","oklch(84.3% 0.039 268.4)","oklch(95.0% 0.035 268.4)"] }, + "git-added": { "role": "tertiary", "displayName": "Added Green", "canonical": "#9ece6a", "oklch": "oklch(79.5% 0.139 130.1)", "tonalRamp": ["oklch(20.0% 0.139 130.1)","oklch(30.7% 0.131 130.1)","oklch(41.4% 0.122 130.1)","oklch(52.1% 0.113 130.1)","oklch(62.9% 0.104 130.1)","oklch(73.6% 0.095 130.1)","oklch(84.3% 0.086 130.1)","oklch(95.0% 0.077 130.1)"] }, + "git-removed": { "role": "tertiary", "displayName": "Removed Red", "canonical": "#f7768e", "oklch": "oklch(72.3% 0.159 10.3)", "tonalRamp": ["oklch(20.0% 0.159 10.3)","oklch(30.7% 0.149 10.3)","oklch(41.4% 0.138 10.3)","oklch(52.1% 0.128 10.3)","oklch(62.9% 0.118 10.3)","oklch(73.6% 0.108 10.3)","oklch(84.3% 0.098 10.3)","oklch(95.0% 0.087 10.3)"] }, + "git-modified": { "role": "tertiary", "displayName": "Modified Amber", "canonical": "#e0af68", "oklch": "oklch(78.4% 0.106 75.4)", "tonalRamp": ["oklch(20.0% 0.106 75.4)","oklch(30.7% 0.099 75.4)","oklch(41.4% 0.092 75.4)","oklch(52.1% 0.085 75.4)","oklch(62.9% 0.079 75.4)","oklch(73.6% 0.072 75.4)","oklch(84.3% 0.065 75.4)","oklch(95.0% 0.058 75.4)"] }, + "git-untracked":{ "role": "tertiary", "displayName": "Untracked Blue", "canonical": "#7aa2f7", "oklch": "oklch(71.9% 0.132 264.2)", "tonalRamp": ["oklch(20.0% 0.132 264.2)","oklch(30.7% 0.124 264.2)","oklch(41.4% 0.115 264.2)","oklch(52.1% 0.107 264.2)","oklch(62.9% 0.098 264.2)","oklch(73.6% 0.090 264.2)","oklch(84.3% 0.081 264.2)","oklch(95.0% 0.073 264.2)"] } + }, + "typographyMeta": { + "display": { "displayName": "Display", "purpose": "Top-level surface title; at most one per surface. Bold." }, + "headline": { "displayName": "Headline", "purpose": "Section / pane titles within a surface. Bold." }, + "title": { "displayName": "Title", "purpose": "List headings, dialog titles, column headers. Semibold." }, + "body": { "displayName": "Body", "purpose": "Default: lists, file names, conversation lines, diffs. Regular." }, + "label": { "displayName": "Label", "purpose": "Keybindings, status-bar segments, short affordances. Semibold." } + }, + "shadows": [], + "motion": [ + { "name": "reduced-motion", "value": "prefers-reduced-motion: reduce", "purpose": "Every spinner, transition, and animation must resolve to a static or crossfade state. Non-negotiable." }, + { "name": "state-only", "value": "opacity/character-cell swap, 120-160ms, ease-out", "purpose": "Motion conveys state (spinner, focus shift), never decoration. No choreographed entrances." } + ], + "breakpoints": [ + { "name": "min", "value": "80x24 cells", "purpose": "Minimum usable terminal size; layout must remain functional." }, + { "name": "compact","value": "120x40 cells", "purpose": "Default working size; two-pane layouts become comfortable." }, + { "name": "wide", "value": "200x50+ cells","purpose": "Multi-pane + side preview; full density available." } + ] + }, + "components": [ + { + "name": "List Item (active)", + "kind": "custom", + "refersTo": "list-item-active", + "description": "Focused row in any list (files, sessions, commands). Selection fill + accent marker. Web stand-in for the TUI list cell.", + "html": "
src/main.rsM
", + "css": ".ds-list-item { font-family: monospace; background: #283457; color: #c0caf5; padding: 4px 12px; display: flex; align-items: center; gap: 8px; border: 1px solid #414868; } .ds-marker { color: #7aa2f7; } .ds-name { flex: 1; } .ds-meta { color: #e0af68; font-weight: 600; }" + }, + { + "name": "Popup / Overlay", + "kind": "card", + "refersTo": "popup", + "description": "Centered dialog or search overlay: canvas bg, plain chrome border, 1-cell padding. Esc closes.", + "html": "
Global Search
Type to search files, commands, sessions…
", + "css": ".ds-popup { font-family: monospace; background: #1a1b26; color: #c0caf5; border: 1px solid #414868; padding: 12px 16px; max-width: 360px; } .ds-popup-title { font-weight: 700; color: #c0caf5; margin-bottom: 8px; } .ds-popup-body { color: #565f89; font-size: 13px; }" + }, + { + "name": "Input Field", + "kind": "input", + "refersTo": "input", + "description": "Text input: raised fill (lighter than canvas), plain chrome border, muted placeholder that stays contrast-safe.", + "html": "", + "css": ".ds-input { font-family: monospace; background: #24283b; color: #c0caf5; border: 1px solid #414868; padding: 6px 12px; outline: none; width: 100%; box-sizing: border-box; } .ds-input::placeholder { color: #565f89; } .ds-input:focus { border-color: #7aa2f7; }" + }, + { + "name": "Status Bar", + "kind": "nav", + "refersTo": "status-bar", + "description": "Lowest visual layer: recessed bg (darker than canvas), status fg, label-weight key hints.", + "html": "
GITqquit/search
", + "css": ".ds-statusbar { font-family: monospace; background: #16161e; color: #a9b1d6; padding: 4px 12px; display: flex; align-items: center; gap: 10px; border-top: 1px solid #414868; font-size: 12px; } .ds-tab { color: #7aa2f7; font-weight: 700; } .ds-spacer { flex: 1; } .ds-key { color: #c0caf5; font-weight: 700; } .ds-hint { color: #565f89; }" + }, + { + "name": "Git Status Row (signature)", + "kind": "custom", + "refersTo": "git-status", + "description": "Color + glyph together, never color alone. The signature component; accessibility-critical.", + "html": "
+ added.rs~ modified.rs- removed.rs? untracked.rs
", + "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();