diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs
index b71c432..b1d5bef 100644
--- a/solitaire_engine/src/animation_plugin.rs
+++ b/solitaire_engine/src/animation_plugin.rs
@@ -179,10 +179,13 @@ pub struct ToastStackRoot;
pub struct ToastNode;
/// Logical-pixel gap between the screen bottom and the toast stack,
-/// before safe-area insets. Clears the Phase F bottom action bar on
-/// touch (compact 44px buttons + primary 64px trio + bar padding);
-/// desktop's shorter bar needs less.
-const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
+/// before safe-area insets. Clears the floating glass tab bar on touch
+/// (its margin + height + breathing room); desktop's docked bar needs less.
+const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
+ crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
+} else {
+ 72.0
+};
/// Spawns the persistent [`ToastStackRoot`] container at startup.
fn spawn_toast_stack_root(mut commands: Commands) {
diff --git a/solitaire_engine/src/assets/hud_icon_svg.rs b/solitaire_engine/src/assets/hud_icon_svg.rs
new file mode 100644
index 0000000..3e5713c
--- /dev/null
+++ b/solitaire_engine/src/assets/hud_icon_svg.rs
@@ -0,0 +1,127 @@
+//! SVG builders for the floating tab bar's action icons.
+//!
+//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
+//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
+//! is missing on Android entirely), so the icons are authored here as tiny
+//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
+//! as the card themes (`assets::rasterize_svg`).
+//!
+//! Icons are drawn in **white** on a transparent background inside a
+//! 24-unit logical box; the spawn site tints them to the theme colour via
+//! `ImageNode::color` (a per-node multiply), so one texture serves every
+//! button state.
+
+use bevy::image::Image;
+use bevy::math::UVec2;
+
+use super::svg_loader::{SvgLoaderError, rasterize_svg};
+
+/// Rasterisation size for one tab-bar icon texture. Icons display at
+/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
+/// factor (and any future 3× phone) while staying trivially small.
+pub const HUD_ICON_TEXTURE_PX: u32 = 96;
+
+/// The five actions the floating touch tab bar can show.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HudIcon {
+ /// Counter-clockwise "take back" arrow.
+ Undo,
+ /// Two offset card outlines — draw from the stock.
+ Draw,
+ /// Lightbulb — highlight a suggested move.
+ Hint,
+ /// Two vertical bars.
+ Pause,
+ /// Three-line hamburger — the floating menu button.
+ Menu,
+}
+
+impl HudIcon {
+ /// Build the icon's SVG document. All icons share one visual language:
+ /// 2-unit white strokes, round caps, transparent fill, 24-unit box.
+ pub fn svg(self) -> String {
+ let body = match self {
+ // Arrow head pointing left into a half-circle sweeping back
+ // over the top — the conventional undo mark.
+ HudIcon::Undo => concat!(
+ r##""##,
+ r##""##,
+ ),
+ // Back card peeks out behind the front card; the front card is
+ // drawn second so its stroke reads on top.
+ HudIcon::Draw => concat!(
+ r##""##,
+ r##""##,
+ ),
+ // Bulb dome with a flat lamp base beneath.
+ HudIcon::Hint => concat!(
+ r##""##,
+ r##""##,
+ ),
+ HudIcon::Pause => concat!(
+ r##""##,
+ r##""##,
+ ),
+ HudIcon::Menu => concat!(
+ r##""##,
+ r##""##,
+ r##""##,
+ ),
+ };
+ format!(
+ concat!(
+ r##""##,
+ ),
+ body = body
+ )
+ }
+
+ /// Rasterise this icon into a Bevy [`Image`] at
+ /// [`HUD_ICON_TEXTURE_PX`] square.
+ pub fn rasterize(self) -> Result {
+ rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const ALL: [HudIcon; 5] = [
+ HudIcon::Undo,
+ HudIcon::Draw,
+ HudIcon::Hint,
+ HudIcon::Pause,
+ HudIcon::Menu,
+ ];
+
+ /// Every icon must survive the real usvg/resvg pipeline at the real
+ /// texture size — a malformed path here would otherwise only surface
+ /// as a blank button on device.
+ #[test]
+ fn every_icon_rasterizes_at_texture_size() {
+ for icon in ALL {
+ let img = icon.rasterize().expect("icon SVG must rasterise");
+ assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
+ assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
+ }
+ }
+
+ /// Rasterised icons must contain visible (non-transparent) pixels —
+ /// guards against a stroke accidentally drawn outside the viewBox.
+ #[test]
+ fn every_icon_has_visible_pixels() {
+ for icon in ALL {
+ let img = icon.rasterize().expect("icon SVG must rasterise");
+ let data = img.data.as_ref().expect("rasterised image has data");
+ let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
+ assert!(
+ opaque > 100,
+ "{icon:?} renders only {opaque} visible pixels"
+ );
+ }
+ }
+}
diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs
index 7b11934..21d946a 100644
--- a/solitaire_engine/src/assets/mod.rs
+++ b/solitaire_engine/src/assets/mod.rs
@@ -5,6 +5,7 @@
//! custom `AssetSource` implementations used by the theme system.
pub mod card_face_svg;
+pub mod hud_icon_svg;
pub mod icon_svg;
pub mod sources;
pub mod svg_loader;
diff --git a/solitaire_engine/src/hud_plugin/fx.rs b/solitaire_engine/src/hud_plugin/fx.rs
index d58ec9c..5308103 100644
--- a/solitaire_engine/src/hud_plugin/fx.rs
+++ b/solitaire_engine/src/hud_plugin/fx.rs
@@ -109,12 +109,19 @@ pub(super) fn apply_action_fade(
/// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared
-/// `ActionButton` marker.
+/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry
+/// their own per-button colour triple and are painted by
+/// `paint_glass_tab_buttons` instead (this system's opaque `ACTION_BTN_*`
+/// fills would destroy the translucent glass look).
#[allow(clippy::type_complexity)]
pub(super) fn paint_action_buttons(
mut buttons: Query<
(&Interaction, &mut BackgroundColor),
- (With, Changed),
+ (
+ With,
+ Without,
+ Changed,
+ ),
>,
) {
for (interaction, mut bg) in &mut buttons {
diff --git a/solitaire_engine/src/hud_plugin/mod.rs b/solitaire_engine/src/hud_plugin/mod.rs
index ffd8448..a2ef864 100644
--- a/solitaire_engine/src/hud_plugin/mod.rs
+++ b/solitaire_engine/src/hud_plugin/mod.rs
@@ -12,11 +12,13 @@ use bevy::window::WindowResized;
mod fx;
mod interaction;
mod spawn;
+pub(crate) mod tab_bar;
mod updates;
pub use fx::*;
use interaction::*;
use spawn::*;
+use tab_bar::*;
use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
@@ -595,6 +597,12 @@ impl Plugin for HudPlugin {
handle_menu_backdrop_click,
close_menu_popover_on_escape,
paint_action_buttons,
+ paint_glass_tab_buttons,
+ drive_tab_expansion,
+ // Writes tab-label TextColor and reads TextFont — entity-
+ // disjoint from the UiTextFx readout writers (tab labels
+ // vs HUD readouts), which Bevy can't prove, so declare it.
+ animate_tab_expansion.ambiguous_with(UiTextFx),
)
.chain()
.in_set(HudButtons)
diff --git a/solitaire_engine/src/hud_plugin/spawn.rs b/solitaire_engine/src/hud_plugin/spawn.rs
index d07758c..1d227fb 100644
--- a/solitaire_engine/src/hud_plugin/spawn.rs
+++ b/solitaire_engine/src/hud_plugin/spawn.rs
@@ -360,6 +360,7 @@ pub(super) fn handle_avatar_button(
pub(super) fn spawn_action_buttons(
font_res: Option>,
windows: Query<&Window>,
+ images: Option>>,
mut commands: Commands,
) {
let action_font_size =
@@ -370,9 +371,18 @@ pub(super) fn spawn_action_buttons(
..default()
};
- // Bottom bar: full-width, centered, sits above the gesture-navigation zone.
+ // Touch layout: the floating glass tab bar (pill + circular Menu).
+ // `Assets` is absent under `MinimalPlugins`; `rasterize_into`
+ // returning `None` switches the bar to its text-fallback form.
+ if USE_TOUCH_UI_LAYOUT {
+ let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
+ spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
+ return;
+ }
+
+ // Desktop bottom bar: full-width, centered, docked to the bottom edge.
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
- // Android reports it (frames 1-3); initial value is 0.0.
+ // the platform reports it; initial value is 0.0.
commands
.spawn((
Node {
@@ -400,73 +410,10 @@ pub(super) fn spawn_action_buttons(
HudActionBar,
))
.with_children(|row| {
- if USE_TOUCH_UI_LAYOUT {
- spawn_touch_action_bar(row, &font);
- } else {
- spawn_desktop_action_bar(row, &font);
- }
+ spawn_desktop_action_bar(row, &font);
});
}
-/// Phase F touch action bar — five buttons, three of them big.
-///
-/// The core gameplay trio (**Undo · Draw · Hint**) gets enlarged
-/// thumb-reach targets; Menu and Pause stay compact at the edges. The
-/// utility actions the desktop bar carries are reachable elsewhere on
-/// touch and are deliberately absent here: Help lives in Menu → System,
-/// mode switching lives on the Home screen (Phase B), and New Game is
-/// Home's hero button. Draw duplicates the stock-pile tap so the most
-/// frequent action of all no longer requires reaching the top half of a
-/// tall folded screen.
-pub(super) fn spawn_touch_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
- // The trailing `order` argument feeds `Focusable { group: Hud, order }`
- // so Tab (external keyboard) cycles the bar in visual reading order.
- spawn_action_button(
- row,
- MenuButton,
- "Menu",
- None,
- "Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
- font,
- 0,
- TEXT_SECONDARY,
- );
- spawn_primary_action_button(
- row,
- UndoButton,
- "Undo",
- "Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
- font,
- 1,
- );
- spawn_primary_action_button(
- row,
- DrawButton,
- "Draw",
- "Draw from the stock — same as tapping the deck.",
- font,
- 2,
- );
- spawn_primary_action_button(
- row,
- HintButton,
- TOUCH_HINT_LABEL,
- "Highlight a suggested move. Cycles through alternatives on repeat taps.",
- font,
- 3,
- );
- spawn_action_button(
- row,
- PauseButton,
- "Pause",
- None,
- "Pause the game and freeze the timer.",
- font,
- 4,
- TEXT_SECONDARY,
- );
-}
-
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
/// is touch-only). All seven actions, uniform sizing.
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
@@ -585,44 +532,9 @@ pub(super) fn spawn_action_button(
);
}
-/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
-/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
-/// and Hint read (and hit) as the primary actions. Hotkey chips are
-/// irrelevant on touch, so the variant takes none.
-pub(super) fn spawn_primary_action_button(
- row: &mut ChildSpawnerCommands,
- marker: M,
- label: &str,
- tooltip: &'static str,
- font: &TextFont,
- order: i32,
-) {
- let (pad, min_w, min_h) = primary_action_button_metrics();
- let primary_font = TextFont {
- font: font.font.clone(),
- // 1.35× the bar's responsive size, capped so landscape tablets
- // don't blow the row height out.
- font_size: (font.font_size * 1.35).min(24.0),
- ..default()
- };
- spawn_action_button_sized(
- row,
- marker,
- label,
- None,
- tooltip,
- &primary_font,
- order,
- TEXT_PRIMARY,
- pad,
- min_w,
- min_h,
- );
-}
-
-/// Shared body of [`spawn_action_button`] / [`spawn_primary_action_button`]
-/// — one place owns the component set so `paint_action_buttons`, tooltips,
-/// and the focus ring treat every bar button identically.
+/// Shared body of [`spawn_action_button`] — one place owns the component
+/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
+/// bar button identically.
#[allow(clippy::too_many_arguments)]
fn spawn_action_button_sized(
row: &mut ChildSpawnerCommands,
diff --git a/solitaire_engine/src/hud_plugin/tab_bar.rs b/solitaire_engine/src/hud_plugin/tab_bar.rs
new file mode 100644
index 0000000..5d3ef8c
--- /dev/null
+++ b/solitaire_engine/src/hud_plugin/tab_bar.rs
@@ -0,0 +1,470 @@
+//! Floating "liquid glass" touch tab bar — layout and animation.
+//!
+//! Replaces the docked full-width touch action bar (Phase F) with an
+//! iOS-26-style floating arrangement: a pill of icon buttons hovering
+//! above the bottom edge, plus a detached circular Menu button to its
+//! right. The *visual treatment* (translucent sheen, specular rim, drop
+//! shadow) lives in [`crate::ui_glass`]; this module owns only geometry,
+//! button wiring, and the expand/collapse label animation, so the glass
+//! material can be swapped without touching layout.
+//!
+//! Interaction compatibility: every button keeps the same marker
+//! components as the old bar (`UndoButton`, `DrawButton`, …) plus
+//! `ActionButton`, `Button`, `Tooltip`, and `Focusable`, so the click
+//! handlers, tooltips, focus ring, and the tap-to-toggle chrome guard in
+//! `interaction.rs` all work unchanged.
+//!
+//! Expansion model (user decision, 2026-07-15): **Draw** — the most
+//! frequent action — is the persistently-expanded "active" pill showing
+//! icon + label; the other buttons are icon-only and reveal their label
+//! with a width-animated slide while pressed.
+
+use super::*;
+use crate::assets::hud_icon_svg::HudIcon;
+use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
+use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
+use bevy::text::{LineBreak, TextLayout};
+use bevy::ui::Overflow;
+
+/// 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
+/// floor with 8 px of glass around them.
+pub(crate) const TAB_BAR_HEIGHT: f32 = 64.0;
+
+/// Gap between the bar and the bottom screen edge (the "floating" margin;
+/// the Android gesture-bar inset is added on top by `SafeAreaAnchoredBottom`).
+const TAB_BAR_MARGIN: f32 = SPACE_3;
+
+/// Vertical space UI stacked above the bar (the toast stack) must clear:
+/// floating margin + bar height + one spacing step of breathing room.
+pub(crate) const TAB_BAR_CLEARANCE_PX: f32 = TAB_BAR_MARGIN + TAB_BAR_HEIGHT + SPACE_2;
+
+/// Diameter of a button inside the pill (and its fully-round radius ×2).
+const TAB_BUTTON_SIZE: f32 = 48.0;
+
+/// Displayed icon size in logical px (texture is rasterised larger; see
+/// `assets::hud_icon_svg::HUD_ICON_TEXTURE_PX`).
+const TAB_ICON_PX: f32 = 24.0;
+
+/// FiraMono glyph advance as a fraction of font size. The bar is
+/// monospace-only, so `chars × advance × font_size` is the exact label
+/// width — the same ratio `action_bar_font_size` documents.
+const FIRA_ADVANCE: f32 = 0.62;
+
+/// Gap between an icon and its revealed label, folded into the animated
+/// wrapper width so it collapses together with the text.
+const LABEL_LEAD_PX: f32 = SPACE_2;
+
+/// Idle fill of an inactive icon button — fully transparent so the pill's
+/// glass shows through untouched.
+const GLASS_BTN_IDLE: Color = Color::srgba(1.0, 1.0, 1.0, 0.0);
+/// Hover fill of an inactive icon button (desktop pointer / DPAD focus).
+const GLASS_BTN_HOVER: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
+/// Pressed fill of an inactive icon button.
+const GLASS_BTN_PRESSED: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
+/// Pressed state of the active (accent-filled) pill — darker than idle so
+/// the press reads as the surface receding, mirroring `BG_ELEVATED_PRESSED`.
+const GLASS_ACTIVE_PRESSED: Color = Color::srgb(0.55, 0.22, 0.22);
+
+/// Idle / hovered / pressed fills for one tab-bar button. Presence of this
+/// component also routes the button to `paint_glass_tab_buttons` instead of
+/// the legacy `paint_action_buttons` (which excludes it).
+#[derive(Component, Debug)]
+pub struct GlassTabButton {
+ idle: Color,
+ hovered: Color,
+ pressed: Color,
+}
+
+/// Animated label-reveal state, on the clipping wrapper node between the
+/// button and its `Text` label. `progress` ∈ [0, 1] maps to wrapper width
+/// via [`expansion_width`]; `target` is where it is heading.
+#[derive(Component, Debug)]
+pub struct TabLabelWrap {
+ /// Current reveal amount, 0 = collapsed, 1 = fully expanded.
+ pub progress: f32,
+ /// Reveal amount the animation is moving towards.
+ pub target: f32,
+ /// `true` keeps the label expanded regardless of interaction —
+ /// the persistent Draw pill, and every button in text-fallback mode.
+ persistent: bool,
+ /// Label length; width is recomputed from the live font size each
+ /// frame so viewport-driven font rescales stay correct.
+ label_chars: usize,
+ /// Last width written to the node, to skip no-op layout writes.
+ last_width: f32,
+}
+
+/// Marker on the label `Text` inside a [`TabLabelWrap`].
+#[derive(Component, Debug)]
+pub struct TabLabel;
+
+/// Rasterised icon textures for the five tab-bar actions.
+pub(super) struct TabIcons {
+ undo: Handle,
+ draw: Handle,
+ hint: Handle,
+ pause: Handle,
+ menu: Handle,
+}
+
+impl TabIcons {
+ /// Rasterise all five icons into `images`. Returns `None` (and warns)
+ /// if any icon fails — the caller then falls back to text-only labels,
+ /// so a bad SVG degrades the look, never the playability.
+ pub(super) fn rasterize_into(images: &mut Assets) -> Option {
+ let mut add = |icon: HudIcon| match icon.rasterize() {
+ Ok(img) => Some(images.add(img)),
+ Err(e) => {
+ warn!("tab_bar: failed to rasterise {icon:?} icon: {e}");
+ None
+ }
+ };
+ Some(Self {
+ undo: add(HudIcon::Undo)?,
+ draw: add(HudIcon::Draw)?,
+ hint: add(HudIcon::Hint)?,
+ pause: add(HudIcon::Pause)?,
+ menu: add(HudIcon::Menu)?,
+ })
+ }
+}
+
+/// Wrapper width in logical px for a label of `chars` monospace glyphs at
+/// `font_size`, revealed to `progress`. Pure so the animation curve is
+/// unit-testable without an app.
+pub(super) fn expansion_width(chars: usize, font_size: f32, progress: f32) -> f32 {
+ progress.clamp(0.0, 1.0) * (LABEL_LEAD_PX + chars as f32 * font_size * FIRA_ADVANCE)
+}
+
+/// Advance `progress` towards `target` by one frame of `dt` seconds.
+/// Linear tween over `MOTION_SLIDE_SECS`, snapping when reduce-motion is
+/// active. Pure for the same reason as [`expansion_width`].
+pub(super) fn step_expansion(progress: f32, target: f32, dt: f32, reduce_motion: bool) -> f32 {
+ if reduce_motion {
+ return target;
+ }
+ let max_step = dt / crate::ui_theme::MOTION_SLIDE_SECS;
+ progress + (target - progress).clamp(-max_step, max_step)
+}
+
+/// Spawns the floating glass tab bar: one full-width invisible flex row
+/// (carrying the `HudActionBar` marker so chrome show/hide keeps working)
+/// centring the glass pill and the detached circular Menu button.
+///
+/// `icons: None` (rasteriser unavailable, e.g. `MinimalPlugins` tests, or
+/// an SVG regression) switches every button to text-fallback: labels stay
+/// expanded so the bar remains fully usable without textures.
+pub(super) fn spawn_glass_tab_bar(
+ commands: &mut Commands,
+ font: &TextFont,
+ icons: Option<&TabIcons>,
+) {
+ let text_fallback = icons.is_none();
+ commands
+ .spawn((
+ Node {
+ position_type: PositionType::Absolute,
+ bottom: Val::Px(TAB_BAR_MARGIN),
+ left: Val::Px(0.0),
+ width: Val::Percent(100.0),
+ flex_direction: FlexDirection::Row,
+ justify_content: JustifyContent::Center,
+ align_items: AlignItems::Center,
+ column_gap: VAL_SPACE_3,
+ ..default()
+ },
+ ZIndex(Z_HUD),
+ SafeAreaAnchoredBottom {
+ base_bottom: TAB_BAR_MARGIN,
+ },
+ HudActionBar,
+ ))
+ .with_children(|bar| {
+ // The main pill: Undo · [Draw] · Hint · Pause.
+ bar.spawn((
+ Node {
+ height: Val::Px(TAB_BAR_HEIGHT),
+ flex_direction: FlexDirection::Row,
+ align_items: AlignItems::Center,
+ padding: UiRect::axes(Val::Px(SPACE_2), Val::Px(0.0)),
+ column_gap: Val::Px(4.0),
+ border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
+ border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
+ ..default()
+ },
+ glass_surface(),
+ ))
+ .with_children(|pill| {
+ spawn_tab_button(
+ pill,
+ UndoButton,
+ "Undo",
+ "Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
+ font,
+ 0,
+ icons.map(|i| i.undo.clone()),
+ false,
+ text_fallback,
+ );
+ spawn_tab_button(
+ pill,
+ DrawButton,
+ "Draw",
+ "Draw from the stock — same as tapping the deck.",
+ font,
+ 1,
+ icons.map(|i| i.draw.clone()),
+ true,
+ text_fallback,
+ );
+ spawn_tab_button(
+ pill,
+ HintButton,
+ TOUCH_HINT_LABEL,
+ "Highlight a suggested move. Cycles through alternatives on repeat taps.",
+ font,
+ 2,
+ icons.map(|i| i.hint.clone()),
+ false,
+ text_fallback,
+ );
+ spawn_tab_button(
+ pill,
+ PauseButton,
+ "Pause",
+ "Pause the game and freeze the timer.",
+ font,
+ 3,
+ icons.map(|i| i.pause.clone()),
+ false,
+ text_fallback,
+ );
+ });
+
+ // The detached circular Menu button, same glass material.
+ bar.spawn((
+ MenuButton,
+ ActionButton,
+ Button,
+ GlassTabButton {
+ idle: GLASS_BTN_IDLE,
+ hovered: GLASS_BTN_HOVER,
+ pressed: GLASS_BTN_PRESSED,
+ },
+ Tooltip::new(
+ "Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
+ ),
+ Focusable {
+ group: FocusGroup::Hud,
+ order: 4,
+ },
+ Node {
+ width: Val::Px(TAB_BAR_HEIGHT),
+ height: Val::Px(TAB_BAR_HEIGHT),
+ justify_content: JustifyContent::Center,
+ align_items: AlignItems::Center,
+ border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
+ border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
+ ..default()
+ },
+ BackgroundColor(GLASS_BTN_IDLE),
+ glass_surface(),
+ ))
+ .with_children(|circle| {
+ if let Some(icons) = icons {
+ spawn_tab_icon(circle, icons.menu.clone(), TEXT_PRIMARY);
+ } else {
+ // Text fallback: a menu glyph-free label keeps the
+ // button discoverable without any texture.
+ circle.spawn((
+ Text::new("Menu"),
+ TextLayout::new_with_linebreak(LineBreak::NoWrap),
+ font.clone(),
+ TextColor(TEXT_PRIMARY),
+ ));
+ }
+ });
+ });
+}
+
+/// Spawns one button inside the pill: icon, then the width-animated label
+/// wrapper. `active` gives the accent-filled persistent pill (Draw);
+/// `text_fallback` pins every label open when icons are unavailable.
+#[allow(clippy::too_many_arguments)]
+fn spawn_tab_button(
+ pill: &mut ChildSpawnerCommands,
+ marker: M,
+ label: &str,
+ tooltip: &'static str,
+ font: &TextFont,
+ order: i32,
+ icon: Option>,
+ active: bool,
+ text_fallback: bool,
+) {
+ let (idle, hovered, pressed) = if active {
+ (ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, GLASS_ACTIVE_PRESSED)
+ } else {
+ (GLASS_BTN_IDLE, GLASS_BTN_HOVER, GLASS_BTN_PRESSED)
+ };
+ let icon_tint = if active { TEXT_PRIMARY } else { TEXT_SECONDARY };
+ let persistent = active || text_fallback;
+ let start = if persistent { 1.0 } else { 0.0 };
+ let chars = label.chars().count();
+
+ pill.spawn((
+ marker,
+ ActionButton,
+ Button,
+ GlassTabButton {
+ idle,
+ hovered,
+ pressed,
+ },
+ Tooltip::new(tooltip),
+ Focusable {
+ group: FocusGroup::Hud,
+ order,
+ },
+ Node {
+ height: Val::Px(TAB_BUTTON_SIZE),
+ min_width: Val::Px(TAB_BUTTON_SIZE),
+ flex_direction: FlexDirection::Row,
+ justify_content: JustifyContent::Center,
+ align_items: AlignItems::Center,
+ padding: UiRect::axes(Val::Px(SPACE_3), Val::Px(0.0)),
+ border_radius: BorderRadius::all(Val::Px(TAB_BUTTON_SIZE / 2.0)),
+ ..default()
+ },
+ BackgroundColor(idle),
+ ))
+ .with_children(|b| {
+ if let Some(handle) = icon {
+ spawn_tab_icon(b, handle, icon_tint);
+ }
+ // The clipping wrapper the animation drives. Label text sits
+ // left-anchored inside, so growing the width reveals it
+ // left-to-right and shrinking clips it from the right.
+ b.spawn((
+ TabLabelWrap {
+ progress: start,
+ target: start,
+ persistent,
+ label_chars: chars,
+ last_width: f32::NAN,
+ },
+ Node {
+ overflow: Overflow::clip(),
+ width: Val::Px(expansion_width(chars, font.font_size, start)),
+ height: Val::Percent(100.0),
+ align_items: AlignItems::Center,
+ justify_content: JustifyContent::FlexEnd,
+ ..default()
+ },
+ ))
+ .with_children(|wrap| {
+ let mut color = TEXT_PRIMARY;
+ color.set_alpha(start);
+ wrap.spawn((
+ TabLabel,
+ ActionButtonLabel,
+ Text::new(label),
+ TextLayout::new_with_linebreak(LineBreak::NoWrap),
+ font.clone(),
+ TextColor(color),
+ ));
+ });
+ });
+}
+
+/// One fixed-size tinted icon node.
+fn spawn_tab_icon(parent: &mut ChildSpawnerCommands, handle: Handle, tint: Color) {
+ parent.spawn((
+ ImageNode {
+ color: tint,
+ ..ImageNode::new(handle)
+ },
+ Node {
+ width: Val::Px(TAB_ICON_PX),
+ height: Val::Px(TAB_ICON_PX),
+ flex_shrink: 0.0,
+ ..default()
+ },
+ ));
+}
+
+/// Sets each label wrapper's animation target from its button's
+/// interaction state: persistent wrappers pin to 1, the rest expand while
+/// pressed or hovered and collapse on release.
+pub(super) fn drive_tab_expansion(
+ buttons: Query<(&Interaction, &Children), With>,
+ mut wraps: Query<&mut TabLabelWrap>,
+) {
+ for (interaction, children) in &buttons {
+ for child in children.iter() {
+ let Ok(mut wrap) = wraps.get_mut(child) else {
+ continue;
+ };
+ let target = if wrap.persistent || !matches!(interaction, Interaction::None) {
+ 1.0
+ } else {
+ 0.0
+ };
+ // Bypass change detection on no-ops so `animate_tab_expansion`
+ // and layout stay idle while nothing moves.
+ if wrap.target != target {
+ wrap.target = target;
+ }
+ }
+ }
+}
+
+/// Advances every label reveal towards its target and writes the wrapper
+/// width + label alpha. Width is derived from the label's *live* font size,
+/// so the Fold's posture-change font rescale re-widens expanded labels
+/// automatically. Node writes are skipped when the width is unchanged to
+/// avoid dirtying UI layout every frame.
+pub(super) fn animate_tab_expansion(
+ time: Res