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:
@@ -117,6 +117,24 @@ pub struct Settings {
|
|||||||
/// solely on colour.
|
/// solely on colour.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color_blind_mode: bool,
|
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`
|
/// Window size and screen position to restore on next launch. `None`
|
||||||
/// means "use platform defaults" — set on first run, then populated
|
/// means "use platform defaults" — set on first run, then populated
|
||||||
/// as the player resizes / moves the window. Older `settings.json`
|
/// as the player resizes / moves the window. Older `settings.json`
|
||||||
@@ -314,6 +332,8 @@ impl Default for Settings {
|
|||||||
selected_background: 0,
|
selected_background: 0,
|
||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
|
high_contrast_mode: false,
|
||||||
|
reduce_motion_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: default_theme_id(),
|
selected_theme_id: default_theme_id(),
|
||||||
shown_achievement_onboarding: false,
|
shown_achievement_onboarding: false,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::{AnimSpeed, Settings};
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
@@ -196,7 +196,7 @@ fn init_slide_duration(
|
|||||||
mut dur: ResMut<EffectiveSlideDuration>,
|
mut dur: ResMut<EffectiveSlideDuration>,
|
||||||
) {
|
) {
|
||||||
if let Some(s) = settings {
|
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>,
|
mut dur: ResMut<EffectiveSlideDuration>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
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
|
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]
|
#[test]
|
||||||
fn card_anim_at_half_elapsed_passes_geometric_midpoint() {
|
fn card_anim_at_half_elapsed_passes_geometric_midpoint() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
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_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||||
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_CAPTION,
|
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TEXT_PRIMARY_HC,
|
||||||
Z_STOCK_BADGE,
|
TYPE_CAPTION, Z_STOCK_BADGE,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
/// 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);
|
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
||||||
/// Suit colour for hearts + diamonds — Terminal `#fb9fb1` (suit-pink).
|
/// Suit colour for hearts + diamonds — Terminal `#fb9fb1` (suit-pink).
|
||||||
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.984, 0.624, 0.694);
|
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).
|
/// Suit colour for spades + clubs — Terminal `#d0d0d0` (TEXT_PRIMARY).
|
||||||
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.816, 0.816, 0.816);
|
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 selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
let back_colour = card_back_colour(selected_back);
|
let back_colour = card_back_colour(selected_back);
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
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 selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
let back_colour = card_back_colour(selected_back);
|
let back_colour = card_back_colour(selected_back);
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
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,
|
slide_secs: f32,
|
||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
@@ -573,10 +587,10 @@ fn sync_cards(
|
|||||||
Some(&(entity, cur, has_anim)) => {
|
Some(&(entity, cur, has_anim)) => {
|
||||||
update_card_entity(
|
update_card_entity(
|
||||||
&mut commands, entity, card, position, z, layout,
|
&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,
|
layout: &Layout,
|
||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
) {
|
) {
|
||||||
@@ -690,7 +705,7 @@ fn spawn_card_entity(
|
|||||||
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(text_colour(card, color_blind)),
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
label_visibility(card),
|
label_visibility(card),
|
||||||
));
|
));
|
||||||
@@ -709,6 +724,7 @@ fn update_card_entity(
|
|||||||
slide_secs: f32,
|
slide_secs: f32,
|
||||||
back_colour: Color,
|
back_colour: Color,
|
||||||
color_blind: bool,
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
cur: Vec3,
|
cur: Vec3,
|
||||||
has_card_animation: bool,
|
has_card_animation: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
@@ -762,7 +778,7 @@ fn update_card_entity(
|
|||||||
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(text_colour(card, color_blind)),
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
Transform::from_xyz(0.0, 0.0, 0.01),
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
label_visibility(card),
|
label_visibility(card),
|
||||||
));
|
));
|
||||||
@@ -797,23 +813,35 @@ fn label_for(card: &Card) -> String {
|
|||||||
|
|
||||||
/// Suit colour for the rank/suit overlay rendered atop the constant
|
/// Suit colour for the rank/suit overlay rendered atop the constant
|
||||||
/// fallback sprite (only fires under `MinimalPlugins` — production
|
/// fallback sprite (only fires under `MinimalPlugins` — production
|
||||||
/// renders the suit glyph baked into the PNG). When `color_blind` is
|
/// renders the suit glyph baked into the PNG). Two independent
|
||||||
/// enabled, red-suit cards swap to `RED_SUIT_COLOUR_CBM` (lime) — the
|
/// accessibility flags compose:
|
||||||
/// "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.
|
|
||||||
///
|
///
|
||||||
/// The CBM swap is lime (not the new brick-red `ACCENT_PRIMARY`)
|
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
|
||||||
/// because the primary accent is itself red-family — the CBM
|
/// (lime) — the "Settings toggle swaps red→lime" half of the
|
||||||
/// alternative needs to be hue-distinct from the original red suit.
|
/// design system's colour-blind support. CBM is a hue-replacement
|
||||||
fn text_colour(card: &Card, color_blind: bool) -> Color {
|
/// 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 card.suit.is_red() {
|
||||||
if color_blind {
|
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
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
} else {
|
} else {
|
||||||
RED_SUIT_COLOUR
|
RED_SUIT_COLOUR
|
||||||
}
|
}
|
||||||
|
} else if high_contrast {
|
||||||
|
TEXT_PRIMARY_HC
|
||||||
} else {
|
} else {
|
||||||
BLACK_SUIT_COLOUR
|
BLACK_SUIT_COLOUR
|
||||||
}
|
}
|
||||||
@@ -1769,8 +1797,8 @@ mod tests {
|
|||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
assert_eq!(text_colour(&h, false), RED_SUIT_COLOUR);
|
assert_eq!(text_colour(&h, false, false), RED_SUIT_COLOUR);
|
||||||
assert_eq!(text_colour(&d, false), RED_SUIT_COLOUR);
|
assert_eq!(text_colour(&d, false, false), RED_SUIT_COLOUR);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1787,8 +1815,8 @@ mod tests {
|
|||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
assert_eq!(text_colour(&c, false), BLACK_SUIT_COLOUR);
|
assert_eq!(text_colour(&c, false, false), BLACK_SUIT_COLOUR);
|
||||||
assert_eq!(text_colour(&s, false), BLACK_SUIT_COLOUR);
|
assert_eq!(text_colour(&s, false, false), BLACK_SUIT_COLOUR);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2082,7 +2110,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn text_colour_color_blind_mode_swaps_red_suits_to_lime() {
|
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 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!(
|
assert_eq!(
|
||||||
cbm_colour, RED_SUIT_COLOUR_CBM,
|
cbm_colour, RED_SUIT_COLOUR_CBM,
|
||||||
"color-blind mode must replace the red suit colour with the CBM lime",
|
"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() {
|
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 };
|
let black_card = Card { id: 0, suit: Suit::Clubs, rank: Rank::Jack, face_up: true };
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
text_colour(&black_card, true),
|
text_colour(&black_card, true, false),
|
||||||
BLACK_SUIT_COLOUR,
|
BLACK_SUIT_COLOUR,
|
||||||
"color-blind mode must not alter black-suit text 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)
|
// label_visibility (pure)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ pub const BG_HUD_BAND: Color = Color::srgba(0.125, 0.125, 0.125, 1.0);
|
|||||||
/// `#d0d0d0`.
|
/// `#d0d0d0`.
|
||||||
pub const TEXT_PRIMARY: Color = Color::srgb(0.816, 0.816, 0.816);
|
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`.
|
/// Secondary text — captions, hints, muted labels. `#a0a0a0`.
|
||||||
pub const TEXT_SECONDARY: Color = Color::srgb(0.627, 0.627, 0.627);
|
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`.
|
/// `#353535`.
|
||||||
pub const BORDER_SUBTLE: Color = Color::srgba(0.208, 0.208, 0.208, 1.0);
|
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.
|
/// Strong border — hover outline, focused button, active popover.
|
||||||
/// `outline` from the design system. `#505050`.
|
/// `outline` from the design system. `#505050`.
|
||||||
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
||||||
|
|||||||
Reference in New Issue
Block a user