Phase 3 step 5e of the UX overhaul. Wraps the leaderboard list inside
the standard ui_modal scaffold; converts the Opt In / Opt Out buttons
to use spawn_modal_button (so they pick up the shared hover / press
paint system); replaces "Press L to close" prose with a primary Done
button.
Changes:
- spawn_leaderboard_screen now goes through spawn_modal(LeaderboardScreen,
Z_MODAL_PANEL, ...). The bespoke 0.82-alpha scrim and hand-rolled
card surface are gone — same visual contract as every other overlay.
- Opt In becomes a Secondary modal button; Opt Out becomes Tertiary.
Both fire the same fetch tasks they did before.
- Header / data cells switch to ui_theme tokens. The top-3 podium
effect now uses ACCENT_PRIMARY (yellow) for #1 and TEXT_PRIMARY for
#2/#3 instead of metallic-coloured srgb literals; #4+ use
TEXT_SECONDARY.
- Header-cell and data-cell helpers now take a `&TextFont` so all
three sizes (HEADLINE / BODY_LG / BODY / CAPTION) come from the
shared scale instead of inline 13px / 15px sizes.
- "Fetching\u{2026}" loading state uses STATE_INFO; empty-state copy
uses TEXT_SECONDARY.
- handle_leaderboard_close_button is the click counterpart to L; it
also sets ClosedThisFrame so update_leaderboard_panel doesn't
immediately respawn the modal when a fetch completes in the same
frame.
The sort-by-score code is replaced with `sort_by_key(Reverse(...))`
to satisfy clippy's unnecessary_sort_by lint that surfaced once the
file was otherwise warning-free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 step 5d of the UX overhaul. Wraps the profile sections (Sync,
Progression, Achievements, Statistics Summary) in the standard modal
scaffold; replaces every inline colour with a ui_theme token; adds an
explicit "Sync" section header so the four sections all read in the
same shape; replaces the "Press P to close" prose hint with a primary
Done button.
The previous bare full-screen scrim + inline-text approach was on the
audit's "feels like a debug panel" list — same fix as Stats /
Achievements / Help.
Section headers now use STATE_INFO at TYPE_BODY_LG, body lines use
TEXT_PRIMARY at TYPE_BODY, secondary lines (sync status, "no
achievements yet") use TEXT_SECONDARY. The achievement-count line
adopts ACCENT_PRIMARY (yellow) and unlocked-achievement entries use
STATE_SUCCESS (green) — same colour vocabulary the Achievements
overlay uses.
The unused `spawn_spacer` helper now takes a `Val` so callers can
pass spacing-token constants directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 step 5c of the UX overhaul. Wraps the achievements list in
the standard ui_modal scaffold, recolours every line via tokens, and
replaces the "Press A to close" caption with a primary Done button.
The achievements list itself keeps its previous shape (unlocked
first then alphabetical, secret achievements hidden until unlocked,
each row showing name + description + reward + unlock date). The
visual changes:
- Headline now comes from spawn_modal_header (TYPE_HEADLINE,
TEXT_PRIMARY) — was bespoke 26px white.
- Unlocked names use ACCENT_PRIMARY (yellow); descriptions in
TEXT_PRIMARY at TYPE_BODY.
- Locked names and descriptions use TEXT_DISABLED so they read as
"future content" without disappearing.
- Reward lines use STATE_SUCCESS (green) at TYPE_CAPTION.
- Unlock dates use TEXT_SECONDARY at TYPE_CAPTION.
- A subtle BORDER_SUBTLE separator follows each row instead of one
big separator under the header — easier to scan a long list.
- The "✓" / "○" status glyphs stay; their colours come from the
per-state tokens.
handle_achievements_close_button is the click counterpart to the A
key. font_res threaded through toggle_achievements_screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 step 5b of the UX overhaul. Wraps the existing 8-cell stats
grid + progression / weekly-goals / time-attack sections inside the
standard modal scaffold. The cell layout (the audit's pick for
"best layout in the codebase") is preserved.
Changes:
- spawn_stats_screen now calls spawn_modal(StatsScreen, ...) and
populates the card with the same content as before, retoned to
ui_theme: stat values are TYPE_HEADLINE in ACCENT_PRIMARY (yellow
numbers pop against the midnight-purple card), labels are TYPE_BODY
in TEXT_SECONDARY.
- Stat cells lose their 6%-alpha-white fill (clashed with the new
card surface) and gain a BORDER_SUBTLE outline at RADIUS_SM
instead — same visual purpose, fits the new palette.
- Section headers ("Progression", "Weekly Goals") use STATE_INFO and
TEXT_SECONDARY respectively at TYPE_BODY_LG.
- Time Attack callout uses STATE_WARNING.
- "Press S to close" prose hint replaced by a primary "Done" button
carrying its "S" hotkey chip.
A new handle_stats_close_button system mirrors the keyboard `S`
toggle for clicks. font_res threaded through toggle_stats_screen so
the modal scaffold can pick up FiraMono.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 step 5a of the UX overhaul. Replaces the old monospace
text-dump (a flat vertical column of " D Draw from
stock"-style lines) with a proper modal layout: section titles,
two-column rows where each shortcut renders inside a small
border-outlined chip alongside its description.
Modal contents:
- Header: "Controls"
- Body: three sections (Gameplay / New Game / Overlays), each with a
section title in TEXT_SECONDARY plus a row per shortcut.
- Each row: a 64 px-min-width chip (caption font, border, radius-sm)
carrying the key name, then the description in TEXT_PRIMARY at
TYPE_BODY.
- Actions: a primary "Close" button (hotkey hint "F1").
CONTROL_SECTIONS is a static const-data table of `ControlRow`
records grouped into `ControlSection`s — easier to maintain than the
prior `Vec<String>` of free-form text and easier to extend.
handle_help_close_button is the click counterpart to F1; it
despawns the modal when the player clicks Close.
The audit identified the prior layout as the worst of the
"feels like a 2010 monospace debug dump" overlays. This
restructure is the largest visual upgrade so far in the overhaul.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 step 4b of the UX overhaul. Same shape as the Confirm modal
conversion (3f922ed): replace plain "Press N for new game" /
"Press G to forfeit" text hints with real Button entities, hover
and press feedback included.
The audit flagged the Game Over overlay as the second instance of
the "feels like a debug panel" problem. Players had to know the
hotkeys to escape the screen — there was no clickable affordance.
Modal contents:
- Header: "No more moves available"
- Body: "Final score: {N}" (TYPE_BODY_LG, TEXT_PRIMARY)
- Actions:
Undo (Secondary, hotkey "U") — left
New Game (Primary yellow, hotkey "N") — right
The G/forfeit hint is dropped from the modal because:
1. Forfeit is handled globally by `input_plugin::handle_forfeit`
(which works whether the modal is up or not).
2. The proposal calls for replacing the toast-countdown forfeit
flow with its own modal in step 4c (next commit).
A new `handle_game_over_button_input` system mirrors the keyboard
handler for clicks. Existing N/Esc and U accelerators continue to
work via the original `handle_game_over_input`.
The `game_over_screen_text_content` test is updated to assert the
new button-label / hotkey-chip strings instead of the prior prose
hints. All 797 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Continues the UI-first pass. The five informational overlays were
each behind a single-key shortcut (S/A/P/O/L) with no visible UI
affordance. Add a "Menu ▾" button to the action bar that toggles a
popover with one row per overlay. Each row dispatches the same code
path the keyboard accelerator uses by writing a new
`Toggle*RequestEvent`:
- Stats → ToggleStatsRequestEvent
- Achievements → ToggleAchievementsRequestEvent
- Profile → ToggleProfileRequestEvent
- Settings → ToggleSettingsRequestEvent
- Leaderboard → ToggleLeaderboardRequestEvent
Each plugin's existing toggle handler now reads either its key or
the matching request event so the spawn / despawn / fetch logic stays
in the owning plugin (the popover never duplicates that behaviour).
Action bar order is now (left → right):
Menu ▾ Undo Pause Help Modes ▾ New Game
Menu sits on the far left because it's a navigation aggregator;
New Game stays on the far right as the most consequential action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continues the UI-first pass. The five game modes were each behind a
keyboard shortcut (N/Z/X/T/C) with no visible UI affordance, three of
them additionally gated by an unlock level the player has to discover
themselves.
Add a "Modes ▾" button to the action bar that toggles a popover panel
beneath. Each row dispatches the same code path the keyboard
accelerator uses by writing a new `Start*RequestEvent` (or
`NewGameRequestEvent` for Classic):
- Classic → NewGameRequestEvent::default()
- Daily Challenge → StartDailyChallengeRequestEvent
- Zen → StartZenRequestEvent
- Challenge → StartChallengeRequestEvent
- Time Attack → StartTimeAttackRequestEvent
The existing keyboard handlers in input_plugin (Z), challenge_plugin
(X), time_attack_plugin (T), and daily_challenge_plugin (C) now read
either their key or the matching request event, so level gates,
TimeAttackResource setup, daily seed lookup, and toast feedback for
locked modes all stay in their owning plugins — the popover never
duplicates that logic.
The popover only lists modes available to the player: Classic always
shows, Daily Challenge shows when DailyChallengeResource is loaded,
and Zen/Challenge/Time Attack show once the player reaches level 5
(the existing CHALLENGE_UNLOCK_LEVEL).
Click handler despawns the popover after dispatch; clicking the
Modes button again toggles it shut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continues the UI-first pass started by the New Game button. Per the
design principle in CLAUDE.md / ARCHITECTURE.md §1, every player action
must be reachable from a visible UI control with the keyboard shortcut
as an optional accelerator. Refactor the single New Game button into a
flex-row "action bar" anchored top-right with four buttons: Undo,
Pause, Help, New Game (left → right; New Game rightmost as the most
consequential action).
Plumbing:
- New `PauseRequestEvent` and `HelpRequestEvent` in events.rs.
- pause_plugin::toggle_pause reads either Esc or PauseRequestEvent so
the button and the keyboard accelerator drive the same code path
(with the existing drag / game-over / selection guards).
- help_plugin::toggle_help_screen reads either F1 or HelpRequestEvent;
also fix the stale module-doc claim that H toggles help (it's F1 —
H is bound to hint cycle in input_plugin).
- hud_plugin now spawns four ActionButton-marked buttons via a
ChildSpawnerCommands helper, with one click handler per button
firing its respective request event. A single
paint_action_buttons system covers hover/pressed colour for all of
them via the shared ActionButton marker. The click handlers
defensively re-register their request events so the plugin works in
isolation under MinimalPlugins (tests). add_message is idempotent.
- ARCHITECTURE.md HudPlugin row updated to call out the action bar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported during 2026-04-29 smoke test: pressing Y on the
ConfirmNewGameScreen modal closed nothing and didn't start a new game.
Trace:
Frame N: handle_confirm_input despawns the modal entity (deferred),
writes NewGameRequestEvent.
End of N: command flush — modal gone.
Frame N+1: handle_new_game reads the event. needs_confirm is still
true (game state unchanged). confirm_already_open is now
false (modal flushed). Condition matches → spawn_confirm_
dialog runs again, the modal reappears, and the new game
never starts.
Add a `confirmed: bool` field to NewGameRequestEvent. handle_confirm_
input writes it as true on Y/Enter so handle_new_game's dialog-spawn
guard short-circuits and the existing despawn-and-start branch runs.
All other writers (button click, N hotkey, mode hotkeys, daily/
challenge/time-attack auto-deal, tests) stay at `confirmed: false`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first resize-jitter fix (366fd6d) only snapped card transforms,
not the Sprite::custom_size. Cards stayed at the old size after a
window resize until the next StateChangedEvent (move, draw, undo,
new-game) refreshed them via sync_cards_on_change. Reported during
smoke testing: "the placeholder grey boxes change size but the cards
do not until I make an update to the window".
Replace the manual transform-only loop in snap_cards_on_window_resize
with a call to sync_cards(slide_secs = 0.0). update_card_entity
unconditionally inserts a fresh Sprite via card_sprite() with the
current layout.card_size, so cards now visibly resize. With
slide_secs=0 it also takes the snap branch (no CardAnim slide), so
the underlying jitter fix from 366fd6d is preserved.
apply_stock_empty_indicator is still called separately because
sync_cards doesn't touch the stock-empty "↺" label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
on_window_resized was firing StateChangedEvent on every WindowResized
event. That ran sync_cards_on_change → update_card_entity, which
inserts a CardAnim slide tween for every card whose target moves >1
unit. During a corner drag the resize fires every frame, retargeting
the slide each time from the cards' current mid-tween positions, so
cards never reach steady state — the visible "snap back and forth"
jitter reported during the 2026-04-29 smoke test.
Replace the StateChangedEvent emit with a direct snap path:
- Add LayoutSystem::UpdateOnResize SystemSet in layout.rs so cross-
plugin ordering is explicit (Bevy's automatic conflict-based order
only forces non-parallel execution, not a particular order).
- table_plugin::on_window_resized: drop the StateChangedEvent emit;
mark the system in_set(LayoutSystem::UpdateOnResize). It already
snaps backgrounds and pile markers directly, so this aligns cards
with the same instant-snap policy.
- card_plugin: new snap_cards_on_window_resize system listens for
WindowResized, runs .after(LayoutSystem::UpdateOnResize), writes
fresh transforms via the existing card_positions() helper, and
removes any in-flight CardAnim. It also reapplies the stock-empty
indicator so the "↺" label's font_size (derived from
layout.card_size.x) still rescales on resize.
Other StateChangedEvent listeners — start_settle_anim,
detect_auto_complete, clear_selection_on_state_change, check_no_moves,
reset_hint_cycle_on_state_change, clear_right_click_highlights — no
longer fire spuriously on resize. They should not fire on a layout
change anyway; that was a pre-existing minor bug masked by the
jitter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a drag was rejected, ShakeAnim was inserted on each dragged card
with origin_x = transform.translation.x — the drop-location X, not the
origin pile slot's X. tick_shake_anim restores translation.x to
origin_x at the end of the 0.3s shake, which fights the sync_cards
slide that StateChangedEvent triggers and pins the card at the drop
location. The visible symptom (reported during the 2026-04-29 smoke
test) was "the card returns to the slot beside the pile".
Compute the target X using the existing card_position() helper
against the origin pile and the card's stack_index, then save that as
ShakeAnim::origin_x. The shake now ends with the card at its correct
resting slot. Apply the same fix to both the mouse path (end_drag)
and the touch path (touch_end_drag), and update the existing Task #57
test to reflect the new contract (origin_x = origin slot X, not
drop-location X).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the UI-first design principle (CLAUDE.md, ARCHITECTURE.md §1),
every player action must be reachable from a visible UI control with
the keyboard shortcut as an optional accelerator. Add a top-right
"New Game" button that fires NewGameRequestEvent on click; the
existing ConfirmNewGameScreen modal in GamePlugin handles the abandon-
current-game confirmation flow when a game is already in progress.
- NewGameButton marker component, BackgroundColor-styled with idle /
hover / pressed states.
- spawn_new_game_button startup system anchors the button at the top
right of the window using absolute positioning.
- handle_new_game_button reads Changed<Interaction> on Pressed and
writes NewGameRequestEvent::default(); paint_new_game_button
applies the colour for the current state.
The N key still works as an accelerator. The legacy
NewGameConfirmEvent toast / countdown machinery in InputPlugin is
left in place for now — the button gives players a discoverable
path that bypasses the toast/modal collision reported during the
2026-04-29 smoke test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The resource is defined in table_plugin and used by the rest of the
engine, but it was the only one of the prominent table_plugin types not
re-exported from lib.rs. Add it next to PileMarker / TableBackground so
downstream binaries can reference it without reaching into the module
path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ARCHITECTURE.md §5 lists SyncCompleteEvent(Result<SyncResponse, String>) as
a cross-system event, but it was never declared or fired. Add the message
to events.rs, register it in SyncPlugin, and emit it from poll_pull_result
on both the success path (carrying the merged payload + conflicts as
SyncResponse) and the failure path (carrying the user-facing error
message). UI/persistence systems can now react to sync completion without
polling SyncStatusResource.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace compile-time include_bytes!() embedding for card faces, backgrounds,
and font with runtime AssetServer::load() calls. Swap in 52 xCards @2x PNGs
(LGPL-3.0) as card face assets and xCards bicycle_blue as back_0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
update_stats_on_new_game and handle_forfeit ran .before(GameMutation)
while being inside StatsUpdate. win_summary_plugin constrains
cache_win_data.before(StatsUpdate), which forces the entire StatsUpdate
set to run after GameMutation — creating an unsolvable cycle that panicked
Bevy 0.18's schedule solver at startup.
Only update_stats_on_win (post-GameMutation) belongs in StatsUpdate.
The pre-GameMutation systems still run before GameMutation but outside
the set, so external .before(StatsUpdate)/.after(StatsUpdate) constraints
remain consistent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Collapse nested-if patterns into let-chains across 13 plugins (42 instances)
- Add #[allow(clippy::too_many_arguments)] to 5 Bevy systems in card_plugin
and input_plugin where ECS parameter count exceeds the lint threshold
- Gate Theme import in table_plugin under #[cfg(test)] — only used by
test-only colour helpers; removing the unconditional import silences the
unused-import lint without breaking the test suite
- Wrap ButtonInput<MouseButton> in Option<> in update_input_platform so that
tests using MinimalPlugins (no InputPlugin) no longer panic on startup
All 789 tests pass; cargo clippy --workspace -- -D warnings is clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the single shared face.png placeholder with 52 individual card
face images (120×168 px each), generated by the updated gen_art tool:
- solitaire_assetgen: add ab_glyph dep; rewrite gen_art to render each
card with FiraMono rank characters, programmatic suit symbols (heart,
spade, diamond, club drawn via circles/triangles), and standard pip
layout for numbered cards (A–10) plus large face letter for J/Q/K.
- CardImageSet: replace single `face` handle with `faces: [[Handle; 13]; 4]`
indexed by [suit][rank].
- card_sprite(): select the per-card face image by suit/rank indices.
- spawn/update_card_entity: suppress Text2d overlay when PNG faces are
loaded (rank/suit baked into image); keep overlay in solid-colour
fallback for tests.
- gen_sfx.rs: rename `gen` variable to `make` (reserved keyword in 2024).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Art pass (Phase 4):
- Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via
solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!)
- Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time
- Add FontPlugin: loads font at startup, exposes FontResource; gracefully
falls back to default handle when Assets<Font> absent (MinimalPlugins tests)
- Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour
sprites when available; tests continue using colour fallback via MinimalPlugins
- Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour
background; empty set inserted when Assets<Image> absent in tests
- Fix hint highlight system (input_plugin): tint sprite.color directly instead
of replacing the whole Sprite (which would discard the image handle)
- Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib
- Register FontPlugin in solitaire_app before other plugins
Dependency upgrades (latest releases):
- keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into
separate core library crate)
- auth_tokens.rs: Entry::new now returns Result; delete_password →
delete_credential; NoDefaultStore error variant handled
- solitaire_app: add keyring::use_native_store(true) at startup for Linux
Secret Service / macOS Keychain / Windows Credential Store selection
ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section,
add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables,
update Section 14 to reflect actual include_bytes!() rendering approach,
add Decision Log entries for embedded PNG and font decisions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deletes the solitaire_gpgs crate and all GPGS references from settings,
sync client, profile plugin, CLAUDE.md, and ARCHITECTURE.md. The
self-hosted server covers all sync needs without the Android-only backend.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EventReader was removed in Bevy 0.18 in favour of MessageReader.
Three touch drag/tap handlers used the old type, causing compile errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- engine/lib.rs: re-export CardFaceRevealedEvent so external crates can consume flip-midpoint audio events
- gpgs/stub.rs: add explicit impls for all six defaulted SyncProvider methods; future trait changes now cause a compile error in the stub rather than silently picking up wrong defaults
- core/game_state.rs: add /// doc comments to DrawMode and GameMode variants
- server/auth.rs: replace terse BCRYPT_COST comment with full /// doc comment matching ARCHITECTURE.md §19
- server/leaderboard.rs: add /// doc comment to DISPLAY_NAME_MAX; fix misplaced comment that was prepended to the opt_in handler instead of the constant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SelectionPlugin: add clear_selection_on_state_change system so undo/move/reject
never leave a stale selection pointing at the wrong card
- SelectionPlugin: expose SelectionKeySet system set for cross-plugin ordering
- PausePlugin: skip Escape→pause when a card is keyboard-selected; toggle_pause
now runs before SelectionKeySet so it reads SelectionState before it is cleared
- InputPlugin: guard Space→DrawRequestEvent when SelectionState has an active pile
so Space executes a card move instead of also drawing from stock
- window: enforce 800×600 minimum via WindowResizeConstraints
- game_state: add precondition doc to next_auto_complete_move (waste exclusion)
- card_plugin: 12 unit tests for constants, face_colour, label_visibility, label_for
- pause_plugin: add paused_resource_default and draw_mode_label exhaustiveness tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P0 fixes:
- Register WinSummaryPlugin, SelectionPlugin, CardAnimationPlugin in main.rs
(all three were exported but never wired — features silently did nothing)
- game_state::draw(): increment move_count on waste→stock recycle, not just
on normal draws; add move_count_increments_on_recycle regression test
P1 fixes:
- solitaire_server/Cargo.toml: remove duplicate dev-dependencies
(solitaire_sync, uuid, chrono, jsonwebtoken were in both sections)
P2 — input_plugin refactor:
- Split 198-line handle_keyboard() into three focused systems under 110 lines each:
handle_keyboard_core (U/N/Z/D/Space), handle_keyboard_hint (H), handle_keyboard_forfeit (G)
- Introduce KeyboardConfirmState resource to share countdown timers across systems
- Add three new unit tests: all_hints_suggests_draw_*, all_hints_is_empty_when_truly_stuck,
new_game_confirm_window_is_positive
P2 — achievement predicate tests (solitaire_core):
- Add 10 direct unit tests for speed_demon, lightning, no_undo, high_scorer,
on_a_roll, comeback predicates (previously only covered via check_achievements())
- 141 core tests now passing
P2 — server tests:
- solitaire_server/src/sync.rs: 4 unit tests for merge logic (no DB required)
- solitaire_server/src/leaderboard.rs: 2 unit tests for entry shape and sort order
P3 — documentation:
- Add struct-level /// to 12 Plugin structs (ChallengePlugin, CursorPlugin,
AnimationPlugin, HelpPlugin, PausePlugin, AudioPlugin, DailyChallengePlugin,
HudPlugin, LeaderboardPlugin, OnboardingPlugin, TimeAttackPlugin, WeeklyGoalsPlugin)
- Add field-level /// to Card, Pile, Deck, GameState, AchievementContext, AchievementDef
- Add /// to WeeklyGoalKind, WeeklyGoalDef, WeeklyGoalContext, StatsExt::update_on_win
card_animation module (new files from previous session):
- chain.rs, diagnostics.rs, tuning.rs, updated interaction.rs/animation.rs/mod.rs/lib.rs
- Remove unused HOVER_SCALE_DEFAULT / DRAG_LIFT_SCALE_DEFAULT / HOVER_LERP_SPEED_DEFAULT constants
- Add handle_touch_stock_tap so touch users can draw from the stock pile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Import paths simplified: manager/tween modules re-exported from kira root
(AudioManager, AudioManagerSettings, DefaultBackend, Tween all via kira::*)
- Volume::Amplitude removed; replaced with Value<Decibels> using a new
amplitude_to_decibels() helper (20*log10 conversion, clamps to SILENCE)
- output_destination field removed from StaticSoundSettings; sounds routed
to sub-tracks by calling TrackHandle::play() directly instead of
AudioManager::play()
- set_volume() now accepts f32 (Decibels) not f64
- start_ambient_loop signature updated to take &mut Option<TrackHandle>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BorderRadius is no longer a Component; moved into Node.border_radius
field at all 15 spawn sites across 6 plugin files
- Events<T> renamed to Messages<T> in test code (12 files)
- KeyboardEvents SystemParam renamed to KeyboardMessages to match the
MessageWriter rename done in the 0.17 hop
- WindowResolution::from((f32,f32)) removed; use (u32,u32) tuple in main.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Event/EventReader/EventWriter renamed to Message/MessageReader/MessageWriter
- add_event → add_message for all 67 call sites
- ScrollPosition changed to tuple struct ScrollPosition(Vec2)
- CursorIcon import moved from bevy::winit::cursor to bevy::window
- WindowResolution::from((f32,f32)) removed — use (u32,u32) tuple
- World::send_event → World::write_message in test code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces solitaire_engine::card_animation — a drop-in upgrade over the
existing linear CardAnim. Supports MotionCurve easing, parabolic z-lift,
scale interpolation, delay, retargeting mid-flight, and per-card timing
variation. Coexists with the legacy AnimationPlugin during migration.
Also adds .claude/ to .gitignore so Claude Code local tooling is never
committed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves 15 violations found by `cargo clippy --workspace --tests -D warnings`:
- Remove unused imports (Card, Rank) in cursor_plugin tests
- Replace absurd i32::MAX comparison with a meaningful >= 0 check
- Use range .contains() instead of manual >= && <= (manual_range_contains)
- Move impl FromRequestParts before test module in middleware.rs (items_after_test_module)
- Move _VEC3_REFERENCED const before test module in input_plugin.rs
- Convert runtime assert on constant to const { assert!(...) }
- Use .contains() instead of .iter().any() for slice membership
- Replace .get(...).is_none() with !.contains_key(...) in HashMap checks
- Collapse Default::default() + field assignment into struct literal initializers
across solitaire_sync, solitaire_data, and solitaire_engine test helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #5: Add RightClickHighlightTimer(1.5 s) so destination highlights
auto-despawn after 1.5 s. Existing clear-on-state-change and
clear-on-pause logic still fires early when a move is made or the game
is paused. Three unit tests cover timer countdown behaviour.
Task #6: Add HintVisualEvent emitted on H key. Source card gets
HintHighlight + HintHighlightTimer(2 s) for a yellow glow. Destination
PileMarker gets HintPileHighlight with a gold tint (Color::srgb(1.0,
0.85, 0.1)) that restores the original colour when the 2 s timer
expires. Five unit tests cover timer expiry and colour invariants.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Round 7 — Input & feedback
- H key cycles hints; F1 opens help (conflict resolved)
- N key cancels active Time Attack session
- Hint text distinguishes "draw from stock" vs "recycle waste"
- Forfeit (G) clears AutoCompleteState so chime does not bleed into new deal
- Zen mode timer clears immediately on Z press
- HUD shows recycle count in both draw modes
- Settings scroll position persisted across open/close
Round 8 — Polish & clarity
- Undo unavailable fires "Nothing to undo" toast
- Streak-break toast on forfeit/abandon when streak > 1
- F11 fullscreen toggle with toast; documented in help and home screens
- H-after-win, new-game countdown expiry, Tab-no-cards toasts
- Win cascade duration/stagger scales with animation speed setting
- Draw-Three cycle counter HUD ("Cycle: N/3")
- Forfeit requires G×2 confirmation within 3 s (mirrors N key)
Round 9 — Game feel & information
- Escape dismisses game-over/stuck overlay (PausePlugin skips Escape when overlay visible)
- Shake animation on rejected drag before snap-back
- Forfeit countdown cancels when any other key is pressed (U/H/D/Z/Space)
- Tab wrap-around fires "Back to first card" toast
- HUD selection indicator shows active Tab-selected pile in yellow
- Challenge time-limit HUD turns orange < 60s, red < 30s
- Win summary shows XP breakdown (+50 base, +25 no-undo, +N speed)
- Game-over overlay: "No more moves available" with clear N/Escape/G instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #56 — HudAutoComplete badge: shows "AUTO" in green when AutoCompleteState.active
is true; announce_auto_complete system fires InfoToastEvent on leading edge.
Task #57 — ConfirmNewGameScreen: intercepts NewGameRequestEvent when move_count > 0 and
is_won=false, shows modal with Y/N keyboard handling.
Task #58 — GameOverScreen: spawns when check_no_moves detects no legal moves; handle_game_over_input
responds to N (new game) and U (undo + despawn). Fix animation system ordering
(chain enqueue_toasts → drive_toast_display) to eliminate flaky test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add FeedbackAnimPlugin with three card feedback animations:
- #54 ShakeAnim: horizontal shake on MoveRejectedEvent targeting
destination pile cards; 0.3 s damped sine wave
- #55 SettleAnim: Y-scale bounce on valid placement (StateChangedEvent);
1.0 → 0.92 → 1.0 over 0.15 s for all top-of-pile cards
- #69 Deal animation: slides each card from stock position to its deal
position on NewGameRequestEvent (move_count == 0), using existing
CardAnim with 0.04 s per-card stagger
Pure-function helpers shake_offset, settle_scale, and deal_stagger_delay
are public and covered by 6 unit tests. Fix pre-existing compile/clippy
errors: stubbed handle_confirm_input/handle_game_over_input, removed dead
CycleCardBack/CycleBackground variants, annotated ambient_handle field,
and fixed draw_mode.clone() in pause_plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #27: Double-click auto-move — best_destination() finds optimal target
(foundation over tableau); handle_double_click() fires MoveRequestEvent.
Task #28: Hint system — find_hint() returns first legal from/to/count triple;
H key tints the source stack HintHighlight (yellow pulse via tick_hint_highlight).
Task #29: No-moves detection — has_legal_moves() checks stock/waste/all face-up
cards; check_no_moves system fires InfoToastEvent("No moves available") once per
stalemate (debounced so it fires only once until the state changes).
Task #30: Forfeit — G key fires ForfeitEvent; StatsPlugin records abandoned game,
persists stats, starts a new deal.
Task #37: Mute-all (M) and mute-music (Shift+M) toggles; MuteState resource
applied in apply_volume_on_change.
Task #39: Daily challenge HUD constraint label (time limit / target score).
Task #40: Undo-count HUD label; amber colour when undos > 0.
Task #44: Win-streak and level line on pause screen.
Task #48: Undo sound routes UndoRequestEvent → lib.flip audio channel.
Task #49: Onboarding banner rich-text key highlights — D and H rendered as
orange KeyHighlightSpan children so they stand out from body text.
Also registers CursorPlugin in solitaire_app (tasks #31/#32 wire-up).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task #34: CardFlipAnim component + start_flip_anim/tick_flip_anim systems animate revealed
cards by squashing scale.x to 0 then expanding back to 1 (2×0.08 s). Skipped at Instant speed.
Task #35: spawn_pile_markers now adds a Text2d child (S/H/D/C, 45% alpha) on Foundation
markers so the suit is visible while the pile is empty.
Task #43: Tableau pile markers get a "K" Text2d child (35% alpha) indicating only Kings land
on empty columns.
Task #38: update_drag_shadow system maintains a single ShadowEntity while dragging — a
card_w+8 × card_h+8 dark semi-transparent sprite at z−1 behind the top dragged card.
Also fixed pre-existing clippy/compiler errors in hud_plugin, pause_plugin, stats_plugin,
cursor_plugin, and settings_plugin (missing imports, too-many-arguments, doc formatting).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies that pressing X when the player's level is below
CHALLENGE_UNLOCK_LEVEL emits an InfoToastEvent containing the unlock
level, rather than silently doing nothing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>