diff --git a/docs/ui-redesign-2026-07.md b/docs/ui-redesign-2026-07.md index acfe41e..8e64db8 100644 --- a/docs/ui-redesign-2026-07.md +++ b/docs/ui-redesign-2026-07.md @@ -1,7 +1,9 @@ # Menu UX Redesign — July 2026 -Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and -out of scope — this is about **structure and interaction**, not colors or type. +Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on +`feat/home-hierarchy` (2026-07-13); F / H and the I–M backlog remain open. +Visual identity (Terminal / base16-eighties) is settled and out of +scope — this is about **structure and interaction**, not colors or type. ## Diagnosis (from code survey, 2026-07-07) diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index c670712..51116bf 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -9,7 +9,10 @@ use std::io; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel}; +use solitaire_core::{ + DrawStockConfig, + game_state::{DifficultyLevel, GameMode}, +}; const SETTINGS_FILE_NAME: &str = "settings.json"; @@ -248,6 +251,12 @@ pub struct Settings { /// cleanly to `None` via `#[serde(default)]`. #[serde(default)] pub last_difficulty: Option, + /// Mode of the last game the player launched from the home overlay. + /// The home hero "New Game" button replays this mode with one tap. + /// Older `settings.json` files written before this field existed + /// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`. + #[serde(default)] + pub last_mode: GameMode, /// Custom public name displayed on the leaderboard. When `None`, the /// player's server `username` is used instead. Trimmed to 32 characters /// before submission. Older `settings.json` files written before this @@ -415,6 +424,7 @@ impl Default for Settings { disable_smart_default_size: false, replay_move_interval_secs: default_replay_move_interval_secs(), last_difficulty: None, + last_mode: GameMode::Classic, leaderboard_display_name: None, leaderboard_opted_in: false, take_from_foundation: true, diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 5193a13..bf1a880 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -1,22 +1,40 @@ -//! Mode-launcher overlay shown when the player presses **M** or clicks the -//! Modes affordance. +//! Home overlay shown when the player presses **M** or clicks the Home +//! affordance. //! -//! Replaces the prior "keyboard shortcut reference" Home modal with a -//! vertical stack of five mode cards — Classic, Daily Challenge, Zen, -//! Challenge, Time Attack. Clicking a card fires the same launch event -//! the corresponding hotkey does, then closes the overlay. The shortcut -//! reference now lives only in Help (`F1`), which is the canonical place -//! for that information. +//! Phase B of the 2026-07 UI redesign (`docs/ui-redesign-2026-07.md`) +//! turned the flat mode picker into a hierarchy: +//! +//! 1. **Continue** card — only when a game is in progress (moves made, +//! not yet won); shows mode / elapsed / score and dismisses the +//! overlay on click. +//! 2. **Hero New Game** — one tap re-launches `Settings::last_mode` +//! with the current deal options. A "Deal options" disclosure under +//! the hero holds the draw 1/3 toggle, the winnable-only toggle, and +//! the difficulty tier chips (Classic-mode concerns, off the top level). +//! 3. Compact **2×3 mode grid** — Classic, Daily, Zen, Challenge, +//! Time Attack, Play by Seed; descriptions render on wide viewports only. +//! 4. **Stats strip** at the bottom (click opens Profile). +//! +//! On wide viewports ([`HOME_TWO_PANE_MIN_WIDTH`]) the body renders two +//! panes — hero + grid left; Continue, daily callout, and stats right. +//! `Cancel` is now **Back to table** and only renders while a live +//! (un-won) game exists underneath the overlay. //! //! Level-gated modes (Zen, Challenge, Time Attack) are disabled below //! `CHALLENGE_UNLOCK_LEVEL`; clicking a locked card fires an //! [`InfoToastEvent`] explaining the gate but does not launch the mode -//! or close the overlay. +//! or close the overlay. The shortcut reference lives only in Help +//! (`F1`), which is the canonical place for that information. +use bevy::ecs::system::SystemParam; use bevy::input::ButtonInput; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; -use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel}; +use bevy::window::PrimaryWindow; +use solitaire_core::{ + DrawStockConfig, + game_state::{DifficultyLevel, GameMode}, +}; use solitaire_data::save_settings_to; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; @@ -29,17 +47,19 @@ use crate::events::{ }; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; +use crate::resources::GameStateResource; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath}; use crate::stats_plugin::StatsResource; use crate::ui_focus::{Disabled, FocusGroup, Focusable}; use crate::ui_modal::{ - ButtonVariant, ModalButton, ScrimDismissible, spawn_modal, spawn_modal_actions, - spawn_modal_button, spawn_modal_header, + ButtonVariant, MODAL_CARD_MAX_WIDTH, ModalButton, ScrimDismissible, spawn_modal_actions, + spawn_modal_button, spawn_modal_header, spawn_modal_sized, }; use crate::ui_theme::{ - ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder, - RADIUS_MD, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, - TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, + ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, + HighContrastBorder, RADIUS_MD, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, + TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, + Z_MODAL_PANEL, }; // --------------------------------------------------------------------------- @@ -50,11 +70,28 @@ use crate::ui_theme::{ #[derive(Component, Debug)] pub struct HomeScreen; -/// Marker on the bottom-row "Cancel" button that dismisses the Home modal -/// without launching a mode. +/// Marker on the bottom-row "Back to table" button that dismisses the +/// Home modal without launching a mode. Only spawned while a live +/// (un-won) game exists underneath the overlay. #[derive(Component, Debug)] pub struct HomeCancelButton; +/// Marker on the Continue card shown when a game is in progress (moves +/// made, not yet won). Clicking it dismisses the overlay, returning the +/// player to their game — same effect as "Back to table". +#[derive(Component, Debug)] +pub struct HomeContinueCard; + +/// Marker on the hero "New Game" button. One tap re-launches +/// `Settings::last_mode` with the current deal options. +#[derive(Component, Debug)] +pub struct HomeNewGameHero; + +/// Marker on the "Winnable only" toggle chip inside the deal-options +/// disclosure. Clicking flips `Settings.winnable_deals_only`. +#[derive(Component, Debug)] +struct HomeWinnableToggle; + /// Marker on the player-stats chip strip at the top of the Home modal. /// Clicking the strip opens the Profile overlay so the player can drill /// into level / XP / cosmetics without first dismissing Home. @@ -80,10 +117,11 @@ struct HomeDrawThreeButton; #[derive(Component, Debug)] struct HomeScrollable; -/// Marker on the "▶ Difficulty" / "▼ Difficulty" toggle button that -/// expands / collapses the difficulty tier chip row. +/// Marker on the "> Deal options" / "v Deal options" toggle button that +/// expands / collapses the deal-options disclosure (draw 1/3, winnable +/// only, difficulty tier chips) under the hero New Game button. #[derive(Component, Debug)] -struct HomeDifficultyToggle; +struct HomeDealOptionsToggle; /// Marker on each difficulty tier chip inside the expanded difficulty /// section. The wrapped `DifficultyLevel` identifies which tier was @@ -91,15 +129,15 @@ struct HomeDifficultyToggle; #[derive(Component, Debug)] struct HomeDifficultyChip(DifficultyLevel); -/// Whether the difficulty section is currently expanded. Toggled by -/// `handle_home_difficulty_toggle` and checked by `spawn_home_screen` +/// Whether the deal-options disclosure is currently expanded. Toggled by +/// `handle_home_deal_options_toggle` and checked by `spawn_home_screen` /// to determine initial render state. /// /// Initialised at plugin startup; `spawn_home_on_launch` upgrades it /// to `true` when `settings.last_difficulty` is already set so /// returning players see their tier pre-expanded. #[derive(Resource, Default, Debug)] -pub struct DifficultyExpanded(pub bool); +pub struct DealOptionsExpanded(pub bool); // --------------------------------------------------------------------------- // Private mode-card data shape @@ -197,6 +235,20 @@ impl HomeMode { fn is_unlocked(self, level: u32) -> bool { !self.requires_unlock() || level >= CHALLENGE_UNLOCK_LEVEL } + + /// The [`GameMode`] persisted as `Settings::last_mode` when this card + /// launches, or `None` for launches the hero New Game button cannot + /// replay (Daily is date-fixed; Play by Seed re-opens the seed + /// prompt rather than starting a deal). + fn to_game_mode(self) -> Option { + match self { + HomeMode::Classic => Some(GameMode::Classic), + HomeMode::Zen => Some(GameMode::Zen), + HomeMode::Challenge => Some(GameMode::Challenge), + HomeMode::TimeAttack => Some(GameMode::TimeAttack), + HomeMode::Daily | HomeMode::PlayBySeed => None, + } + } } /// Marker component placed on each mode-card `Button` so the click @@ -256,7 +308,7 @@ impl Plugin for HomePlugin { // Pre-mark the auto-show as already done in headless mode so the // gating system is a permanent no-op for tests. app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch)) - .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -272,10 +324,10 @@ impl Plugin for HomePlugin { // runs cleanly under MinimalPlugins headless tests too. .add_message::() // `.chain()` because several systems (M-toggle, card click, - // cancel button, digit-key shortcut, difficulty handlers) - // all read the `HomeScreen` entity and may queue a despawn - // on it in the same tick. Chaining serialises these systems - // and keeps the despawn deterministic. + // hero button, back-to-table button, digit-key shortcut, + // deal-options handlers) all read the `HomeScreen` entity and + // may queue a despawn on it in the same tick. Chaining + // serialises these systems and keeps the despawn deterministic. .add_systems( Update, ( @@ -283,10 +335,12 @@ impl Plugin for HomePlugin { toggle_home_screen, attach_focusable_to_home_mode_cards, handle_home_card_click, + handle_home_new_game_hero, handle_home_cancel_button, handle_home_profile_chip, handle_home_draw_mode_buttons, - handle_home_difficulty_toggle, + handle_home_winnable_toggle, + handle_home_deal_options_toggle, handle_home_difficulty_chip_click, handle_home_digit_keys, ) @@ -296,6 +350,114 @@ impl Plugin for HomePlugin { } } +// --------------------------------------------------------------------------- +// Shared system params + launch dispatch +// --------------------------------------------------------------------------- + +/// Read access to every resource the Home modal renders from, bundled +/// so the several spawn / repaint call sites share one parameter set. +/// +/// `settings` is `ResMut` because the deal-option handlers mutate it in +/// place before respawning; read-only systems simply never write through +/// it (Bevy change detection only triggers on `DerefMut`, so holding the +/// mutable handle without writing stays quiet). +#[derive(SystemParam)] +struct HomeSpawnSources<'w, 's> { + progress: Option>, + stats: Option>, + settings: Option>, + daily: Option>, + font_res: Option>, + game: Option>, + windows: Query<'w, 's, &'static Window, With>, +} + +/// The launch-event writers shared by the card click, digit key, and +/// hero New Game handlers, plus a dispatch table so all three surfaces +/// fire byte-identical events. +#[derive(SystemParam)] +struct HomeLaunchWriters<'w> { + new_game: MessageWriter<'w, NewGameRequestEvent>, + zen: MessageWriter<'w, StartZenRequestEvent>, + challenge: MessageWriter<'w, StartChallengeRequestEvent>, + time_attack: MessageWriter<'w, StartTimeAttackRequestEvent>, + daily: MessageWriter<'w, StartDailyChallengeRequestEvent>, + play_by_seed: MessageWriter<'w, StartPlayBySeedRequestEvent>, + difficulty: MessageWriter<'w, StartDifficultyRequestEvent>, +} + +impl HomeLaunchWriters<'_> { + /// Fires the launch event for a mode card. The caller has already + /// checked the unlock gate. + fn dispatch(&mut self, mode: HomeMode) { + match mode { + HomeMode::Classic => { + self.new_game.write(NewGameRequestEvent::default()); + } + HomeMode::Daily => { + self.daily.write(StartDailyChallengeRequestEvent); + } + HomeMode::Zen => { + self.zen.write(StartZenRequestEvent); + } + HomeMode::Challenge => { + self.challenge.write(StartChallengeRequestEvent); + } + HomeMode::TimeAttack => { + self.time_attack.write(StartTimeAttackRequestEvent); + } + HomeMode::PlayBySeed => { + self.play_by_seed.write(StartPlayBySeedRequestEvent); + } + } + } + + /// Fires the launch event for the persisted [`GameMode`] the hero + /// New Game button replays. + fn dispatch_game_mode(&mut self, mode: GameMode) { + match mode { + GameMode::Classic => { + self.new_game.write(NewGameRequestEvent::default()); + } + GameMode::Zen => { + self.zen.write(StartZenRequestEvent); + } + GameMode::Challenge => { + self.challenge.write(StartChallengeRequestEvent); + } + GameMode::TimeAttack => { + self.time_attack.write(StartTimeAttackRequestEvent); + } + GameMode::Difficulty(level) => { + self.difficulty.write(StartDifficultyRequestEvent { level }); + } + } + } +} + +/// Persists `Settings::last_mode` after a launch dispatch so the hero +/// New Game button replays it next time. No-op when the mode is already +/// current or settings aren't wired (headless tests). +fn persist_last_mode( + mode: GameMode, + settings: &mut Option>, + storage_path: Option<&SettingsStoragePath>, + changed: &mut MessageWriter, +) { + let Some(s) = settings.as_mut() else { return }; + if s.0.last_mode == mode { + return; + } + s.0.last_mode = mode; + if let Some(p) = storage_path + && let Some(path) = p.0.as_deref() + && let Err(e) = save_settings_to(path, &s.0) + { + warn!("home: failed to persist last_mode: {e}"); + } + changed.write(SettingsChangedEvent(s.0.clone())); +} + // --------------------------------------------------------------------------- // Auto-show on launch // --------------------------------------------------------------------------- @@ -327,12 +489,8 @@ fn spawn_home_on_launch( restore_prompts: Query<(), With>, pending_restore: Option>, existing: Query<(), With>, - progress: Option>, - stats: Option>, - settings: Option>, - daily: Option>, - font_res: Option>, - mut diff_expanded: ResMut, + sources: HomeSpawnSources, + mut deal_expanded: ResMut, ) { if shown.0 || !splash.is_empty() @@ -343,25 +501,17 @@ fn spawn_home_on_launch( return; } - // Pre-expand the difficulty section when the player has a saved preference. - if settings + // Pre-expand the deal options when the player has a saved difficulty + // preference — returning players see their tier chips immediately. + if sources + .settings .as_ref() .is_some_and(|s| s.0.last_difficulty.is_some()) { - diff_expanded.0 = true; + deal_expanded.0 = true; } - spawn_home_screen( - &mut commands, - build_home_context( - progress.as_deref(), - stats.as_deref(), - settings.as_deref(), - daily.as_deref(), - font_res.as_deref(), - diff_expanded.0, - ), - ); + spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); shown.0 = true; } @@ -369,19 +519,14 @@ fn spawn_home_on_launch( // M-key toggle // --------------------------------------------------------------------------- -#[allow(clippy::too_many_arguments)] fn toggle_home_screen( mut commands: Commands, keys: Res>, mut requests: MessageReader, - progress: Option>, - stats: Option>, - settings: Option>, - daily: Option>, - font_res: Option>, + sources: HomeSpawnSources, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, - diff_expanded: Res, + deal_expanded: Res, ) { let button_clicked = requests.read().count() > 0; if !keys.just_pressed(KeyCode::KeyM) && !button_clicked { @@ -390,17 +535,7 @@ fn toggle_home_screen( if let Ok(entity) = screens.single() { commands.entity(entity).despawn(); } else if other_modal_scrims.is_empty() { - spawn_home_screen( - &mut commands, - build_home_context( - progress.as_deref(), - stats.as_deref(), - settings.as_deref(), - daily.as_deref(), - font_res.as_deref(), - diff_expanded.0, - ), - ); + spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); } } @@ -409,13 +544,15 @@ fn toggle_home_screen( /// (typical for `MinimalPlugins` headless tests that don't install /// every contributor plugin). fn build_home_context<'a>( - progress: Option<&ProgressResource>, - stats: Option<&StatsResource>, - settings: Option<&SettingsResource>, - daily: Option<&DailyChallengeResource>, - font_res: Option<&'a FontResource>, - difficulty_expanded: bool, + sources: &'a HomeSpawnSources, + deal_options_expanded: bool, ) -> HomeContext<'a> { + let progress = sources.progress.as_deref(); + let stats = sources.stats.as_deref(); + let settings = sources.settings.as_deref(); + let daily = sources.daily.as_deref(); + let game = sources.game.as_deref(); + let daily_today = daily.map(|d| { let completed_today = progress .and_then(|p| p.0.daily_challenge_last_completed) @@ -427,6 +564,32 @@ fn build_home_context<'a>( } }); + // A live game is one the player could return to (dealt, not yet + // won); it gates the "Back to table" action. The Continue card + // additionally requires at least one move — a fresh untouched deal + // is indistinguishable from "New Game" and doesn't earn a card. + let game_live = game.is_some_and(|g| !g.0.is_won()); + let continue_info = game + .filter(|g| game_live && g.0.move_count() > 0) + .map(|g| { + let g = &g.0; + let mut caption = format!( + "{} \u{2022} {}", + game_mode_label(g.mode), + format_elapsed(g.elapsed_seconds) + ); + if g.mode != GameMode::Zen { + caption.push_str(&format!(" \u{2022} Score {}", format_compact(g.score() as u64))); + } + ContinueInfo { caption } + }); + + let wide = sources + .windows + .iter() + .next() + .is_some_and(|w| w.width() >= HOME_TWO_PANE_MIN_WIDTH); + HomeContext { level: progress.map_or(0, |p| p.0.level), total_xp: progress.map_or(0, |p| p.0.total_xp), @@ -439,9 +602,14 @@ fn build_home_context<'a>( draw_mode: settings .map(|s| s.0.draw_mode) .unwrap_or(DrawStockConfig::DrawOne), - font_res, - difficulty_expanded, + font_res: sources.font_res.as_deref(), + deal_options_expanded, last_difficulty: settings.and_then(|s| s.0.last_difficulty), + last_mode: settings.map(|s| s.0.last_mode).unwrap_or_default(), + winnable_only: settings.is_some_and(|s| s.0.winnable_deals_only), + continue_info, + game_live, + wide, } } @@ -452,7 +620,8 @@ fn build_home_context<'a>( /// Dispatches a click on a mode card. /// /// - **Unlocked** modes fire the matching `Start*RequestEvent` (or -/// [`NewGameRequestEvent`] for Classic) and despawn the modal. +/// [`NewGameRequestEvent`] for Classic), persist `Settings::last_mode` +/// (for replayable modes), and despawn the modal. /// - **Locked** modes (level below [`CHALLENGE_UNLOCK_LEVEL`]) fire only /// an [`InfoToastEvent`] and leave the modal open so the player can /// pick another mode. @@ -462,13 +631,11 @@ fn handle_home_card_click( cards: Query<(&Interaction, &HomeModeCard), Changed>, progress: Option>, screens: Query>, - mut new_game: MessageWriter, - mut zen: MessageWriter, - mut challenge: MessageWriter, - mut time_attack: MessageWriter, - mut daily: MessageWriter, - mut play_by_seed: MessageWriter, + mut writers: HomeLaunchWriters, mut info_toast: MessageWriter, + mut settings: Option>, + storage_path: Option>, + mut changed: MessageWriter, ) { let level = progress.as_ref().map_or(0, |p| p.0.level); @@ -486,26 +653,10 @@ fn handle_home_card_click( continue; } - match card.0 { - HomeMode::Classic => { - new_game.write(NewGameRequestEvent::default()); - } - HomeMode::Daily => { - daily.write(StartDailyChallengeRequestEvent); - } - HomeMode::Zen => { - zen.write(StartZenRequestEvent); - } - HomeMode::Challenge => { - challenge.write(StartChallengeRequestEvent); - } - HomeMode::TimeAttack => { - time_attack.write(StartTimeAttackRequestEvent); - } - HomeMode::PlayBySeed => { - play_by_seed.write(StartPlayBySeedRequestEvent); - } + if let Some(mode) = card.0.to_game_mode() { + persist_last_mode(mode, &mut settings, storage_path.as_deref(), &mut changed); } + writers.dispatch(card.0); // Close the modal after dispatching the launch event. for entity in &screens { @@ -515,13 +666,57 @@ fn handle_home_card_click( } // --------------------------------------------------------------------------- -// Cancel button handler +// Hero New Game handler // --------------------------------------------------------------------------- +/// Click on the hero "New Game" button — re-launch `Settings::last_mode` +/// with the current deal options, then close the modal. A persisted +/// gated mode the player can no longer launch (e.g. profile reset) +/// falls back to Classic instead of dead-clicking. +fn handle_home_new_game_hero( + mut commands: Commands, + heroes: Query<&Interaction, (With, Changed)>, + screens: Query>, + progress: Option>, + settings: Option>, + mut writers: HomeLaunchWriters, +) { + if screens.is_empty() { + return; + } + if !heroes.iter().any(|i| *i == Interaction::Pressed) { + return; + } + let level = progress.as_ref().map_or(0, |p| p.0.level); + let mut mode = settings.as_ref().map(|s| s.0.last_mode).unwrap_or_default(); + let gated = matches!( + mode, + GameMode::Zen | GameMode::Challenge | GameMode::TimeAttack + ); + if gated && level < CHALLENGE_UNLOCK_LEVEL { + mode = GameMode::Classic; + } + writers.dispatch_game_mode(mode); + for entity in &screens { + commands.entity(entity).despawn(); + } +} + +// --------------------------------------------------------------------------- +// Back-to-table / Continue handlers +// --------------------------------------------------------------------------- + +#[allow(clippy::type_complexity)] fn handle_home_cancel_button( mut commands: Commands, keys: Option>>, - cancel_buttons: Query<&Interaction, (With, Changed)>, + cancel_buttons: Query< + &Interaction, + ( + Or<(With, With)>, + Changed, + ), + >, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, ) { @@ -599,14 +794,10 @@ fn handle_home_draw_mode_buttons( three_buttons: Query<&Interaction, (With, Changed)>, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, - mut settings: Option>, + mut sources: HomeSpawnSources, storage_path: Option>, mut changed: MessageWriter, - progress: Option>, - stats: Option>, - daily: Option>, - font_res: Option>, - diff_expanded: Res, + deal_expanded: Res, ) { if screens.is_empty() { return; @@ -622,7 +813,7 @@ fn handle_home_draw_mode_buttons( if !want_one && !want_three { return; } - let Some(settings) = settings.as_mut() else { + let Some(settings) = sources.settings.as_mut() else { return; }; let target = if want_one { @@ -642,45 +833,27 @@ fn handle_home_draw_mode_buttons( } changed.write(SettingsChangedEvent(settings.0.clone())); - // Repaint by despawn + respawn so the chip styling and any - // dependent labels (none today, but Phase B may surface a - // "Standard (Draw 1)" caption like MSSC) reflect the new state. + // Repaint by despawn + respawn so the chip styling and the hero + // caption ("Classic • Draw 3") reflect the new state. for entity in &screens { commands.entity(entity).despawn(); } - spawn_home_screen( - &mut commands, - build_home_context( - progress.as_deref(), - stats.as_deref(), - Some(settings), - daily.as_deref(), - font_res.as_deref(), - diff_expanded.0, - ), - ); + spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); } -// --------------------------------------------------------------------------- -// Difficulty section handlers -// --------------------------------------------------------------------------- - -/// Click on the "▶/▼ Difficulty" header — toggle `DifficultyExpanded` and -/// repaint the Home modal so the chevron and chip row update. Mirrors -/// `handle_home_draw_mode_buttons`: despawn + respawn keeps all styling in -/// `spawn_difficulty_section` rather than scattered across mutation helpers. +/// Click on the "Winnable only" chip — flip `Settings.winnable_deals_only`, +/// persist, fire `SettingsChangedEvent`, and respawn the modal for the +/// chip styling. Same repaint-by-rebuild shape as the draw-mode handler. #[allow(clippy::too_many_arguments)] -fn handle_home_difficulty_toggle( +fn handle_home_winnable_toggle( mut commands: Commands, - toggles: Query<&Interaction, (With, Changed)>, + toggles: Query<&Interaction, (With, Changed)>, screens: Query>, other_modal_scrims: Query<(), (With, Without)>, - mut diff_expanded: ResMut, - progress: Option>, - stats: Option>, - settings: Option>, - daily: Option>, - font_res: Option>, + mut sources: HomeSpawnSources, + storage_path: Option>, + mut changed: MessageWriter, + deal_expanded: Res, ) { if screens.is_empty() { return; @@ -691,26 +864,60 @@ fn handle_home_difficulty_toggle( if !toggles.iter().any(|i| *i == Interaction::Pressed) { return; } - diff_expanded.0 = !diff_expanded.0; + let Some(settings) = sources.settings.as_mut() else { + return; + }; + settings.0.winnable_deals_only = !settings.0.winnable_deals_only; + if let Some(p) = storage_path + && let Some(path) = p.0.as_deref() + && let Err(e) = save_settings_to(path, &settings.0) + { + warn!("home: failed to persist winnable-only change: {e}"); + } + changed.write(SettingsChangedEvent(settings.0.clone())); + for entity in &screens { commands.entity(entity).despawn(); } - spawn_home_screen( - &mut commands, - build_home_context( - progress.as_deref(), - stats.as_deref(), - settings.as_deref(), - daily.as_deref(), - font_res.as_deref(), - diff_expanded.0, - ), - ); + spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); } -/// Click on a difficulty tier chip — persist `last_difficulty`, fire -/// `StartDifficultyRequestEvent`, and close the Home modal. -#[allow(clippy::too_many_arguments)] +// --------------------------------------------------------------------------- +// Deal-options disclosure handlers +// --------------------------------------------------------------------------- + +/// Click on the "> / v Deal options" header — toggle [`DealOptionsExpanded`] +/// and repaint the Home modal so the chevron and disclosure body update. +/// Mirrors `handle_home_draw_mode_buttons`: despawn + respawn keeps all +/// styling in `spawn_deal_options_section` rather than scattered across +/// mutation helpers. +fn handle_home_deal_options_toggle( + mut commands: Commands, + toggles: Query<&Interaction, (With, Changed)>, + screens: Query>, + other_modal_scrims: Query<(), (With, Without)>, + mut deal_expanded: ResMut, + sources: HomeSpawnSources, +) { + if screens.is_empty() { + return; + } + if !other_modal_scrims.is_empty() { + return; + } + if !toggles.iter().any(|i| *i == Interaction::Pressed) { + return; + } + deal_expanded.0 = !deal_expanded.0; + for entity in &screens { + commands.entity(entity).despawn(); + } + spawn_home_screen(&mut commands, build_home_context(&sources, deal_expanded.0)); +} + +/// Click on a difficulty tier chip — persist `last_difficulty` and +/// `last_mode`, fire `StartDifficultyRequestEvent`, and close the Home +/// modal. fn handle_home_difficulty_chip_click( mut commands: Commands, chips: Query<(&Interaction, &HomeDifficultyChip), Changed>, @@ -730,6 +937,7 @@ fn handle_home_difficulty_chip_click( if let Some(s) = settings.as_mut() { s.0.last_difficulty = Some(level); + s.0.last_mode = GameMode::Difficulty(level); if let Some(p) = storage_path && let Some(path) = p.0.as_deref() && let Err(e) = save_settings_to(path, &s.0) @@ -784,12 +992,10 @@ fn handle_home_digit_keys( keys: Res>, progress: Option>, screens: Query>, - mut new_game: MessageWriter, - mut zen: MessageWriter, - mut challenge: MessageWriter, - mut time_attack: MessageWriter, - mut daily: MessageWriter, - mut play_by_seed: MessageWriter, + mut writers: HomeLaunchWriters, + mut settings: Option>, + storage_path: Option>, + mut changed: MessageWriter, ) { // Modal-scoped: do nothing when the Mode Launcher isn't open. if screens.is_empty() { @@ -816,26 +1022,10 @@ fn handle_home_digit_keys( return; } - match mode { - HomeMode::Classic => { - new_game.write(NewGameRequestEvent::default()); - } - HomeMode::Daily => { - daily.write(StartDailyChallengeRequestEvent); - } - HomeMode::Zen => { - zen.write(StartZenRequestEvent); - } - HomeMode::Challenge => { - challenge.write(StartChallengeRequestEvent); - } - HomeMode::TimeAttack => { - time_attack.write(StartTimeAttackRequestEvent); - } - HomeMode::PlayBySeed => { - play_by_seed.write(StartPlayBySeedRequestEvent); - } + if let Some(game_mode) = mode.to_game_mode() { + persist_last_mode(game_mode, &mut settings, storage_path.as_deref(), &mut changed); } + writers.dispatch(mode); // Close the modal after dispatching the launch event — same shape as // the click handler. @@ -848,12 +1038,10 @@ fn handle_home_digit_keys( // Spawn helpers // --------------------------------------------------------------------------- -/// Bundles the data the Home modal needs to render the new -/// MSSC-inspired header chips, per-mode score chips, and draw-mode -/// row. Built fresh by the two call sites (`spawn_home_on_launch` -/// and `toggle_home_screen`) from the live progress / stats / -/// settings resources, with sensible defaults when a resource is -/// missing under `MinimalPlugins` headless tests. +/// Bundles the data the Home modal needs to render — built fresh by +/// every spawn / repaint call site via [`build_home_context`] from the +/// live resources in [`HomeSpawnSources`], with sensible defaults when +/// a resource is missing under `MinimalPlugins` headless tests. struct HomeContext<'a> { level: u32, total_xp: u64, @@ -865,11 +1053,29 @@ struct HomeContext<'a> { daily_today: Option, draw_mode: DrawStockConfig, font_res: Option<&'a FontResource>, - /// Whether the difficulty section header is currently expanded. - difficulty_expanded: bool, + /// Whether the deal-options disclosure is currently expanded. + deal_options_expanded: bool, /// The last difficulty tier the player selected (persisted in Settings). /// When `Some`, that tier's chip is highlighted. last_difficulty: Option, + /// The persisted mode the hero New Game button replays. + last_mode: GameMode, + /// Winnable-only deal filter state for the deal-options chip. + winnable_only: bool, + /// In-progress game summary. `None` when there is no game, the deal + /// is untouched, or the game is already won. + continue_info: Option, + /// `true` while a live (un-won) game exists underneath the overlay — + /// gates the "Back to table" action row. + game_live: bool, + /// `true` on wide viewports — two-pane body plus card descriptions. + wide: bool, +} + +/// In-progress game summary rendered on the Continue card. +struct ContinueInfo { + /// Pre-formatted "mode • elapsed • score" line. + caption: String, } /// Today's daily-challenge metadata as the Home picker needs it. Only @@ -889,17 +1095,34 @@ struct DailyToday { completed_today: bool, } -/// Spawns the Home modal with the player-stats header strip, draw-mode -/// row, five mode cards, and a Cancel button. -fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) { - let HomeContext { font_res, .. } = ctx; - let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| { - spawn_modal_header(card, "Choose a Mode", font_res); +/// Logical window width at/above which the Home modal renders the +/// two-pane layout (hero + grid left; Continue / daily / stats right) +/// and mode-card descriptions. 1000 keeps a folded phone portrait +/// (900 logical px on the Fold 7) on the single-column layout while +/// catching desktop windows and the unfolded posture. +const HOME_TWO_PANE_MIN_WIDTH: f32 = 1000.0; - // Scrollable middle — chips + draw row + tile grid. Constrained - // to 70vh so the modal fits on small viewports (the 5-tile - // grid alone is ~540 px). Cancel button sits outside this - // node so it's always one click away. +/// Card max width while the two-pane layout is active — the default +/// 720 px card cannot fit two readable panes side by side. +const HOME_WIDE_CARD_MAX_WIDTH: f32 = 1080.0; + +/// Spawns the Home modal — Continue card, hero New Game with the +/// deal-options disclosure, the 2×3 mode grid, and the stats strip, +/// plus "Back to table" while a live game exists. See the module docs +/// for the phase-B hierarchy. +fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) { + let font_res = ctx.font_res; + let max_width = if ctx.wide { + HOME_WIDE_CARD_MAX_WIDTH + } else { + MODAL_CARD_MAX_WIDTH + }; + let scrim = spawn_modal_sized(commands, HomeScreen, Z_MODAL_PANEL, max_width, |card| { + spawn_modal_header(card, "Home", font_res); + + // Scrollable middle. Constrained to 70vh so the modal fits on + // small viewports (the tile grid alone is ~400 px). The action + // row sits outside this node so it's always one click away. card.spawn(( HomeScrollable, ScrollPosition::default(), @@ -913,60 +1136,268 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) { }, )) .with_children(|body| { - spawn_home_header_chips(body, &ctx); - spawn_draw_mode_row(body, &ctx); + if ctx.wide { + // Two panes: launch surfaces left, "my state" right. + body.spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: VAL_SPACE_3, + align_items: AlignItems::FlexStart, + width: Val::Percent(100.0), + ..default() + }) + .with_children(|panes| { + panes + .spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_3, + width: Val::Percent(58.0), + flex_grow: 1.0, + ..default() + }) + .with_children(|left| { + spawn_new_game_hero(left, &ctx); + spawn_deal_options_section(left, &ctx); + spawn_mode_grid(left, &ctx); + }); + panes + .spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_3, + width: Val::Percent(40.0), + ..default() + }) + .with_children(|right| { + spawn_continue_card(right, &ctx); + spawn_daily_callout(right, &ctx); + spawn_home_stats_strip(right, &ctx); + }); + }); + } else { + // Single column, hierarchy top to bottom: Continue, + // hero New Game (+ deal options), mode grid, stats. + spawn_continue_card(body, &ctx); + spawn_new_game_hero(body, &ctx); + spawn_deal_options_section(body, &ctx); + spawn_mode_grid(body, &ctx); + spawn_home_stats_strip(body, &ctx); + } + }); - // Mode tiles in a wrapping 2-column grid. Each tile takes 48% - // of the row so column_gap fits comfortably; the 5 modes wrap - // to a third row of one tile, which we leave left-aligned — - // the asymmetry matches MSSC's "Daily Challenges / Today's - // Event" half-cell on the right of their grid and keeps the - // visual rhythm. - body.spawn(Node { - flex_direction: FlexDirection::Row, - flex_wrap: FlexWrap::Wrap, - row_gap: VAL_SPACE_3, - column_gap: VAL_SPACE_3, - width: Val::Percent(100.0), - ..default() - }) - .with_children(|grid| { - for mode in [ - HomeMode::Classic, - HomeMode::Daily, - HomeMode::Zen, - HomeMode::Challenge, - HomeMode::TimeAttack, - HomeMode::PlayBySeed, - ] { - spawn_mode_card(grid, mode, &ctx); - } + // "Back to table" only renders while a live game exists — at a + // fresh launch there is nothing meaningful to go back to, and + // the hero New Game is the primary path forward. + if ctx.game_live { + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + HomeCancelButton, + "Back to table", + Some("M"), + ButtonVariant::Tertiary, + font_res, + ); }); - - spawn_difficulty_section(body, &ctx); - }); - - spawn_modal_actions(card, |actions| { - spawn_modal_button( - actions, - HomeCancelButton, - "Cancel", - Some("M"), - ButtonVariant::Tertiary, - font_res, - ); - }); + } }); // Home is read-only — opt into click-outside-to-dismiss. commands.entity(scrim).insert(ScrimDismissible); } +/// The wrapping 2-column mode tile grid — Classic, Daily, Zen, +/// Challenge, Time Attack, Play by Seed (a symmetric 2×3). +fn spawn_mode_grid(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { + parent + .spawn(Node { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + row_gap: VAL_SPACE_3, + column_gap: VAL_SPACE_3, + width: Val::Percent(100.0), + ..default() + }) + .with_children(|grid| { + for mode in [ + HomeMode::Classic, + HomeMode::Daily, + HomeMode::Zen, + HomeMode::Challenge, + HomeMode::TimeAttack, + HomeMode::PlayBySeed, + ] { + spawn_mode_card(grid, mode, ctx); + } + }); +} + +/// Continue card — only rendered while a game is in progress. Shows the +/// mode / elapsed / score caption; clicking dismisses the overlay (the +/// cancel handler also listens on [`HomeContinueCard`]). +fn spawn_continue_card(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { + let Some(info) = ctx.continue_info.as_ref() else { + return; + }; + let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_title = TextFont { + font: font_handle.clone(), + font_size: TYPE_BODY_LG, + ..default() + }; + let font_caption = TextFont { + font: font_handle, + font_size: TYPE_BODY, + ..default() + }; + + parent + .spawn(( + HomeContinueCard, + Button, + ModalButton(ButtonVariant::Secondary), + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_1, + padding: UiRect::all(VAL_SPACE_3), + width: Val::Percent(100.0), + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BackgroundColor(BG_ELEVATED_HI), + BorderColor::all(ACCENT_PRIMARY), + HighContrastBorder::with_default(ACCENT_PRIMARY), + )) + .with_children(|c| { + c.spawn(( + Text::new("Continue"), + font_title.clone(), + TextColor(ACCENT_PRIMARY), + )); + c.spawn(( + Text::new(info.caption.clone()), + font_caption.clone(), + TextColor(TEXT_SECONDARY), + )); + }); +} + +/// Hero "New Game" button — the primary launch surface. One tap replays +/// `Settings::last_mode` with the current deal options; the caption +/// spells out exactly what that tap starts. +fn spawn_new_game_hero(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { + let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_title = TextFont { + font: font_handle.clone(), + font_size: TYPE_BODY_LG, + ..default() + }; + let font_caption = TextFont { + font: font_handle, + font_size: TYPE_CAPTION, + ..default() + }; + + let draw_label = match ctx.draw_mode { + DrawStockConfig::DrawOne => "Draw 1", + DrawStockConfig::DrawThree => "Draw 3", + }; + let mut caption = format!("{} \u{2022} {}", game_mode_label(ctx.last_mode), draw_label); + if ctx.winnable_only { + caption.push_str(" \u{2022} winnable"); + } + + parent + .spawn(( + HomeNewGameHero, + Button, + ModalButton(ButtonVariant::Primary), + Node { + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + row_gap: VAL_SPACE_1, + padding: UiRect::all(VAL_SPACE_3), + width: Val::Percent(100.0), + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BackgroundColor(ACCENT_PRIMARY), + BorderColor::all(BORDER_STRONG), + HighContrastBorder::with_default(BORDER_STRONG), + )) + .with_children(|c| { + c.spawn((Text::new("New Game"), font_title.clone(), TextColor(BG_BASE))); + c.spawn((Text::new(caption), font_caption.clone(), TextColor(BG_BASE))); + }); +} + +/// Daily callout for the wide layout's right pane — mirrors the dated +/// caption on the Daily grid card so "today's state" lives beside +/// Continue and the stats strip. +fn spawn_daily_callout(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { + let Some(today) = ctx.daily_today.as_ref() else { + return; + }; + let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_label = TextFont { + font: font_handle.clone(), + font_size: TYPE_CAPTION, + ..default() + }; + let font_value = TextFont { + font: font_handle, + font_size: TYPE_BODY, + ..default() + }; + + let date_text = if today.completed_today { + format!("Today, {} \u{2022} Done", today.date_label) + } else { + format!("Today, {}", today.date_label) + }; + let date_color = if today.completed_today { + ACCENT_PRIMARY + } else { + STATE_INFO + }; + + parent + .spawn(( + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_1, + padding: UiRect::all(VAL_SPACE_3), + width: Val::Percent(100.0), + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BackgroundColor(BG_ELEVATED), + BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|c| { + c.spawn(( + Text::new("Daily Challenge"), + font_label.clone(), + TextColor(TEXT_SECONDARY), + )); + c.spawn((Text::new(date_text), font_value.clone(), TextColor(date_color))); + if let Some(goal) = today.goal.as_ref() { + c.spawn(( + Text::new(format!("Goal: {goal}")), + font_label.clone(), + TextColor(TEXT_SECONDARY), + )); + } + }); +} + /// Player-stats chip strip — Level, XP, Lifetime Score. Clickable as a /// whole to open the Profile overlay (mirrors the MSSC top-right -/// avatar+rewards corner that surfaces level + premium status). Falls -/// back to plain Text in headless contexts where `Button` interaction -/// isn't driven by the input pipeline anyway. -fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { +/// avatar+rewards corner that surfaces level + premium status). Phase B +/// moved it from the header to the bottom of the column (right pane on +/// wide viewports) so launch surfaces own the top of the hierarchy. +fn spawn_home_stats_strip(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), @@ -1027,10 +1458,9 @@ fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext< } /// Draw-mode row — "Draw 1" / "Draw 3" toggle. Affects the next Classic -/// deal (the Settings value the new-game flow reads). Surfacing it on -/// the Home modal keeps the per-game choice one tap away rather than -/// buried in Settings, mirroring the dropdown MSSC puts on its -/// difficulty picker. +/// deal (the Settings value the new-game flow reads). Lives inside the +/// deal-options disclosure so the per-game choice stays one tap away +/// without occupying the top level of the Home hierarchy. fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { @@ -1107,36 +1537,45 @@ fn spawn_draw_mode_chip( }); } -/// Collapsible difficulty-tier section injected below the mode tile grid. +/// Collapsible deal-options disclosure rendered directly under the hero +/// New Game button — the "on the Classic card / New Game hero" home for +/// the Classic-mode deal concerns (Phase B decision 2). /// /// Structure: /// ```text -/// ▶ Difficulty ← HomeDifficultyToggle (Button, row) -/// [Easy] [Medium] [Hard] [Expert] [GM] [Random] ← visible only when expanded +/// > Deal options ← HomeDealOptionsToggle (Button, row) +/// Draw mode [Draw 1] [Draw 3] ← expanded only +/// Deals [Winnable only] ← expanded only +/// Difficulty [Easy] [Medium] ... [Random] ← expanded only /// ``` /// /// The toggle header despawns + respawns the home screen (same pattern as -/// the draw-mode toggle) so the chevron direction and chip row visibility +/// the draw-mode chips) so the chevron direction and disclosure body /// update without Visibility component surgery. -fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { +fn spawn_deal_options_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) { let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() }; + let font_caption = TextFont { + font: font_handle.clone(), + font_size: TYPE_CAPTION, + ..default() + }; let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() }; - let chevron = if ctx.difficulty_expanded { "v" } else { ">" }; + let chevron = if ctx.deal_options_expanded { "v" } else { ">" }; // Header row — click to toggle expand/collapse. parent .spawn(( - HomeDifficultyToggle, + HomeDealOptionsToggle, Button, Node { flex_direction: FlexDirection::Row, @@ -1153,56 +1592,111 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext TextColor(TEXT_SECONDARY), )); row.spawn(( - Text::new("Difficulty"), + Text::new("Deal options"), font_label.clone(), TextColor(TEXT_SECONDARY), )); }); - // Tier chips — only rendered when expanded. - if ctx.difficulty_expanded { - parent - .spawn(Node { - flex_direction: FlexDirection::Row, - flex_wrap: FlexWrap::Wrap, - row_gap: VAL_SPACE_2, - column_gap: VAL_SPACE_2, - width: Val::Percent(100.0), - ..default() - }) - .with_children(|row| { - for level in [ - DifficultyLevel::Easy, - DifficultyLevel::Medium, - DifficultyLevel::Hard, - DifficultyLevel::Expert, - DifficultyLevel::Grandmaster, - DifficultyLevel::Random, - ] { - let active = ctx.last_difficulty == Some(level); - let (bg, fg) = if active { - (ACCENT_PRIMARY, BG_ELEVATED) - } else { - (BG_ELEVATED_HI, TEXT_PRIMARY) - }; - row.spawn(( - HomeDifficultyChip(level), - Button, - Node { - padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1), - border: UiRect::all(Val::Px(1.0)), - border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), - ..default() - }, - BackgroundColor(bg), - BorderColor::all(BORDER_SUBTLE), - HighContrastBorder::with_default(BORDER_SUBTLE), - )) - .with_children(|c| { - c.spawn((Text::new(level.label()), font_chip.clone(), TextColor(fg))); - }); - } - }); + if !ctx.deal_options_expanded { + return; + } + + spawn_draw_mode_row(parent, ctx); + + // Winnable-only filter chip. + parent + .spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|row| { + row.spawn(( + Text::new("Deals"), + font_caption.clone(), + TextColor(TEXT_SECONDARY), + )); + spawn_draw_mode_chip::( + row, + HomeWinnableToggle, + "Winnable only", + ctx.winnable_only, + &font_chip, + ); + }); + + // Difficulty tier chips. + parent + .spawn(Node { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + align_items: AlignItems::Center, + row_gap: VAL_SPACE_2, + column_gap: VAL_SPACE_2, + width: Val::Percent(100.0), + ..default() + }) + .with_children(|row| { + row.spawn(( + Text::new("Difficulty"), + font_caption.clone(), + TextColor(TEXT_SECONDARY), + )); + for level in [ + DifficultyLevel::Easy, + DifficultyLevel::Medium, + DifficultyLevel::Hard, + DifficultyLevel::Expert, + DifficultyLevel::Grandmaster, + DifficultyLevel::Random, + ] { + let active = ctx.last_difficulty == Some(level); + let (bg, fg) = if active { + (ACCENT_PRIMARY, BG_ELEVATED) + } else { + (BG_ELEVATED_HI, TEXT_PRIMARY) + }; + row.spawn(( + HomeDifficultyChip(level), + Button, + Node { + padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1), + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BackgroundColor(bg), + BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|c| { + c.spawn((Text::new(level.label()), font_chip.clone(), TextColor(fg))); + }); + } + }); +} + +/// Short display label for a persisted [`GameMode`] — used by the +/// Continue card caption and the hero New Game subtitle. +fn game_mode_label(mode: GameMode) -> String { + match mode { + GameMode::Classic => "Classic".to_string(), + GameMode::Zen => "Zen".to_string(), + GameMode::Challenge => "Challenge".to_string(), + GameMode::TimeAttack => "Time Attack".to_string(), + GameMode::Difficulty(level) => format!("Classic {}", level.label()), + } +} + +/// `m:ss` (or `h:mm:ss` past an hour) for the Continue card caption. +fn format_elapsed(secs: u64) -> String { + let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") } } @@ -1378,9 +1872,10 @@ fn spawn_mode_card(parent: &mut ChildSpawnerCommands, mode: HomeMode, ctx: &Home row_gap: VAL_SPACE_2, padding: UiRect::all(VAL_SPACE_3), // 48% per tile + the row's column_gap = a clean 2-up - // grid that wraps to a single tile on the third row. + // grid — six modes make a symmetric 2×3. Compact on + // narrow viewports where the description is hidden. width: Val::Percent(48.0), - min_height: Val::Px(180.0), + min_height: Val::Px(if ctx.wide { 180.0 } else { 120.0 }), align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), @@ -1448,12 +1943,15 @@ fn spawn_mode_card(parent: &mut ChildSpawnerCommands, mode: HomeMode, ctx: &Home } }); - // Description line. - c.spawn(( - Text::new(mode.description().to_string()), - font_desc.clone(), - TextColor(desc_color), - )); + // Description line — wide viewports only; the compact + // narrow tile is glyph + name (+ state chips). + if ctx.wide { + c.spawn(( + Text::new(mode.description().to_string()), + font_desc.clone(), + TextColor(desc_color), + )); + } // Per-mode score / streak chip — populated only when the // player has data for this mode. Hidden on a 0 best so a @@ -2145,4 +2643,304 @@ mod tests { "Digit keys with no modal open must not fire StartDailyChallengeRequestEvent" ); } + + // ----------------------------------------------------------------------- + // Phase B: Continue card, hero New Game, deal-options disclosure + // ----------------------------------------------------------------------- + + /// Count entities carrying `C` — shorthand for the presence asserts + /// the Phase B tests make repeatedly. + fn count_with(app: &mut App) -> usize { + app.world_mut() + .query_filtered::<(), With>() + .iter(app.world()) + .count() + } + + #[test] + fn continue_card_absent_on_untouched_deal() { + let mut app = headless_app(); + let _ = open_home(&mut app); + + assert_eq!( + count_with::(&mut app), + 0, + "an untouched deal must not earn a Continue card" + ); + // But the deal is live, so Back to table renders. + assert_eq!( + count_with::(&mut app), + 1, + "Back to table must render while a live game exists" + ); + } + + #[test] + fn continue_card_click_closes_modal_without_launching() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::() + .0 + .draw() + .expect("draw from a fresh deal must succeed"); + let _ = open_home(&mut app); + + app.world_mut() + .resource_mut::>() + .clear(); + + let card = app + .world_mut() + .query::<(Entity, &HomeContinueCard)>() + .single(app.world()) + .map(|(e, _)| e) + .expect("Continue card must exist once a move was made"); + press_button(&mut app, card); + + assert_eq!( + app.world_mut() + .query::<&HomeScreen>() + .iter(app.world()) + .count(), + 0, + "Continue must dismiss the modal (back to the running game)" + ); + let new_game = app.world().resource::>(); + let mut nc = new_game.get_cursor(); + assert!( + nc.read(new_game).next().is_none(), + "Continue must not fire NewGameRequestEvent" + ); + } + + #[test] + fn back_to_table_and_continue_absent_when_game_won() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::() + .0 + .set_test_won(true); + let _ = open_home(&mut app); + + assert_eq!( + count_with::(&mut app), + 0, + "a won game is over — no Back to table" + ); + assert_eq!( + count_with::(&mut app), + 0, + "a won game is over — no Continue card" + ); + } + + #[test] + fn hero_new_game_fires_classic_by_default() { + let mut app = headless_app(); + let _ = open_home(&mut app); + + app.world_mut() + .resource_mut::>() + .clear(); + + let hero = app + .world_mut() + .query::<(Entity, &HomeNewGameHero)>() + .single(app.world()) + .map(|(e, _)| e) + .expect("hero New Game button must exist"); + press_button(&mut app, hero); + + let new_game = app.world().resource::>(); + let mut nc = new_game.get_cursor(); + assert_eq!( + nc.read(new_game).count(), + 1, + "hero must fire exactly one NewGameRequestEvent by default" + ); + assert_eq!( + app.world_mut() + .query::<&HomeScreen>() + .iter(app.world()) + .count(), + 0, + "Home modal must close after the hero launch" + ); + } + + #[test] + fn hero_new_game_replays_persisted_zen_mode() { + let mut app = headless_app(); + app.insert_resource(SettingsResource(solitaire_data::Settings { + last_mode: GameMode::Zen, + ..Default::default() + })); + app.world_mut().resource_mut::().0.level = CHALLENGE_UNLOCK_LEVEL; + let _ = open_home(&mut app); + + app.world_mut() + .resource_mut::>() + .clear(); + app.world_mut() + .resource_mut::>() + .clear(); + + let hero = app + .world_mut() + .query::<(Entity, &HomeNewGameHero)>() + .single(app.world()) + .map(|(e, _)| e) + .expect("hero New Game button must exist"); + press_button(&mut app, hero); + + let zen = app.world().resource::>(); + let mut zc = zen.get_cursor(); + assert_eq!( + zc.read(zen).count(), + 1, + "hero must replay the persisted Zen mode" + ); + let new_game = app.world().resource::>(); + let mut nc = new_game.get_cursor(); + assert!( + nc.read(new_game).next().is_none(), + "hero must not also fire a Classic NewGameRequestEvent" + ); + } + + #[test] + fn hero_falls_back_to_classic_when_last_mode_locked() { + let mut app = headless_app(); + app.insert_resource(SettingsResource(solitaire_data::Settings { + last_mode: GameMode::Zen, + ..Default::default() + })); + // Level 0 — Zen is locked, the hero must not dead-click. + let _ = open_home(&mut app); + + app.world_mut() + .resource_mut::>() + .clear(); + app.world_mut() + .resource_mut::>() + .clear(); + + let hero = app + .world_mut() + .query::<(Entity, &HomeNewGameHero)>() + .single(app.world()) + .map(|(e, _)| e) + .expect("hero New Game button must exist"); + press_button(&mut app, hero); + + let zen = app.world().resource::>(); + let mut zc = zen.get_cursor(); + assert!( + zc.read(zen).next().is_none(), + "a locked persisted mode must not launch" + ); + let new_game = app.world().resource::>(); + let mut nc = new_game.get_cursor(); + assert_eq!( + nc.read(new_game).count(), + 1, + "hero must fall back to Classic when last_mode is locked" + ); + } + + #[test] + fn card_click_persists_last_mode() { + let mut app = headless_app(); + app.insert_resource(SettingsResource(solitaire_data::Settings::default())); + app.world_mut().resource_mut::().0.level = CHALLENGE_UNLOCK_LEVEL; + let _ = open_home(&mut app); + + let card = find_card(&mut app, HomeMode::Zen); + press_button(&mut app, card); + + assert_eq!( + app.world().resource::().0.last_mode, + GameMode::Zen, + "launching Zen from its card must persist last_mode" + ); + } + + #[test] + fn daily_click_does_not_change_last_mode() { + let mut app = headless_app(); + app.insert_resource(SettingsResource(solitaire_data::Settings::default())); + let _ = open_home(&mut app); + + let card = find_card(&mut app, HomeMode::Daily); + press_button(&mut app, card); + + assert_eq!( + app.world().resource::().0.last_mode, + GameMode::Classic, + "Daily is date-fixed and must not become the hero's replay mode" + ); + } + + #[test] + fn deal_options_collapsed_by_default_and_expand_on_toggle() { + let mut app = headless_app(); + let _ = open_home(&mut app); + + assert_eq!( + count_with::(&mut app), + 0, + "draw chips must be hidden while the disclosure is collapsed" + ); + assert_eq!( + count_with::(&mut app), + 0, + "winnable chip must be hidden while the disclosure is collapsed" + ); + + let toggle = app + .world_mut() + .query::<(Entity, &HomeDealOptionsToggle)>() + .single(app.world()) + .map(|(e, _)| e) + .expect("deal-options toggle must exist"); + press_button(&mut app, toggle); + // The toggle respawns the modal; run one more tick so the fresh + // hierarchy is in place before querying. + app.update(); + + assert_eq!( + count_with::(&mut app), + 1, + "draw chips must render once the disclosure expands" + ); + assert_eq!( + count_with::(&mut app), + 1, + "winnable chip must render once the disclosure expands" + ); + } + + #[test] + fn winnable_toggle_flips_setting() { + let mut app = headless_app(); + app.insert_resource(SettingsResource(solitaire_data::Settings::default())); + app.insert_resource(DealOptionsExpanded(true)); + let _ = open_home(&mut app); + + let chip = app + .world_mut() + .query::<(Entity, &HomeWinnableToggle)>() + .single(app.world()) + .map(|(e, _)| e) + .expect("winnable chip must exist while expanded"); + press_button(&mut app, chip); + + assert!( + app.world() + .resource::() + .0 + .winnable_deals_only, + "clicking the winnable chip must flip winnable_deals_only" + ); + } } diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 004d1fc..021c6d4 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -178,6 +178,26 @@ pub fn spawn_modal( z_panel: i32, build_card: F, ) -> Entity +where + F: FnOnce(&mut ChildSpawnerCommands), +{ + spawn_modal_sized(commands, plugin_marker, z_panel, MODAL_CARD_MAX_WIDTH, build_card) +} + +/// Default maximum card width in logical pixels — every [`spawn_modal`] +/// caller gets this. Surfaces that lay out side-by-side panes (e.g. the +/// two-pane Home on wide viewports) can raise it via [`spawn_modal_sized`]. +pub const MODAL_CARD_MAX_WIDTH: f32 = 720.0; + +/// [`spawn_modal`] with an explicit card `max_width`. Behaviour is +/// otherwise identical — same scrim, enter animation, and card chrome. +pub fn spawn_modal_sized( + commands: &mut Commands, + plugin_marker: M, + z_panel: i32, + max_width: f32, + build_card: F, +) -> Entity where F: FnOnce(&mut ChildSpawnerCommands), { @@ -235,7 +255,7 @@ where padding: UiRect::all(VAL_SPACE_5), border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_LG)), - max_width: Val::Px(720.0), + max_width: Val::Px(max_width), align_items: AlignItems::Stretch, ..default() },