feat(accessibility): finish HC rollout — HUD + modal buttons + radial rim

Closes the v0.21.2 carve-out: dynamic-paint sites that were left
un-tagged because their paint cycles were assumed to race
`update_high_contrast_borders`. Re-reading the code revealed only
one of three sites is actually a border-paint cycle — the other
two paint backgrounds, with static borders that take the marker
pattern cleanly:

* HUD action buttons (`spawn_action_button`): `paint_action_buttons`
  only mutates `BackgroundColor`. Tag the spawn with
  `HighContrastBorder::with_default(BORDER_SUBTLE)`.
* Modal buttons (`spawn_modal_button`): `paint_modal_buttons` also
  only mutates `BackgroundColor`. Same marker pattern.
* Radial menu rim (`radial_redraw_overlay`): full despawn-respawn
  every frame; sprites, not UI nodes; the marker can't apply. Folds
  the HC choice into the spawn site instead — under HC the
  *focused* rim boosts to `BORDER_SUBTLE_HC` rather than
  `BORDER_STRONG`. Naive marker substitution would invert the
  visual hierarchy because `BORDER_SUBTLE_HC` (#a0a0a0) is lighter
  than `BORDER_STRONG` (#505050); folding the choice in keeps the
  focused rim *more* visible under HC, not less.

Decision logic for the rim is extracted to `radial_rim_outline` —
a pure function with a 4-row truth-table test (focused × HC).

After this commit, every UI surface tagged in v0.21.x's
accessibility arc either carries `HighContrastBorder` or has its
HC behaviour folded into its own spawn cycle. No "un-tagged
because race-risk" surfaces remain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 14:34:05 -07:00
parent 93b67f1d0b
commit c153363626
3 changed files with 66 additions and 5 deletions
+2 -1
View File
@@ -19,7 +19,7 @@ use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT; use crate::layout::HUD_BAND_HEIGHT;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS, BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS, MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
@@ -715,6 +715,7 @@ fn spawn_action_button<M: Component>(
}, },
BackgroundColor(ACTION_BTN_IDLE), BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
+63 -4
View File
@@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource}; use crate::resources::{DragState, GameStateResource};
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS}; use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
/// Sprite-space `Transform.z` for radial-menu overlay sprites. /// Sprite-space `Transform.z` for radial-menu overlay sprites.
/// ///
@@ -533,8 +534,17 @@ fn radial_handle_release_or_cancel(
/// Despawns and respawns the radial overlay sprites every frame the /// Despawns and respawns the radial overlay sprites every frame the
/// state is `Active`; despawns them when the state returns to `Idle`. /// state is `Active`; despawns them when the state returns to `Idle`.
///
/// Reads [`SettingsResource`] so the focused-icon outline can boost to
/// [`BORDER_SUBTLE_HC`] under high-contrast mode. Per-frame respawn is
/// the simplest place to fold HC in: this is the only system that
/// owns the rim sprite, so there's no parallel paint path to fight.
/// ([`HighContrastBorder`](crate::ui_theme::HighContrastBorder) doesn't
/// apply because the rim is a `Sprite`, not a UI node with
/// `BorderColor`, and the entities don't persist across frames.)
fn radial_redraw_overlay( fn radial_redraw_overlay(
state: Res<RightClickRadialState>, state: Res<RightClickRadialState>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands, mut commands: Commands,
existing_icons: Query<Entity, With<RadialIcon>>, existing_icons: Query<Entity, With<RadialIcon>>,
existing_centres: Query<Entity, With<RadialCentre>>, existing_centres: Query<Entity, With<RadialCentre>>,
@@ -569,13 +579,12 @@ fn radial_redraw_overlay(
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01), Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
)); ));
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() { for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
let focused = *hovered_index == Some(i); let focused = *hovered_index == Some(i);
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 }; let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY }; let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
// Hovered icon gets a strong yellow rim; resting icons get a let outline = radial_rim_outline(focused, high_contrast);
// muted purple rim so the focused one reads as the obvious target.
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
commands commands
.spawn(( .spawn((
@@ -606,6 +615,27 @@ fn radial_redraw_overlay(
} }
} }
/// Pure decision logic for the radial-icon rim outline colour.
///
/// Resting icons always carry [`BORDER_SUBTLE`] so the focused icon
/// reads as the obvious target. Under high-contrast mode the focused
/// rim boosts to [`BORDER_SUBTLE_HC`] (`#a0a0a0`) instead of
/// [`BORDER_STRONG`] (`#505050`) — naive marker substitution via
/// [`HighContrastBorder`](crate::ui_theme::HighContrastBorder) would
/// invert the hierarchy because the resting colour
/// (`#353535`) is darker than `BORDER_STRONG`. This shape keeps the
/// focused rim *more* visible under HC, not less.
///
/// Factored out as a pure function so the truth-table is unit-testable
/// without spinning up the per-frame respawn system.
fn radial_rim_outline(focused: bool, high_contrast: bool) -> Color {
match (focused, high_contrast) {
(true, true) => BORDER_SUBTLE_HC,
(true, false) => BORDER_STRONG,
(false, _) => BORDER_SUBTLE,
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -940,4 +970,33 @@ mod tests {
"face-down cards must not open the radial" "face-down cards must not open the radial"
); );
} }
// -----------------------------------------------------------------------
// radial_rim_outline — accessibility / high-contrast truth table
// -----------------------------------------------------------------------
#[test]
fn rim_resting_uses_subtle_outline_without_hc() {
assert_eq!(radial_rim_outline(false, false), BORDER_SUBTLE);
}
#[test]
fn rim_focused_uses_strong_outline_without_hc() {
assert_eq!(radial_rim_outline(true, false), BORDER_STRONG);
}
#[test]
fn rim_focused_boosts_to_subtle_hc_under_hc() {
assert_eq!(radial_rim_outline(true, true), BORDER_SUBTLE_HC);
}
#[test]
fn rim_resting_stays_subtle_under_hc_to_preserve_hierarchy() {
// Naive marker substitution would also flip the resting outline
// to BORDER_SUBTLE_HC, which is *lighter* than BORDER_STRONG —
// that would invert the focused/resting hierarchy. Holding the
// resting colour at BORDER_SUBTLE keeps the focused icon the
// obvious target under HC.
assert_eq!(radial_rim_outline(false, true), BORDER_SUBTLE);
}
} }
+1
View File
@@ -372,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
}, },
BackgroundColor(idle_bg(variant)), BackgroundColor(idle_bg(variant)),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label.into()), font_label, TextColor(label_color))); b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));