From c15336362681d1f528208a23d25ca4a1419f09b1 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 8 May 2026 14:34:05 -0700 Subject: [PATCH] =?UTF-8?q?feat(accessibility):=20finish=20HC=20rollout=20?= =?UTF-8?q?=E2=80=94=20HUD=20+=20modal=20buttons=20+=20radial=20rim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_engine/src/hud_plugin.rs | 3 +- solitaire_engine/src/radial_menu.rs | 67 +++++++++++++++++++++++++++-- solitaire_engine/src/ui_modal.rs | 1 + 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 9d541ee..de206c2 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -19,7 +19,7 @@ use crate::settings_plugin::SettingsResource; use crate::layout::HUD_BAND_HEIGHT; use crate::ui_theme::{ 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, 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, @@ -715,6 +715,7 @@ fn spawn_action_button( }, BackgroundColor(ACTION_BTN_IDLE), BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index 70820fa..956d747 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent; use crate::layout::{Layout, LayoutResource}; use crate::pause_plugin::PausedResource; 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. /// @@ -533,8 +534,17 @@ fn radial_handle_release_or_cancel( /// Despawns and respawns the radial overlay sprites every frame the /// 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( state: Res, + settings: Option>, mut commands: Commands, existing_icons: Query>, existing_centres: Query>, @@ -569,13 +579,12 @@ fn radial_redraw_overlay( 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() { let focused = *hovered_index == Some(i); let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 }; let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY }; - // Hovered icon gets a strong yellow rim; resting icons get a - // muted purple rim so the focused one reads as the obvious target. - let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE }; + let outline = radial_rim_outline(focused, high_contrast); commands .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 // --------------------------------------------------------------------------- @@ -940,4 +970,33 @@ mod tests { "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); + } } diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 215fdf9..cd2134b 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -372,6 +372,7 @@ pub fn spawn_modal_button( }, BackgroundColor(idle_bg(variant)), BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));