//! "Liquid glass" material for floating UI surfaces. //! //! Produces the translucent, light-catching treatment used by the floating //! touch tab bar (`hud_plugin::tab_bar`): a semi-transparent fill with a //! vertical sheen, a specular rim highlight around the border, and a soft //! drop shadow underneath. The table shows through the fill *sharp* — Bevy's //! UI pipeline has no backdrop-blur concept, so this module approximates //! frosted glass with layered gradients instead of sampling what's behind //! the node. That keeps it a plain `bevy_ui` bundle: no custom shaders, no //! extra render passes, and identical behaviour on desktop, Android, and //! WebGL2. //! //! # Swapping the treatment //! //! Layout code depends only on [`glass_surface`] and [`GLASS_BORDER_PX`]. //! A future real-refraction material (e.g. a `UiMaterial` sampling a blurred //! render target) can replace this module's internals without touching any //! caller — that boundary is the reason this file exists separately from //! `hud_plugin/tab_bar.rs`. //! //! # Departure from the Terminal design system //! //! `ui_theme` documents HUD chrome as opaque "status-line" panels. The glass //! bar is a deliberate, user-approved exception for the floating touch tab //! bar only; do not reuse this material for modals or the top HUD band //! without a matching design decision. use bevy::prelude::*; 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. const GLASS_FILL_BOTTOM: Color = Color::srgba(0.055, 0.055, 0.063, 0.62); /// Fill at the *top* of the sheet — the same glass lifted towards white, /// reading as the curved surface catching overhead light. const GLASS_FILL_TOP: Color = Color::srgba(0.20, 0.21, 0.23, 0.66); /// Rim highlight at the top edge — the bright specular line where a real /// glass pill would catch the light source. const GLASS_RIM_TOP: Color = Color::srgba(1.0, 1.0, 1.0, 0.38); /// Rim at the midpoint — almost gone, so the highlight reads as a glint /// rather than a drawn outline. const GLASS_RIM_MID: Color = Color::srgba(1.0, 1.0, 1.0, 0.06); /// Rim at the bottom edge — a faint secondary catch-light, as on the /// underside of a curved surface above a bright table. 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 /// render correctly: /// - `border: UiRect::all(Val::Px(GLASS_BORDER_PX))` — the rim gradient /// paints into the border strip and is invisible without it. /// - `border_radius` — half the node height for a fully-round pill. /// /// 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); ( GlassSurface, sheen, rim, // Soft shadow underneath sells the "floating above the table" read. BoxShadow(vec![ShadowStyle { color: GLASS_SHADOW, x_offset: Val::Px(0.0), y_offset: Val::Px(6.0), spread_radius: Val::Px(0.0), blur_radius: Val::Px(16.0), }]), ) } /// 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 /// glass into a plain translucent box. #[test] fn glass_surface_inserts_sheen_rim_and_shadow() { let mut world = World::new(); let e = world.spawn(glass_surface()).id(); assert!(world.get::(e).is_some()); assert!(world.get::(e).is_some()); assert!(world.get::(e).is_some()); } /// The fill must stay translucent (that is the whole point of glass) but /// opaque enough that text keeps contrast over busy card art. #[test] fn glass_fill_alpha_stays_in_readable_band() { for fill in [GLASS_FILL_TOP, GLASS_FILL_BOTTOM] { let a = fill.alpha(); assert!( (0.4..=0.85).contains(&a), "glass fill alpha {a} outside readable 0.4–0.85 band" ); } } /// The rim gradient must actually glint: strictly brightest at the top, /// dimmest in the middle. #[test] 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()); } }