diff --git a/CHANGELOG.md b/CHANGELOG.md index 931dd3e..19e9968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button` + now checks `scrims.is_empty()` — a `Query<(), With>` guard — + before calling `spawn_menu_popover`. Tapping ≡ while any modal (Stats, + Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile + could be open simultaneously. +- **UX-7: Help text single-line overflow** (`help_plugin.rs`). The HUD menu + button description "Menu: Stats, Settings, Profile, Achievements" wrapped to + two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)" + which fits on one line. Verified on device. +- **UX-5b: Home mode glyph corruption** (`home_plugin.rs`). Mode selector icons + were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono + font — rendered as missing-glyph rectangles on Android. Replaced with card + suits (U+2660–2666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge. +- **UX-1: Modal Done button in gesture zone** (`safe_area.rs`). New + `apply_safe_area_to_modal_scrims` Bevy system pads every `ModalScrim` bottom + by `SafeAreaInsets.bottom / scale_factor`. Modal cards are now centred over + the safe area, not the full physical screen. The Settings / Help / Stats Done + buttons are reachable on gesture-nav Android devices. Verified on device. + --- ## [0.23.0] — 2026-05-12 diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 565cd1c..6a04606 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -159,7 +159,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[ ControlRow { keys: "||", description: "Pause / resume" }, ControlRow { keys: "?", description: "This help screen" }, ControlRow { keys: "→", description: "Show a hint" }, - ControlRow { keys: "≡", description: "Menu: Stats, Settings, Profile, Achievements" }, + ControlRow { keys: "≡", description: "Open menu (Stats, Settings, Profile...)" }, ], }, ]; diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 5898467..a08704d 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -150,32 +150,24 @@ impl HomeMode { /// readability rather than visual fidelity. Swap to `Image` nodes /// when art lands; the rest of the tile layout doesn't change. /// - /// Picks are constrained to **card suits** (U+2660-2666) and basic - /// **Geometric Shapes** (U+25xx) — the two ranges the bundled - /// FiraMono-Medium face actually covers. Earlier choices in - /// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as - /// missing-glyph rectangles because FiraMono's coverage there is - /// minimal. + /// Picks are constrained to **card suits** (U+2660-2666), the + /// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed + /// present in the bundled FiraMono-Medium face. The Geometric + /// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in + /// that range render as missing-glyph rectangles on Android. fn glyph(self) -> &'static str { match self { - // Black club — card suit, the obvious solitaire mark. + // Black club — card suit; the obvious solitaire mark. HomeMode::Classic => "\u{2663}", - // Black diamond — Geometric Shapes; reads as the day's gem. - HomeMode::Daily => "\u{25C6}", - // White circle — Geometric Shapes; reads as the Zen enso. - HomeMode::Zen => "\u{25CB}", - // Black up-pointing triangle — Geometric Shapes; reads as - // a mountain / a step up in difficulty. - HomeMode::Challenge => "\u{25B2}", - // Rightwards arrow — Arrows block (U+2190-21FF), a core - // range every dev-oriented monospace font (FiraMono - // included) ships. Reads as "go / fast-forward" for the - // timed mode. Earlier ▶ (U+25B6) did not render; FiraMono - // ships ▲ (up triangle) but evidently not the sideways - // siblings. + // Black diamond suit — "gem of the day" reading. + HomeMode::Daily => "\u{2666}", + // Black heart suit — calm/warm; conveys the Zen mood. + HomeMode::Zen => "\u{2665}", + // Black spade suit — sharp/high-stakes; signals difficulty. + HomeMode::Challenge => "\u{2660}", + // Rightwards arrow — "go / fast-forward" for the timed mode. HomeMode::TimeAttack => "\u{2192}", - // Number sign — ASCII, universally available. Reads as - // "a specific number / seed ID". + // Number sign — ASCII; "a specific seed ID". HomeMode::PlayBySeed => "#", } } diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 3dffd16..645c83d 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -40,6 +40,7 @@ use crate::resources::GameStateResource; use crate::selection_plugin::SelectionState; use crate::time_attack_plugin::TimeAttackResource; use crate::ui_focus::{FocusGroup, Focusable}; +use crate::ui_modal::ModalScrim; use crate::ui_tooltip::Tooltip; /// Marker on the score text node. @@ -1074,6 +1075,7 @@ fn handle_menu_button( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, backdrops: Query>, + scrims: Query<(), With>, font_res: Option>, mut commands: Commands, ) { @@ -1088,7 +1090,7 @@ fn handle_menu_button( for e in &backdrops { commands.entity(e).despawn(); } - } else { + } else if scrims.is_empty() { spawn_menu_popover(&mut commands, font_res.as_deref()); } } diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs index 63f6754..0ed5cb7 100644 --- a/solitaire_engine/src/safe_area.rs +++ b/solitaire_engine/src/safe_area.rs @@ -19,6 +19,8 @@ use bevy::prelude::*; +use crate::ui_modal::ModalScrim; + /// Pixel sizes of the system-reserved regions on each edge of the /// surface. Zero on desktop. #[derive(Resource, Debug, Clone, Copy, Default, PartialEq)] @@ -54,7 +56,7 @@ pub struct SafeAreaInsetsPlugin; impl Plugin for SafeAreaInsetsPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .add_systems(Update, apply_safe_area_anchors); + .add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims)); #[cfg(target_os = "android")] app.add_systems(Update, android::refresh_insets); @@ -87,6 +89,29 @@ fn apply_safe_area_anchors( } } +/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so +/// modal cards don't extend into the Android gesture-navigation zone. +/// +/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets +/// arriving a few frames after app start) AND when a new `ModalScrim` is +/// spawned (covers modals opened after insets have already settled). +fn apply_safe_area_to_modal_scrims( + insets: Res, + windows: Query<&Window>, + mut scrims: Query<&mut Node, With>, + new_scrims: Query<(), (With, Added)>, +) { + let has_new = !new_scrims.is_empty(); + if !insets.is_changed() && !has_new { + return; + } + let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); + let bottom_logical = insets.bottom / scale; + for mut node in &mut scrims { + node.padding.bottom = Val::Px(bottom_logical); + } +} + #[cfg(target_os = "android")] mod android { use super::SafeAreaInsets;