Merge pull request 'feat(engine): glass bar accessibility + drag duck-away' (#187) from feat/glass-bar-a11y into master
Test / fmt (push) Successful in 3s
Build and Deploy / build-and-push (push) Failing after 7m53s
Test / test (push) Successful in 4m33s
Web E2E / web-e2e (push) Successful in 7m31s

This commit was merged in pull request #187.
This commit is contained in:
2026-07-15 22:23:24 +00:00
9 changed files with 354 additions and 29 deletions
+9
View File
@@ -154,6 +154,14 @@ pub struct Settings {
/// `#[serde(default)]`. /// `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub reduce_motion_mode: bool, 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` /// Window size and screen position to restore on next launch. `None`
/// means "use platform defaults" — set on first run, then populated /// means "use platform defaults" — set on first run, then populated
/// as the player resizes / moves the window. Older `settings.json` /// as the player resizes / moves the window. Older `settings.json`
@@ -451,6 +459,7 @@ impl Default for Settings {
color_blind_mode: false, color_blind_mode: false,
high_contrast_mode: false, high_contrast_mode: false,
reduce_motion_mode: false, reduce_motion_mode: false,
reduce_transparency_mode: false,
window_geometry: None, window_geometry: None,
selected_theme_id: default_theme_id(), selected_theme_id: default_theme_id(),
shown_achievement_onboarding: false, shown_achievement_onboarding: false,
+1
View File
@@ -603,6 +603,7 @@ impl Plugin for HudPlugin {
// disjoint from the UiTextFx readout writers (tab labels // disjoint from the UiTextFx readout writers (tab labels
// vs HUD readouts), which Bevy can't prove, so declare it. // vs HUD readouts), which Bevy can't prove, so declare it.
animate_tab_expansion.ambiguous_with(UiTextFx), animate_tab_expansion.ambiguous_with(UiTextFx),
duck_tab_bar_during_drag,
) )
.chain() .chain()
.in_set(HudButtons) .in_set(HudButtons)
+60 -1
View File
@@ -24,7 +24,10 @@ use crate::assets::hud_icon_svg::HudIcon;
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface}; use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3}; use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
use bevy::text::{LineBreak, TextLayout}; use bevy::text::{LineBreak, TextLayout};
use bevy::ui::Overflow; use bevy::ui::{Overflow, UiTransform, Val2};
use crate::resources::DragState;
use crate::safe_area::SafeAreaInsets;
/// Height of both the pill and the circular Menu button, in logical px. /// Height of both the pill and the circular Menu button, in logical px.
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch /// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
@@ -99,6 +102,20 @@ pub struct TabLabelWrap {
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct TabLabel; pub struct TabLabel;
/// Duck-away state on the bar container: while a card drag is committed
/// the whole bar slides below the screen edge so it never occludes a
/// drop target on the bottom tableau rows, then slides back on release.
/// `progress` ∈ [0, 1]; 1 = fully off-screen.
#[derive(Component, Debug)]
pub struct TabBarDuck {
/// Current slide amount, animated by [`duck_tab_bar_during_drag`].
pub progress: f32,
}
/// Extra slide distance past the bar's own height so its drop shadow
/// (16 px blur + 6 px offset) also clears the screen edge.
const DUCK_SHADOW_ALLOWANCE_PX: f32 = 24.0;
/// Rasterised icon textures for the five tab-bar actions. /// Rasterised icon textures for the five tab-bar actions.
pub(super) struct TabIcons { pub(super) struct TabIcons {
undo: Handle<Image>, undo: Handle<Image>,
@@ -179,6 +196,8 @@ pub(super) fn spawn_glass_tab_bar(
base_bottom: TAB_BAR_MARGIN, base_bottom: TAB_BAR_MARGIN,
}, },
HudActionBar, HudActionBar,
TabBarDuck { progress: 0.0 },
UiTransform::IDENTITY,
)) ))
.with_children(|bar| { .with_children(|bar| {
// The main pill: Undo · [Draw] · Hint · Pause. // The main pill: Undo · [Draw] · Hint · Pause.
@@ -453,6 +472,46 @@ pub(super) fn animate_tab_expansion(
} }
} }
/// Slides the bar off the bottom edge while a card drag is committed and
/// back once it ends. Effectively touch-only: the desktop docked bar
/// never carries [`TabBarDuck`], so the query is empty there.
///
/// The slide animates `UiTransform::translation` (a render-time offset,
/// +y = down) rather than `Node::bottom`, for two reasons: layout is
/// never dirtied mid-drag (the whole point is not to cost frames while
/// the player is dragging), and `Node::bottom` is owned by the safe-area
/// anchor system — two writers on one field would fight.
pub(super) fn duck_tab_bar_during_drag(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
drag: Option<Res<DragState>>,
insets: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
mut bars: Query<(&mut TabBarDuck, &mut UiTransform), With<HudActionBar>>,
) {
let target = if drag.as_deref().is_some_and(|d| d.committed) {
1.0
} else {
0.0
};
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
let dt = time.delta_secs();
// Physical-px inset → logical, same conversion the safe-area anchors
// apply (CLAUDE.md §10): the bar sits `inset` above its base position,
// so the slide must cover that extra travel to fully clear the edge.
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let inset_logical = insets.as_deref().map_or(0.0, |i| i.bottom / scale);
let slide_px = TAB_BAR_CLEARANCE_PX + inset_logical + DUCK_SHADOW_ALLOWANCE_PX;
for (mut duck, mut transform) in &mut bars {
let next = step_expansion(duck.progress, target, dt, reduce_motion);
if next == duck.progress && duck.progress == target {
continue;
}
duck.progress = next;
transform.translation = Val2::px(0.0, next * slide_px);
}
}
/// Idle / hover / pressed feedback for glass tab buttons — the glass /// Idle / hover / pressed feedback for glass tab buttons — the glass
/// counterpart of `paint_action_buttons`, using each button's own colour /// counterpart of `paint_action_buttons`, using each button's own colour
/// triple so the accent-filled active pill and the transparent icon /// triple so the accent-filled active pill and the transparent icon
+48
View File
@@ -1014,6 +1014,54 @@ fn expansion_width_is_linear_in_progress() {
assert_eq!(expansion_width(4, 16.0, 1.5), full); assert_eq!(expansion_width(4, 16.0, 1.5), full);
} }
/// While a committed drag is active the bar must slide down (positive
/// `UiTransform` y — off-screen) and return to identity once the drag
/// ends. Manual time steps make the tween deterministic.
#[test]
fn tab_bar_ducks_during_committed_drag_and_returns() {
use crate::resources::DragState;
use bevy::ui::UiTransform;
let mut app = bar_only_app(true);
set_manual_time_step(&mut app, 0.05);
app.add_systems(Update, duck_tab_bar_during_drag);
app.insert_resource(DragState {
committed: true,
..default()
});
let translation_y_px = |app: &mut App| -> f32 {
let world = app.world_mut();
let mut q = world.query_filtered::<&UiTransform, With<HudActionBar>>();
let t = q.single(world).expect("bar carries UiTransform");
match t.translation.y {
Val::Px(px) => px,
other => panic!("duck must write Val::Px, got {other:?}"),
}
};
// 0.18s slide at 0.05s/frame: fully ducked within 5 frames.
app.update();
app.update();
let mid = translation_y_px(&mut app);
assert!(mid > 0.0, "bar must start sliding down: {mid}");
for _ in 0..5 {
app.update();
}
let ducked = translation_y_px(&mut app);
assert!(
ducked > TAB_BAR_CLEARANCE_PX,
"fully ducked bar must clear its own floating footprint: {ducked}"
);
// Drag ends → bar slides back to identity.
app.world_mut().resource_mut::<DragState>().committed = false;
for _ in 0..8 {
app.update();
}
assert_eq!(translation_y_px(&mut app), 0.0);
}
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot, /// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
/// and snap instantly under reduce-motion. /// and snap instantly under reduce-motion.
#[test] #[test]
@@ -353,6 +353,14 @@ pub(super) fn handle_settings_buttons(
**t = on_off_label(settings.0.reduce_motion_mode); **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 => { SettingsButton::ToggleTouchInputMode => {
use solitaire_data::settings::TouchInputMode; use solitaire_data::settings::TouchInputMode;
settings.0.touch_input_mode = match settings.0.touch_input_mode { settings.0.touch_input_mode = match settings.0.touch_input_mode {
+21 -2
View File
@@ -135,6 +135,10 @@ struct HighContrastText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct ReduceMotionText; 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. /// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TouchInputModeText; struct TouchInputModeText;
@@ -277,6 +281,10 @@ enum SettingsButton {
/// non-essential motion (card-slide animations become instant /// non-essential motion (card-slide animations become instant
/// snaps) per `design-system.md` §Accessibility (#3). /// snaps) per `design-system.md` §Accessibility (#3).
ToggleReduceMotion, 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` /// Toggle [`Settings::touch_input_mode`] between `OneTap`
/// (auto-move on tap, default) and `TapToSelect` (first tap selects /// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it). /// a card/stack, second tap on a target pile moves it).
@@ -361,8 +369,9 @@ impl SettingsButton {
// run before continuing to the picker rows. // run before continuing to the picker rows.
SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62, SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63, SettingsButton::ToggleReduceTransparency => 63,
SettingsButton::CycleUiScale => 64, SettingsButton::ToggleTouchInputMode => 64,
SettingsButton::CycleUiScale => 65,
// Picker rows — every swatch in a row shares the row's // Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right. // priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectCardBack(_) => 70,
@@ -514,6 +523,16 @@ impl Plugin for SettingsPlugin {
update_smart_default_size_text, update_smart_default_size_text,
), ),
); );
app.add_systems(
Update,
(
update_reduce_transparency_text,
// Guards internally on settings-change / newly added
// glass — `Added<GlassSurface>` can't be expressed as
// a resource run condition.
update_glass_surfaces,
),
);
app.add_systems( app.add_systems(
Update, Update,
( (
@@ -317,6 +317,15 @@ fn spawn_accessibility_tab(
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.", "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res, 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( toggle_row(
body, body,
"Touch Input Mode", "Touch Input Mode",
@@ -9,8 +9,10 @@ use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::ThemeThumbnailCache; use crate::theme::ThemeThumbnailCache;
use crate::ui_glass::{GlassSurface, glass_decorations};
use crate::ui_modal::ModalScrim; use crate::ui_modal::ModalScrim;
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder}; use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
use bevy::ui::{BackgroundGradient, BorderGradient};
/// Spawns the Settings panel when `SettingsScreen` becomes `true`; /// Spawns the Settings panel when `SettingsScreen` becomes `true`;
/// despawns it when it becomes `false`. /// 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<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceTransparencyText>>,
) {
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<SettingsResource>,
mut surfaces: Query<(&mut BackgroundGradient, &mut BorderGradient), With<GlassSurface>>,
added: Query<(), Added<GlassSurface>>,
) {
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( pub(super) fn update_touch_input_mode_text(
settings: Res<SettingsResource>, settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>, mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
+161 -26
View File
@@ -30,10 +30,19 @@ use bevy::ui::{
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle, 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 /// Border width every glass surface must reserve in its `Node::border` so
/// the rim gradient has a strip to paint into. /// the rim gradient has a strip to paint into.
pub const GLASS_BORDER_PX: f32 = 1.0; 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% /// 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 /// opacity so the felt and cards remain visible through the bar while
/// keeping icon/label contrast comfortable. /// 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. /// Drop shadow under the floating surface.
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35); 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. /// Visual components for a floating glass surface.
/// ///
/// The caller owns the `Node` and must set two fields for the material to /// 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, /// 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. /// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
pub fn glass_surface() -> impl Bundle { 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 GlassSurface,
// the lighting so there is a single source of truth for the surface sheen,
// colour (a separate `BackgroundColor` would just be painted over). rim,
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(),
]),
// Soft shadow underneath sells the "floating above the table" read. // Soft shadow underneath sells the "floating above the table" read.
BoxShadow(vec![ShadowStyle { BoxShadow(vec![ShadowStyle {
color: GLASS_SHADOW, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use bevy::ui::Gradient;
/// The bundle must insert all three visual components — a regression /// The bundle must insert all three visual components — a regression
/// here (e.g. a refactor dropping the rim) would silently flatten the /// here (e.g. a refactor dropping the rim) would silently flatten the
@@ -142,5 +234,48 @@ mod tests {
fn rim_highlight_peaks_at_top() { fn rim_highlight_peaks_at_top() {
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha()); assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.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::<GlassSurface>(e).is_some());
} }
} }