feat(replay): HC-mode coverage for scrub track + notches
The 1 px scrub track and 5 quarter-mark notch ticks paint their shape via BackgroundColor (not BorderColor — they're tiny full-bleed Nodes, not borders on wider containers), so the existing HighContrastBorder marker doesn't apply to them. Add a parallel primitive in ui_theme: HighContrastBackground marker carrying default_color, mirroring HighContrastBorder's shape exactly. Add update_high_contrast_backgrounds system in settings_plugin alongside update_high_contrast_borders — same on/off rule (off → marker.default_color, on → BORDER_SUBTLE_HC), same change-suppression idiom (only mutate when different so Bevy's change-detection doesn't trigger per-frame repaints). Tag the scrub track Node and all five notch Nodes with HighContrastBackground::with_default(BORDER_SUBTLE) so the existing settings repaint cycle picks them up under HC mode. The scrub fill (ACCENT_PRIMARY brick-red) and WIN MOVE marker (STATE_SUCCESS lime-green) don't get the marker — accent and state colours are already saturated and don't need an HC luminance variant. 2 new tests: spawn-time marker presence on the track and cardinality-matches-notch-count on the ticks. Tests: 1250 → 1252. Clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ReplayOverlayScrubNotch>>()
|
||||
.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::<Entity, With<ReplayOverlayScrubFill>>();
|
||||
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]
|
||||
|
||||
@@ -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<SettingsResource>,
|
||||
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<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user