feat(engine): add ui_modal primitive (scaffold + button variants)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<M: Component, F>(
|
||||
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<String>,
|
||||
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<String>,
|
||||
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<F>(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<Interaction>` to detect clicks.
|
||||
pub fn spawn_modal_button<M: Component>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: impl Into<String>,
|
||||
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<Interaction>` 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<Interaction>,
|
||||
>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user