diff --git a/solitaire_engine/src/replay_overlay.rs b/solitaire_engine/src/replay_overlay.rs index 571318c..87631aa 100644 --- a/solitaire_engine/src/replay_overlay.rs +++ b/solitaire_engine/src/replay_overlay.rs @@ -36,9 +36,9 @@ use crate::replay_playback::{ use solitaire_data::ReplayMove; use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_theme::{ - ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, STATE_SUCCESS, TEXT_PRIMARY, - TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, - Z_DROP_OVERLAY, + ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder, + STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, TYPE_HEADLINE, + VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, }; // --------------------------------------------------------------------------- @@ -537,6 +537,14 @@ fn spawn_overlay( ..default() }, BackgroundColor(BORDER_SUBTLE), + // HC marker: bumps the 1 px track from #505050 + // → #a0a0a0 under high-contrast mode. The track + // paints via BackgroundColor (it's a 1 px Node, + // not a border on a wider container) so the + // BorderColor-targeting HighContrastBorder marker + // doesn't apply — HighContrastBackground is the + // parallel primitive for this case. + HighContrastBackground::with_default(BORDER_SUBTLE), )) .with_children(|track| { track.spawn(( @@ -592,6 +600,12 @@ fn spawn_overlay( ..default() }, BackgroundColor(BORDER_SUBTLE), + // Same HC-paint reasoning as the track + // above: 5 px tall × 1 px wide tick mark + // paints via BackgroundColor, so + // HighContrastBackground (not -Border) is + // the right marker. + HighContrastBackground::with_default(BORDER_SUBTLE), )); } }); @@ -1809,6 +1823,74 @@ mod tests { ); } + /// Each spawned notch carries `HighContrastBackground` so the + /// existing `update_high_contrast_backgrounds` system bumps + /// `BORDER_SUBTLE` → `BORDER_SUBTLE_HC` under HC mode. + /// Five-of-five — every notch tagged. + #[test] + fn scrub_notches_carry_high_contrast_background_marker() { + let mut app = headless_app(); + set_state( + &mut app, + ReplayPlaybackState::Playing { + replay: synthetic_replay(10), + cursor: 0, + secs_to_next: 0.5, + paused: false, + }, + ); + app.update(); + + let count = app + .world_mut() + .query_filtered::<&HighContrastBackground, With>() + .iter(app.world()) + .count(); + assert_eq!( + count, + scrub_notch_positions().len(), + "every notch must carry HighContrastBackground for HC repaint coverage", + ); + } + + /// The 1 px scrub track also carries `HighContrastBackground` so + /// the unfilled portion bumps under HC. The fill (ACCENT_PRIMARY, + /// brick-red) doesn't need a marker — accent colours are + /// already saturated and don't need an HC variant. + #[test] + fn scrub_track_carries_high_contrast_background_marker() { + let mut app = headless_app(); + set_state( + &mut app, + ReplayPlaybackState::Playing { + replay: synthetic_replay(10), + cursor: 0, + secs_to_next: 0.5, + paused: false, + }, + ); + app.update(); + + // Track is the parent Node of the scrub-fill. Find it by + // walking up from `ReplayOverlayScrubFill` to its parent. + let world = app.world_mut(); + let mut fill_q = world.query_filtered::>(); + let fill = fill_q + .iter(world) + .next() + .expect("scrub fill must exist while overlay is spawned"); + let mut parent_q = world.query::<&ChildOf>(); + let parent = parent_q + .get(world, fill) + .map(|p| p.parent()) + .expect("scrub fill must have a parent (the track)"); + let mut hc_q = world.query::<&HighContrastBackground>(); + assert!( + hc_q.get(world, parent).is_ok(), + "scrub track Node (parent of scrub fill) must carry HighContrastBackground", + ); + } + /// Notches share the overlay tree's lifecycle — they despawn on /// `Playing → Inactive` along with the banner root. #[test] diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 22dcbfe..b84b267 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -34,7 +34,8 @@ use crate::ui_modal::{ }; use crate::ui_tooltip::Tooltip; use crate::ui_theme::{ - BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBorder, + BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground, + HighContrastBorder, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, }; @@ -365,6 +366,7 @@ impl Plugin for SettingsPlugin { update_color_blind_text, update_high_contrast_text, update_high_contrast_borders, + update_high_contrast_backgrounds, update_reduce_motion_text, update_tooltip_delay_text, update_time_bonus_multiplier_text, @@ -674,6 +676,41 @@ fn update_high_contrast_borders( } } +/// Repaints `BackgroundColor` on every entity tagged with +/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`. +/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC` +/// (`#a0a0a0`). Compares against the current background and only +/// mutates when different so Bevy's change-detection doesn't trigger +/// repaints every frame. +/// +/// Parallel to [`update_high_contrast_borders`]. Same on/off rule, +/// same change-suppression idiom, different colour channel — +/// `BackgroundColor` for tick marks, decorative strips, fine +/// separators that paint their shape directly rather than via a +/// `BorderColor` on a wider Node. +/// +/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track +/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`). +/// +/// More sites can be tagged in follow-ups by adding +/// `HighContrastBackground::with_default(...)` to their spawn tuple. +pub(crate) fn update_high_contrast_backgrounds( + settings: Res, + mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>, +) { + let high_contrast = settings.0.high_contrast_mode; + for (marker, mut bg) in backgrounds.iter_mut() { + let target = if high_contrast { + BORDER_SUBTLE_HC + } else { + marker.default_color + }; + if bg.0 != target { + *bg = BackgroundColor(target); + } + } +} + fn update_reduce_motion_text( settings: Res, mut text_nodes: Query<&mut Text, With>, diff --git a/solitaire_engine/src/ui_theme.rs b/solitaire_engine/src/ui_theme.rs index 032c62a..b9a8351 100644 --- a/solitaire_engine/src/ui_theme.rs +++ b/solitaire_engine/src/ui_theme.rs @@ -252,6 +252,35 @@ impl HighContrastBorder { } } +/// Marker for entities whose [`BackgroundColor`] should swap to +/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on. +/// Parallel to [`HighContrastBorder`] but for sites that paint their +/// shape via `BackgroundColor` rather than `BorderColor` — +/// `bevy::ui` 1 px decorative strips, tick marks, fine separators +/// often render as tiny full-bleed `Node`s, not as borders, so the +/// border-marker pattern doesn't apply. +/// +/// `default_color` records the off-state colour the entity was +/// spawned with so the system can revert when HC is toggled back +/// off. The accompanying paint system is +/// [`update_high_contrast_backgrounds`](crate::settings_plugin::update_high_contrast_backgrounds). +/// +/// [`BackgroundColor`]: bevy::prelude::BackgroundColor +#[derive(bevy::prelude::Component, Debug, Clone, Copy)] +pub struct HighContrastBackground { + /// Background colour to use when high-contrast mode is *off* — + /// the site's normal idle / active-state colour. + pub default_color: bevy::prelude::Color, +} + +impl HighContrastBackground { + /// Convenience constructor — + /// `HighContrastBackground::with_default(BORDER_SUBTLE)`. + pub const fn with_default(default_color: bevy::prelude::Color) -> Self { + Self { default_color } + } +} + /// 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);