From c5787c69537c10583b2855777fee5e4d2b00b678 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 8 May 2026 11:23:22 -0700 Subject: [PATCH] feat(accessibility): wire high-contrast + reduce-motion modes through engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume-prompt Option F, part 1 of 2. Adds two accessibility flags to Settings and threads each through the engine surfaces that react to them. Settings UI toggle rows follow in a separate commit; players who want to test today can edit `settings.json` manually. Spec at `docs/ui-mockups/design-system.md` §Accessibility (#2 and #3). ### High-contrast mode `Settings::high_contrast_mode: bool` (defaults to false; serde- default for back-compat). When on: - Red-suit text colour boosts from `RED_SUIT_COLOUR` (`#fb9fb1`) to a new `RED_SUIT_COLOUR_HC` (`#ff8aa0`). - Black-suit text colour boosts from `BLACK_SUIT_COLOUR` (`#d0d0d0`) to a new `TEXT_PRIMARY_HC` (`#f5f5f5`). - New `BORDER_SUBTLE_HC` (`#a0a0a0`) constant available for future chrome-side wiring (this commit only routes HC through card text rendering — chrome border boost is a separable follow-up). The HC and CBM flags compose. CBM red→lime wins over HC on red suits when both are on (lime is itself a high-luminance accent, so the HC boost has nothing further to do). HC still applies to black suits when both flags are on (CBM doesn't touch black). Four new `text_colour` tests pin the truth table. ### Reduce-motion mode `Settings::reduce_motion_mode: bool` (defaults to false; serde- default for back-compat). When on: - Card-slide animation duration is forced to `0.0` regardless of the player's `AnimSpeed` selection — cards snap instantly to their target position. Implemented by extracting a new `effective_slide_secs(&Settings)` helper that wraps `anim_speed_to_secs` with the reduce-motion gate. - Future scaffolding hooks (splash scanline, warning-chip pulse, card-lift z-bump animation) follow the same `if settings.reduce_motion_mode { skip }` pattern when wired — stays out of scope for this commit since each motion path needs its own per-system gate. Two new tests cover the gate behaviour and the fall-through-to- AnimSpeed pass-through path. ### Threading `text_colour` signature extended with a `high_contrast: bool` parameter; `sync_cards` / `sync_cards_startup` / `sync_cards_on_change` / `sync_cards` core / `spawn_card_entity` / `update_card_entity` all gain a parallel parameter mirroring the existing `color_blind: bool` plumbing. Verbose but matches the established pattern; a future refactor could pack both into an `AccessibilityView` struct, but bigger blast radius. ### Stats 1191 passing / 0 failing across the workspace (net +6 from v0.21.0's 1185 baseline once the icon-pin test landed): - 4 new `text_colour` HC tests in `card_plugin` (red-suit boost, black-suit boost, CBM-wins-on-red, black-suits-with-CBM+HC-still-boost). - 2 new `effective_slide_secs` tests in `animation_plugin` (zero-out under reduce-motion, fall-through to AnimSpeed when off). `cargo clippy --workspace --all-targets -- -D warnings` clean. Co-Authored-By: Claude Opus 4.7 --- solitaire_data/src/settings.rs | 20 ++++ solitaire_engine/src/animation_plugin.rs | 58 +++++++++- solitaire_engine/src/card_plugin.rs | 138 +++++++++++++++++++---- solitaire_engine/src/ui_theme.rs | 14 +++ 4 files changed, 203 insertions(+), 27 deletions(-) diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 390054c..8d890a2 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -117,6 +117,24 @@ pub struct Settings { /// solely on colour. #[serde(default)] pub color_blind_mode: bool, + /// When `true`, boost foreground text + suit-red glyphs to higher- + /// luminance variants for better legibility on low-quality displays + /// or for low-vision users. Per `design-system.md` §Accessibility: + /// on-surface `#d0d0d0` → `#f5f5f5`, suit-red `#fb9fb1` → `#ff8aa0`, + /// outline `#505050` → `#a0a0a0`. Older `settings.json` files + /// written before this field existed deserialize cleanly to + /// `false` thanks to `#[serde(default)]`. + #[serde(default)] + pub high_contrast_mode: bool, + /// When `true`, suppresses non-essential motion: card-lift slide + /// transitions become instant snaps, splash scanline / cursor pulse + /// animations are disabled, and the warning-chip pulse holds at + /// rest. Per `design-system.md` §Accessibility — the WCAG-required + /// reduce-motion mode. Older `settings.json` files written before + /// this field existed deserialize cleanly to `false` thanks to + /// `#[serde(default)]`. + #[serde(default)] + pub reduce_motion_mode: bool, /// Window size and screen position to restore on next launch. `None` /// means "use platform defaults" — set on first run, then populated /// as the player resizes / moves the window. Older `settings.json` @@ -314,6 +332,8 @@ impl Default for Settings { selected_background: 0, first_run_complete: false, color_blind_mode: false, + high_contrast_mode: false, + reduce_motion_mode: false, window_geometry: None, selected_theme_id: default_theme_id(), shown_achievement_onboarding: false, diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index a05cade..9561181 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -13,7 +13,7 @@ use std::collections::VecDeque; use bevy::prelude::*; -use solitaire_data::AnimSpeed; +use solitaire_data::{AnimSpeed, Settings}; use crate::achievement_plugin::display_name_for; use crate::auto_complete_plugin::AutoCompleteState; @@ -196,7 +196,7 @@ fn init_slide_duration( mut dur: ResMut, ) { if let Some(s) = settings { - dur.slide_secs = anim_speed_to_secs(&s.0.animation_speed); + dur.slide_secs = effective_slide_secs(&s.0); } } @@ -205,7 +205,20 @@ fn sync_slide_duration( mut dur: ResMut, ) { for ev in events.read() { - dur.slide_secs = anim_speed_to_secs(&ev.0.animation_speed); + dur.slide_secs = effective_slide_secs(&ev.0); + } +} + +/// Resolves the player's effective per-slide animation duration — +/// pre-Settings the same as `anim_speed_to_secs(&settings.animation_speed)`, +/// but reduce-motion mode forces it to `0.0` regardless of the +/// `AnimSpeed` selection so cards snap instantly to their target +/// position. Spec at `design-system.md` §Accessibility (#3). +fn effective_slide_secs(settings: &Settings) -> f32 { + if settings.reduce_motion_mode { + 0.0 + } else { + anim_speed_to_secs(&settings.animation_speed) } } @@ -733,6 +746,45 @@ mod tests { app } + #[test] + fn effective_slide_secs_zeros_out_under_reduce_motion() { + // Reduce-motion forces slide_secs to 0.0 regardless of the + // AnimSpeed selection — cards snap instantly. Spec at + // `design-system.md` §Accessibility (#3). + let s = Settings { + animation_speed: AnimSpeed::Normal, + reduce_motion_mode: true, + ..Settings::default() + }; + assert_eq!(effective_slide_secs(&s), 0.0); + + let s = Settings { + animation_speed: AnimSpeed::Fast, + reduce_motion_mode: true, + ..Settings::default() + }; + assert_eq!(effective_slide_secs(&s), 0.0, "Fast + reduce-motion still 0.0"); + } + + #[test] + fn effective_slide_secs_falls_through_to_anim_speed_when_motion_unchecked() { + // With reduce-motion off, the resolver is just a pass-through + // to anim_speed_to_secs — preserves the existing AnimSpeed + // ladder for players who aren't using the accessibility flag. + for speed in [AnimSpeed::Normal, AnimSpeed::Fast, AnimSpeed::Instant] { + let s = Settings { + animation_speed: speed, + reduce_motion_mode: false, + ..Settings::default() + }; + assert_eq!( + effective_slide_secs(&s), + anim_speed_to_secs(&speed), + "without reduce-motion the effective duration must equal the AnimSpeed mapping", + ); + } + } + #[test] fn card_anim_at_half_elapsed_passes_geometric_midpoint() { let mut app = App::new(); diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index 25a1263..a30e109 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -34,8 +34,8 @@ use crate::font_plugin::FontResource; use crate::ui_theme::{ CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z, CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG, - CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_CAPTION, - Z_STOCK_BADGE, + CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC, + TYPE_CAPTION, Z_STOCK_BADGE, }; /// Fraction of card height used as vertical offset between face-up tableau cards. @@ -65,6 +65,17 @@ const FONT_SIZE_FRAC: f32 = 0.28; pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102); /// Suit colour for hearts + diamonds — Terminal `#fb9fb1` (suit-pink). pub const RED_SUIT_COLOUR: Color = Color::srgb(0.984, 0.624, 0.694); +/// High-contrast variant of [`RED_SUIT_COLOUR`] — `#ff8aa0`. Lifted +/// chroma + luminance for the Settings → Accessibility → High- +/// contrast mode toggle. Spec at `design-system.md` §Accessibility +/// (#2): suit-red boosts from `#fb9fb1` to `#ff8aa0` so red suits +/// remain unambiguously distinguishable from foreground gray on +/// low-quality displays. Independent of `RED_SUIT_COLOUR_CBM` +/// (lime) — high-contrast is *additive* over the default colour +/// palette; CBM is a *replacement* of red with a hue-distinct +/// alternative. The two modes can stack; CBM wins when both are on +/// because the CBM lime is itself a high-contrast colour. +pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.541, 0.627); /// Suit colour for spades + clubs — Terminal `#d0d0d0` (TEXT_PRIMARY). pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.816, 0.816, 0.816); @@ -506,7 +517,8 @@ fn sync_cards_startup( let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back); let back_colour = card_back_colour(selected_back); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); - sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back); + let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); + sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back); } } @@ -529,7 +541,8 @@ fn sync_cards_on_change( let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back); let back_colour = card_back_colour(selected_back); let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode); - sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, &entities, card_images.as_deref(), selected_back); + let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode); + sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back); } } @@ -541,6 +554,7 @@ fn sync_cards( slide_secs: f32, back_colour: Color, color_blind: bool, + high_contrast: bool, entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>, card_images: Option<&CardImageSet>, selected_back: usize, @@ -573,10 +587,10 @@ fn sync_cards( Some(&(entity, cur, has_anim)) => { update_card_entity( &mut commands, entity, card, position, z, layout, - slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back, + slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, ) } - None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back), + None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back), } } } @@ -661,6 +675,7 @@ fn spawn_card_entity( layout: &Layout, back_colour: Color, color_blind: bool, + high_contrast: bool, card_images: Option<&CardImageSet>, selected_back: usize, ) { @@ -690,7 +705,7 @@ fn spawn_card_entity( font_size: layout.card_size.x * FONT_SIZE_FRAC, ..default() }, - TextColor(text_colour(card, color_blind)), + TextColor(text_colour(card, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(card), )); @@ -709,6 +724,7 @@ fn update_card_entity( slide_secs: f32, back_colour: Color, color_blind: bool, + high_contrast: bool, cur: Vec3, has_card_animation: bool, card_images: Option<&CardImageSet>, @@ -762,7 +778,7 @@ fn update_card_entity( font_size: layout.card_size.x * FONT_SIZE_FRAC, ..default() }, - TextColor(text_colour(card, color_blind)), + TextColor(text_colour(card, color_blind, high_contrast)), Transform::from_xyz(0.0, 0.0, 0.01), label_visibility(card), )); @@ -797,23 +813,35 @@ fn label_for(card: &Card) -> String { /// Suit colour for the rank/suit overlay rendered atop the constant /// fallback sprite (only fires under `MinimalPlugins` — production -/// renders the suit glyph baked into the PNG). When `color_blind` is -/// enabled, red-suit cards swap to `RED_SUIT_COLOUR_CBM` (lime) — the -/// "Settings toggle swaps red→lime" half of the design system's -/// colour-blind support. The other half (always-on filled-vs-outlined -/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art and -/// has no constant-fallback equivalent. +/// renders the suit glyph baked into the PNG). Two independent +/// accessibility flags compose: /// -/// The CBM swap is lime (not the new brick-red `ACCENT_PRIMARY`) -/// because the primary accent is itself red-family — the CBM -/// alternative needs to be hue-distinct from the original red suit. -fn text_colour(card: &Card, color_blind: bool) -> Color { +/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM` +/// (lime) — the "Settings toggle swaps red→lime" half of the +/// design system's colour-blind support. CBM is a hue-replacement +/// for red, so HC has no further effect on red when CBM is on +/// (the lime is itself a high-contrast colour). +/// - `high_contrast`: when CBM is off, red suits boost to +/// `RED_SUIT_COLOUR_HC` (`#ff8aa0` from the spec); black suits +/// boost from `#d0d0d0` to `#f5f5f5` (`TEXT_PRIMARY_HC`). +/// +/// The other half of CBM support (always-on filled-vs-outlined +/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art +/// and has no constant-fallback equivalent. +fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color { if card.suit.is_red() { if color_blind { + // CBM lime wins — the colour-blind swap replaces the + // red hue entirely, and the lime is already high- + // luminance, so an HC boost on top has nothing to do. RED_SUIT_COLOUR_CBM + } else if high_contrast { + RED_SUIT_COLOUR_HC } else { RED_SUIT_COLOUR } + } else if high_contrast { + TEXT_PRIMARY_HC } else { BLACK_SUIT_COLOUR } @@ -1769,8 +1797,8 @@ mod tests { rank: Rank::Ace, face_up: true, }; - assert_eq!(text_colour(&h, false), RED_SUIT_COLOUR); - assert_eq!(text_colour(&d, false), RED_SUIT_COLOUR); + assert_eq!(text_colour(&h, false, false), RED_SUIT_COLOUR); + assert_eq!(text_colour(&d, false, false), RED_SUIT_COLOUR); } #[test] @@ -1787,8 +1815,8 @@ mod tests { rank: Rank::Ace, face_up: true, }; - assert_eq!(text_colour(&c, false), BLACK_SUIT_COLOUR); - assert_eq!(text_colour(&s, false), BLACK_SUIT_COLOUR); + assert_eq!(text_colour(&c, false, false), BLACK_SUIT_COLOUR); + assert_eq!(text_colour(&s, false, false), BLACK_SUIT_COLOUR); } #[test] @@ -2082,7 +2110,7 @@ mod tests { #[test] fn text_colour_color_blind_mode_swaps_red_suits_to_lime() { let red_card = Card { id: 0, suit: Suit::Diamonds, rank: Rank::Queen, face_up: true }; - let cbm_colour = text_colour(&red_card, true); + let cbm_colour = text_colour(&red_card, true, false); assert_eq!( cbm_colour, RED_SUIT_COLOUR_CBM, "color-blind mode must replace the red suit colour with the CBM lime", @@ -2097,12 +2125,74 @@ mod tests { fn text_colour_color_blind_mode_does_not_change_black_suits() { let black_card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Jack, face_up: true }; assert_eq!( - text_colour(&black_card, true), + text_colour(&black_card, true, false), BLACK_SUIT_COLOUR, "color-blind mode must not alter black-suit text colour", ); } + // ----------------------------------------------------------------------- + // text_colour (pure) — high-contrast mode + // + // Spec at `design-system.md` §Accessibility (#2): on-surface + // boosts to `#f5f5f5` (TEXT_PRIMARY_HC) and suit-red to + // `#ff8aa0` (RED_SUIT_COLOUR_HC). Independent of CBM: + // the two flags compose, with CBM winning on red when both + // are on (the CBM lime is itself a high-contrast colour, so + // an HC bump on top has no further effect). + // ----------------------------------------------------------------------- + + #[test] + fn text_colour_high_contrast_boosts_red_suits_to_hc_red() { + let red_card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Five, face_up: true }; + assert_eq!( + text_colour(&red_card, false, true), + RED_SUIT_COLOUR_HC, + "high-contrast mode must boost red suits to the HC red variant", + ); + assert_ne!( + text_colour(&red_card, false, true), + RED_SUIT_COLOUR, + "HC red must be visibly distinct from the default red suit colour", + ); + } + + #[test] + fn text_colour_high_contrast_boosts_black_suits_to_hc_white() { + let black_card = Card { id: 0, suit: Suit::Spades, rank: Rank::Two, face_up: true }; + assert_eq!( + text_colour(&black_card, false, true), + TEXT_PRIMARY_HC, + "high-contrast mode must boost black suits to TEXT_PRIMARY_HC", + ); + } + + #[test] + fn text_colour_color_blind_wins_over_high_contrast_on_red_suits() { + // When both modes are enabled, red→lime (CBM) wins because + // the CBM lime is itself a high-luminance accent and the HC + // boost would pick a different hue, defeating the purpose of + // the colour-blind swap. + let red_card = Card { id: 0, suit: Suit::Diamonds, rank: Rank::Ace, face_up: true }; + assert_eq!( + text_colour(&red_card, true, true), + RED_SUIT_COLOUR_CBM, + "CBM lime must win over HC red when both modes are on", + ); + } + + #[test] + fn text_colour_high_contrast_alone_does_not_change_black_suits_under_cbm() { + // CBM doesn't touch black suits, so HC remains the only + // source of variation for the black row when both are on. + let black_card = Card { id: 0, suit: Suit::Clubs, rank: Rank::King, face_up: true }; + assert_eq!( + text_colour(&black_card, true, true), + TEXT_PRIMARY_HC, + "with CBM + HC both on, black suits still pick up the HC boost", + ); + } + // ----------------------------------------------------------------------- // label_visibility (pure) // ----------------------------------------------------------------------- diff --git a/solitaire_engine/src/ui_theme.rs b/solitaire_engine/src/ui_theme.rs index 7149086..cea6a41 100644 --- a/solitaire_engine/src/ui_theme.rs +++ b/solitaire_engine/src/ui_theme.rs @@ -61,6 +61,11 @@ pub const BG_HUD_BAND: Color = Color::srgba(0.125, 0.125, 0.125, 1.0); /// `#d0d0d0`. pub const TEXT_PRIMARY: Color = Color::srgb(0.816, 0.816, 0.816); +/// High-contrast variant of [`TEXT_PRIMARY`] — `#f5f5f5`. Boosted +/// luminance for the Settings → Accessibility → High-contrast mode +/// toggle. Spec at `design-system.md` §Accessibility (#2). +pub const TEXT_PRIMARY_HC: Color = Color::srgb(0.961, 0.961, 0.961); + /// Secondary text — captions, hints, muted labels. `#a0a0a0`. pub const TEXT_SECONDARY: Color = Color::srgb(0.627, 0.627, 0.627); @@ -212,6 +217,15 @@ pub const CARD_SHADOW_LOCAL_Z: f32 = -0.05; /// `#353535`. pub const BORDER_SUBTLE: Color = Color::srgba(0.208, 0.208, 0.208, 1.0); +/// High-contrast variant of [`BORDER_SUBTLE`] — `#a0a0a0`. Lifts +/// outlines from near-invisible to clearly visible for the +/// Settings → Accessibility → High-contrast mode toggle. Spec at +/// `design-system.md` §Accessibility (#2): outline jumps from +/// `#505050` to `#a0a0a0` so card borders, popover edges, and +/// focus rings are legible on low-quality displays / for low- +/// vision users. +pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0); + /// Strong border — hover outline, focused button, active popover. /// `outline` from the design system. `#505050`. pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);