From c2256582cda6480970f3fc7e7218d0c76af8b400 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 15 Jul 2026 15:16:32 -0700 Subject: [PATCH] feat(engine): reduce-transparency + high-contrast support for glass chrome New Settings::reduce_transparency_mode (serde-default, Accessibility toggle row) swaps every GlassSurface's gradients for a flat opaque BG_ELEVATED fill; high-contrast mode now also boosts the glass fill opacity and rim luminance to BORDER_SUBTLE_HC levels. Both applied by settings_plugin::update_glass_surfaces, which retargets the gradients in place on toggle or on newly spawned glass. Co-Authored-By: Claude Fable 5 --- solitaire_data/src/settings.rs | 9 + solitaire_engine/src/settings_plugin/input.rs | 8 + solitaire_engine/src/settings_plugin/mod.rs | 23 ++- solitaire_engine/src/settings_plugin/ui.rs | 9 + .../src/settings_plugin/updates.rs | 37 ++++ solitaire_engine/src/ui_glass.rs | 187 +++++++++++++++--- 6 files changed, 245 insertions(+), 28 deletions(-) diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 1201c1e..3eb70b9 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -154,6 +154,14 @@ pub struct Settings { /// `#[serde(default)]`. #[serde(default)] pub reduce_motion_mode: bool, + /// When `true`, replaces translucent "glass" chrome (the floating + /// touch tab bar) with opaque fills so table content behind it can + /// never reduce label/icon contrast. The accessibility counterpart + /// of iOS "Reduce Transparency". Older `settings.json` files written + /// before this field existed deserialize cleanly to `false` thanks + /// to `#[serde(default)]`. + #[serde(default)] + pub reduce_transparency_mode: bool, /// Window size and screen position to restore on next launch. `None` /// means "use platform defaults" — set on first run, then populated /// as the player resizes / moves the window. Older `settings.json` @@ -451,6 +459,7 @@ impl Default for Settings { color_blind_mode: false, high_contrast_mode: false, reduce_motion_mode: false, + reduce_transparency_mode: false, window_geometry: None, selected_theme_id: default_theme_id(), shown_achievement_onboarding: false, diff --git a/solitaire_engine/src/settings_plugin/input.rs b/solitaire_engine/src/settings_plugin/input.rs index 680e914..cdef105 100644 --- a/solitaire_engine/src/settings_plugin/input.rs +++ b/solitaire_engine/src/settings_plugin/input.rs @@ -353,6 +353,14 @@ pub(super) fn handle_settings_buttons( **t = on_off_label(settings.0.reduce_motion_mode); } } + SettingsButton::ToggleReduceTransparency => { + settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode; + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + // Text refreshed by `update_reduce_transparency_text`; the + // glass chrome itself follows via `update_glass_surfaces` + // next frame. + } SettingsButton::ToggleTouchInputMode => { use solitaire_data::settings::TouchInputMode; settings.0.touch_input_mode = match settings.0.touch_input_mode { diff --git a/solitaire_engine/src/settings_plugin/mod.rs b/solitaire_engine/src/settings_plugin/mod.rs index 9ed84f2..ea580f9 100644 --- a/solitaire_engine/src/settings_plugin/mod.rs +++ b/solitaire_engine/src/settings_plugin/mod.rs @@ -135,6 +135,10 @@ struct HighContrastText; #[derive(Component, Debug)] struct ReduceMotionText; +/// Marks the `Text` node showing the current reduce-transparency state. +#[derive(Component, Debug)] +struct ReduceTransparencyText; + /// Marks the `Text` node showing the current touch input mode state. #[derive(Component, Debug)] struct TouchInputModeText; @@ -277,6 +281,10 @@ enum SettingsButton { /// non-essential motion (card-slide animations become instant /// snaps) per `design-system.md` §Accessibility (#3). ToggleReduceMotion, + /// Toggle the [`Settings::reduce_transparency_mode`] flag — swaps + /// translucent glass chrome (the floating touch tab bar) for opaque + /// fills so table content never bleeds through UI text. + ToggleReduceTransparency, /// Toggle [`Settings::touch_input_mode`] between `OneTap` /// (auto-move on tap, default) and `TapToSelect` (first tap selects /// a card/stack, second tap on a target pile moves it). @@ -361,8 +369,9 @@ impl SettingsButton { // run before continuing to the picker rows. SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleReduceMotion => 62, - SettingsButton::ToggleTouchInputMode => 63, - SettingsButton::CycleUiScale => 64, + SettingsButton::ToggleReduceTransparency => 63, + SettingsButton::ToggleTouchInputMode => 64, + SettingsButton::CycleUiScale => 65, // Picker rows — every swatch in a row shares the row's // priority so entity-index tiebreaking yields left → right. SettingsButton::SelectCardBack(_) => 70, @@ -514,6 +523,16 @@ impl Plugin for SettingsPlugin { update_smart_default_size_text, ), ); + app.add_systems( + Update, + ( + update_reduce_transparency_text, + // Guards internally on settings-change / newly added + // glass — `Added` can't be expressed as + // a resource run condition. + update_glass_surfaces, + ), + ); app.add_systems( Update, ( diff --git a/solitaire_engine/src/settings_plugin/ui.rs b/solitaire_engine/src/settings_plugin/ui.rs index 89f286f..9c29e4a 100644 --- a/solitaire_engine/src/settings_plugin/ui.rs +++ b/solitaire_engine/src/settings_plugin/ui.rs @@ -317,6 +317,15 @@ fn spawn_accessibility_tab( "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.", font_res, ); + toggle_row( + body, + "Reduce Transparency", + ReduceTransparencyText, + on_off_label(settings.reduce_transparency_mode), + SettingsButton::ToggleReduceTransparency, + "Replaces the see-through glass toolbar with a solid panel so cards behind it never reduce readability.", + font_res, + ); toggle_row( body, "Touch Input Mode", diff --git a/solitaire_engine/src/settings_plugin/updates.rs b/solitaire_engine/src/settings_plugin/updates.rs index 3363bfe..63a2b6e 100644 --- a/solitaire_engine/src/settings_plugin/updates.rs +++ b/solitaire_engine/src/settings_plugin/updates.rs @@ -9,8 +9,10 @@ use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::theme::ThemeThumbnailCache; +use crate::ui_glass::{GlassSurface, glass_decorations}; use crate::ui_modal::ModalScrim; use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder}; +use bevy::ui::{BackgroundGradient, BorderGradient}; /// Spawns the Settings panel when `SettingsScreen` becomes `true`; /// despawns it when it becomes `false`. @@ -330,6 +332,41 @@ pub(super) fn update_reduce_motion_text( } } +pub(super) fn update_reduce_transparency_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_transparency_mode); + } +} + +/// Retargets every [`GlassSurface`]'s sheen + rim gradients when the +/// reduce-transparency or high-contrast toggles change. Runs on +/// `SettingsResource` change and on newly added surfaces (so glass +/// spawned after startup picks up an already-active toggle). Parallel to +/// [`update_high_contrast_borders`]: same trigger, different components. +pub(super) fn update_glass_surfaces( + settings: Res, + mut surfaces: Query<(&mut BackgroundGradient, &mut BorderGradient), With>, + added: Query<(), Added>, +) { + if !settings.is_changed() && added.is_empty() { + return; + } + let (sheen, rim) = glass_decorations( + settings.0.reduce_transparency_mode, + settings.0.high_contrast_mode, + ); + for (mut s, mut r) in &mut surfaces { + *s = sheen.clone(); + *r = rim.clone(); + } +} + pub(super) fn update_touch_input_mode_text( settings: Res, mut text_nodes: Query<&mut Text, With>, diff --git a/solitaire_engine/src/ui_glass.rs b/solitaire_engine/src/ui_glass.rs index 4a773c8..f63cd76 100644 --- a/solitaire_engine/src/ui_glass.rs +++ b/solitaire_engine/src/ui_glass.rs @@ -30,10 +30,19 @@ use bevy::ui::{ BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle, }; +use crate::ui_theme::{BG_ELEVATED, BORDER_SUBTLE, BORDER_SUBTLE_HC}; + /// Border width every glass surface must reserve in its `Node::border` so /// the rim gradient has a strip to paint into. pub const GLASS_BORDER_PX: f32 = 1.0; +/// Marker on every node that received [`glass_surface`]. The settings +/// plugin's `update_glass_surfaces` retargets these when the player +/// toggles reduce-transparency or high-contrast, swapping the gradients +/// in place via [`glass_decorations`]. +#[derive(Component, Debug)] +pub struct GlassSurface; + /// Base fill at the *bottom* of the glass sheet — near-black at ~62% /// opacity so the felt and cards remain visible through the bar while /// keeping icon/label contrast comfortable. @@ -58,6 +67,22 @@ const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16); /// Drop shadow under the floating surface. const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35); +/// High-contrast fill — same glass, far less see-through, so text and +/// icons keep contrast over any card art. Per `design-system.md` +/// §Accessibility the HC toggle trades aesthetics for legibility. +const GLASS_FILL_TOP_HC: Color = Color::srgba(0.20, 0.21, 0.23, 0.90); +/// High-contrast fill at the bottom of the sheet. +const GLASS_FILL_BOTTOM_HC: Color = Color::srgba(0.055, 0.055, 0.063, 0.88); + +/// High-contrast rim at the top edge — matches the luminance of +/// `BORDER_SUBTLE_HC` so the bar's outline reads as strongly as every +/// other HC-boosted border. +const GLASS_RIM_TOP_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.75); +/// High-contrast rim at the midpoint. +const GLASS_RIM_MID_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.35); +/// High-contrast rim at the bottom edge. +const GLASS_RIM_BOTTOM_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.50); + /// Visual components for a floating glass surface. /// /// The caller owns the `Node` and must set two fields for the material to @@ -69,33 +94,11 @@ const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35); /// Everything here is fragment-shader work inside Bevy's stock UI pipeline, /// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes. pub fn glass_surface() -> impl Bundle { + let (sheen, rim) = glass_decorations(false, false); ( - // Sheen: one top-to-bottom linear gradient carries both the fill and - // the lighting so there is a single source of truth for the surface - // colour (a separate `BackgroundColor` would just be painted over). - BackgroundGradient(vec![ - LinearGradient::new( - LinearGradient::TO_BOTTOM, - vec![ - ColorStop::new(GLASS_FILL_TOP, Val::Percent(0.0)), - ColorStop::new(GLASS_FILL_BOTTOM, Val::Percent(60.0)), - ], - ) - .into(), - ]), - // Specular rim: bright at the top edge, fading out through the - // sides, with a faint return at the bottom. - BorderGradient(vec![ - LinearGradient::new( - LinearGradient::TO_BOTTOM, - vec![ - ColorStop::new(GLASS_RIM_TOP, Val::Percent(0.0)), - ColorStop::new(GLASS_RIM_MID, Val::Percent(55.0)), - ColorStop::new(GLASS_RIM_BOTTOM, Val::Percent(100.0)), - ], - ) - .into(), - ]), + GlassSurface, + sheen, + rim, // Soft shadow underneath sells the "floating above the table" read. BoxShadow(vec![ShadowStyle { color: GLASS_SHADOW, @@ -107,9 +110,98 @@ pub fn glass_surface() -> impl Bundle { ) } +/// The sheen + rim pair for the requested accessibility state. The +/// settings plugin overwrites every [`GlassSurface`]'s components with +/// these when the relevant toggles change. +/// +/// - Default: translucent fill with a top-lit sheen and a specular rim. +/// - `high_contrast`: same shape, near-opaque fill, and a rim boosted to +/// `BORDER_SUBTLE_HC` luminance so the outline stays legible. +/// - `reduce_transparency`: flat opaque `BG_ELEVATED` fill and a flat +/// border — no see-through at all. Combined with `high_contrast` the +/// flat border brightens to `BORDER_SUBTLE_HC`. +pub fn glass_decorations( + reduce_transparency: bool, + high_contrast: bool, +) -> (BackgroundGradient, BorderGradient) { + if reduce_transparency { + // "Gradients" with a single stop render as flat fills — reusing the + // same component types means the settings toggle swaps values, not + // component sets. + let border = if high_contrast { + BORDER_SUBTLE_HC + } else { + BORDER_SUBTLE + }; + return ( + BackgroundGradient(vec![ + LinearGradient::new( + LinearGradient::TO_BOTTOM, + vec![ColorStop::new(BG_ELEVATED, Val::Percent(0.0))], + ) + .into(), + ]), + BorderGradient(vec![ + LinearGradient::new( + LinearGradient::TO_BOTTOM, + vec![ColorStop::new(border, Val::Percent(0.0))], + ) + .into(), + ]), + ); + } + + let (fill_top, fill_bottom, rim_top, rim_mid, rim_bottom) = if high_contrast { + ( + GLASS_FILL_TOP_HC, + GLASS_FILL_BOTTOM_HC, + GLASS_RIM_TOP_HC, + GLASS_RIM_MID_HC, + GLASS_RIM_BOTTOM_HC, + ) + } else { + ( + GLASS_FILL_TOP, + GLASS_FILL_BOTTOM, + GLASS_RIM_TOP, + GLASS_RIM_MID, + GLASS_RIM_BOTTOM, + ) + }; + ( + // Sheen: one top-to-bottom linear gradient carries both the fill and + // the lighting so there is a single source of truth for the surface + // colour (a separate `BackgroundColor` would just be painted over). + BackgroundGradient(vec![ + LinearGradient::new( + LinearGradient::TO_BOTTOM, + vec![ + ColorStop::new(fill_top, Val::Percent(0.0)), + ColorStop::new(fill_bottom, Val::Percent(60.0)), + ], + ) + .into(), + ]), + // Specular rim: bright at the top edge, fading out through the + // sides, with a faint return at the bottom. + BorderGradient(vec![ + LinearGradient::new( + LinearGradient::TO_BOTTOM, + vec![ + ColorStop::new(rim_top, Val::Percent(0.0)), + ColorStop::new(rim_mid, Val::Percent(55.0)), + ColorStop::new(rim_bottom, Val::Percent(100.0)), + ], + ) + .into(), + ]), + ) +} + #[cfg(test)] mod tests { use super::*; + use bevy::ui::Gradient; /// The bundle must insert all three visual components — a regression /// here (e.g. a refactor dropping the rim) would silently flatten the @@ -142,5 +234,48 @@ mod tests { fn rim_highlight_peaks_at_top() { assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha()); assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha()); + assert!(GLASS_RIM_TOP_HC.alpha() > GLASS_RIM_BOTTOM_HC.alpha()); + assert!(GLASS_RIM_BOTTOM_HC.alpha() > GLASS_RIM_MID_HC.alpha()); + } + + /// Reduce-transparency must produce fully opaque fills — the entire + /// point of the toggle is that nothing shows through. + #[test] + fn reduce_transparency_is_fully_opaque() { + for high_contrast in [false, true] { + let (sheen, _) = glass_decorations(true, high_contrast); + for gradient in &sheen.0 { + let Gradient::Linear(linear) = gradient else { + panic!("reduce-transparency sheen must stay linear"); + }; + for stop in &linear.stops { + assert_eq!( + stop.color.alpha(), + 1.0, + "opaque variant leaked translucency (hc={high_contrast})" + ); + } + } + } + } + + /// High-contrast glass must be meaningfully less transparent than the + /// default, and its rim meaningfully brighter — otherwise the toggle + /// does nothing perceptible on this surface. + #[test] + fn high_contrast_boosts_fill_and_rim() { + assert!(GLASS_FILL_TOP_HC.alpha() >= GLASS_FILL_TOP.alpha() + 0.15); + assert!(GLASS_FILL_BOTTOM_HC.alpha() >= GLASS_FILL_BOTTOM.alpha() + 0.15); + assert!(GLASS_RIM_TOP_HC.alpha() >= GLASS_RIM_TOP.alpha() + 0.25); + } + + /// `glass_surface()` must carry the marker the settings applier + /// retargets — without it the accessibility toggles silently skip + /// the bar. + #[test] + fn glass_surface_carries_retarget_marker() { + let mut world = World::new(); + let e = world.spawn(glass_surface()).id(); + assert!(world.get::(e).is_some()); } }