From 07e035771c4dc95b6a9c2f532fa9687cb12ec843 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 8 May 2026 11:26:24 -0700 Subject: [PATCH] feat(accessibility): add Settings UI toggles for high-contrast + reduce-motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume-prompt Option F, part 2 of 2 — pairs with the engine wiring in c5787c6. Adds two toggle rows to the Settings panel under Cosmetic so players can flip the new accessibility flags without hand-editing settings.json. Mirrors the Color-blind Mode row pattern almost exactly: - Two new marker components (`HighContrastText`, `ReduceMotionText`) tagging the Text nodes that show ON/OFF. - Two new `SettingsButton` enum variants (`ToggleHighContrast`, `ToggleReduceMotion`) with `focus_order` 61/62 — sit right after `ToggleColorBlind` (60) so tab-walk visits all three accessibility flags in one vertical run before continuing to picker rows. - Two new click-handler branches in `handle_settings_buttons` flipping the bool, persisting, broadcasting `SettingsChangedEvent`, and updating the row label. - Two new live-label updaters (`update_high_contrast_text`, `update_reduce_motion_text`) so the row reflects external changes (e.g. someone editing settings.json mid-session, or a future a11y-import feature). - Generic `on_off_label(enabled: bool) -> String` helper shared by both new toggles. Could fold `color_blind_label` and `winnable_deals_only_label` into it too — punted for scope; both already work and a name-only refactor would just churn the diff. Query-disambiguator chains updated: every existing settings-text query in `handle_settings_buttons` gains `Without, Without` at the end so the new components don't ambiguate the existing mutations. The two new queries carry mirrored `Without<...>` chains for the same reason. Verbose but matches the existing pattern; future Bevy archetype-set query API would simplify this, not in 0.18. Workspace clippy + cargo test --workspace clean. 1191 passing (unchanged from c5787c6 — UI plumbing has no test coverage in this commit; the toggle behaviour is exercised through the engine tests in c5787c6). Closes Resume-prompt Option F. Co-Authored-By: Claude Opus 4.7 --- solitaire_engine/src/settings_plugin.rs | 103 ++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index f15261f..0e2de05 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -125,6 +125,14 @@ struct BackgroundText; #[derive(Component, Debug)] struct ColorBlindText; +/// Marks the `Text` node showing the current high-contrast mode state. +#[derive(Component, Debug)] +struct HighContrastText; + +/// Marks the `Text` node showing the current reduce-motion mode state. +#[derive(Component, Debug)] +struct ReduceMotionText; + /// Marks the `Text` node showing the live tooltip-delay value. #[derive(Component, Debug)] struct TooltipDelayText; @@ -201,6 +209,14 @@ enum SettingsButton { ReplayMoveIntervalUp, ToggleTheme, ToggleColorBlind, + /// Toggle the [`Settings::high_contrast_mode`] flag — boosts + /// foreground / suit-red glyphs to higher-luminance variants per + /// `design-system.md` §Accessibility (#2). + ToggleHighContrast, + /// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses + /// non-essential motion (card-slide animations become instant + /// snaps) per `design-system.md` §Accessibility (#3). + ToggleReduceMotion, /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// random Classic-mode deals are filtered through /// [`solitaire_core::solver::try_solve`] until one is provably @@ -255,6 +271,11 @@ impl SettingsButton { // Cosmetic section SettingsButton::ToggleTheme => 55, SettingsButton::ToggleColorBlind => 60, + // Accessibility-section toggles sit alongside Color-blind so + // tab-walk visits all three a11y flags in the same vertical + // run before continuing to the picker rows. + SettingsButton::ToggleHighContrast => 61, + SettingsButton::ToggleReduceMotion => 62, // Picker rows — every swatch in a row shares the row's // priority so entity-index tiebreaking yields left → right. SettingsButton::SelectCardBack(_) => 70, @@ -342,6 +363,8 @@ impl Plugin for SettingsPlugin { update_background_text, update_anim_speed_text, update_color_blind_text, + update_high_contrast_text, + update_reduce_motion_text, update_tooltip_delay_text, update_time_bonus_multiplier_text, update_replay_move_interval_text, @@ -602,6 +625,30 @@ fn update_color_blind_text( } } +fn update_high_contrast_text( + settings: Res, + mut text_nodes: Query<&mut Text, With>, +) { + if !settings.is_changed() { + return; + } + for mut text in &mut text_nodes { + **text = on_off_label(settings.0.high_contrast_mode); + } +} + +fn update_reduce_motion_text( + settings: Res, + mut text_nodes: Query<&mut Text, With>, +) { + if !settings.is_changed() { + return; + } + for mut text in &mut text_nodes { + **text = on_off_label(settings.0.reduce_motion_mode); + } +} + /// Refreshes the live "Winnable deals only" toggle value in the /// Gameplay section whenever `SettingsResource` changes (button click, /// hand-edited `settings.json` reload, etc.). @@ -720,12 +767,14 @@ fn handle_settings_buttons( path: Res, mut changed: MessageWriter, mut manual_sync: MessageWriter, - mut sfx_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, - mut music_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, - mut draw_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, - mut theme_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, - mut anim_speed_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, - mut color_blind_text: Query<&mut Text, (With, Without, Without, Without, Without, Without)>, + mut sfx_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, + mut music_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, + mut draw_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, + mut theme_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, + mut anim_speed_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, + mut color_blind_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, + mut high_contrast_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, + mut reduce_motion_text: Query<&mut Text, (With, Without, Without, Without, Without, Without, Without, Without)>, ) { for (interaction, button) in &interaction_query { if *interaction != Interaction::Pressed { @@ -879,6 +928,22 @@ fn handle_settings_buttons( **t = color_blind_label(settings.0.color_blind_mode); } } + SettingsButton::ToggleHighContrast => { + settings.0.high_contrast_mode = !settings.0.high_contrast_mode; + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + if let Ok(mut t) = high_contrast_text.single_mut() { + **t = on_off_label(settings.0.high_contrast_mode); + } + } + SettingsButton::ToggleReduceMotion => { + settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode; + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + if let Ok(mut t) = reduce_motion_text.single_mut() { + **t = on_off_label(settings.0.reduce_motion_mode); + } + } SettingsButton::ToggleWinnableDealsOnly => { settings.0.winnable_deals_only = !settings.0.winnable_deals_only; persist(&path, &settings.0); @@ -951,6 +1016,14 @@ fn color_blind_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } +/// Generic ON/OFF label shared by the high-contrast and reduce- +/// motion accessibility toggles. Same format as +/// [`color_blind_label`] / [`winnable_deals_only_label`] — +/// keeping all simple boolean toggle rows visually uniform. +fn on_off_label(enabled: bool) -> String { + if enabled { "ON".into() } else { "OFF".into() } +} + /// Display string for the "Winnable deals only" toggle. Mirrors /// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform /// with the rest of the Gameplay-section toggles. @@ -1386,6 +1459,24 @@ fn spawn_settings_panel( "Show shape glyphs alongside suit colors. Suit-blind friendly.", font_res, ); + toggle_row( + body, + "High Contrast", + HighContrastText, + on_off_label(settings.high_contrast_mode), + SettingsButton::ToggleHighContrast, + "Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.", + font_res, + ); + toggle_row( + body, + "Reduce Motion", + ReduceMotionText, + on_off_label(settings.reduce_motion_mode), + SettingsButton::ToggleReduceMotion, + "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.", + font_res, + ); if theme_overrides_back { // The active theme provides its own back; the legacy // picker has no visible effect, so we replace its