From 37919cfe17e6c4c46f37cecc043d4d0f999626ce Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 15 Jul 2026 13:40:39 -0700 Subject: [PATCH] feat(engine): add ui_glass liquid-glass material module Layered bevy_ui treatment (sheen gradient, specular BorderGradient rim, BoxShadow) approximating frosted glass without shaders or backdrop sampling - WebGL2-safe by construction. Layout callers depend only on glass_surface() + GLASS_BORDER_PX so the material can be swapped later. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/lib.rs | 1 + solitaire_engine/src/ui_glass.rs | 146 +++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 solitaire_engine/src/ui_glass.rs diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 35f7fd2..0ec1a4b 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -63,6 +63,7 @@ pub mod theme_store_plugin; pub mod time_attack_plugin; pub mod touch_selection_plugin; pub mod ui_focus; +pub mod ui_glass; pub mod ui_modal; pub mod ui_theme; pub mod ui_tooltip; diff --git a/solitaire_engine/src/ui_glass.rs b/solitaire_engine/src/ui_glass.rs new file mode 100644 index 0000000..4a773c8 --- /dev/null +++ b/solitaire_engine/src/ui_glass.rs @@ -0,0 +1,146 @@ +//! "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, +}; + +/// 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; + +/// 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); + +/// 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 { + ( + // 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(), + ]), + // 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), + }]), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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()); + } +}