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:
@@ -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)));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
Reference in New Issue
Block a user