From 8da62bd05f3ef6a206d5ebaabe5563aff0379b75 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 30 Apr 2026 00:43:14 +0000 Subject: [PATCH] feat(engine): add ui_modal primitive (scaffold + button variants) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 step 3 of the UX overhaul. Adds a reusable modal helper that the next 6 commits use to convert each overlay screen. The audit found 11 overlays using 3 different visual styles with scrim alpha drift between 0.60 and 0.92; this primitive collapses all of that into one consistent shape. API surface: - spawn_modal(commands, plugin_marker, z, build_card) — full-screen scrim (uniform SCRIM token) + centred card (BG_ELEVATED, RADIUS_LG, BORDER_STRONG outline, max-width 720, min-width 360, padding SPACE_5). Returns the scrim entity for one-call despawn. - spawn_modal_header(parent, title, font_res) — TYPE_HEADLINE + TEXT_PRIMARY, the canonical overlay heading. - spawn_modal_body_text(parent, text, color, font_res) — TYPE_BODY_LG paragraph; pass TEXT_PRIMARY or TEXT_SECONDARY. - spawn_modal_actions(parent, build_buttons) — flex-row justify-end with margin-top. - spawn_modal_button(parent, marker, label, hotkey, variant, font_res) — real Button entity with optional TYPE_CAPTION hotkey-hint chip. ButtonVariant enum drives colour: Primary idle ACCENT_PRIMARY hover ACCENT_PRIMARY_HOVER pressed ACCENT_SECONDARY (yellow → pink press flash) Secondary idle BG_ELEVATED_HI hover BG_ELEVATED_TOP pressed BG_ELEVATED Tertiary idle BG_ELEVATED hover BG_ELEVATED_HI pressed BG_ELEVATED_PRESSED A new BG_ELEVATED_TOP token plus ACCENT_PRIMARY_HOVER cover the new hover/press combinations cleanly. UiModalPlugin registers paint_modal_buttons so every ModalButton gets hover and press feedback automatically — overlay plugins don't add their own paint systems. Plugin registered in solitaire_app. A self-test asserts each variant's idle / hover / pressed colours are all distinct; another verifies the plugin builds under MinimalPlugins. This commit is purely additive — no overlay calls the new helpers yet. The next commits convert each overlay to use them. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_app/src/main.rs | 4 +- solitaire_engine/src/lib.rs | 6 + solitaire_engine/src/ui_modal.rs | 397 +++++++++++++++++++++++++++++++ solitaire_engine/src/ui_theme.rs | 8 + 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 solitaire_engine/src/ui_modal.rs diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 614a14f..d5c23b9 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -5,7 +5,8 @@ use solitaire_engine::{ CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, - StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, + StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiModalPlugin, WeeklyGoalsPlugin, + WinSummaryPlugin, }; fn main() { @@ -83,5 +84,6 @@ fn main() { .add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(LeaderboardPlugin) .add_plugins(WinSummaryPlugin) + .add_plugins(UiModalPlugin) .run(); } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index f629568..e360afd 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -30,6 +30,7 @@ pub mod stats_plugin; pub mod sync_plugin; pub mod table_plugin; pub mod time_attack_plugin; +pub mod ui_modal; pub mod ui_theme; pub mod weekly_goals_plugin; pub mod win_summary_plugin; @@ -96,6 +97,11 @@ pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScroll pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState}; pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate}; pub use sync_plugin::{SyncPlugin, SyncProviderResource}; +pub use ui_modal::{ + spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, + spawn_modal_header, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, + ModalHeader, ModalScrim, UiModalPlugin, +}; pub use table_plugin::{ BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin, }; diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs new file mode 100644 index 0000000..2bdbfc1 --- /dev/null +++ b/solitaire_engine/src/ui_modal.rs @@ -0,0 +1,397 @@ +//! Reusable modal-overlay primitive: a uniform scrim + centred card with +//! header / body / actions slots, plus a button variant system that maps +//! to the design tokens in [`crate::ui_theme`]. +//! +//! The audit found that the 11 existing overlay screens used three +//! different visual styles (card-centred dialog, bare full-screen, and +//! one outlier) with scrim alpha drift between 0.60 and 0.92. Every +//! overlay built its own root `Node` and its own colour decisions. +//! +//! This module collapses all of that into a single helper. Each +//! conversion commit replaces an overlay's bespoke spawn function with +//! a call to [`spawn_modal`] plus body content built in a closure. +//! +//! # Example +//! +//! ```ignore +//! spawn_modal( +//! &mut commands, +//! ConfirmNewGameScreen, +//! ui_theme::Z_MODAL_PANEL, +//! |card| { +//! spawn_modal_header(card, "Abandon current game?", font_res); +//! spawn_modal_body_text( +//! card, +//! "Your progress will be lost.", +//! ui_theme::TEXT_SECONDARY, +//! font_res, +//! ); +//! spawn_modal_actions(card, |actions| { +//! spawn_modal_button( +//! actions, +//! CancelButton, +//! "Cancel", +//! Some("Esc"), +//! ButtonVariant::Secondary, +//! font_res, +//! ); +//! spawn_modal_button( +//! actions, +//! ConfirmButton, +//! "Yes, abandon", +//! Some("Y"), +//! ButtonVariant::Primary, +//! font_res, +//! ); +//! }); +//! }, +//! ); +//! ``` + +use bevy::prelude::*; + +use crate::font_plugin::FontResource; +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, 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, +}; + +// --------------------------------------------------------------------------- +// Marker components — let click handlers query / paint systems target / +// despawn helpers find every part of a standard modal. +// --------------------------------------------------------------------------- + +/// Marker on the full-screen scrim entity. Carries `BackgroundColor` +/// `SCRIM` and the modal's z-index. +#[derive(Component, Debug)] +pub struct ModalScrim; + +/// Marker on the centred card entity. Child of the scrim. +#[derive(Component, Debug)] +pub struct ModalCard; + +/// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`). +#[derive(Component, Debug)] +pub struct ModalHeader; + +/// Marker on a body paragraph `Text`. +#[derive(Component, Debug)] +pub struct ModalBody; + +/// Marker on the actions row (flex-row, justify-end). +#[derive(Component, Debug)] +pub struct ModalActions; + +/// Marker on a button inside a modal. Carries its variant so the paint +/// system can recolour it on hover / press. +#[derive(Component, Debug, Clone, Copy)] +pub struct ModalButton(pub ButtonVariant); + +// --------------------------------------------------------------------------- +// Button variants — three rungs of emphasis. A single overlay should have +// at most one Primary; Secondary and Tertiary fill out the rest. +// --------------------------------------------------------------------------- + +/// Visual emphasis tier applied to a [`ModalButton`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonVariant { + /// Loud yellow CTA — Confirm, Play Again. One per modal; right-aligned. + Primary, + /// Mid-emphasis — Cancel, Close, Done. + Secondary, + /// Low-emphasis — Quit, secondary navigation. + Tertiary, +} + +// --------------------------------------------------------------------------- +// Spawn helpers +// --------------------------------------------------------------------------- + +/// Spawns a full-screen scrim and a centred card. The closure populates +/// the card's children — typically `spawn_modal_header`, +/// `spawn_modal_body_text`, and `spawn_modal_actions`. +/// +/// Returns the scrim entity so callers can despawn the whole modal with +/// a single `commands.entity(scrim).despawn()` call (Bevy's hierarchy +/// despawn cascades to the card and its descendants). +/// +/// `plugin_marker` is the overlay's plugin-specific marker +/// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers +/// can find their own modal. +pub fn spawn_modal( + commands: &mut Commands, + plugin_marker: M, + z_panel: i32, + build_card: F, +) -> Entity +where + F: FnOnce(&mut ChildSpawnerCommands), +{ + commands + .spawn(( + plugin_marker, + ModalScrim, + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + width: Val::Percent(100.0), + height: Val::Percent(100.0), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..default() + }, + BackgroundColor(SCRIM), + ZIndex(z_panel), + )) + .with_children(|root| { + root.spawn(( + ModalCard, + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_4, + 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), + min_width: Val::Px(360.0), + align_items: AlignItems::Stretch, + ..default() + }, + BackgroundColor(BG_ELEVATED), + BorderColor::all(BORDER_STRONG), + )) + .with_children(build_card); + }) + .id() +} + +/// Spawns the standard modal header — `TYPE_HEADLINE` + `TEXT_PRIMARY`. +pub fn spawn_modal_header( + parent: &mut ChildSpawnerCommands, + title: impl Into, + font_res: Option<&FontResource>, +) { + let font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_HEADLINE, + ..default() + }; + parent.spawn(( + ModalHeader, + Text::new(title.into()), + font, + TextColor(TEXT_PRIMARY), + )); +} + +/// Spawns a body paragraph at `TYPE_BODY_LG`. Pass `TEXT_PRIMARY` for +/// primary copy, `TEXT_SECONDARY` for caption-style supporting copy. +pub fn spawn_modal_body_text( + parent: &mut ChildSpawnerCommands, + text: impl Into, + color: Color, + font_res: Option<&FontResource>, +) { + let font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_BODY_LG, + ..default() + }; + parent.spawn(( + ModalBody, + Text::new(text.into()), + font, + TextColor(color), + )); +} + +/// Spawns the bottom actions row — flex-row with primary right-aligned. +/// The closure populates the row's buttons via `spawn_modal_button`. +pub fn spawn_modal_actions(parent: &mut ChildSpawnerCommands, build_buttons: F) +where + F: FnOnce(&mut ChildSpawnerCommands), +{ + parent + .spawn(( + ModalActions, + Node { + flex_direction: FlexDirection::Row, + column_gap: VAL_SPACE_3, + justify_content: JustifyContent::FlexEnd, + margin: UiRect::top(VAL_SPACE_2), + ..default() + }, + )) + .with_children(build_buttons); +} + +/// Spawns a real `Button` entity with consistent geometry, colours, and +/// optional hotkey-hint chip. +/// +/// `marker` is the click-handler-targeting component (e.g. +/// `ConfirmYesButton`); plugin systems query for it on +/// `Changed` to detect clicks. +pub fn spawn_modal_button( + parent: &mut ChildSpawnerCommands, + marker: M, + label: impl Into, + hotkey: Option<&'static str>, + variant: ButtonVariant, + font_res: Option<&FontResource>, +) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_label = TextFont { + font: font_handle.clone(), + font_size: TYPE_BODY_LG, + ..default() + }; + let font_caption = TextFont { + font: font_handle, + font_size: TYPE_CAPTION, + ..default() + }; + + let label_color = match variant { + // Primary buttons sit on the loud yellow accent — dark text on + // top reads well and passes AAA contrast. + ButtonVariant::Primary => BG_BASE, + ButtonVariant::Secondary | ButtonVariant::Tertiary => TEXT_PRIMARY, + }; + let caption_color = match variant { + // Use a slightly muted version of the label colour so the chip + // reads as a secondary detail without disappearing. + ButtonVariant::Primary => Color::srgba(0.0, 0.0, 0.0, 0.55), + ButtonVariant::Secondary | ButtonVariant::Tertiary => TEXT_SECONDARY, + }; + + parent + .spawn(( + marker, + ModalButton(variant), + Button, + Node { + padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_2, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BackgroundColor(idle_bg(variant)), + BorderColor::all(BORDER_SUBTLE), + )) + .with_children(|b| { + b.spawn((Text::new(label.into()), font_label, TextColor(label_color))); + if let Some(key) = hotkey { + b.spawn((Text::new(key), font_caption, TextColor(caption_color))); + } + }); +} + +// --------------------------------------------------------------------------- +// Helpers + paint system +// --------------------------------------------------------------------------- + +/// Idle-state background colour for a button variant. +fn idle_bg(variant: ButtonVariant) -> Color { + match variant { + ButtonVariant::Primary => ACCENT_PRIMARY, + // Secondary sits at a higher elevation than Tertiary at idle so + // the hierarchy reads even before hover; the paint system then + // bumps each variant one rung on hover. + ButtonVariant::Secondary => BG_ELEVATED_HI, + ButtonVariant::Tertiary => BG_ELEVATED, + } +} + +/// Hover-state background colour. Each variant steps up one rung from +/// its idle colour so idle / hover / pressed are visually distinct. +fn hover_bg(variant: ButtonVariant) -> Color { + match variant { + ButtonVariant::Primary => ACCENT_PRIMARY_HOVER, + ButtonVariant::Secondary => BG_ELEVATED_TOP, + ButtonVariant::Tertiary => BG_ELEVATED_HI, + } +} + +/// Pressed-state background colour. Primary swaps to the magenta +/// secondary accent for a moment of celebration; Secondary darkens to +/// the base elevation; Tertiary darkens further. +fn pressed_bg(variant: ButtonVariant) -> Color { + match variant { + ButtonVariant::Primary => ACCENT_SECONDARY, + ButtonVariant::Secondary => BG_ELEVATED, + ButtonVariant::Tertiary => BG_ELEVATED_PRESSED, + } +} + +/// Repaints every `ModalButton` on `Changed` so hover and +/// press states are visible without each overlay registering its own +/// paint system. +#[allow(clippy::type_complexity)] +pub fn paint_modal_buttons( + mut buttons: Query< + (&Interaction, &ModalButton, &mut BackgroundColor), + Changed, + >, +) { + for (interaction, modal_button, mut bg) in &mut buttons { + bg.0 = match interaction { + Interaction::Pressed => pressed_bg(modal_button.0), + Interaction::Hovered => hover_bg(modal_button.0), + Interaction::None => idle_bg(modal_button.0), + }; + } +} + +// --------------------------------------------------------------------------- +// Plugin registration +// --------------------------------------------------------------------------- + +/// Registers `paint_modal_buttons` so every `ModalButton` automatically +/// gets hover / press feedback. Add this plugin to the app once; +/// individual overlay plugins don't need their own paint systems. +pub struct UiModalPlugin; + +impl Plugin for UiModalPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Update, paint_modal_buttons); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Idle / hover / pressed cycle through three distinct colours per + /// variant — guards against a future refactor accidentally mapping + /// two states to the same colour. + #[test] + fn paint_states_are_distinct_per_variant() { + for variant in [ + ButtonVariant::Primary, + ButtonVariant::Secondary, + ButtonVariant::Tertiary, + ] { + let i = idle_bg(variant); + let h = hover_bg(variant); + let p = pressed_bg(variant); + assert_ne!(i, h, "idle and hover must differ for {variant:?}"); + assert_ne!(h, p, "hover and pressed must differ for {variant:?}"); + assert_ne!(i, p, "idle and pressed must differ for {variant:?}"); + } + } + + #[test] + fn ui_modal_plugin_registers_paint_system() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin); + // App built without panic — paint_modal_buttons is registered. + app.update(); + } +} + diff --git a/solitaire_engine/src/ui_theme.rs b/solitaire_engine/src/ui_theme.rs index fc4431d..fc8c57e 100644 --- a/solitaire_engine/src/ui_theme.rs +++ b/solitaire_engine/src/ui_theme.rs @@ -36,6 +36,10 @@ pub const BG_ELEVATED: Color = Color::srgb(0.176, 0.106, 0.412); /// currently-active row of a popover. `#3A2580`. pub const BG_ELEVATED_HI: Color = Color::srgb(0.227, 0.145, 0.502); +/// Top elevation step — Secondary button hover, popover currently- +/// hovered row. One rung above `BG_ELEVATED_HI`. `#482F97`. +pub const BG_ELEVATED_TOP: Color = Color::srgb(0.282, 0.184, 0.592); + /// Pressed-button surface — `BG_ELEVATED` darkened ~15%. `#26155B`. pub const BG_ELEVATED_PRESSED: Color = Color::srgb(0.149, 0.082, 0.357); @@ -61,6 +65,10 @@ pub const TEXT_DISABLED: Color = Color::srgb(0.420, 0.373, 0.522); /// AAA contrast. `#FFD23F`. pub const ACCENT_PRIMARY: Color = Color::srgb(1.000, 0.824, 0.247); +/// Brightened `ACCENT_PRIMARY` for hover states on primary buttons. +/// Picks up saturation while keeping the same hue. `#FFE36B`. +pub const ACCENT_PRIMARY_HOVER: Color = Color::srgb(1.000, 0.890, 0.420); + /// Warm magenta secondary accent — celebratory states (achievement /// unlocked, streak milestones). Used sparingly so it stays special. /// `#FF6B9D`.