Merge pull request 'polish(engine): Phase H part 1 — unified toast stack, onboarding fixes' (#178) from feat/feedback-polish into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m24s
Build and Deploy / build-and-push (push) Successful in 9m8s
Web E2E / web-e2e (push) Successful in 8m15s

This commit was merged in pull request #178.
This commit is contained in:
2026-07-14 00:14:03 +00:00
3 changed files with 186 additions and 47 deletions
+158 -37
View File
@@ -29,7 +29,9 @@ use crate::events::{
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::progress_plugin::LevelUpEvent; use crate::progress_plugin::LevelUpEvent;
use crate::safe_area::SafeAreaAnchoredBottom;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{ use crate::ui_theme::{
@@ -160,6 +162,68 @@ pub struct ActiveToast {
/// Duration of each queued info-toast in seconds. /// Duration of each queued info-toast in seconds.
const QUEUED_TOAST_SECS: f32 = 2.5; 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<Entity, (With<ToastNode>, Without<ChildOf>)>,
root: Query<Entity, With<ToastStackRoot>>,
) {
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. /// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin; pub struct AnimationPlugin;
@@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin {
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
.init_resource::<ActiveToast>() .init_resource::<ActiveToast>()
.add_systems(Startup, init_slide_duration) .add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
.add_systems( .add_systems(
Update, Update,
( (
@@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin {
handle_warning_toast, handle_warning_toast,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (enqueue_toasts, drive_toast_display).chain(),
adopt_toasts_into_stack,
) )
.after(GameMutation), .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 /// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
/// by [`InfoToastEvent`] which is by definition neutral system info. /// by [`InfoToastEvent`] which is by definition neutral system info.
/// Variants other than `Info` belong on the immediate-fire path /// Variants other than `Info` belong on the immediate-fire path
/// ([`spawn_toast`]) where the call site knows the semantic intent. /// ([`spawn_toast`]) where the call site knows the semantic intent.
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity { fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
spawn_toast_node( spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
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),
)
} }
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) { fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
@@ -744,12 +797,6 @@ fn spawn_toast(
(ToastOverlay, ToastTimer(duration_secs)), (ToastOverlay, ToastTimer(duration_secs)),
message, message,
variant, 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 /// rungs; 18 is the closest rung that preserves the scale invariants
/// tested in `ui_theme::tests`. /// tested in `ui_theme::tests`.
/// - [`RADIUS_MD`] corners. /// - [`RADIUS_MD`] corners.
/// - Bottom-anchored absolute position; `bottom_pct` differs between ///
/// queued and immediate paths so they layer instead of overlap. /// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
// The 8-argument signature is intentional — these are the per-toast /// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
// layout values that genuinely differ between the queued and fire-and- /// into the shared bottom-anchored column — one anchor for every toast,
// forget call sites. A struct wrapper would just rename the same data. /// simultaneous toasts stack instead of overlapping.
#[allow(clippy::too_many_arguments)]
fn spawn_toast_node<B: Bundle>( fn spawn_toast_node<B: Bundle>(
commands: &mut Commands, commands: &mut Commands,
bundle: B, bundle: B,
message: String, message: String,
variant: ToastVariant, variant: ToastVariant,
bottom_pct: Val,
left_pct: Val,
width_pct: Val,
padding: UiRect,
) -> Entity { ) -> Entity {
commands commands
.spawn(( .spawn((
bundle, bundle,
ToastNode,
Visibility::Hidden,
Node { Node {
position_type: PositionType::Absolute, max_width: Val::Percent(70.0),
left: left_pct, padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
bottom: bottom_pct,
width: width_pct,
padding,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
@@ -799,7 +840,6 @@ fn spawn_toast_node<B: Bundle>(
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(variant.border_color()), BorderColor::all(variant.border_color()),
ZIndex(Z_TOAST),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
@@ -1315,4 +1355,85 @@ mod tests {
fn cascade_duration_instant_is_zero() { fn cascade_duration_instant_is_zero() {
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0); 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::<Entity, With<ToastStackRoot>>()
.single(app.world())
.expect("exactly one ToastStackRoot must exist");
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
.world_mut()
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
.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<ToastNode>>()
.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"
);
}
} }
+11
View File
@@ -475,6 +475,11 @@ fn persist_last_mode(
/// must be empty — when the player has a saved in-progress game the /// must be empty — when the player has a saved in-progress game the
/// restore prompt takes precedence; the home picker would compete /// restore prompt takes precedence; the home picker would compete
/// with it for attention. /// 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 /// * `HomeScreen` must not already exist (defensive — e.g. the player
/// pressed `M` between ticks). /// pressed `M` between ticks).
/// * `LaunchHomeShown` flips to `true` after the first spawn so this /// * `LaunchHomeShown` flips to `true` after the first spawn so this
@@ -488,6 +493,7 @@ fn spawn_home_on_launch(
splash: Query<(), With<crate::splash_plugin::SplashRoot>>, splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>, restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>, pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
onboarding: Query<(), With<crate::onboarding_plugin::OnboardingScreen>>,
existing: Query<(), With<HomeScreen>>, existing: Query<(), With<HomeScreen>>,
sources: HomeSpawnSources, sources: HomeSpawnSources,
mut deal_expanded: ResMut<DealOptionsExpanded>, mut deal_expanded: ResMut<DealOptionsExpanded>,
@@ -496,6 +502,11 @@ fn spawn_home_on_launch(
|| !splash.is_empty() || !splash.is_empty()
|| !restore_prompts.is_empty() || !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some()) || 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() || !existing.is_empty()
{ {
return; return;
+17 -10
View File
@@ -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. /// Slide 2 — How to play.
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) { fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Drag cards to play", font_res); spawn_modal_header(card, "Drag cards to play", font_res);
spawn_modal_body_text( spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
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_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( spawn_modal_button(
actions, actions,