Compare commits

...

4 Commits

Author SHA1 Message Date
funman300 3f922ede28 feat(engine): convert ConfirmNewGameScreen to real-button modal
CI / Test & Lint (push) Failing after 21s
CI / Release Build (push) Has been skipped
Phase 3 step 4a of the UX overhaul. Closes the player's #2 smoke-test
complaint head-on: the abandon-current-game prompt previously rendered
"Yes (Y)" and "No (N)" as plain `Text` entities — not real `Button`s.
Clicks did nothing, hover/press feedback was absent, and the only path
through the modal was the keyboard.

Replace the bespoke 60-line spawn function with a 30-line call to the
ui_modal primitive:

- spawn_modal(ConfirmNewGameScreen, Z_MODAL_PANEL, ...) — uniform
  scrim + centred card with header / body / actions slots.
- Header: "Abandon current game?" (TYPE_HEADLINE, TEXT_PRIMARY).
- Body: "Your progress will be lost." (TYPE_BODY_LG, TEXT_SECONDARY).
- Actions row:
    Cancel (Secondary variant, hotkey "Esc") — left
    Yes, abandon (Primary yellow CTA, hotkey "Y") — right

The ConfirmNewGameScreen marker rides on the scrim entity per
ui_modal's contract; OriginalNewGameRequest is attached to the same
entity after spawn so handle_confirm_input / handle_confirm_button_input
can read it.

A new handle_confirm_button_input system mirrors the keyboard handler
for clicks: it queries `Changed<Interaction>` on `ConfirmYesButton` /
`ConfirmNoButton` and dispatches the same despawn + new-game-fire
logic. Keyboard accelerators (Y/Enter, N/Esc) still work; both paths
reach the same code through the existing `confirmed: true` flag on
NewGameRequestEvent (62cd1cf).

UiModalPlugin's paint_modal_buttons system (8da62bd) handles
hover/press recolouring automatically; no per-modal paint logic
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:51:28 +00:00
funman300 8da62bd05f 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>
2026-04-30 00:43:14 +00:00
funman300 73cad7e205 feat(engine): restructure HUD into 4-tier layout, adopt design tokens
Phase 3 step 2 of the UX overhaul. Closes the player's #1 complaint
("HUD too cluttered") by regrouping the 10 readouts that previously
sat in a single dense horizontal row.

HUD structure (top → bottom):
- Tier 1 (always on)        Score · Moves · Timer
                            Score uses TYPE_HEADLINE so it's the
                            visual protagonist; Moves/Timer use
                            TYPE_BODY_LG with TEXT_SECONDARY tone.
- Tier 2 (mode context)     Mode · Daily-challenge constraint ·
                            Draw-cycle indicator. Each cell is
                            empty when not relevant — the row
                            collapses visually if all are empty.
- Tier 3 (penalty / bonus)  Undos · Recycles · Auto-complete badge.
                            Both penalty counters now share
                            STATE_WARNING — the audit found Undos
                            were amber but Recycles were white,
                            making the "you took a penalty" signal
                            inconsistent.
- Tier 4 (selection chip)   keyboard-driven pile selector.

Action bar polish:
- Each button gains a TYPE_CAPTION hotkey-hint chip (Undo · U,
  Pause · Esc, Help · F1, New Game · N). Menu and Modes get no
  chip because each row in their popovers carries its own hotkey.
- Buttons recoloured to BG_ELEVATED / BG_ELEVATED_HI /
  BG_ELEVATED_PRESSED — the bright-blue palette stood out from
  the rest of the (still-to-come) midnight purple chrome.
- Buttons gain a BORDER_SUBTLE outline so the boundary reads even
  when hovered over the felt.

Other migrations in this commit:
- Popover panels (Menu, Modes) now use BG_ELEVATED instead of an
  ad-hoc dark grey.
- challenge_time_color now returns STATE_DANGER / STATE_WARNING /
  STATE_INFO tokens instead of literal hexes; tests updated.
- The Undos in-place colour toggle uses TEXT_PRIMARY / STATE_WARNING.

The four `ui_theme` self-tests plus all existing 791 tests stay
green (795 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:30:42 +00:00
funman300 e14852c093 feat(engine): add ui_theme.rs design-token module
Phase 3 step 1 of the UX overhaul. Centralises every UI design token —
colours, typography, spacing, border-radius, z-index, and motion
durations — so subsequent overhaul commits read from one source of
truth instead of scattering hex codes and magic numbers across plugin
files.

The audit (2026-04-30) found:
- 40+ hardcoded Color::srgb literals across UI surfaces.
- 12 distinct font sizes (14/15/16/17/18/22/26/28/30/32/40/48 px)
  with no scale.
- 8+ z-index magic numbers across overlay plugins (200, 210, 220,
  230, 250, 300, 400) with no documented hierarchy.
- Motion durations only partially honouring AnimSpeed — slide and
  cascade did, but toast / shake / settle / deal were hardcoded.

ui_theme.rs collapses these into:
- Midnight Purple base (BG_BASE / BG_ELEVATED / BG_ELEVATED_HI /
  BG_ELEVATED_PRESSED) + Balatro-yellow ACCENT_PRIMARY + warm
  magenta ACCENT_SECONDARY + state colours (success/warning/danger/
  info) + text tiers (primary/secondary/disabled) + a uniform SCRIM.
- 5-rung typography scale (display 40 / headline 26 / body-lg 18 /
  body 14 / caption 11).
- 4-multiple spacing scale (4/8/12/16/24/32/48), with VAL_SPACE_*
  Val::Px convenience constants.
- 3 border-radius rungs (sm 4 / md 8 / lg 16).
- Documented monotonically-increasing z-index hierarchy enforced
  by a unit test.
- All MOTION_* duration constants funnelled through scaled_duration()
  so AnimSpeed (Normal/Fast/Instant) applies to every animation,
  not just slide and cascade.

This commit is purely additive — no call sites change yet.
Subsequent commits in the overhaul migrate plugins to the tokens
one region at a time (HUD restructure, modal primitive, then per-
overlay conversions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:20:19 +00:00
6 changed files with 1060 additions and 172 deletions
+3 -1
View File
@@ -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();
}
+105 -67
View File
@@ -18,7 +18,13 @@ use crate::events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
};
use crate::ui_theme;
// ---------------------------------------------------------------------------
// Task #57 — Confirm-new-game dialog
@@ -94,6 +100,7 @@ impl Plugin for GamePlugin {
)
.add_systems(Update, check_no_moves.after(GameMutation))
.add_systems(Update, handle_confirm_input.after(GameMutation))
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
.add_systems(Update, handle_game_over_input.after(GameMutation))
.init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time)
@@ -157,6 +164,7 @@ fn handle_new_game(
mut changed: MessageWriter<StateChangedEvent>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>,
font_res: Option<Res<FontResource>>,
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
) {
@@ -174,7 +182,7 @@ fn handle_new_game(
for entity in &game_over_screens {
commands.entity(entity).despawn();
}
spawn_confirm_dialog(&mut commands, *ev);
spawn_confirm_dialog(&mut commands, *ev, font_res.as_deref());
continue;
}
@@ -205,75 +213,70 @@ fn handle_new_game(
}
}
/// Spawns the confirm-new-game modal overlay.
/// Marker on the primary "Yes, abandon" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmYesButton;
/// Marker on the secondary "Cancel" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmNoButton;
/// Spawns the confirm-new-game modal using the standard `ui_modal`
/// primitive — uniform scrim, centred card, real buttons with hover /
/// press states.
///
/// Shown when the player requests a new game while moves have been made and
/// the game is not yet won. The overlay stores the original request so the
/// `handle_confirm_input` system can replay it on confirmation.
fn spawn_confirm_dialog(commands: &mut Commands, original_request: NewGameRequestEvent) {
commands
.spawn((
/// Shown when the player requests a new game while moves have been made
/// and the game is not yet won. The original `NewGameRequestEvent` is
/// stored on the scrim entity so `handle_confirm_input` /
/// `handle_confirm_button` can replay it with the same seed / mode on
/// confirmation.
///
/// Replaces a bespoke layout that used plain `Text` labels for "Yes (Y)"
/// and "No (N)" — those were not real Button entities, so the player
/// had no hover / press feedback and the modal felt like a debug panel
/// (the user's smoke-test "#2 complaint").
fn spawn_confirm_dialog(
commands: &mut Commands,
original_request: NewGameRequestEvent,
font_res: Option<&FontResource>,
) {
let scrim = spawn_modal(
commands,
ConfirmNewGameScreen,
// Store the request so we can replay it on confirmation.
OriginalNewGameRequest(original_request),
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(20.0),
..default()
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,
ConfirmNoButton,
"Cancel",
Some("Esc"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
ConfirmYesButton,
"Yes, abandon",
Some("Y"),
ButtonVariant::Primary,
font_res,
);
});
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.70)),
ZIndex(250),
))
.with_children(|root| {
// Dialog card
root.spawn((
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(40.0)),
row_gap: Val::Px(20.0),
min_width: Val::Px(360.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
))
.with_children(|card| {
// Heading
card.spawn((
Text::new("Abandon current game?"),
TextFont { font_size: 30.0, ..default() },
TextColor(Color::WHITE),
));
// Button row
card.spawn((Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(24.0),
..default()
},))
.with_children(|row| {
// Yes button
row.spawn((
Text::new("Yes (Y)"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.3, 1.0, 0.4)),
));
// No button
row.spawn((
Text::new("No (N)"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(1.0, 0.4, 0.4)),
));
});
});
});
);
// Attach the original request to the scrim so handle_confirm_input
// and handle_confirm_button can read it on confirmation.
commands
.entity(scrim)
.insert(OriginalNewGameRequest(original_request));
}
/// Carries the original `NewGameRequestEvent` on the confirm overlay so
@@ -318,6 +321,41 @@ fn handle_confirm_input(
}
}
/// Mouse / touch counterpart to `handle_confirm_input`. Reads
/// `Changed<Interaction>` on the modal's `ConfirmYesButton` /
/// `ConfirmNoButton` so the modal closes and (on confirm) starts a new
/// game whether the player uses the keyboard accelerator or clicks.
///
/// This is the system that closes the user's #2 smoke-test complaint:
/// previously the dialog had only `Text::new("Yes (Y)")` labels — not
/// real button entities — so clicks did nothing and the only path
/// through the modal was the keyboard.
#[allow(clippy::too_many_arguments)]
fn handle_confirm_button_input(
mut commands: Commands,
yes_buttons: Query<&Interaction, (With<ConfirmYesButton>, Changed<Interaction>)>,
no_buttons: Query<&Interaction, (With<ConfirmNoButton>, Changed<Interaction>)>,
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
let Ok((entity, original)) = screens.single() else {
return;
};
let confirmed = yes_buttons.iter().any(|i| *i == Interaction::Pressed);
let cancelled = no_buttons.iter().any(|i| *i == Interaction::Pressed);
if confirmed {
commands.entity(entity).despawn();
new_game.write(NewGameRequestEvent {
seed: original.0.seed,
mode: original.0.mode,
confirmed: true,
});
} else if cancelled {
commands.entity(entity).despawn();
}
}
fn handle_draw(
mut draws: MessageReader<DrawRequestEvent>,
mut game: ResMut<GameStateResource>,
+187 -91
View File
@@ -15,6 +15,12 @@ use crate::auto_complete_plugin::AutoCompleteState;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::progress_plugin::ProgressResource;
use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
BORDER_SUBTLE, RADIUS_MD, STATE_DANGER, STATE_INFO, STATE_SUCCESS, STATE_WARNING,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
};
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
@@ -167,12 +173,15 @@ pub enum MenuOption {
}
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
const Z_HUD: i32 = 50;
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
/// Idle / hover / pressed colours shared by every action button.
const ACTION_BTN_IDLE: Color = Color::srgb(0.20, 0.55, 0.85);
const ACTION_BTN_HOVER: Color = Color::srgb(0.28, 0.65, 0.95);
const ACTION_BTN_PRESSED: Color = Color::srgb(0.15, 0.45, 0.75);
/// Idle / hover / pressed colours shared by every action button. Aliased
/// to the theme tokens so the HUD picks up palette changes for free.
const ACTION_BTN_IDLE: Color = BG_ELEVATED;
const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI;
const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED;
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
pub struct HudPlugin;
@@ -219,79 +228,145 @@ impl Plugin for HudPlugin {
}
}
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
/// top-left of the play area.
///
/// Tiers (top to bottom):
/// 1. **Primary** — Score (display weight) · Moves · Timer.
/// Always visible during gameplay.
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
/// Draw-cycle indicator. Each cell is empty when not relevant; the
/// row collapses visually when all cells are empty.
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
/// Both penalty counters share `STATE_WARNING` (the audit found
/// they were inconsistent: Undos amber, Recycles white).
/// 4. **Selection** — keyboard-driven pile selector chip.
///
/// The audit identified the original single-row layout (10 readouts in
/// one horizontal flex row, 5+ colour families competing) as the
/// player's #1 complaint. This restructure groups by purpose, lets
/// transient items disappear cleanly, and uses the typography scale to
/// make Score the visual protagonist.
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
let white = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: 18.0,
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
let font_score = TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
};
let font_lg = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_body = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
let row_node = || Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
align_items: AlignItems::Baseline,
..default()
};
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(12.0),
top: Val::Px(8.0),
flex_direction: FlexDirection::Row,
column_gap: Val::Px(20.0),
align_items: AlignItems::Center,
left: VAL_SPACE_3,
top: VAL_SPACE_2,
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
..default()
},
ZIndex(Z_HUD),
))
.with_children(|b| {
b.spawn((HudScore, Text::new("Score: 0"), font.clone(), white));
b.spawn((HudMoves, Text::new("Moves: 0"), font.clone(), white));
b.spawn((HudTime, Text::new("0:00"), font.clone(), white));
b.spawn((
.with_children(|hud| {
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
// Moves and Timer are supporting context (BODY_LG, secondary tone).
hud.spawn(row_node()).with_children(|t1| {
t1.spawn((
HudScore,
Text::new("Score: 0"),
font_score.clone(),
TextColor(TEXT_PRIMARY),
));
t1.spawn((
HudMoves,
Text::new("Moves: 0"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
t1.spawn((
HudTime,
Text::new("0:00"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
});
// Tier 2 — mode context. Each cell is empty until update_hud
// populates it (and clears it when no longer relevant), so the
// row collapses when nothing in this tier applies.
hud.spawn(row_node()).with_children(|t2| {
t2.spawn((
HudMode,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(1.0, 0.85, 0.25)),
font_body.clone(),
TextColor(ACCENT_PRIMARY),
));
// Daily-challenge constraint (hidden until a challenge is active).
b.spawn((
t2.spawn((
HudChallenge,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.4, 0.9, 1.0)),
font_body.clone(),
TextColor(STATE_INFO),
));
// Undo counter (white by default; turns amber when undos are used).
b.spawn((
HudUndos,
Text::new(""),
font.clone(),
white,
));
// Auto-complete badge (green "AUTO" when sequence is running).
b.spawn((
HudAutoComplete,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(0.2, 0.9, 0.3)),
));
// Recycle counter — hidden until the first recycle in either draw mode.
b.spawn((
HudRecycles,
Text::new(""),
font.clone(),
white,
));
// Draw-cycle indicator — only visible in Draw-Three mode.
b.spawn((
t2.spawn((
HudDrawCycle,
Text::new(""),
font,
TextColor(Color::srgb(0.7, 0.85, 1.0)),
font_body.clone(),
TextColor(STATE_INFO),
));
// Keyboard-selection indicator — shows which pile is Tab-selected.
b.spawn((
});
// Tier 3 — penalty / bonus. Undos and Recycles share the
// warning hue so they read as the same category ("you took a
// penalty"); the auto-complete badge stays success-green.
hud.spawn(row_node()).with_children(|t3| {
t3.spawn((
HudUndos,
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudRecycles,
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudAutoComplete,
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
));
});
// Tier 4 — selection chip. Stays in HUD for now; a future
// pass can reposition it next to the selected pile.
hud.spawn(row_node()).with_children(|t4| {
t4.spawn((
HudSelection,
Text::new(""),
TextFont { font_size: 17.0, ..default() },
TextColor(Color::srgb(1.0, 1.0, 0.5)),
font_body,
TextColor(ACCENT_SECONDARY),
));
});
});
}
/// Spawns the action button bar anchored to the top-right of the window.
@@ -322,12 +397,15 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
ZIndex(Z_HUD),
))
.with_children(|row| {
spawn_action_button(row, MenuButton, "Menu \u{25BE}", &font);
spawn_action_button(row, UndoButton, "Undo", &font);
spawn_action_button(row, PauseButton, "Pause", &font);
spawn_action_button(row, HelpButton, "Help", &font);
spawn_action_button(row, ModesButton, "Modes \u{25BE}", &font);
spawn_action_button(row, NewGameButton, "New Game", &font);
// Menu and Modes don't have a single hotkey accelerator
// (each row inside their popover has its own); their button
// labels carry the dropdown chevron in lieu of a key chip.
spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font);
spawn_action_button(row, UndoButton, "Undo", Some("U"), &font);
spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font);
spawn_action_button(row, HelpButton, "Help", Some("F1"), &font);
spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font);
spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font);
});
}
@@ -338,23 +416,37 @@ fn spawn_action_button<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
hotkey: Option<&'static str>,
font: &TextFont,
) {
let hotkey_font = TextFont {
font: font.font.clone(),
font_size: TYPE_CAPTION,
..default()
};
row.spawn((
marker,
ActionButton,
Button,
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(8.0)),
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(6.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
column_gap: VAL_SPACE_2,
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label —
// keeps the keyboard accelerator discoverable without
// hijacking the button's primary affordance.
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
}
});
}
@@ -476,7 +568,7 @@ fn spawn_modes_popover(
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
@@ -497,7 +589,7 @@ fn spawn_modes_popover(
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
});
@@ -605,7 +697,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
border_radius: BorderRadius::all(Val::Px(6.0)),
..default()
},
BackgroundColor(Color::srgba(0.10, 0.12, 0.15, 0.96)),
BackgroundColor(BG_ELEVATED),
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
@@ -626,7 +718,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(Color::WHITE)));
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
});
@@ -679,6 +771,7 @@ fn handle_menu_option_click(
/// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared
/// `ActionButton` marker.
#[allow(clippy::type_complexity)]
fn paint_action_buttons(
mut buttons: Query<
(&Interaction, &mut BackgroundColor),
@@ -894,11 +987,12 @@ fn update_hud(
let count = g.undo_count;
if count == 0 {
**t = String::new();
*color = TextColor(Color::srgba(1.0, 1.0, 1.0, 0.80));
*color = TextColor(TEXT_PRIMARY);
} else {
**t = format!("Undos: {count}");
// Amber warning: using undo blocks the no-undo achievement.
*color = TextColor(Color::srgb(1.0, 0.7, 0.2));
// STATE_WARNING signals "you took a penalty" — same hue
// as the Recycles counter so they read as one category.
*color = TextColor(STATE_WARNING);
}
}
@@ -1025,20 +1119,22 @@ fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
}
}
/// Returns the colour for the challenge time-limit HUD label based on seconds remaining.
/// Returns the colour for the challenge time-limit HUD label based on
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
/// palette changes for free.
///
/// | Remaining | Colour |
/// |-------------|--------|
/// | ≥ 60 s | Cyan (default) |
/// | 30 59 s | Orange (warning) |
/// | < 30 s | Red (urgent) |
/// | Remaining | Token |
/// |-------------|------------------|
/// | ≥ 60 s | `STATE_INFO` |
/// | 30 59 s | `STATE_WARNING` |
/// | < 30 s | `STATE_DANGER` |
pub fn challenge_time_color(remaining: u64) -> Color {
if remaining < 30 {
Color::srgb(1.0, 0.2, 0.2)
STATE_DANGER
} else if remaining < 60 {
Color::srgb(1.0, 0.6, 0.0)
STATE_WARNING
} else {
Color::srgb(0.4, 0.9, 1.0)
STATE_INFO
}
}
@@ -1189,39 +1285,39 @@ mod tests {
}
#[test]
fn challenge_time_color_above_60_is_cyan() {
fn challenge_time_color_above_60_is_info() {
let c = challenge_time_color(61);
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_exactly_60_is_cyan() {
fn challenge_time_color_exactly_60_is_info() {
let c = challenge_time_color(60);
assert_eq!(c, Color::srgb(0.4, 0.9, 1.0));
assert_eq!(c, STATE_INFO);
}
#[test]
fn challenge_time_color_59_is_orange() {
fn challenge_time_color_59_is_warning() {
let c = challenge_time_color(59);
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_30_is_orange() {
fn challenge_time_color_30_is_warning() {
let c = challenge_time_color(30);
assert_eq!(c, Color::srgb(1.0, 0.6, 0.0));
assert_eq!(c, STATE_WARNING);
}
#[test]
fn challenge_time_color_29_is_red() {
fn challenge_time_color_29_is_danger() {
let c = challenge_time_color(29);
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
assert_eq!(c, STATE_DANGER);
}
#[test]
fn challenge_time_color_zero_is_red() {
fn challenge_time_color_zero_is_danger() {
let c = challenge_time_color(0);
assert_eq!(c, Color::srgb(1.0, 0.2, 0.2));
assert_eq!(c, STATE_DANGER);
}
// -----------------------------------------------------------------------
+7
View File
@@ -30,6 +30,8 @@ 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;
@@ -95,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,
};
+397
View File
@@ -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();
}
}
+348
View File
@@ -0,0 +1,348 @@
//! Centralised UI design tokens — colours, typography, spacing, radius,
//! z-index hierarchy, and motion durations.
//!
//! Every UI surface (HUD, modals, popovers, toasts) reads from these
//! tokens instead of hardcoding hex codes or magic numbers. The audit
//! that produced this module found 40+ scattered colour literals, 12+
//! distinct font sizes, and 8+ hardcoded z-index values across the
//! engine; collapsing them into one source of truth keeps the visual
//! system coherent and makes future palette swaps a single-file change.
//!
//! Palette is "Midnight Purple + Balatro accent" — see the 2026-04-30
//! UX overhaul Phase 2 proposal for the rationale behind specific
//! values. The tokens are exposed as `pub const` so static contexts
//! (default colours on Sprite components, etc.) can use them; a future
//! `UiTheme` resource can layer runtime switching on top without
//! changing the constant API.
use bevy::color::Color;
use bevy::prelude::Val;
use solitaire_data::AnimSpeed;
// ---------------------------------------------------------------------------
// Colours — Midnight Purple base with a Balatro-yellow primary accent.
// ---------------------------------------------------------------------------
/// Window backstop and the default text colour on top of `ACCENT_PRIMARY`.
/// Deep midnight purple, near-black. `#1A0F2E`.
pub const BG_BASE: Color = Color::srgb(0.102, 0.059, 0.180);
/// Elevated surface — modal cards, popover panels, button backgrounds.
/// One step lighter than `BG_BASE` so cards visually float above the
/// felt without needing real drop shadows. `#2D1B69`.
pub const BG_ELEVATED: Color = Color::srgb(0.176, 0.106, 0.412);
/// Hovered/highlighted surface — used on button hover and on the
/// 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);
/// Uniform scrim under every modal. The audit found 0.600.92 alpha
/// drift across 11 overlay plugins; this single value replaces all of
/// them. `rgba(13, 7, 28, 0.85)`.
pub const SCRIM: Color = Color::srgba(0.051, 0.027, 0.110, 0.85);
/// Primary text — warm off-white with a hint of purple to fit the
/// midnight palette without feeling clinical. `#F5F0FF`.
pub const TEXT_PRIMARY: Color = Color::srgb(0.961, 0.941, 1.000);
/// Secondary text — captions, hints, muted labels. Lavender-grey.
/// `#B5A8D5`.
pub const TEXT_SECONDARY: Color = Color::srgb(0.710, 0.659, 0.835);
/// Disabled text — greyed-out buttons, locked items. `#6B5F85`.
pub const TEXT_DISABLED: Color = Color::srgb(0.420, 0.373, 0.522);
/// Balatro-yellow primary accent — the loudest colour in the palette.
/// Reserved for primary actions (Confirm, Play Again), win states, and
/// "look here" callouts. `BG_BASE` text on top of this colour passes
/// 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`.
pub const ACCENT_SECONDARY: Color = Color::srgb(1.000, 0.420, 0.616);
/// Success — foundation completion, valid drop tint, sync OK. `#4ADE80`.
pub const STATE_SUCCESS: Color = Color::srgb(0.290, 0.871, 0.502);
/// Warning — penalty signal. **Both** Undo and Recycle counters use
/// this when non-zero (the audit found these were inconsistent — Undos
/// amber, Recycles white). `#FFA94D`.
pub const STATE_WARNING: Color = Color::srgb(1.000, 0.663, 0.302);
/// Danger — rejection shake, illegal placement, sync error. `#F77272`.
pub const STATE_DANGER: Color = Color::srgb(0.969, 0.447, 0.447);
/// Info — daily-challenge constraint, draw-cycle indicator. `#6BBBFF`.
pub const STATE_INFO: Color = Color::srgb(0.420, 0.733, 1.000);
/// Subtle border — default popover, card, and idle button outline.
pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
/// Strong border — hover outline, focused button, active popover.
pub const BORDER_STRONG: Color = Color::srgba(0.647, 0.549, 1.000, 0.30);
// ---------------------------------------------------------------------------
// Typography scale (px) — 5 rungs replace the prior
// 14/15/16/17/18/22/26/28/30/32/40/48 jungle. All UI uses FiraMono via
// `FontResource`; sizes carry the hierarchy.
// ---------------------------------------------------------------------------
/// Display titles — Home, Win Summary, Onboarding header. 40 px.
pub const TYPE_DISPLAY: f32 = 40.0;
/// Modal / overlay headers. 26 px.
pub const TYPE_HEADLINE: f32 = 26.0;
/// Primary HUD numbers, button labels, body copy that needs weight.
/// 18 px.
pub const TYPE_BODY_LG: f32 = 18.0;
/// Secondary HUD, body copy, list items. 14 px.
pub const TYPE_BODY: f32 = 14.0;
/// Hotkey-hint chips, microcopy, dates. 11 px.
pub const TYPE_CAPTION: f32 = 11.0;
// ---------------------------------------------------------------------------
// Spacing scale (px) — 4-multiple rungs. Every padding, margin, and gap
// in the engine snaps to one of these.
// ---------------------------------------------------------------------------
/// 4 px — inline padding, chip gap.
pub const SPACE_1: f32 = 4.0;
/// 8 px — default gap between row items.
pub const SPACE_2: f32 = 8.0;
/// 12 px — standard button padding-X, action row gap.
pub const SPACE_3: f32 = 12.0;
/// 16 px — section gap inside a modal.
pub const SPACE_4: f32 = 16.0;
/// 24 px — modal card outer padding.
pub const SPACE_5: f32 = 24.0;
/// 32 px — block separator inside large overlays.
pub const SPACE_6: f32 = 32.0;
/// 48 px — outer modal margin from the window edge.
pub const SPACE_7: f32 = 48.0;
/// `Val::Px` form of `SPACE_1`, for ergonomic Node construction.
pub const VAL_SPACE_1: Val = Val::Px(SPACE_1);
/// `Val::Px` form of `SPACE_2`.
pub const VAL_SPACE_2: Val = Val::Px(SPACE_2);
/// `Val::Px` form of `SPACE_3`.
pub const VAL_SPACE_3: Val = Val::Px(SPACE_3);
/// `Val::Px` form of `SPACE_4`.
pub const VAL_SPACE_4: Val = Val::Px(SPACE_4);
/// `Val::Px` form of `SPACE_5`.
pub const VAL_SPACE_5: Val = Val::Px(SPACE_5);
/// `Val::Px` form of `SPACE_6`.
pub const VAL_SPACE_6: Val = Val::Px(SPACE_6);
/// `Val::Px` form of `SPACE_7`.
pub const VAL_SPACE_7: Val = Val::Px(SPACE_7);
// ---------------------------------------------------------------------------
// Border radius (px)
// ---------------------------------------------------------------------------
/// 4 px — hotkey chip, inline pill.
pub const RADIUS_SM: f32 = 4.0;
/// 8 px — buttons, popover panels.
pub const RADIUS_MD: f32 = 8.0;
/// 16 px — modal cards.
pub const RADIUS_LG: f32 = 16.0;
// ---------------------------------------------------------------------------
// Z-index hierarchy — replaces 8+ scattered magic numbers across plugins
// (background, pile markers, HUD, several overlay tiers, win cascade,
// toasts). Documented order:
//
// background → pile markers → cards → HUD → HUD popovers →
// modal scrim → modal panel → pause → onboarding →
// win cascade → toasts (always on top)
// ---------------------------------------------------------------------------
pub const Z_BACKGROUND: i32 = -10;
pub const Z_PILE_MARKER: i32 = -1;
/// Base layer for HUD readouts (top-left).
pub const Z_HUD: i32 = 50;
/// Action bar + popovers — above HUD readouts so dropdowns can overlap.
pub const Z_HUD_TOP: i32 = 60;
pub const Z_MODAL_SCRIM: i32 = 200;
pub const Z_MODAL_PANEL: i32 = 210;
/// Pause overlay outranks normal modals — pausing should always be on top.
pub const Z_PAUSE: i32 = 220;
pub const Z_ONBOARDING: i32 = 230;
/// Win cascade sits between modals and toasts so the celebration plays
/// over a paused / mid-modal screen.
pub const Z_WIN_CASCADE: i32 = 300;
/// Toasts always render above everything else.
pub const Z_TOAST: i32 = 400;
// ---------------------------------------------------------------------------
// Motion — durations in seconds at `AnimSpeed::Normal`. `Fast` halves
// every value, `Instant` zeroes them. Use `scaled_duration` to apply.
// ---------------------------------------------------------------------------
/// Card slide during gameplay — tweened with `MotionCurve::SmoothSnap`
/// (ease-out-cubic). 180 ms; bumped from the old 150 ms because cubic
/// feels slower at endpoints — the perceived speed is unchanged.
pub const MOTION_SLIDE_SECS: f32 = 0.18;
/// Settle bounce on placement — only the moved card, not every top
/// card on every state change. 180 ms.
pub const MOTION_SETTLE_SECS: f32 = 0.18;
/// Shake on rejected drop — tightened from 300 ms; frequency drops to
/// 35 rad/s to match the new settle bounce so the two feedback signals
/// no longer feel discordant. 250 ms.
pub const MOTION_SHAKE_SECS: f32 = 0.25;
/// Shake angular frequency in rad/s.
pub const MOTION_SHAKE_OMEGA: f32 = 35.0;
/// Card flip — half-time per phase (squash + grow). 100 ms each =
/// 200 ms total. Pair with a ±8° Z-rotation at the midpoint for a 3D
/// feel without 3D rendering.
pub const MOTION_FLIP_HALF_SECS: f32 = 0.10;
/// Per-card stagger on the new-game deal animation — centre value;
/// each card gets ±10% jitter applied at deal time so the deal feels
/// organic instead of mechanical. 40 ms.
pub const MOTION_DEAL_STAGGER_SECS: f32 = 0.04;
/// Deal slide duration with an `ease-out` curve and a 40 ms
/// scale-pop on land so cards "arrive" instead of just stopping.
/// 280 ms.
pub const MOTION_DEAL_SLIDE_SECS: f32 = 0.28;
/// Win cascade per-card stagger — slightly slower than the prior
/// 50 ms for a more theatrical feel. 60 ms.
pub const MOTION_CASCADE_STAGGER_SECS: f32 = 0.06;
/// Win cascade per-card slide — uses `MotionCurve::Expressive`
/// (overshoot) plus ±15° Z-rotation. 500 ms.
pub const MOTION_CASCADE_SLIDE_SECS: f32 = 0.50;
/// Screen shake on win — wider and longer than the old 0.6 s / 8 px.
/// 800 ms.
pub const MOTION_WIN_SHAKE_SECS: f32 = 0.80;
/// Peak displacement of the win screen shake. 12 px.
pub const MOTION_WIN_SHAKE_AMPLITUDE: f32 = 12.0;
/// Toast in — scale-from-0.92-to-1.0 fade-in. 200 ms.
pub const MOTION_TOAST_IN_SECS: f32 = 0.20;
/// Toast out — fade. 250 ms.
pub const MOTION_TOAST_OUT_SECS: f32 = 0.25;
/// Modal in/out — scale-from-0.96-to-1.0 + scrim fade. 220 ms.
pub const MOTION_MODAL_SECS: f32 = 0.22;
/// Button hover/press colour blend — short, snappy. 100 ms.
pub const MOTION_BUTTON_BLEND_SECS: f32 = 0.10;
/// Score-pulse — when score increases by ≥ 50, briefly scale the
/// readout 1.0 → 1.1 → 1.0. 250 ms.
pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
/// Loading-ellipsis cycle — `.`/`..`/`...` toggles every step.
/// 400 ms.
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Scales a `MOTION_*_SECS` value by the player's animation-speed
/// preference. `Normal` × 1.0, `Fast` × 0.5, `Instant` → 0.
///
/// Pass any duration constant from this module through this helper
/// before handing it to a tween. The audit found that only slide and
/// cascade respected `AnimSpeed`; toasts, deal stagger, shake, and
/// settle were hardcoded. Routing every duration through this function
/// fixes that.
pub fn scaled_duration(secs: f32, speed: AnimSpeed) -> f32 {
match speed {
AnimSpeed::Normal => secs,
AnimSpeed::Fast => secs * 0.5,
AnimSpeed::Instant => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every spacing rung is a positive multiple of 4 — keeps the scale
/// honest if someone tweaks values later.
#[test]
fn spacing_scale_is_a_4_multiple_geometric_progression() {
for v in [SPACE_1, SPACE_2, SPACE_3, SPACE_4, SPACE_5, SPACE_6, SPACE_7] {
assert!(v > 0.0, "spacing tokens must be positive");
assert!(
(v.rem_euclid(4.0)).abs() < f32::EPSILON,
"spacing token {v} must be a 4-multiple"
);
}
}
/// Type scale is monotonically decreasing display → caption.
#[test]
fn type_scale_is_monotonically_decreasing() {
let scale = [TYPE_DISPLAY, TYPE_HEADLINE, TYPE_BODY_LG, TYPE_BODY, TYPE_CAPTION];
for window in scale.windows(2) {
assert!(
window[0] > window[1],
"type scale must be monotonically decreasing: {} should be > {}",
window[0],
window[1]
);
}
}
/// Z-index hierarchy is monotonically increasing through documented
/// layers, so a future add-a-layer change can't accidentally land
/// in the wrong slot.
#[test]
fn z_index_hierarchy_is_monotonically_increasing() {
let layers = [
Z_BACKGROUND,
Z_PILE_MARKER,
Z_HUD,
Z_HUD_TOP,
Z_MODAL_SCRIM,
Z_MODAL_PANEL,
Z_PAUSE,
Z_ONBOARDING,
Z_WIN_CASCADE,
Z_TOAST,
];
for window in layers.windows(2) {
assert!(
window[0] < window[1],
"z-index hierarchy must be monotonically increasing: {} should be < {}",
window[0],
window[1]
);
}
}
#[test]
fn scaled_duration_matches_anim_speed() {
assert!((scaled_duration(0.18, AnimSpeed::Normal) - 0.18).abs() < f32::EPSILON);
assert!((scaled_duration(0.18, AnimSpeed::Fast) - 0.09).abs() < f32::EPSILON);
assert_eq!(scaled_duration(0.18, AnimSpeed::Instant), 0.0);
}
}