feat(accessibility): wire high-contrast + reduce-motion modes through engine

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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 11:23:22 -07:00
parent 716a025352
commit c5787c6953
4 changed files with 203 additions and 27 deletions
+20
View File
@@ -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,
+55 -3
View File
@@ -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<EffectiveSlideDuration>,
) {
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<EffectiveSlideDuration>,
) {
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();
+114 -24
View File
@@ -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)
// -----------------------------------------------------------------------
+14
View File
@@ -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);