reqwest/hyper-util's GaiResolver calls tokio::runtime::Handle::current()
which panics with "no reactor running" when driven by Bevy's
AsyncComputeTaskPool (async-executor, not Tokio). Fixed all three spawn
sites in sync_plugin.rs (start_pull, handle_manual_sync_request,
push_replay_on_win) and the push_on_exit fallback by wrapping each HTTP
future in tokio::runtime::Builder::new_current_thread().enable_all().
Also fixes a clippy type_complexity warning in hud_plugin.rs by
extracting HudScoreFont / HudMovesFont / HudTimeFont type aliases for
the update_hud_typography query parameters.
Closes P4 AVD JNI bridge test: keystore JNI verified working on
Android 14 x86_64 AVD (load_access_token returned NotFound correctly);
clipboard JNI compiled and linked, runtime test deferred to a real-device
session with a won game and active sync server.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the final two P2 Android playability items:
1. HUD typography — new `update_hud_typography` system fires on
`WindowResized` and adjusts Tier-1 font sizes: below 480 logical px
Score drops HEADLINE(26)→BODY_LG(18) and Moves/Timer drop
BODY_LG(18)→CAPTION(11), so all three fit in the 180dp HUD column
on a 360dp phone without wrapping.
2. Orientation lock — `[package.metadata.android.application.activity]`
with `orientation = "portrait"` in solitaire_app/Cargo.toml; cargo-apk
maps this to `android:screenOrientation="portrait"` in the generated
AndroidManifest.xml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Action buttons sized to text + 8 px padding made "Undo" end up
~46 x 33 px — fine for a mouse but at the threshold of a finger.
Adds `min_width: 48 px` and `min_height: 48 px` to the button
Node so every button meets Material's 48 dp thumb-target guideline.
Applied universally; the floor is a no-op for buttons whose
content already exceeds 48 px on either axis (Menu, Modes,
New Game, Pause, Help all clear 48 px wide; height was the
binding constraint at ~33 px).
Closes P1 #2 of docs/android/PLAYABILITY_TODO.md. Engine tests
pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The U / Esc / F1 / N caption chips next to the HUD action buttons
are meaningless on a touch device and visibly clutter the
narrow-viewport action row (visible as "Esc A [] N" in the v0.22.3
screenshot). `spawn_action_button` now rebinds `hotkey` to `None`
under `#[cfg(target_os = "android")]` so the chip-spawn branch is
skipped on touch builds.
Menu / Modes chevrons are unaffected — they indicate dropdown
behaviour and still apply on touch. Other hint surfaces
(onboarding, pause modal Esc hint, mode-card chips, replay
footer, modal toggle chips, help screen) live behind navigation
and are tracked as a P3 sweep in PLAYABILITY_TODO.md.
Closes P1 #1 of docs/android/PLAYABILITY_TODO.md. 855 engine
tests pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The v0.22.3 hardware screenshot showed the 6-button action row
(~510 px when laid out) overflowing into a 360 dp viewport from
the right anchor, with Menu and Undo clipped off-screen left and
Pause/Help/Modes/New_Game overlapping the left HUD column's
Score / Moves / Timer text.
Cap both clusters at `max_width: 50 %` so on mobile each takes
half the viewport (~180 px) and on desktop the cap is wider than
either cluster's natural width so the existing single-line
layout is preserved.
- Action button row: adds `flex_wrap: Wrap`, `row_gap`, and
`justify_content: FlexEnd` so the row breaks to multiple
right-aligned lines instead of clipping. 6 buttons become 2-3
lines of 2-3 buttons.
- HUD column tier rows: add `flex_wrap: Wrap` and `row_gap` to
the shared `row_node` helper so a long Mode/Challenge/Draw-cycle
combo soft-wraps onto two lines instead of pushing into the
action button column.
Closes P0 #2 of docs/android/PLAYABILITY_TODO.md. All 854 engine
tests pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.
UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.
Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).
Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to
solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel).
Five seed catalogs (40 seeds each) are pre-verified by the new
gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves).
DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed →
NewGameRequestEvent; Random uses a system-time seed and bypasses the
winnable-only filter. The home overlay gets an expandable Difficulty section
between Draw Mode and the mode grid; last-played tier persists in Settings.
Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the v0.21.2 carve-out: dynamic-paint sites that were left
un-tagged because their paint cycles were assumed to race
`update_high_contrast_borders`. Re-reading the code revealed only
one of three sites is actually a border-paint cycle — the other
two paint backgrounds, with static borders that take the marker
pattern cleanly:
* HUD action buttons (`spawn_action_button`): `paint_action_buttons`
only mutates `BackgroundColor`. Tag the spawn with
`HighContrastBorder::with_default(BORDER_SUBTLE)`.
* Modal buttons (`spawn_modal_button`): `paint_modal_buttons` also
only mutates `BackgroundColor`. Same marker pattern.
* Radial menu rim (`radial_redraw_overlay`): full despawn-respawn
every frame; sprites, not UI nodes; the marker can't apply. Folds
the HC choice into the spawn site instead — under HC the
*focused* rim boosts to `BORDER_SUBTLE_HC` rather than
`BORDER_STRONG`. Naive marker substitution would invert the
visual hierarchy because `BORDER_SUBTLE_HC` (#a0a0a0) is lighter
than `BORDER_STRONG` (#505050); folding the choice in keeps the
focused rim *more* visible under HC, not less.
Decision logic for the rim is extracted to `radial_rim_outline` —
a pure function with a 4-row truth-table test (focused × HC).
After this commit, every UI surface tagged in v0.21.x's
accessibility arc either carries `HighContrastBorder` or has its
HC behaviour folded into its own spawn cycle. No "un-tagged
because race-risk" surfaces remain.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The post-Option-D screenshot showed the left-anchored HUD column
("Score: 0 Moves: 0 0:00") and the right-anchored action button
row colliding mid-screen at portrait/narrow window widths. Both
were absolute-positioned siblings without a shared flex parent,
so Bevy 0.15's UI couldn't auto-arrange them when their natural
widths exceeded the available horizontal space.
The action button text was a hardcoded `font_size: 16.0` literal
— a miss from the typography migration audit, since every other
text element in `hud_plugin.rs` already routes through the
`TYPE_*` tokens. Switching to `TYPE_BODY` (14.0) brings the
button row in line with the design system *and* trims roughly
12% off label widths.
Pairs with a horizontal-padding cut from VAL_SPACE_3 to
VAL_SPACE_2: 8px less on each side, six buttons, ~96px total
reclaimed across the row. Vertical padding stays at VAL_SPACE_2
so button height tracks the rest of the chrome band.
Combined effect: the action button row narrows by ~150-200px,
which is enough margin to clear typical portrait window widths
without requiring a structural refactor (a shared SpaceBetween
flex parent for HUD+actions would be more robust but touches
many query sites and was out of scope for the visual-polish
pass).
cargo clippy + cargo test --workspace clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quat: opening Modes / Menu showed a solid dark-purple block in the
top-right with no readable content. Cause: the auto-fade system on
the top-level action bar was fading the popover rows too — they
share the `ActionButton` marker so `paint_action_buttons` can still
paint hover/press, but `apply_action_fade` matched the same marker
and dropped their alpha to whatever the cursor-position-based
fade happened to be (typically 0 because the cursor was inside the
opened popover, well below the top reveal zone). The popover
container stayed at full opacity (its background is `BG_ELEVATED`,
not driven by the fade), so what the player saw was the empty
rounded box with no labels.
Fix: new `PopoverRow` marker on the rows in `spawn_modes_popover`
and `spawn_menu_popover` (both share the same row-spawn shape).
`apply_action_fade` excludes `PopoverRow` via `Without<PopoverRow>`.
Hover / press paint still applies — the popover rows just opt out
of the cursor-position auto-fade since they only render when the
player has explicitly opened the dropdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the current deal's (seed, draw_mode, mode) triple matches an
entry in the rolling ReplayHistory, the HUD's tier-2 context row
now shows "✓ Won before" in the success-green colour. Cleared when
the active game itself is won (the on-screen victory cue is enough)
and on fresh deals the player hasn't beaten before.
The indicator answers a question the rolling-history feature
implicitly raised: when a new game starts on a seed the player has
already conquered, surface that fact so they know they can try for
a faster / higher-scoring win on the same layout. Seed re-rolls in
"Winnable deals only" + system-time seeds make this a natural pace
for the indicator to fire — usually empty, occasionally lit.
Implementation: new `HudWonPreviously` marker spawned in tier-2
alongside Mode / Challenge / DrawCycle. Driven by a separate
`update_won_previously` system rather than threading the marker
through `update_hud`'s ten-way query disambiguation. Reads the
existing `ReplayHistoryResource` from `stats_plugin`; gracefully
no-ops in headless tests that don't load StatsPlugin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UX improvements bundled because they share ui_theme token
edits.
Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
via "−" / "+" icon buttons next to a value readout. Range
[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
is absent (test path). New tooltip_should_show(elapsed, delay)
pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
load. Five round-trip / default / legacy-deserialise tests.
Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
when win_streak_current crosses any of [3, 5, 10] (only the
threshold crossing — not every subsequent win). HUD streak readout
scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
(0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.
Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
per-component reveal: Base score, Time bonus (m:ss), No-undo
bonus, Mode multiplier, separator, Total. Rows fade in over
MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
it animates. Skipped rows: zero time bonus, undo-tainted no-undo
bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
GameWonEvent.score, time bonus from
solitaire_core::scoring::compute_time_bonus, no-undo from a +25
constant when undo_count == 0, mode multiplier from GameMode (Zen
zeros the total). 9 new tests cover the math and the reveal
cadence.
Test count net: +25 across the workspace (1007 → 1031).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.
Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.
can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.
next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.
The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.
Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.
9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
is unaffected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Player request: the Menu / Undo / Pause / Help / Modes / New Game
buttons stay visible during play even when the player isn't looking
at them. Fade them out when the cursor is in the play area, fade
back in when it returns to the top of the window.
Implementation mirrors video-player auto-hide UX:
- HudActionFade resource holds (alpha, target). Default both 1.0 so
the bar starts visible on first launch.
- update_action_fade reads cursor.y each frame, sets target to 1.0
when the cursor is in the top reveal zone (HUD_BAND_HEIGHT + 32 px)
or off-window (keyboard navigation), 0.0 otherwise. Lerps alpha
toward target at 6/sec ≈ 167 ms per full transition.
- apply_action_fade overrides BackgroundColor + child TextColor on
every ActionButton. Runs in Last so a hover-state change in the
same frame doesn't blip back to opaque mid-fade.
No interactivity guard needed: hover requires the cursor to be on a
button, and a faded button is geometrically out of reach (cursor must
re-enter the reveal zone, which is exactly the trigger that fades
the bar back in).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Player report: the action button bar (Menu / Undo / Pause / Help /
Modes / New Game) and Score / Moves / Timer text were sharing the
same vertical band as the stock + foundation row, with no visual
separation. The HUD read as part of the play surface.
Two-part fix:
1. layout.rs reserves HUD_BAND_HEIGHT (64 px) at the top of the
window. Card-grid math takes that off the available vertical
budget so cards still fit; top_y shifts down by the same amount.
New layout test pins the reservation. Existing
worst_case_tableau_fits_vertically tests verify the height-budget
arithmetic still holds.
2. hud_plugin.rs spawns a translucent purple band (BG_HUD_BAND, new
token in ui_theme.rs at the BG_BASE hue with 0.70 alpha) filling
that reserved zone. Z-index sits one rung below Z_HUD so action
buttons paint on top while the band reads as their container. The
band's bottom edge lines up with the top edge of the highest
playable card, so the buttons feel anchored to a "tools strip"
rather than floating in the play area.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier HUD tooltip pass deliberately skipped the popover row
content because the spawn helpers were inline and the popovers
ephemeral. Coming back to them now: every row in the Modes popover
(Classic / Daily Challenge / Zen / Challenge / Time Attack) and
every row in the Menu popover (Stats / Achievements / Profile /
Settings / Leaderboard) gets a one-sentence tooltip explaining what
opening that mode or screen does.
The row tuple in each popover spawn helper grew from
(Marker, label) to (Marker, label, tooltip), with Tooltip::new(...)
attached at the spawn site. No public helper signatures changed.
popover_rows_carry_tooltip_strings asserts every row's exact
canonical text by querying (With<ModeOption>, &Tooltip) and
(With<MenuOption>, &Tooltip), spawning the popovers directly via
world.commands() to keep the test independent of headless click
simulation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applies the tooltip infrastructure to the HUD: ten readouts (Score,
Moves, Time, Mode, daily-challenge target, draw cycle, undo count,
recycle count, auto-complete badge, keyboard selection chip) and the
six action-bar buttons (Menu, Undo, Pause, Help, Modes, New Game)
each gain a one-sentence tooltip in the established Balatro voice.
The strings earn their keep by surfacing information that isn't
visible: the link between the undo counter and the No Undo
achievement, the recycle counter and Comeback, the dual count-up /
countdown semantics of the timer in Time Attack, and the keyboard
shortcuts plus side-effects on action buttons.
spawn_action_button now requires a tooltip parameter so every action
bar entry gets one — there is no opt-out, by design. The popover Mode
and Menu rows are intentionally skipped: they're inside ephemeral
overlays whose hover surfaces are brief and already labeled.
Adds hud_elements_carry_expected_tooltip_strings, asserting the exact
text on each of the 16 instrumented elements.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HUD action bar (Menu / Undo / Pause / Help / Modes / New Game) and
the five Home mode-launcher cards now participate in keyboard focus,
extending Phase 1's modal-only coverage.
The HUD focus group activates only when no modal is open and the
mouse is hovering an action-bar button — the design decision avoids
stealing Tab from selection_plugin's card-selection nav for the
common "playing on the board" case. Once engaged, Tab/Shift-Tab cycles
the bar in spawn order and Enter activates. Moving the mouse off the
bar clears focus so the ring doesn't linger.
Home mode cards opt into FocusGroup::Modal(home_scrim) via an
ancestry-walking system that mirrors the Phase 1 attach helper, so
spawn_mode_card's signature is unchanged. Locked cards (Zen,
Challenge, Time Attack at level <5) get the Disabled marker so Tab
skips them and Enter is a no-op — mirroring the existing visual
locked state with real keyboard semantics.
handle_focus_keys gains a Hud-on-hover branch in its active-group
resolver and a clear_hud_focus_on_unhover system. Together they
implement the agreed UX: focus follows hover when the bar is active,
Tab cycles within the hovered group, and the ring disappears the
instant the mouse leaves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Score readouts now react to mutations: ScorePulse drives a triangular
1.0 → 1.1 → 1.0 scale on the HUD score over MOTION_SCORE_PULSE_SECS,
and jumps of at least SCORE_FLOATER_THRESHOLD points spawn a floating
"+N" that drifts up 40px and fades over 2× the pulse duration before
despawning. Detection runs after GameMutation so the visuals trail the
state update by exactly one frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates the last remaining colour, spacing, font-size, and z-index
literals in animation_plugin (toasts), hud_plugin (action bar +
Modes/Menu popovers), and win_summary_plugin (full win modal restyle)
onto the ui_theme token system established in step 1. Win summary now
uses SCRIM/BG_ELEVATED/ACCENT_PRIMARY/STATE_* with a yellow Play Again
button. Sprite-tinted gameplay art (cards, felt, drop-zone hints,
pile markers) and sub-rung pixel sizes (1px borders, fixed cell
widths) are intentionally left untouched.
cargo build / clippy --workspace -- -D warnings / test --workspace
all green (819 passed, 0 failed, 8 ignored).
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>
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>
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>
- 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>
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>
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>
- 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>
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>
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 #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>
fix(input_plugin): card_position() now applies the same X-fan offset for
Draw-Three waste cards as card_plugin uses for rendering. Previously the
top waste card appeared at base_x + 0.56 * card_width but was only
hittable at base_x, making it impossible to drag from its visual position.
test(hud_plugin): add five behaviour tests — score/moves/time display
format, Zen mode score suppression, Draw-Three mode badge.
test(input_plugin): add find_draggable test that clicks the top fanned
waste card at its visual X position and confirms it hits in Draw-Three.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Zen mode is intended for relaxed play with no performance pressure.
The HUD now clears score and elapsed-time displays when GameMode::Zen
is active, matching the ARCHITECTURE.md spec ("No timer. No score
display."). The mode badge still shows "ZEN" so the player knows
which mode they're in.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Classic + DrawOne shows nothing (clean default). Classic + DrawThree shows
a "Draw 3" badge in the mode position so the player always knows their
current game variant without opening the stats or settings screen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
During Time Attack sessions the time display updates every frame with the
remaining countdown (from TimeAttackResource) rather than the per-second
game clock tick, giving the player a live countdown. Non-Time-Attack mode
is unchanged — the clock still updates once per second via game state change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds HudPlugin with a persistent top-left overlay that shows score,
move count, and elapsed time during every game. A mode badge highlights
DAILY, CHALLENGE, ZEN, or TIME ATTACK when the game is not in Classic
mode. HUD updates whenever GameStateResource changes (moves and per-second
time ticks) without a separate polling system.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>