From 0c69d6859d01d3f9ffa9e0fa81ce6365f1a6cb7c Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 7 Jul 2026 16:52:31 -0700 Subject: [PATCH 1/2] refactor(engine): extract shared spawn_tab_chip widget into ui_modal Settings' tab_chip becomes a thin wrapper; the You hub (Phase E) will reuse the same widget so tabbed modals stay visually identical. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/settings_plugin/ui.rs | 40 ++++++------------- solitaire_engine/src/ui_modal.rs | 46 ++++++++++++++++++++-- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/solitaire_engine/src/settings_plugin/ui.rs b/solitaire_engine/src/settings_plugin/ui.rs index 9d125e4..2b7d068 100644 --- a/solitaire_engine/src/settings_plugin/ui.rs +++ b/solitaire_engine/src/settings_plugin/ui.rs @@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry}; use crate::ui_focus::FocusRow; use crate::ui_modal::{ ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, + spawn_tab_chip, }; use crate::ui_theme::{ BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, @@ -117,41 +118,22 @@ pub(super) fn spawn_settings_panel( }); } -/// One Settings tab chip. The active chip is filled + bright; inactive -/// chips are quiet outlines. +/// One Settings tab chip — thin wrapper over the shared +/// [`spawn_tab_chip`] widget so Settings and the You hub stay visually +/// identical. fn tab_chip( parent: &mut ChildSpawnerCommands, tab: SettingsTab, active: bool, font_res: Option<&FontResource>, ) { - let font = TextFont { - font: font_res.map(|f| f.0.clone()).unwrap_or_default(), - font_size: TYPE_CAPTION, - ..default() - }; - parent - .spawn(( - SettingsTabButton(tab), - Button, - Node { - padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), - justify_content: JustifyContent::Center, - border: UiRect::all(Val::Px(1.0)), - border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), - ..default() - }, - BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }), - BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }), - HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }), - )) - .with_children(|b| { - b.spawn(( - Text::new(tab.label()), - font, - TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }), - )); - }); + spawn_tab_chip( + parent, + SettingsTabButton(tab), + tab.label(), + active, + font_res, + ); } /// Audio tab: the two volume rows. diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index cb94708..004d1fc 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder, - MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, - TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, - scaled_duration, + MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY, + TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, + VAL_SPACE_4, VAL_SPACE_5, scaled_duration, }; // --------------------------------------------------------------------------- @@ -402,6 +402,46 @@ pub fn spawn_modal_button( }); } +/// One tab chip for a tabbed modal (Settings, the You hub). The active +/// chip is filled + bright with a success-green border; inactive chips +/// are quiet outlines. `marker` is the plugin's click-target component +/// carrying which tab the chip selects. +pub fn spawn_tab_chip( + parent: &mut ChildSpawnerCommands, + marker: M, + label: &str, + active: bool, + font_res: Option<&FontResource>, +) { + let font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }; + parent + .spawn(( + marker, + Button, + Node { + padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), + justify_content: JustifyContent::Center, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + ..default() + }, + BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }), + BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }), + HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }), + )) + .with_children(|b| { + b.spawn(( + Text::new(label), + font, + TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }), + )); + }); +} + // --------------------------------------------------------------------------- // Generic touch-scroll helper // --------------------------------------------------------------------------- -- 2.47.3 From 9f038250d9afc3eeea0b70be9a6e65f7d51fd4c4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 7 Jul 2026 17:10:56 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(engine):=20You=20hub=20=E2=80=94=20Pro?= =?UTF-8?q?file/Stats/Achievements/Replays=20in=20one=20tabbed=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the modal shell (header, shared tab chips, single Done); each tab's content is a body builder extracted from its original plugin with every marker component unchanged, so per-row update/scroll/selector systems keep working. The replay selector gets its own Replays tab (Watch/Copy buttons move into the tab body). - Toggle*RequestEvents + P/S/A accelerators open the hub on the right tab, switch tabs in place, or toggle closed on a same-tab request; Esc/Done/scrim-click close - Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the hub scrim for the active tab — external queries and tests keep their meaning - Standalone toggle/close systems and per-screen Done buttons removed (ProfileCloseButton, StatsCloseButton, AchievementsCloseButton) - Tests: 2 new hub lifecycle tests; profile/stats/achievements modal tests adapted (fixtures add YouHubPlugin; selector tests target the Replays tab). Engine suite 916 green, clippy -D warnings, fmt. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/achievement_plugin.rs | 89 +--- solitaire_engine/src/core_game_plugin.rs | 1 + solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/profile_plugin.rs | 121 +---- solitaire_engine/src/stats_plugin.rs | 196 ++++----- solitaire_engine/src/you_hub_plugin.rs | 486 +++++++++++++++++++++ 6 files changed, 618 insertions(+), 277 deletions(-) create mode 100644 solitaire_engine/src/you_hub_plugin.rs diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index 0c4f819..0ef4471 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState; use crate::resources::GameStateResource; use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::stats_plugin::{StatsResource, StatsUpdate}; -use crate::ui_modal::{ - ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, - spawn_modal_button, spawn_modal_header, -}; use crate::ui_theme::{ ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, - TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL, + TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, }; use crate::ui_tooltip::Tooltip; @@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin { .after(GameMutation) .after(StatsUpdate), ) - .add_systems(Update, toggle_achievements_screen) - .add_systems(Update, handle_achievements_close_button) + // Open/close/tab handling moved to `you_hub_plugin` + // (Phase E) — this plugin now owns body content + scroll. .add_systems(Update, scroll_achievements_panel) .add_systems( Update, @@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String { achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string()) } -/// Marker on the "Done" button inside the Achievements modal. -#[derive(Component, Debug)] -pub struct AchievementsCloseButton; - -/// Toggle the achievements overlay — `A` keyboard accelerator or -/// `ToggleAchievementsRequestEvent` from the HUD Menu popover. -fn toggle_achievements_screen( - mut commands: Commands, - keys: Res>, - mut requests: MessageReader, - achievements: Res, - font_res: Option>, - screens: Query>, - other_modal_scrims: Query<(), (With, Without)>, -) { - let button_clicked = requests.read().count() > 0; - if !keys.just_pressed(KeyCode::KeyA) && !button_clicked { - return; - } - if let Ok(entity) = screens.single() { - commands.entity(entity).despawn(); - } else if other_modal_scrims.is_empty() { - spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref()); - } -} - -/// Click handler for the modal's "Done" button — despawns the overlay -/// the same way the `A` accelerator does. -fn handle_achievements_close_button( - mut commands: Commands, - close_buttons: Query<&Interaction, (With, Changed)>, - screens: Query>, -) { - if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { - return; - } - for entity in &screens { - commands.entity(entity).despawn(); - } -} - /// Routes mouse-wheel events into the Achievements modal's scrollable body /// while the panel is open. /// @@ -458,14 +413,18 @@ fn scroll_achievements_panel( } } -fn spawn_achievements_screen( - commands: &mut Commands, +/// Builds the Achievements tab body inside the You hub's card. The +/// unlock-count line that used to live in the standalone modal's +/// header renders as the first body line instead (the hub owns the +/// header). All markers (`AchievementRow`, `AchievementsScrollable`) +/// are unchanged. +pub(crate) fn spawn_achievements_body( + card: &mut ChildSpawnerCommands, records: &[AchievementRecord], font_res: Option<&FontResource>, ) { let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect(); let total = ALL_ACHIEVEMENTS.len(); - let header = format!("Achievements ({}/{})", unlocked.len(), total); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_name = TextFont { @@ -486,8 +445,13 @@ fn spawn_achievements_screen( let any_unlocked = records.iter().any(|r| r.unlocked); - let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| { - spawn_modal_header(card, header, font_res); + { + // Unlock progress — formerly the standalone modal's header. + card.spawn(( + Text::new(format!("Unlocked {} / {}", unlocked.len(), total)), + font_name.clone(), + TextColor(TEXT_SECONDARY), + )); // First-time hint — shown until the player has unlocked anything. // The list itself describes individual rewards, but a top-level @@ -594,21 +558,7 @@ fn spawn_achievements_screen( )); } }); - - spawn_modal_actions(card, |actions| { - spawn_modal_button( - actions, - AchievementsCloseButton, - "Done", - Some("A"), - ButtonVariant::Primary, - font_res, - ); - }); - }); - // Achievements is a read-only list — clicking the scrim outside - // the card dismisses alongside the existing A / Done paths. - commands.entity(scrim).insert(ScrimDismissible); + } } fn format_reward(reward: Reward) -> String { @@ -668,7 +618,8 @@ mod tests { .add_plugins(TablePlugin) .add_plugins(StatsPlugin::headless()) .add_plugins(crate::progress_plugin::ProgressPlugin::headless()) - .add_plugins(AchievementPlugin::headless()); + .add_plugins(AchievementPlugin::headless()) + .add_plugins(crate::you_hub_plugin::YouHubPlugin); // StatsPlugin's UI toggle system reads ButtonInput; under // MinimalPlugins it isn't auto-registered. app.init_resource::>(); diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 18aaa98..1cc16dd 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -109,6 +109,7 @@ impl Plugin for CoreGamePlugin { .add_plugins(HelpPlugin) .add_plugins(HomePlugin::default()) .add_plugins(ProfilePlugin) + .add_plugins(crate::you_hub_plugin::YouHubPlugin) .add_plugins(PausePlugin) .add_plugins(SettingsPlugin::default()) .add_plugins(OnboardingPlugin) diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 9e89dd9..630c858 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -64,6 +64,7 @@ pub mod ui_theme; pub mod ui_tooltip; pub mod weekly_goals_plugin; pub mod win_summary_plugin; +pub mod you_hub_plugin; pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen}; #[cfg(not(target_arch = "wasm32"))] @@ -195,3 +196,4 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; pub use win_summary_plugin::{ ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time, }; +pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab}; diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index 3c2b5d2..8eb0148 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -4,7 +4,6 @@ //! summary in a single scrollable panel. Spawned on the first `P` keypress and //! despawned on the second. -use bevy::input::ButtonInput; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use chrono::{Duration, Local, NaiveDate}; @@ -13,23 +12,19 @@ use solitaire_data::SyncBackend; use crate::achievement_plugin::AchievementsResource; #[cfg(not(target_arch = "wasm32"))] -use crate::avatar_plugin::AvatarResource; +pub(crate) use crate::avatar_plugin::AvatarResource; #[cfg(target_arch = "wasm32")] #[derive(bevy::prelude::Resource)] -struct AvatarResource(Option>); +pub(crate) struct AvatarResource(Option>); use crate::events::ToggleProfileRequestEvent; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SyncStatus, SyncStatusResource}; use crate::settings_plugin::SettingsResource; use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate}; -use crate::ui_modal::{ - ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, - spawn_modal_button, spawn_modal_header, -}; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, - TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL, + TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, }; /// Number of days surfaced in the daily-challenge calendar row. @@ -60,13 +55,11 @@ pub struct DailyCalendarDot { pub is_today: bool, } -/// Registers the `P` key toggle for the profile overlay. +/// Registers the Profile body's scroll handling. Opening/closing moved +/// to the You hub (`you_hub_plugin`), which calls +/// [`spawn_profile_body`] for this tab's content. pub struct ProfilePlugin; -/// Marker on the "Done" button inside the Profile modal. -#[derive(Component, Debug)] -pub struct ProfileCloseButton; - /// Marker on the scrollable body Node inside the Profile modal. /// /// The Profile panel renders sync info, progression (incl. 14-day @@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin { // profile-scroll system also runs cleanly under // `MinimalPlugins` in tests. .add_message::() - .add_systems( - Update, - ( - toggle_profile_screen, - handle_profile_close_button, - scroll_profile_panel, - ), - ); + // Open/close/tab handling moved to `you_hub_plugin` + // (Phase E) — this plugin now owns body content + scroll. + .add_systems(Update, scroll_profile_panel); } } @@ -124,70 +112,12 @@ fn scroll_profile_panel( } } -fn handle_profile_close_button( - mut commands: Commands, - close_buttons: Query<&Interaction, (With, Changed)>, - screens: Query>, -) { - if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { - return; - } - for entity in &screens { - commands.entity(entity).despawn(); - } -} - +/// Builds the Profile tab body inside the You hub's card. All markers +/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the +/// scroll and test queries keep working. #[allow(clippy::too_many_arguments)] -fn toggle_profile_screen( - mut commands: Commands, - keys: Res>, - mut requests: MessageReader, - settings: Option>, - sync_status: Option>, - progress: Option>, - achievements: Option>, - stats: Option>, - font_res: Option>, - avatar: Option>, - screens: Query>, - scrims: Query<(), With>, -) { - let button_clicked = requests.read().count() > 0; - let p_pressed = keys.just_pressed(KeyCode::KeyP); - let esc_pressed = keys.just_pressed(KeyCode::Escape); - let already_open = !screens.is_empty(); - // P / button toggles open-or-close. Esc only ever closes — when - // Profile is layered over Home (clicking the new Home stats chip - // opens this on top), Esc must dismiss the *topmost* modal. - // Without this branch, Esc fell through to Home's cancel handler - // and closed the wrong modal. - let want_open = !already_open && (p_pressed || button_clicked); - let want_close = already_open && (p_pressed || button_clicked || esc_pressed); - if !want_open && !want_close { - return; - } - if want_open && !scrims.is_empty() { - return; - } - if let Ok(entity) = screens.single() { - commands.entity(entity).despawn(); - } else { - spawn_profile_screen( - &mut commands, - settings.as_deref(), - sync_status.as_deref(), - progress.as_deref(), - achievements.as_deref(), - stats.as_deref(), - font_res.as_deref(), - avatar.as_deref(), - ); - } -} - -#[allow(clippy::too_many_arguments)] -fn spawn_profile_screen( - commands: &mut Commands, +pub(crate) fn spawn_profile_body( + card: &mut ChildSpawnerCommands, settings: Option<&SettingsResource>, sync_status: Option<&SyncStatusResource>, progress: Option<&ProgressResource>, @@ -208,9 +138,7 @@ fn spawn_profile_screen( ..default() }; - let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| { - spawn_modal_header(card, "Profile", font_res); - + { // Scrollable body — the Profile panel renders sync info, // progression (incl. a 14-day calendar), every unlocked // achievement (up to ~18), and a stats summary, which can @@ -472,20 +400,7 @@ fn spawn_profile_screen( )); } }); - - spawn_modal_actions(card, |actions| { - spawn_modal_button( - actions, - ProfileCloseButton, - "Done", - Some("P"), - ButtonVariant::Primary, - font_res, - ); - }); - }); - // Profile is read-only — opt into click-outside-to-dismiss. - commands.entity(scrim).insert(ScrimDismissible); + } } /// Spawn a fixed-height vertical spacer node. @@ -627,6 +542,7 @@ mod tests { use crate::settings_plugin::SettingsPlugin; use crate::stats_plugin::StatsPlugin; use crate::table_plugin::TablePlugin; + use crate::ui_modal::ModalScrim; fn headless_app() -> App { let mut app = App::new(); @@ -637,7 +553,8 @@ mod tests { .add_plugins(ProgressPlugin::headless()) .add_plugins(AchievementPlugin::headless()) .add_plugins(SettingsPlugin::headless()) - .add_plugins(ProfilePlugin); + .add_plugins(ProfilePlugin) + .add_plugins(crate::you_hub_plugin::YouHubPlugin); app.init_resource::>(); app.update(); app diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index c338546..8c4eeb4 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -8,7 +8,6 @@ use std::path::PathBuf; -use bevy::input::ButtonInput; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use solitaire_data::{ @@ -25,17 +24,13 @@ use crate::events::{ use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; use crate::platform::ClipboardBackendResource; -use crate::progress_plugin::ProgressResource; use crate::resources::GameStateResource; use crate::time_attack_plugin::TimeAttackResource; -use crate::ui_modal::{ - ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, - spawn_modal_button, spawn_modal_header, -}; +use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button}; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, - TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, + TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, }; /// Bevy resource wrapping the current stats. @@ -225,8 +220,9 @@ impl Plugin for StatsPlugin { .before(GameMutation) .before(update_stats_on_new_game), ) - .add_systems(Update, toggle_stats_screen.after(GameMutation)) - .add_systems(Update, handle_stats_close_button) + // Open/close/tab handling moved to `you_hub_plugin` + // (Phase E) — this plugin now owns body content, replay + // selector behavior, and scroll. .add_systems(Update, refresh_replay_history_on_win.after(GameMutation)) .add_systems(Update, handle_watch_replay_button) .add_systems(Update, handle_copy_share_link_button) @@ -632,70 +628,17 @@ fn handle_forfeit( } } -/// Marker on the "Done" button inside the Stats modal. Click despawns -/// the overlay; `S` keyboard shortcut toggles it the same way. -#[derive(Component, Debug)] -pub struct StatsCloseButton; - -#[allow(clippy::too_many_arguments)] -fn toggle_stats_screen( - mut commands: Commands, - keys: Res>, - mut requests: MessageReader, - stats: Res, - progress: Option>, - time_attack: Option>, - font_res: Option>, - latest_replay: Res, - selected_index: Res, - screens: Query>, - other_modal_scrims: Query<(), (With, Without)>, -) { - let button_clicked = requests.read().count() > 0; - if !keys.just_pressed(KeyCode::KeyS) && !button_clicked { - return; - } - if let Ok(entity) = screens.single() { - commands.entity(entity).despawn(); - } else { - if !other_modal_scrims.is_empty() { - return; - } - spawn_stats_screen( - &mut commands, - &stats.0, - progress.as_deref().map(|p| &p.0), - time_attack.as_deref(), - font_res.as_deref(), - &latest_replay.0.replays, - selected_index.0, - ); - } -} - -/// Click handler for the modal's "Done" button — despawns the overlay -/// the same way the `S` accelerator does. -fn handle_stats_close_button( - mut commands: Commands, - close_buttons: Query<&Interaction, (With, Changed)>, - screens: Query>, -) { - if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { - return; - } - for entity in &screens { - commands.entity(entity).despawn(); - } -} - -fn spawn_stats_screen( - commands: &mut Commands, +/// Builds the Stats tab body inside the You hub's card: the 8-cell +/// grid plus per-mode bests, progression, weekly goals, unlocks, and +/// the optional Time Attack line. The replay selector lives on its own +/// hub tab now — see [`spawn_replays_body`]. All markers +/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged. +pub(crate) fn spawn_stats_body( + card: &mut ChildSpawnerCommands, stats: &StatsSnapshot, progress: Option<&PlayerProgress>, time_attack: Option<&TimeAttackResource>, font_res: Option<&FontResource>, - replays: &[Replay], - selected_index: usize, ) { // --- primary stat cells --- // First-launch zero-state: when no games have been played yet, render @@ -756,9 +699,7 @@ fn spawn_stats_screen( ..default() }; - let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| { - spawn_modal_header(card, "Statistics", font_res); - + { // Scrollable body — the Stats panel renders an 8-cell grid plus // multiple sections (per-mode bests, progression, weekly goals, // unlocks, optional Time Attack, latest replay caption) and @@ -935,7 +876,45 @@ fn spawn_stats_screen( TextColor(STATE_WARNING), )); } + }); + } +} +/// Builds the Replays tab body inside the You hub's card: the +/// Prev/Next selector, detail line, and the Watch / Copy-share-link +/// actions (buttons live in the body now — the hub owns the single +/// Done in the action row). All markers (`ReplayPrevButton`, +/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`, +/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the +/// selector/watch/copy systems keep working. +pub(crate) fn spawn_replays_body( + card: &mut ChildSpawnerCommands, + replays: &[Replay], + selected_index: usize, + font_res: Option<&FontResource>, +) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_row = TextFont { + font: font_handle, + font_size: TYPE_BODY, + ..default() + }; + + { + // Scrollable body — mirrors the other hub tabs so short + // windows can still reach every control. + card.spawn(( + StatsScrollable, + ScrollPosition::default(), + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_3, + max_height: Val::Vh(70.0), + overflow: Overflow::scroll_y(), + ..default() + }, + )) + .with_children(|body| { // --- Replay selector --- // Prev / Next chips step through the full replay history; // `repaint_replay_selector_caption` and @@ -1019,12 +998,21 @@ fn spawn_stats_screen( )); }); - spawn_modal_actions(card, |actions| { - // The Watch Replay button is always rendered so the - // affordance is discoverable from a fresh install. When no - // replay exists, the click handler surfaces a clear - // "No replay recorded yet" toast rather than silently - // doing nothing. + // Tab-local actions. The Watch Replay button is always + // rendered so the affordance is discoverable from a fresh + // install — with no replay, its click handler surfaces a + // "No replay recorded yet" toast rather than silently doing + // nothing. Same policy for Copy share link (toast explains + // when no shareable upload exists). + card.spawn(Node { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + column_gap: VAL_SPACE_2, + row_gap: VAL_SPACE_2, + margin: UiRect::top(VAL_SPACE_2), + ..default() + }) + .with_children(|actions| { spawn_modal_button( actions, WatchReplayButton, @@ -1033,11 +1021,6 @@ fn spawn_stats_screen( ButtonVariant::Secondary, font_res, ); - // Copy share link only renders when a sharable URL is in - // hand. The button is intentionally absent (rather than - // disabled) when no upload has happened yet — keeps the - // action bar free of dead controls in the local-only and - // first-launch cases. spawn_modal_button( actions, CopyShareLinkButton, @@ -1046,18 +1029,8 @@ fn spawn_stats_screen( ButtonVariant::Secondary, font_res, ); - spawn_modal_button( - actions, - StatsCloseButton, - "Done", - Some("S"), - ButtonVariant::Primary, - font_res, - ); }); - }); - // Stats is read-only — opt into click-outside-to-dismiss. - commands.entity(scrim).insert(ScrimDismissible); + } } /// Spawn one row of the "Per-mode bests" section: the mode label on the @@ -1290,6 +1263,7 @@ mod tests { // ProgressResource is an optional dependency for the stats screen; // include it so toggle tests exercise the progression panel. app.add_plugins(crate::progress_plugin::ProgressPlugin::headless()); + app.add_plugins(crate::you_hub_plugin::YouHubPlugin); app.update(); app } @@ -1832,6 +1806,25 @@ mod tests { // Prev/Next replay selector spawn-site tests // ----------------------------------------------------------------------- + /// Opens the You hub via `S`, then switches to the Replays tab + /// (where the selector lives since Phase E) and settles the rebuild. + fn open_replays_tab(app: &mut App) { + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyS); + app.update(); + // MinimalPlugins never clears input, so the sticky `just_pressed` + // would keep re-requesting the Stats tab every frame. + app.world_mut() + .resource_mut::>() + .clear(); + app.world_mut() + .resource_mut::() + .0 = crate::you_hub_plugin::YouTab::Replays; + app.update(); + app.update(); + } + #[test] fn selector_row_spawns_when_stats_screen_opens() { let mut app = headless_app(); @@ -1840,10 +1833,7 @@ mod tests { let mut hist = app.world_mut().resource_mut::(); hist.0.replays.push(make_test_replay(90, None)); } - app.world_mut() - .resource_mut::>() - .press(KeyCode::KeyS); - app.update(); + open_replays_tab(&mut app); let prev = app .world_mut() @@ -1878,10 +1868,7 @@ mod tests { let mut hist = app.world_mut().resource_mut::(); hist.0.replays.push(make_test_replay(120, None)); } - app.world_mut() - .resource_mut::>() - .press(KeyCode::KeyS); - app.update(); + open_replays_tab(&mut app); let mut q = app .world_mut() @@ -1901,10 +1888,7 @@ mod tests { let mut hist = app.world_mut().resource_mut::(); hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05" } - app.world_mut() - .resource_mut::>() - .press(KeyCode::KeyS); - app.update(); + open_replays_tab(&mut app); let mut q = app .world_mut() diff --git a/solitaire_engine/src/you_hub_plugin.rs b/solitaire_engine/src/you_hub_plugin.rs new file mode 100644 index 0000000..c99c71f --- /dev/null +++ b/solitaire_engine/src/you_hub_plugin.rs @@ -0,0 +1,486 @@ +//! The "You" hub — Profile · Stats · Achievements · Replays folded +//! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`). +//! +//! The four screens were previously standalone modals reached one at a +//! time through the HUD popover. The hub owns the modal shell (header, +//! tab chips via the shared [`spawn_tab_chip`] widget, a single Done +//! button); each tab's content is a body builder that lives in its +//! original plugin (`spawn_profile_body`, `spawn_stats_body`, +//! `spawn_achievements_body`, `spawn_replays_body`) so every marker +//! component and per-row update system keeps working unchanged. +//! +//! Legacy screen markers (`ProfileScreen`, `StatsScreen`, +//! `AchievementsScreen`) are inserted on the hub scrim while their tab +//! is active, so existing queries and tests keep their meaning. +//! +//! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent` +//! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A +//! accelerators — each opens the hub pre-selected to its tab, toggles +//! the hub closed when its tab is already showing, or switches tabs +//! when a different tab is showing. Esc, Done, and scrim-click close. + +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; + +use solitaire_data::StatsSnapshot; + +use crate::achievement_plugin::{AchievementsResource, AchievementsScreen}; +use crate::events::{ + ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent, +}; +use crate::font_plugin::FontResource; +use crate::profile_plugin::{AvatarResource, ProfileScreen}; +use crate::progress_plugin::ProgressResource; +use crate::resources::SyncStatusResource; +use crate::settings_plugin::SettingsResource; +use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen}; +use crate::time_attack_plugin::TimeAttackResource; +use crate::ui_focus::FocusRow; +use crate::ui_modal::{ + ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, + spawn_modal_button, spawn_modal_header, spawn_tab_chip, +}; +use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL}; + +/// Which tab of the You hub is showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum YouTab { + #[default] + Profile, + Stats, + Achievements, + Replays, +} + +impl YouTab { + /// Every tab, in display order. + pub const ALL: [Self; 4] = [ + Self::Profile, + Self::Stats, + Self::Achievements, + Self::Replays, + ]; + + /// Chip label. + pub fn label(self) -> &'static str { + match self { + Self::Profile => "Profile", + Self::Stats => "Stats", + Self::Achievements => "Awards", + Self::Replays => "Replays", + } + } +} + +/// The active hub tab. Session-only; reopening returns to the last tab +/// unless an open request names a different one. +#[derive(Resource, Debug, Default)] +pub struct ActiveYouTab(pub YouTab); + +/// Marker on the hub modal's scrim root. +#[derive(Component)] +pub struct YouHubScreen; + +/// Per-chip tab selector button. +#[derive(Component, Debug)] +struct YouHubTabButton(YouTab); + +/// Marker on the hub's Done button. +#[derive(Component)] +struct YouHubCloseButton; + +/// Read-only bundle of everything the tab bodies render from. Split +/// out as a [`SystemParam`] because the open + rebuild systems both +/// need the full set and Bevy caps systems at 16 parameters. +#[derive(SystemParam)] +struct YouHubContext<'w> { + settings: Option>, + sync_status: Option>, + progress: Option>, + achievements: Option>, + stats: Option>, + avatar: Option>, + time_attack: Option>, + replay_history: Option>, + selected_replay: Option>, + font_res: Option>, +} + +/// Bevy plugin owning the You hub lifecycle. Requires the profile, +/// stats, and achievement plugins for live data; degrades to empty tab +/// bodies without them (headless tests). +pub struct YouHubPlugin; + +impl Plugin for YouHubPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_message::() + .add_message::() + .add_message::() + .add_systems( + Update, + // Chained: a toggle/chip press must be observed before + // the rebuild, and the rebuild before close — total + // order prevents double-spawns within one frame. + ( + open_or_toggle_you_hub, + handle_tab_buttons, + rebuild_on_tab_change, + handle_close_button, + ) + .chain(), + ); + } +} + +/// Maps this frame's toggle events + accelerator keys to a requested +/// tab, mirroring the semantics the three standalone screens had. +fn requested_tab( + profile_events: &mut MessageReader, + stats_events: &mut MessageReader, + achievements_events: &mut MessageReader, + keys: &ButtonInput, +) -> Option { + let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP); + let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS); + let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA); + if profile { + Some(YouTab::Profile) + } else if stats { + Some(YouTab::Stats) + } else if achievements { + Some(YouTab::Achievements) + } else { + None + } +} + +/// Opens the hub on the requested tab, switches tabs when it's already +/// open on a different one, toggles it closed on a same-tab request or +/// Esc. +#[allow(clippy::too_many_arguments)] +fn open_or_toggle_you_hub( + mut commands: Commands, + keys: Res>, + mut profile_events: MessageReader, + mut stats_events: MessageReader, + mut achievements_events: MessageReader, + screens: Query>, + other_modal_scrims: Query<(), (With, Without)>, + mut active: ResMut, + ctx: YouHubContext, +) { + let requested = requested_tab( + &mut profile_events, + &mut stats_events, + &mut achievements_events, + &keys, + ); + let open = !screens.is_empty(); + + if open { + // Esc closes the topmost modal — the hub, when it's showing. + if keys.just_pressed(KeyCode::Escape) { + for entity in &screens { + commands.entity(entity).despawn(); + } + return; + } + match requested { + Some(tab) if tab == active.0 => { + // Same-tab request toggles closed (P opens Profile, + // P again closes — parity with the old screens). + for entity in &screens { + commands.entity(entity).despawn(); + } + } + Some(tab) => { + // Different tab: switch in place. The rebuild system + // (next in the chain) observes the change. + active.0 = tab; + } + None => {} + } + return; + } + + let Some(tab) = requested else { return }; + if !other_modal_scrims.is_empty() { + return; // Another modal is already visible (§14.2). + } + if active.0 != tab { + // Written before the spawn; the rebuild system also runs this + // frame but sees no live hub (the spawn below is deferred), so + // no double-spawn. + active.0 = tab; + } + spawn_you_hub(&mut commands, tab, &ctx); +} + +/// Switches the active tab when a chip is pressed. +fn handle_tab_buttons( + interactions: Query<(&Interaction, &YouHubTabButton), Changed>, + mut active: ResMut, +) { + for (interaction, chip) in &interactions { + if *interaction != Interaction::Pressed { + continue; + } + if active.0 != chip.0 { + active.0 = chip.0; + } + } +} + +/// Rebuilds the open hub when [`ActiveYouTab`] changes. +fn rebuild_on_tab_change( + active: Res, + screens: Query>, + mut commands: Commands, + ctx: YouHubContext, +) { + if !active.is_changed() || active.is_added() { + return; + } + if screens.is_empty() { + return; + } + for entity in &screens { + commands.entity(entity).despawn(); + } + spawn_you_hub(&mut commands, active.0, &ctx); +} + +/// Despawns the hub when Done is pressed. +fn handle_close_button( + interactions: Query<&Interaction, (Changed, With)>, + screens: Query>, + mut commands: Commands, +) { + for interaction in &interactions { + if *interaction != Interaction::Pressed { + continue; + } + for entity in &screens { + commands.entity(entity).despawn(); + } + } +} + +/// Spawns the hub modal showing `tab`, and stamps the scrim with the +/// tab's legacy screen marker so pre-hub queries keep working. +fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) { + let font_res = ctx.font_res.as_deref(); + let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| { + spawn_modal_header(card, "You", font_res); + + // Tab chips — shared widget with the Settings panel. + card.spawn(( + FocusRow, + Node { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + column_gap: VAL_SPACE_2, + row_gap: VAL_SPACE_2, + margin: UiRect::bottom(VAL_SPACE_3), + ..default() + }, + )) + .with_children(|row| { + for chip_tab in YouTab::ALL { + spawn_tab_chip( + row, + YouHubTabButton(chip_tab), + chip_tab.label(), + chip_tab == tab, + font_res, + ); + } + }); + + match tab { + YouTab::Profile => crate::profile_plugin::spawn_profile_body( + card, + ctx.settings.as_deref(), + ctx.sync_status.as_deref(), + ctx.progress.as_deref(), + ctx.achievements.as_deref(), + ctx.stats.as_deref(), + font_res, + ctx.avatar.as_deref(), + ), + YouTab::Stats => { + let default_stats = StatsSnapshot::default(); + crate::stats_plugin::spawn_stats_body( + card, + ctx.stats.as_deref().map_or(&default_stats, |s| &s.0), + ctx.progress.as_deref().map(|p| &p.0), + ctx.time_attack.as_deref(), + font_res, + ); + } + YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body( + card, + ctx.achievements + .as_deref() + .map_or(&[][..], |a| a.0.as_slice()), + font_res, + ), + YouTab::Replays => crate::stats_plugin::spawn_replays_body( + card, + ctx.replay_history + .as_deref() + .map_or(&[][..], |h| h.0.replays.as_slice()), + ctx.selected_replay.as_deref().map_or(0, |s| s.0), + font_res, + ), + } + + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + YouHubCloseButton, + "Done", + None, + ButtonVariant::Primary, + font_res, + ); + }); + }); + let mut scrim_commands = commands.entity(scrim); + scrim_commands.insert(ScrimDismissible); + // Legacy markers: pre-hub code and tests query these. + match tab { + YouTab::Profile => { + scrim_commands.insert(ProfileScreen); + } + YouTab::Stats | YouTab::Replays => { + scrim_commands.insert(StatsScreen); + } + YouTab::Achievements => { + scrim_commands.insert(AchievementsScreen); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn headless_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin); + app.init_resource::>(); + app.update(); + app + } + + fn hub_count(app: &mut App) -> usize { + app.world_mut() + .query::<&YouHubScreen>() + .iter(app.world()) + .count() + } + + /// Each Toggle*RequestEvent opens the hub pre-selected to its tab, + /// stamped with the tab's legacy screen marker. + #[test] + fn toggle_events_open_hub_on_their_tab() { + use bevy::ecs::message::Messages; + + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .write(ToggleStatsRequestEvent); + app.update(); + app.update(); + + assert_eq!(hub_count(&mut app), 1, "stats request must open the hub"); + assert_eq!(app.world().resource::().0, YouTab::Stats); + assert_eq!( + app.world_mut() + .query::<&StatsScreen>() + .iter(app.world()) + .count(), + 1, + "legacy StatsScreen marker must ride the hub scrim" + ); + + // A different tab's request switches in place — still one hub. + app.world_mut() + .resource_mut::>() + .write(ToggleAchievementsRequestEvent); + app.update(); + app.update(); + assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs"); + assert_eq!( + app.world().resource::().0, + YouTab::Achievements + ); + assert_eq!( + app.world_mut() + .query::<&AchievementsScreen>() + .iter(app.world()) + .count(), + 1 + ); + + // Same-tab request toggles the hub closed. + app.world_mut() + .resource_mut::>() + .write(ToggleAchievementsRequestEvent); + app.update(); + app.update(); + assert_eq!(hub_count(&mut app), 0, "same-tab request must close"); + } + + /// Chip-driven tab switches rebuild the single hub with the new + /// tab's body (Profile scrollable swaps for the Stats one). + #[test] + fn tab_switch_swaps_bodies_without_stacking() { + use crate::profile_plugin::ProfileScrollable; + use crate::stats_plugin::StatsScrollable; + use bevy::ecs::message::Messages; + + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .write(ToggleProfileRequestEvent); + app.update(); + app.update(); + assert_eq!( + app.world_mut() + .query::<&ProfileScrollable>() + .iter(app.world()) + .count(), + 1, + "profile body must spawn on the Profile tab" + ); + + app.world_mut().resource_mut::().0 = YouTab::Stats; + app.update(); + app.update(); + + assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims"); + assert_eq!( + app.world_mut() + .query::<&ProfileScrollable>() + .iter(app.world()) + .count(), + 0, + "profile body must despawn when leaving the tab" + ); + assert_eq!( + app.world_mut() + .query::<&StatsScrollable>() + .iter(app.world()) + .count(), + 1, + "stats body must spawn on the Stats tab" + ); + } +} -- 2.47.3