From 0e07e1d1adee575cbd2cb49633a571af471d6156 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 16:48:46 -0700 Subject: [PATCH 1/2] refactor(engine): unify toasts into one bottom-anchored stack (Phase H) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queued and immediate toasts used two subtly different geometries and staggered anchors purely to dodge each other when simultaneous. Both paths now spawn geometry-free ToastNodes that adopt_toasts_into_stack slots into a single persistent ToastStackRoot flex column — one anchor, one style, simultaneous toasts stack upward. The root rides SafeAreaAnchoredBottom and clears the Phase F touch action bar. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/animation_plugin.rs | 195 ++++++++++++++++++----- 1 file changed, 158 insertions(+), 37 deletions(-) diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index d57e131..50801f5 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -30,6 +30,8 @@ use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::pause_plugin::PausedResource; use crate::progress_plugin::LevelUpEvent; +use crate::platform::USE_TOUCH_UI_LAYOUT; +use crate::safe_area::SafeAreaAnchoredBottom; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::ui_theme::{ @@ -160,6 +162,68 @@ pub struct ActiveToast { /// Duration of each queued info-toast in seconds. const QUEUED_TOAST_SECS: f32 = 2.5; +/// Marker on the persistent bottom-anchored flex column every toast +/// spawns into (Phase H). Stacking through one container gives queued +/// and immediate toasts a single shared anchor — simultaneous toasts +/// stack upward instead of relying on the old staggered-percentage +/// anchors to dodge each other. +#[derive(Component, Debug)] +pub struct ToastStackRoot; + +/// Marker on every toast card node (both paths). Freshly spawned toasts +/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`] +/// re-parents them under [`ToastStackRoot`] and reveals them — one frame +/// of latency, imperceptible at toast timescales, in exchange for the 14 +/// toast handlers keeping their `Commands`-only signatures. +#[derive(Component, Debug)] +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 }; + +/// Spawns the persistent [`ToastStackRoot`] container at startup. +fn spawn_toast_stack_root(mut commands: Commands) { + commands.spawn(( + ToastStackRoot, + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX), + left: Val::Px(0.0), + width: Val::Percent(100.0), + // Newest toast sits nearest the bottom edge; older ones + // push upward. + flex_direction: FlexDirection::ColumnReverse, + align_items: AlignItems::Center, + row_gap: VAL_SPACE_2, + ..default() + }, + SafeAreaAnchoredBottom { + base_bottom: TOAST_STACK_BASE_BOTTOM_PX, + }, + ZIndex(Z_TOAST), + )); +} + +/// Re-parents freshly spawned [`ToastNode`]s under the stack root and +/// reveals them. No-op when every toast is already adopted. +fn adopt_toasts_into_stack( + mut commands: Commands, + orphans: Query, Without)>, + root: Query>, +) { + let Ok(root) = root.single() else { + return; + }; + for toast in &orphans { + commands + .entity(toast) + .insert((ChildOf(root), Visibility::Inherited)); + } +} + /// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence. pub struct AnimationPlugin; @@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin { .init_resource::() .init_resource::() .init_resource::() - .add_systems(Startup, init_slide_duration) + .add_systems(Startup, (init_slide_duration, spawn_toast_stack_root)) .add_systems( Update, ( @@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin { handle_warning_toast, tick_toasts, (enqueue_toasts, drive_toast_display).chain(), + adopt_toasts_into_stack, ) .after(GameMutation), ); @@ -637,26 +702,14 @@ impl ToastVariant { } } -/// Spawns a bottom-anchored `ToastEntity` for the queued toast system. +/// Spawns a `ToastEntity` for the queued toast system. /// /// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed /// by [`InfoToastEvent`] which is by definition neutral system info. /// Variants other than `Info` belong on the immediate-fire path /// ([`spawn_toast`]) where the call site knows the semantic intent. fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity { - spawn_toast_node( - commands, - ToastEntity, - message, - ToastVariant::Info, - // Slightly taller anchor than the immediate-fire path so a - // queued info banner doesn't collide with a celebration toast - // fired in the same frame. - Val::Percent(6.0), - Val::Percent(15.0), - Val::Percent(70.0), - UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), - ) + spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info) } fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader) { @@ -744,12 +797,6 @@ fn spawn_toast( (ToastOverlay, ToastTimer(duration_secs)), message, variant, - // Sits above the queued banner so a celebration toast spawned - // alongside a queued info message remains readable. - Val::Percent(14.0), - Val::Percent(25.0), - Val::Percent(50.0), - UiRect::axes(VAL_SPACE_4, VAL_SPACE_3), ); } @@ -766,31 +813,25 @@ fn spawn_toast( /// rungs; 18 is the closest rung that preserves the scale invariants /// tested in `ui_theme::tests`. /// - [`RADIUS_MD`] corners. -/// - Bottom-anchored absolute position; `bottom_pct` differs between -/// queued and immediate paths so they layer instead of overlap. -// The 8-argument signature is intentional — these are the per-toast -// layout values that genuinely differ between the queued and fire-and- -// forget call sites. A struct wrapper would just rename the same data. -#[allow(clippy::too_many_arguments)] +/// +/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns +/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it +/// into the shared bottom-anchored column — one anchor for every toast, +/// simultaneous toasts stack instead of overlapping. fn spawn_toast_node( commands: &mut Commands, bundle: B, message: String, variant: ToastVariant, - bottom_pct: Val, - left_pct: Val, - width_pct: Val, - padding: UiRect, ) -> Entity { commands .spawn(( bundle, + ToastNode, + Visibility::Hidden, Node { - position_type: PositionType::Absolute, - left: left_pct, - bottom: bottom_pct, - width: width_pct, - padding, + max_width: Val::Percent(70.0), + padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), @@ -799,7 +840,6 @@ fn spawn_toast_node( }, BackgroundColor(BG_ELEVATED), BorderColor::all(variant.border_color()), - ZIndex(Z_TOAST), )) .with_children(|b| { b.spawn(( @@ -1315,4 +1355,85 @@ mod tests { fn cascade_duration_instant_is_zero() { assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0); } + + // ----------------------------------------------------------------------- + // Phase H: unified toast stack + // ----------------------------------------------------------------------- + + /// Both toast paths must end up as visible children of the single + /// [`ToastStackRoot`] — the Phase H "one anchor" contract. + #[test] + fn queued_and_immediate_toasts_stack_under_one_root() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); + app.update(); // Startup: spawns the stack root. + + // One immediate (error) toast + one queued (info) toast in the + // same frame — the exact collision case the old staggered + // anchors existed to dodge. + use solitaire_core::{KlondikePile, Tableau}; + app.world_mut().write_message(MoveRejectedEvent { + from: KlondikePile::Tableau(Tableau::Tableau1), + to: KlondikePile::Tableau(Tableau::Tableau2), + count: 1, + }); + app.world_mut() + .write_message(InfoToastEvent("stacked info".to_string())); + app.update(); // handlers spawn both toasts (hidden, unparented) + app.update(); // adopt_toasts_into_stack re-parents + reveals + + let root = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .expect("exactly one ToastStackRoot must exist"); + + let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app + .world_mut() + .query_filtered::<(Entity, &ChildOf, &Visibility), With>() + .iter(app.world()) + .collect(); + assert_eq!( + toasts.len(), + 2, + "both the immediate and the queued toast must be adopted" + ); + for (entity, child_of, visibility) in toasts { + assert_eq!( + child_of.parent(), + root, + "toast {entity} must be a child of the shared stack root" + ); + assert_eq!( + *visibility, + Visibility::Inherited, + "adopted toast {entity} must be revealed" + ); + } + } + + /// Toast nodes must not carry their own absolute positioning — the + /// stack root owns layout (regression guard against reintroducing + /// per-path anchors). + #[test] + fn toast_nodes_have_no_absolute_position() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin); + app.update(); + + app.world_mut() + .write_message(WarningToastEvent("layout check".to_string())); + app.update(); + + let node = app + .world_mut() + .query_filtered::<&Node, With>() + .single(app.world()) + .expect("warning toast must spawn a ToastNode"); + assert_eq!( + node.position_type, + PositionType::Relative, + "toast nodes are flex children of the stack, not absolute overlays" + ); + } } From 3388169329d510e4c1b0953d1e71fc4986538604 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 16:52:16 -0700 Subject: [PATCH 2/2] fix(engine): platform-correct onboarding copy; Home waits for first-run onboarding (Phase H) - The how-to-play slide told touch players to left/right-click; Android now gets tap/double-tap copy pointing at the bottom-bar Hint button. - On a fresh profile the Home auto-show spawned underneath the onboarding modal, stacking two scrims (v0.44.0 emulator smoke finding). spawn_home_on_launch now waits until first_run_complete and the onboarding modal is gone, so the launch beat is onboarding, then Home, then the table. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/animation_plugin.rs | 2 +- solitaire_engine/src/home_plugin.rs | 11 +++++++++ solitaire_engine/src/onboarding_plugin.rs | 27 ++++++++++++++--------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index 50801f5..b71c432 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -29,8 +29,8 @@ use crate::events::{ use crate::game_plugin::GameMutation; use crate::layout::LayoutResource; use crate::pause_plugin::PausedResource; -use crate::progress_plugin::LevelUpEvent; use crate::platform::USE_TOUCH_UI_LAYOUT; +use crate::progress_plugin::LevelUpEvent; use crate::safe_area::SafeAreaAnchoredBottom; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::time_attack_plugin::TimeAttackEndedEvent; diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 721c31f..1ca0323 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -475,6 +475,11 @@ fn persist_last_mode( /// must be empty — when the player has a saved in-progress game the /// restore prompt takes precedence; the home picker would compete /// with it for attention. +/// * First-run onboarding must be finished (`Settings.first_run_complete` +/// and no `OnboardingScreen` open) — on a fresh profile the onboarding +/// owns the launch beat; Home appearing underneath it stacked two +/// modals (Phase H fix; spotted in the v0.44.0 emulator smoke). Home +/// spawns on the first frame after the player finishes or skips. /// * `HomeScreen` must not already exist (defensive — e.g. the player /// pressed `M` between ticks). /// * `LaunchHomeShown` flips to `true` after the first spawn so this @@ -488,6 +493,7 @@ fn spawn_home_on_launch( splash: Query<(), With>, restore_prompts: Query<(), With>, pending_restore: Option>, + onboarding: Query<(), With>, existing: Query<(), With>, sources: HomeSpawnSources, mut deal_expanded: ResMut, @@ -496,6 +502,11 @@ fn spawn_home_on_launch( || !splash.is_empty() || !restore_prompts.is_empty() || pending_restore.as_ref().is_some_and(|p| p.0.is_some()) + || !onboarding.is_empty() + || sources + .settings + .as_ref() + .is_some_and(|s| !s.0.first_run_complete) || !existing.is_empty() { return; diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index bd26030..8c42655 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -382,20 +382,27 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) }); } +/// How-to-play body copy, phrased for the platform's input vocabulary — +/// a touch player never left-clicks (Phase H polish; spotted in the +/// emulator smoke of v0.44.0). +#[cfg(target_os = "android")] +const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \ + You can drag a whole column at once by grabbing the topmost card \ + you want to move. Double-tap a face-up card to send it to a \ + foundation pile automatically (when the move is legal). \ + Tap Hint in the bottom bar for a suggested move."; +#[cfg(not(target_os = "android"))] +const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \ + You can drag a whole column at once by grabbing the topmost card \ + you want to move. Double-click a face-up card to send it to a \ + foundation pile automatically (when the move is legal). \ + Right-click a card for a hint — valid destinations will highlight."; + /// Slide 2 — How to play. fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal_header(card, "Drag cards to play", font_res); - spawn_modal_body_text( - card, - "Left-click and drag any face-up card to move it between piles. \ - You can drag a whole column at once by grabbing the topmost card \ - you want to move. Double-click a face-up card to send it to a \ - foundation pile automatically (when the move is legal). \ - Right-click a card for a hint — valid destinations will highlight.", - TEXT_SECONDARY, - font_res, - ); + spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res); spawn_modal_actions(card, |actions| { spawn_modal_button( actions,