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:
funman300
2026-05-08 17:14:03 -07:00
parent c8358f4275
commit d3cb1a51d4
3 changed files with 152 additions and 4 deletions
+85 -3
View File
@@ -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]