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
+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();