- UX-1 (safe_area.rs): apply_safe_area_to_modal_scrims pads ModalScrim
bottom by insets.bottom / scale_factor so Done buttons clear the
gesture bar; fires on inset change + Added<ModalScrim>
- UX-5b (home_plugin.rs): replace Geometric Shapes (U+25xx, missing
from FiraMono) with card suits U+2660/2665/2666
- UX-7 (help_plugin.rs): shorten Android ≡ button description to
"Open menu (Stats, Settings, Profile...)" — fits one line at 360 dp
- BUG-3 (hud_plugin.rs): guard spawn_menu_popover with
scrims.is_empty() so tapping ≡ while a modal is open is a no-op
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BUG-1: pause_plugin — auto_resume_on_overlay system despawns PauseScreen
whenever any other ModalScrim becomes live; fixes Pause modal stacking on
top of Stats / Settings / Help / Achievements / Profile overlays opened
from the HUD menu while paused.
BUG-2: game_plugin — tick_elapsed_time skips the first delta_secs after
AppLifecycle::WillSuspend/Suspended so the Android post-resume frame spike
(equal to the full suspension duration) no longer inflates the in-game timer.
UX-2: help_plugin — Android build gets a touch-specific CONTROL_SECTIONS
(Tap / New Game / HUD buttons); desktop sections (Mouse, Keyboard drag,
Mode Launcher, Overlays) remain on non-Android builds only.
UX-3: achievement_plugin — replace \u{25CB} (○) and \u{2713} (✓) prefixes
with ASCII "- " / "+ "; both Geometric Shapes codepoints are absent from
FiraMono and rendered as the fallback letter "o".
Phase 8 work from previous session (already compiled, not yet committed):
hud_plugin — HUD visual hierarchy (Undo/Pause bright, nav buttons dim);
menu popover — Help + Game Modes entries added (7 items total).
card_plugin — stock badge drops "·" prefix, shows plain count.
pause_plugin — Draw Mode segmented control (Draw 1 / Draw 3 explicit buttons).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Register touch_scroll_panel::<HelpScrollable> so the Controls overlay
can be scrolled by swipe on Android. Without it, the Mode Launcher and
Overlays sections (rows 2–19) were unreachable via touch.
Also add 96px bottom padding to HelpScrollable — same fix applied to
settings_plugin — so the last row clears the scroll-container edge.
Register TouchInput message so existing headless tests continue to pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Continues the HC chrome rollout started by `c9af1ea` (which wired
just the modal scaffold). Tags four more static-border surfaces
so they boost to `BORDER_SUBTLE_HC` (#a0a0a0) when high-contrast
mode is on:
- **Tooltip** (`ui_tooltip.rs:191`). The hover-revealed caption
popup. Border legibility matters because tooltips are usually
brief — if the player has to squint to find the panel edge,
the tooltip dismisses before they've parsed it.
- **Onboarding banner key chips** (`onboarding_plugin.rs:388`).
The first-run UI's "press H or ?" key chips. First-run
onboarding has the highest stakes for accessibility — a
low-vision player who can't see the chips can't discover
the help system.
- **Help panel key chips** (`help_plugin.rs:265`). Same
treatment as the onboarding chips: keyboard-shortcut chips
inside the F1 cheat sheet.
- **Stats panel cells** (`stats_plugin.rs:1019`). The S-key
overlay's individual stat cells. A dense grid of bordered
numbers is exactly the kind of surface where HC's
`#505050 → #a0a0a0` boost makes the layout legible.
Each tagging is one line on the spawn tuple plus an import. The
existing `update_high_contrast_borders` system in
`settings_plugin` (added in `c9af1ea`) handles all tagged
entities uniformly — no system changes needed.
### Skipped on this pass
Sites with dynamic hover/focus paint systems (HUD action
buttons, modal buttons, radial menu rim) intentionally not
tagged because their existing paint cycles would race the HC
system. Wiring HC into those needs a different shape — either
fold HC into the dynamic-paint logic, or have HC consult the
hover/focus state. Future scope.
Other HC-tagging candidates (`home_plugin.rs:842/945/1158` home
menu element borders, `settings_plugin.rs:1952/2093/2214/2274`
settings panel rows) are likely fine to tag but I'm capping
this commit at four to keep it reviewable. Pattern is
established; future commits can extend.
1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level test in `c9af1ea`'s scaffolding
covers all tagged entities uniformly). Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Help (F1) modal's Overlays section listed S/A/L/O but skipped
two post-v0.18 entries — M (Home / Mode launcher) and P (Profile) —
and never mentioned the recently-shipped Enter accelerator that
dismisses the Win Summary.
Help is the canonical keyboard-discovery surface. Three new rows
cover the gap so a player who opens F1 sees every overlay-toggle
key, plus the contextual Enter shortcut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a ScrimDismissible marker to ui_modal that opts a modal into
the standard "click outside the card to close" gesture. The new
dismiss_modal_on_scrim_click system fires on a left-mouse press
whose cursor falls on the scrim and outside every ModalCard, then
despawns the topmost dismissible scrim — Bevy's hierarchy despawn
cascades to the card and its children.
Marker design is opt-in per modal so destructive / state-mutating
modals (Settings saves on close, Onboarding requires explicit
acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed
intent) don't lose work to an accidental scrim click. Three
read-only modals opt in this round:
- Stats — informational; press S or click outside to dismiss.
- Achievements — read-only list.
- Help — keyboard reference.
Profile, Leaderboard, and Home will opt in the same way in a
follow-up; they were left out to keep this commit's scope tight.
The hit-test path uses each ModalCard's UiGlobalTransform +
ComputedNode bounding box so stacked modals close cleanly: the
topmost dismissible scrim is the only candidate per click. Tests
spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for
the resolved-border slots Bevy's UI module re-exports) so the
geometry hit-tests deterministically without running the full UI
layout pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.
Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.
Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
weekly goals + unlocks + Time Attack readout + replay caption is
enough content to overflow once the player has any progress;
StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
unlocked achievements + Stats summary overflows once a few
achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
with long display names; LeaderboardScrollable +
scroll_leaderboard_panel (max_height = 50vh — the ranking section
is the only variable-length part).
Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.
Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.
Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
of the same frame; first Tab press selects rather than focus
already being on the primary.
These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Power-user shortcut: hold right-click on a face-up card, a small
ring of icons appears around the cursor with one entry per legal
destination, release over an icon to fire MoveRequestEvent. Skips
the drag motion entirely while preserving the existing
RightClickHighlight tint on the actual pile markers.
A new RadialMenuPlugin owns the flow. RightClickRadialState is a
two-state enum (Idle / Active) carrying the source pile, lifted
cards, pre-computed legal destinations + their world anchors, the
ring centre, and the currently hovered icon index. Four chained
systems handle press → cursor track → release/cancel → redraw, in
that order so a single-tick test can't observe a half-state.
Mutual exclusion with the left-button mouse drag is implicit —
RadialMenuPlugin only listens to MouseButton::Right while the
existing drag pipeline only listens to Left. RightClickHighlight
co-exists at a lower z (50) than the radial overlay (Z_RADIAL_MENU
= 60), so the brief pile-marker tint reads as the same set of legal
destinations the radial offers.
Cancel paths: release the right button outside any icon, press Esc,
or press the left button. All three reset state to Idle without
dispatching a move.
Visual: a centre dot at the press location plus N icons at radius
80 px around it. For one destination the icon sits at 12 o'clock;
for N icons they spread evenly clockwise. Hovered icon scales to
1.15× and tints STATE_SUCCESS so the focused choice is unambiguous.
Twelve new tests pin the contract — five system-level (open on
press over face-up card, release over destination fires move event,
release in dead space cancels, Esc cancels, face-down doesn't
open), seven on the pure helpers (radial_anchor_for_index,
radial_hovered_index, legal_destinations_for_card). Tests inject
cursor positions through a RadialCursorOverride resource so they
work under MinimalPlugins where there's no PrimaryWindow or Camera.
help_plugin's controls reference gains a new "Mouse" section
covering double-click auto-move, right-click highlight, and the
new "Hold RMB" radial. Onboarding slide 3 is intentionally left
keyboard-only — the radial is a power-user discovery, not a
first-run teach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.
A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:
Idle (Tab/Enter/auto-move via SelectionState)
Lifted {
source_pile, count, cards,
legal_destinations, pre-computed at lift time via
destination_index, can_place_on_foundation/_tableau
}
Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.
The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.
help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.
Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pressing M already opens the Home modal (which is the Mode Launcher
post-v0.11) and Tab cycles focus through the cards. The remaining
gap was direct keyboard activation of a specific mode — players had
to tab-and-enter or click. A new modal-scoped digit handler closes
that gap:
1 → Classic (NewGameRequestEvent)
2 → Daily Challenge (StartDailyChallengeRequestEvent)
3 → Zen (StartZenRequestEvent, gated at level 5)
4 → Challenge (StartChallengeRequestEvent, gated at level 5)
5 → Time Attack (StartTimeAttackRequestEvent, gated at level 5)
handle_home_digit_keys runs only when HomeScreen exists and short-
circuits otherwise — the digit keys can't accidentally launch a
mode mid-game. Locked modes (level < CHALLENGE_UNLOCK_LEVEL) silent-
no-op rather than firing a toast, mirroring the click-on-locked-card
behaviour without the InfoToastEvent (the click path's toast is the
authoritative "level too low" surface).
The HomePlugin Update tuple is now .chain()ed because the Bevy 0.18
parallel scheduler would otherwise let handle_home_card_click,
handle_home_cancel_button, and the new digit handler all queue a
HomeScreen despawn concurrently — the second buffer apply panics
on the already-despawned entity.
help_plugin gains a new "Mode Launcher (M)" section with the digit
rows and a level-5 unlock note. onboarding's slide-3 hotkey table
gets one new line ("M — Open Mode Launcher (then 1-5 to pick)") so
first-run players see the full path. The help-modal canonical list
now mirrors the onboarding teach.
Four new headless tests pin the contract: Digit1 launches Classic
and closes the modal; Digit3 at level 0 is a no-op (modal stays
open); Digit3 at unlock level launches Zen and closes; digit keys
outside the modal fire no events at all.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Help modal previously used "Close" while the other five overlay
modals (Home, Stats, Achievements, Settings, Profile, Leaderboard)
used "Done"; standardising on "Done" removes the outlier.
The final onboarding slide changes from "Start playing" to
"Let's play". The microcopy audit suggested matching the win modal's
"Play Again", but that verb is semantically wrong on first launch —
the player has not yet played. "Let's play" reads warmer and matches
the project's Balatro-tone direction without overloading "Play Again"
across two contexts that mean different things.
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>
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>
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>
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>
Pressing N during an active game (move_count > 0, not won) now shows a
"Press N again to start a new game" toast and only starts a new game if
N is pressed a second time within 3 seconds. Starting a fresh game or
pressing N after a win still acts immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The help overlay only listed S and H; added Achievements (A),
Leaderboard (L), and Settings (O) which were already implemented
but undocumented in the cheat sheet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- solitaire_data::Settings { sfx_volume, first_run_complete } with
atomic JSON persistence and clamping sanitizer.
- SettingsPlugin (engine): [ / ] adjust SFX volume by 0.1, clamped;
persists on change; emits SettingsChangedEvent. No-op at rails.
- AudioPlugin applies sfx_volume to kira's main track at startup
and on every change so live tweaks take effect without restart.
- Brief "SFX: N%" toast on each change. Help cheat sheet updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- New MoveRejectedEvent fires from end_drag when the cursor is over
a real pile but the placement is illegal. AudioPlugin plays
card_invalid.wav on it.
- New PausePlugin + PausedResource: Esc toggles a full-window
overlay and the flag. tick_elapsed_time and advance_time_attack
skip work while paused. Help cheat sheet updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- HelpPlugin: full-window cheat sheet listing every keybinding,
toggled with H or ?. Three unit tests cover open/close/slash.
- AnimationPlugin: ChallengeAdvancedEvent now surfaces as a
3-second "Challenge N cleared!" toast.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>