Compare commits

...

19 Commits

Author SHA1 Message Date
funman300 60a80369d4 docs: rewrite SESSION_HANDOFF for completed Phase 4 release-prep state
CI / Test & Lint (push) Failing after 21s
CI / Release Build (push) Has been skipped
The handoff document was written mid-overhaul during Phase 3 / early
Phase 4 and accumulated stale "in-flight" sections, recovery
instructions for a long-completed background agent, and a 7-track
speculative list of future directions that no longer matches reality.
This rewrite halves the document (284 → 142 lines) and reorients it
toward the actual current state: Phase 3 and Phase 4 shipped, every
substantial release-readiness thread landed, working tree clean.

Replaced the pause-state recovery section, original prompt blocks,
and Phase 3 smoke-test checklist with a compact table summarizing
what each Phase 4 commit landed, a chronological commit list as an
audit trail, and a five-item punch list scoping what's left for v1
(xCards URL, smoke test, push, tag v0.1.0, optional polish).

The resume prompt at the end is rewritten to orient a future agent
toward release prep — push, tag, package — rather than ongoing UX
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:55:30 +00:00
funman300 dbe6c60133 feat(engine): tooltips on Modes and Menu popover rows
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>
2026-04-30 23:55:20 +00:00
funman300 74597a8c84 feat(engine): tooltips on every Settings panel control
Eleven Settings controls — volume up/down for SFX and music, the four
toggle pills (draw mode, animation speed, theme, color-blind), the
two picker rows (card backs, backgrounds), and Sync Now — each gain a
one-sentence tooltip in the established Balatro voice. Static labels,
section headers, and live value readouts are intentionally skipped:
they are not interactive and the action button beside each describes
the action.

icon_button, volume_row, toggle_row, and picker_row gain
&'static str tooltip parameters so the tooltip is required at the
spawn site rather than retrofittable later. The Done button stays
tooltip-free (its label and Esc-equivalent affordance speak for
themselves at a modal-action position).

settings_buttons_carry_tooltip locks down the contract: every
SettingsButton outside the modal Done button has a Tooltip, and
SyncNow's tooltip text is asserted exactly to pin the canonical
microcopy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:55:10 +00:00
funman300 5d57b67934 feat(engine): branded splash screen on launch
The window previously snapped straight to a card deal, which read more
like a prototype than a finished game. SplashPlugin lays a fullscreen
overlay (BG_BASE backdrop, ACCENT_PRIMARY title, version subtitle) on
top of the gameplay layer for MOTION_SPLASH_TOTAL_SECS — the board
deals behind it so the splash dissolve hands off naturally to the
deal animation.

Visibility curves through fade-in (300ms), hold (~1s), fade-out
(300ms) using a pure splash_alpha helper that gets pinned by a unit
test rather than wired to the Bevy clock — Time<Virtual>'s 250ms
per-tick clamp makes float-tight alpha assertions around the fade
boundary brittle.

Any keystroke or mouse-button press jumps the age forward to the
fade-out window so the splash dissolves immediately. The dismiss
handler is read-only on ButtonInput / Touches, so the same press is
still visible to gameplay handlers downstream — pressing Space on the
splash both dismisses it and triggers the next-tick stock draw, as
verified by dismissal_keypress_is_visible_to_other_systems.

Z_SPLASH sits above every other UI rung (Z_TOAST + 100) so the splash
owns the viewport for its brief lifetime. The hierarchy test was
extended to enforce the new rung's monotonic position.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:31:13 +00:00
funman300 220e3f040c feat(engine): tooltips on every HUD readout and action button
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>
2026-04-30 23:13:50 +00:00
funman300 54d34972d4 feat(engine): tooltip infrastructure with hover delay (foundation only)
A new ui_tooltip module owns a Tooltip(Cow<'static, str>) component
that turns any UI node into a hover-revealing help target. Bevy 0.18's
required-components attribute auto-inserts an Interaction so callers
just attach Tooltip and the rest is wired.

A single overlay entity is reparented above the focus ring (new
Z_TOOLTIP token = Z_FOCUS_RING + 10) and tracked from the hovered
target's GlobalTransform + ComputedNode. The chained Update systems
start a hover timer on Interaction::Hovered, show the overlay once
MOTION_TOOLTIP_DELAY_SECS (0.5s) has elapsed, hide it the moment hover
ends, and refresh the text when the hover target switches without an
intervening unhover.

Tested headless under MinimalPlugins with a 200ms ManualDuration
ticker — Bevy clamps Time<Virtual>'s max_delta to 250ms by default, so
a one-shot 1s step doesn't actually advance the clock past the
threshold; the tests step five times to exercise both pre- and
post-delay invariants.

This commit ships the infrastructure only — no entity in the engine
has Tooltip attached yet. A follow-up applies tooltips to the HUD
readouts and action bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:03:43 +00:00
funman300 0c86cac2d5 feat(engine): unify destructive-confirm verbs — drop "Yes," prefix
Both confirm modals previously used a "Yes, <verb>" pattern that read
like a question-and-answer dialog ("Are you sure? Yes, forfeit"); the
canonical UX pattern for a destructive confirm is just the bare verb.

The Confirm New Game modal's primary button is now "New game" instead
of "Yes, abandon" — matching the verb the user originally clicked
and framing the action positively rather than as a loss.

The Forfeit Confirm modal's primary button is now "Forfeit" instead
of "Yes, forfeit" — same pattern, less ceremony.

The Pause menu's own Resume / Forfeit buttons are unchanged: it's an
action menu, not a destructive confirm, and bare verbs are already
correct there.

Two doc comments and the ui_modal.rs spawn_modal_button example
docstring are updated to reflect the new copy. Marker symbol names
(ConfirmYesButton, ForfeitConfirmButton) are kept to avoid
unnecessary churn — the rename would ripple into mouse-input handlers
without a matching user-visible benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:45:19 +00:00
funman300 2e080d02ce test(engine): integration coverage for draw_three_master and zen_winner
Closes the audit gap: the two achievements that previously had only
unit-level condition tests now also have full-flow tests that fire a
GameWonEvent and assert the unlock state through the same plugin
ordering production uses (update_stats_on_win runs before
evaluate_on_win, so the freshly bumped stat is visible to the
condition closure).

Four tests, headless under MinimalPlugins:
- draw_three_master_fires_on_tenth_draw_three_win — pre-seed 9 wins,
  fire a Draw3 win, assert unlock
- draw_three_master_does_not_fire_at_nine_wins — pre-seed 8, fire a
  Draw3 win bumping to 9, assert still locked
- zen_winner_fires_on_zen_mode_win — Zen-mode win unlocks the badge
- zen_winner_does_not_fire_for_classic_win — Classic win in same
  fixture leaves it locked

After this commit every advertised achievement has an integration
test that exercises the production unlock path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:28:32 +00:00
funman300 73e210b243 docs: replace bevy_kira_audio references with kira in ARCHITECTURE.md
§3, §5, and §13 all referenced bevy_kira_audio as the audio
dependency, but the workspace Cargo.toml has used kira 0.12 directly
since the kira-direct migration. Four mentions updated so the
architecture document matches the actual dependency graph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:16:59 +00:00
funman300 f866299021 docs: drop xCards URL placeholder from CREDITS.md
The textual attribution to Huub de Beer / xCards / LGPL-3.0 already
satisfies the LGPL's notice requirement on its own; the unfilled
URL placeholder was the only TODO left in the file. Removed rather
than guessed — a confirmed upstream URL can be added in a follow-up
when the project owner decides which xCards mirror or fork to point
at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:12:31 +00:00
funman300 b78a493a0c feat(engine): keyboard focus on Settings panel with arrow-key pickers (Phase 3)
Settings was the last mouse-only surface in the engine. Phase 3 closes
that gap and finishes the keyboard-focus rollout.

Every interactive button in the settings panel — icon buttons (32px
volume, draw mode, color blind, sync now), swatch pickers (5 card
backs, 5 backgrounds), and toggle pills — now opts into Focusable via
a single ancestry-walking system that mirrors the Phase 1/2 pattern.
The Done button continues to be auto-tagged through the modal path.

The two picker rows gain a new FocusRow marker. Inside a FocusRow,
Left/Right arrow keys cycle the swatches (skipping Disabled, wrapping
at endpoints) while Tab/Shift-Tab still escape to the next section's
focusable. Outside a FocusRow, arrow keys are explicit no-ops.

scroll_focus_into_view runs after the focus overlay updates and
adjusts the SettingsPanelScrollable container's ScrollPosition when
the focused button sits outside the visible viewport, with a
SPACE_2 padding so the focus ring never gets clipped at the
viewport edge. The system is a no-op when layout hasn't computed yet,
so headless tests are unaffected.

After Phase 3 every interactive UI element in the engine is
keyboard-navigable: modals (Phase 1), HUD action bar and Home mode
cards (Phase 2), Settings bespoke controls and picker rows (Phase 3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:10:43 +00:00
funman300 51d3454344 feat(engine): keyboard focus on HUD action bar and Home mode cards (Phase 2)
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>
2026-04-30 21:41:31 +00:00
funman300 12789529a1 feat(engine): keyboard focus rings on modal buttons (Phase 1)
Every button spawned via spawn_modal_button is now keyboard-navigable.
Tab/Shift-Tab cycles focus within the active modal, Enter activates
the focused button via the same Interaction::Pressed signal mouse
clicks use, and the primary action auto-focuses on modal open. Mouse
clicks transfer focus so the two input modes stay in sync.

The visual indicator is a single overlay entity that's reparented
above the topmost modal scrim and tracks the focused button's
GlobalTransform + ComputedNode each frame. Sitting outside the
modal-card subtree means the ring isn't affected by the open
animation's 0.96→1.0 scale, and sitting outside any scroll container
means it can't be clipped by Settings' Overflow::scroll_y. Z-order
sits one rung above Z_MODAL_TOP via the new Z_FOCUS_RING token.

Existing 11 modals (Help, Stats, Achievements, Settings, Profile,
Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new game,
Onboarding, Home) get focus support without any call-site changes —
attach_focusable_to_modal_buttons walks the ancestry of any
ModalButton lacking Focusable to find its scrim and tags it
automatically. selection_plugin's Tab handler keeps working when no
modal is open; when one is, focus consumes Tab/Enter before the
selection system sees them.

Phase 1 scope only — HUD action bar, Home mode cards, and Settings
bespoke buttons (icon, swatch, toggle) come in Phase 2/3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:17:25 +00:00
funman300 c1bde18a2c feat(engine): repurpose Home as mode launcher
The Home modal was previously a keyboard-shortcut reference card that
mostly duplicated Help. It now opens directly into a Mode Launcher:
five mode cards (Classic, Daily Challenge, Zen, Challenge, Time
Attack) stacked vertically with a Cancel button at the bottom.

Each card dispatches the canonical request event already used by the
HUD modes-popover (NewGameRequestEvent, StartDailyChallengeRequestEvent,
StartZenRequestEvent, StartChallengeRequestEvent,
StartTimeAttackRequestEvent), so level gates, daily-seed lookup, and
session setup all flow through the existing handlers — Home is just
another entry point.

The three modes that unlock at level 5 (Zen, Challenge, Time Attack)
render with reduced opacity and a "Reach level 5 to unlock" caption
when locked; clicking a locked card is a deliberate no-op so the
player can pick a different mode without dismissing the modal.

The keyboard-shortcut reference is dropped entirely — Help (F1) still
covers it. M continues to toggle the modal open and closed.

Adds 5 new headless tests covering card spawn, locked-state click,
unlocked-state click, Classic launch + close, and Cancel close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:39:26 +00:00
funman300 fd7fb7b6da docs: add CREDITS.md and link from README
Lists the project's third-party assets and major dependencies with
their licenses for v1 release readiness:
- xCards @2x card artwork and back_0 (LGPL-3.0)
- FiraMono-Medium font (OFL)
- Bevy 0.18, kira 0.12, axum, sqlx, tokio, and the rest of the
  ten most prominent Rust deps (MIT/Apache-2.0 across the board)

Generated assets — card backs 1-4, all backgrounds, and every WAV
file — are credited as original work produced by the project's own
solitaire_assetgen pipeline; the audio synthesis stack and the
absence of any external sample sources are documented.

The README gains a brief Credits section linking to the full list.

Note: the upstream xCards source URL is left as a placeholder pending
confirmation; downstream license obligations are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:36:42 +00:00
funman300 138436558f feat(engine): leaderboard error and idle states plus local-only guard
LeaderboardResource was a tuple struct of Option<Vec<Entry>>: None for
pre-fetch and empty Vec for both "actually empty" and "fetch failed"
— the user couldn't tell a network error from a legitimately quiet
leaderboard. The resource is now a four-state enum (Idle / Error /
Loaded), with Loaded covering both populated and empty rows. A
transient error no longer wipes a previously populated list, and the
panel renders "Couldn't reach the leaderboard. Try again later."
when the most recent fetch failed.

The Opt In / Opt Out buttons used to render unconditionally and
silently no-op under LocalOnlyProvider. The panel now reads the
SyncProviderResource backend name and, when no remote is configured,
replaces the buttons with a single line directing the player to
configure cloud sync in Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:18:34 +00:00
funman300 65d595ad12 feat(engine): first-launch polish — em-dash zero stats and welcome line on profile
On first launch the Stats grid previously mixed "0" cells (Games
Played / Won / Lost) with "—" cells (Best Score / Win Rate / Avg
Time), reading as inconsistent. Now every cell renders an em-dash
when games_played == 0, and a "Play a game to start tracking stats."
caption sits above the grid using the existing TYPE_CAPTION /
TEXT_SECONDARY tokens. Once a game has been played the original
formatters resume.

The Profile screen gains a one-line welcome ("Welcome! Play games to
earn XP and unlock achievements.") that renders only when both
total_xp and the daily streak are zero, breaking up the wall of
zero-valued readouts that greeted users on first launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:18:14 +00:00
funman300 abeb4e5cdf feat(engine): unify dismiss verb to Done and warm onboarding CTA to Let's play
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>
2026-04-30 20:18:02 +00:00
funman300 b082bd65a6 feat(engine): bump icon-button hit target to 32px and clarify local-only sync status
ICON_BUTTON_PX moves from 28 to 32 to clear the desktop hit-target
threshold. The change is self-contained: icon buttons are centered in
flex rows whose neighbours retain their alignment, and the swatch
buttons (40px) still dominate the visual hierarchy.

The settings sync status fallback string changes from "Status: not
configured" to "Status: local only" so users running without a remote
backend read it as a deliberate choice rather than incomplete setup.
The other status strings (Idle / Syncing / LastSynced / Error) flow
from sync_status_label and are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 20:17:22 +00:00
22 changed files with 4591 additions and 474 deletions
+4 -4
View File
@@ -133,7 +133,7 @@ Owns:
- `SyncProvider` trait — implemented by `SolitaireServerClient`
### `solitaire_engine`
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
**Dependencies:** `bevy`, `kira`, `solitaire_core`, `solitaire_data`.
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
@@ -246,7 +246,7 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
| `AudioPlugin` | — | Sound effect and music playback via kira |
| `InputPlugin` | — | Keyboard and mouse input routing |
| `CursorPlugin` | — | Custom cursor sprite during drag |
| `SelectionPlugin` | — | Keyboard-driven card selection |
@@ -754,7 +754,7 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
## 13. Audio System
Audio uses `bevy_kira_audio`. All sound files are `.wav`.
Audio uses `kira`. All sound files are `.wav`.
| File | Trigger |
|---|---|
@@ -765,7 +765,7 @@ Audio uses `bevy_kira_audio`. All sound files are `.wav`.
| `win_fanfare.wav` | Game won |
| `ambient_loop.wav` | Looping background music |
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `kira` channel volumes.
Audio systems listen for Bevy events and never block the game thread.
+104
View File
@@ -0,0 +1,104 @@
# Credits
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
the work of many open-source projects and a small handful of third-party
assets. This file lists every component that ships in the binary or in the
`assets/` directory.
---
## Code & Framework
| Component | License | Role |
|---|---|---|
| [Bevy 0.18](https://bevyengine.org/) | MIT OR Apache-2.0 | Game engine, ECS, rendering, UI |
| [kira 0.12](https://crates.io/crates/kira) | MIT OR Apache-2.0 | Audio playback (mixer, sub-tracks, looping ambient) |
| [serde](https://crates.io/crates/serde) / [serde_json](https://crates.io/crates/serde_json) | MIT OR Apache-2.0 | Serialization for save files and the sync API |
| [tokio](https://crates.io/crates/tokio) | MIT | Async runtime for the sync client and server |
| [axum 0.8](https://crates.io/crates/axum) | MIT | HTTP framework for the self-hosted sync server |
| [sqlx 0.8](https://crates.io/crates/sqlx) | MIT OR Apache-2.0 | Compile-time-checked SQLite access on the server |
| [reqwest 0.13](https://crates.io/crates/reqwest) | MIT OR Apache-2.0 | HTTP client for the sync provider |
| [jsonwebtoken 10](https://crates.io/crates/jsonwebtoken) | MIT | JWT issuance and validation |
| [bcrypt 0.19](https://crates.io/crates/bcrypt) | MIT | Password hashing on the server |
| [keyring 4](https://crates.io/crates/keyring) | MIT OR Apache-2.0 | OS keychain integration for credential storage |
| [tower-governor 0.8](https://crates.io/crates/tower-governor) | MIT | Rate limiting on `/api/auth/*` |
| [chrono](https://crates.io/crates/chrono) | MIT OR Apache-2.0 | Date / time handling |
| [uuid](https://crates.io/crates/uuid) | MIT OR Apache-2.0 | User and session identifiers |
| [thiserror](https://crates.io/crates/thiserror) | MIT OR Apache-2.0 | Error type derive |
| [rand 0.9](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Seeded shuffler in `solitaire_core` |
| [png 0.17](https://crates.io/crates/png) | MIT OR Apache-2.0 | PNG encoder used by `solitaire_assetgen` |
| [ab_glyph 0.2](https://crates.io/crates/ab_glyph) | Apache-2.0 | Glyph rasterization for generated card art |
The full transitive dependency tree (several hundred crates) is captured in
`Cargo.lock` and reachable via `cargo tree`. Every crate brought in is
MIT, Apache-2.0, BSD-style, or a dual-licensed combination thereof — no
copyleft code is statically linked into the game binary.
---
## Assets
### Card artwork
| File(s) | Source | License |
|---|---|---|
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | xCards @2x artwork | LGPL-3.0 |
| `assets/cards/backs/back_0.png` (bicycle_blue) | xCards @2x artwork | LGPL-3.0 |
| `assets/cards/backs/back_1.png` `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
xCards is the playing-card artwork bundle by Huub de Beer, published under the
LGPL-3.0. The art is consumed as unmodified PNG files at runtime; the game
binary statically links no LGPL code, so distribution as a self-contained
binary plus the `assets/` directory satisfies the LGPL's relinking clause.
### Backgrounds
| File(s) | Source | License |
|---|---|---|
| `assets/backgrounds/bg_0.png` `bg_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
### Typography
| File | Source | License |
|---|---|---|
| `assets/fonts/main.ttf` (FiraMono-Medium) | [mozilla/Fira](https://github.com/mozilla/Fira) | SIL Open Font License 1.1 |
The OFL permits redistribution and embedding in software so long as the font
file itself is not sold standalone. The file ships unmodified.
### Audio
All six WAV files in `assets/audio/` are **original work** — there are no
third-party audio samples in this project. They are synthesized
programmatically by `solitaire_assetgen/src/bin/gen_sfx.rs`, which writes
44.1 kHz mono 16-bit PCM WAVs using a hand-rolled WAV writer (no `hound` or
`dasp` dependency). The synthesis stack is entirely additive: sine /
square waves, layered harmonics, deterministic LCG noise, AR envelopes,
and a slow LFO for the ambient track.
| File | Synthesis approach |
|---|---|
| `card_deal.wav` | Filtered LCG noise with a sweeping low-pass cutoff for a "whoosh" |
| `card_flip.wav` | High-passed LCG noise under a fast AR envelope |
| `card_place.wav` | 120 Hz sine body + filtered noise click |
| `card_invalid.wav` | Two dissonant square tones (196 Hz + 207.65 Hz) beating against each other |
| `win_fanfare.wav` | C-major arpeggio (C5 / E5 / G5 / C6) with sine + 2nd harmonic |
| `ambient_loop.wav` | 55 Hz fundamental with 2nd and 3rd harmonics, modulated by a 0.2 Hz LFO; loop length is chosen so the tone and LFO both complete an integer number of cycles for seamless looping |
Audio files are MIT-licensed alongside the rest of this project.
---
## License Summary
- **Project code:** MIT — see [LICENSE](LICENSE).
- **xCards card artwork (52 faces + `back_0.png`):** LGPL-3.0, redistributed
unmodified. The LGPL applies only to those PNG files; it does not extend to
the game binary, which links no LGPL code.
- **FiraMono-Medium font:** SIL Open Font License 1.1, redistributed unmodified.
- **All other assets** (backgrounds, generated card backs, every audio file)
are original work covered by this project's MIT license.
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
`LICENSE` file alongside the binary so the LGPL and OFL notices remain
visible to end users.
+8
View File
@@ -68,6 +68,14 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
cargo clippy --workspace -- -D warnings
```
## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
Axum, sqlx, Serde, kira, and many more). Card faces and the default card back
use xCards artwork (LGPL-3.0); the UI font is FiraMono-Medium (OFL). All audio
is synthesized programmatically by this project. See [CREDITS.md](CREDITS.md)
for the full list and license details.
## License
MIT — see [LICENSE](LICENSE).
+103 -245
View File
@@ -1,284 +1,142 @@
# Solitaire Quest — UX Overhaul Session Handoff
**Last updated:** 2026-04-30 — Phase 3 complete + Phase 4 in progress (Track B landed on disk, Track G subset in flight via background agent).
**Last updated:** 2026-04-30 — Phase 3 complete + Phase 4 polish landed. v1 release-readiness scope is largely done; remaining work is final smoke test, push, and tag.
## ⚠️ In-progress work at pause time
## Status at pause
Smoke-test passed; Phase 4 was started. Pushed HEAD is `534870a`. The working tree has **uncommitted** work that is NOT pushed:
- **HEAD:** `5d57b67` — local master is **16 commits ahead of `origin/master`** (unpushed).
- **Working tree:** modified but uncommitted edits in `solitaire_engine/src/hud_plugin.rs` and `solitaire_engine/src/settings_plugin.rs` — an in-flight tooltip-popover extension threaded onto the Settings sliders/togglers/pickers. Not staged, not built against; review and finish-or-revert before resuming new work.
- **Build:** `cargo build --workspace` and `cargo clippy --workspace -- -D warnings` clean as of last commit.
- **Tests:** **872 passed / 0 failed / 9 ignored** across the workspace.
### Track B — window polish (on disk, ready to commit)
## Where we are
- **File:** `solitaire_app/src/main.rs` (+44 lines)
- **What landed:**
- X11/Wayland WM_CLASS via `Window::name = Some("solitaire-quest".into())`
- Default position `WindowPosition::Centered(MonitorSelection::Primary)`
- `install_crash_log_hook()` wraps the default panic hook to also append a `crash.log` next to `settings.json`. Uses `std::time::SystemTime` (no new chrono dep). Falls through silently if the data dir is unavailable.
- **Skipped this round (deferred):**
- App icon hookup — no artwork asset exists yet; add the loader path when art lands.
- Persisted window geometry — needs a `Settings` schema migration.
- F11 fullscreen toggle — already wired in `input_plugin.rs:114`, no change needed.
- **Build status:** `cargo build -p solitaire_app` clean; `cargo clippy -p solitaire_app -- -D warnings` clean.
- **Suggested commit subject:** `feat(app): window polish — class name, centered position, crash-log hook`
Phase 3 of the UX overhaul (design tokens, modal scaffold, animation curves) shipped earlier in the session and is unchanged. Phase 4 (release-grade polish) layered another 22 commits on top: window polish, modal animation, score feedback, three phases of focus rings, Home repurposed as a mode launcher, tooltip infrastructure + HUD wiring, branded splash screen, achievement integration tests, microcopy unification, leaderboard error/idle states, first-launch empty-state polish, hit-target accessibility fix, CREDITS.md, ARCHITECTURE doc-rot fix.
### Track G subset — modal open animation + score-change feedback (in flight)
- A **background agent** (`general-purpose`, no worktree) was launched against this turn's tree to:
- Extend `spawn_modal` in `solitaire_engine/src/ui_modal.rs` with a `ModalEntering` component + `advance_modal_enter` system that animates scrim alpha 0 → `SCRIM` and card scale 0.96 → 1.0 over `MOTION_MODAL_SECS`. Respects `AnimSpeed::Instant` via `scaled_duration`. Animate-OUT path is intentionally out of scope.
- In `solitaire_engine/src/hud_plugin.rs`, add a `ScorePulse` 1.0→1.1→1.0 readout pulse over `MOTION_SCORE_PULSE_SECS` and a floating "+N" Text2d (only for ≥ +50 jumps) that drifts up ~40 px and fades over `MOTION_SCORE_PULSE_SECS * 2`.
- Tests for both behaviours.
- **State at pause:** the agent had partial edits in `solitaire_engine/src/ui_modal.rs` (visible via `git status`) — at least one unused-import warning was already surfacing. It had not reported back when this snapshot was taken.
- **Resume options for the next session:**
1. **Wait for the notification.** The agent runs in background; if Claude Code is still alive, the completion notification will fire.
2. **Inspect and finish manually.** `git diff solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` to see what landed; finish or revert and restart with a tighter prompt.
3. **Discard and restart.** `git restore solitaire_engine/src/ui_modal.rs solitaire_engine/src/hud_plugin.rs` then relaunch the agent with the prompt below.
### Next-session workflow at pause
1. Verify the workspace builds cleanly with **all** in-flight changes: `cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo test --workspace`. The Track B `main.rs` change is independent — even if Track G is reverted, B compiles on its own.
2. If Track B is clean and Track G is incomplete or broken: commit Track B first using the subject above, then deal with Track G.
3. If both are clean: commit each as a separate landing — one feature per commit per project convention.
4. Use:
```
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "<subject>"
```
5. Push with `git push origin master` (requires interactive credentials on `git.aleshym.co`).
### Original Track G subset prompt (for relaunch if needed)
The agent's full brief is preserved here verbatim — paste into a fresh agent if the current one is unrecoverable:
```
Two UI/UX polish items from track G. Tree clean at HEAD `534870a`.
Sub-agents CANNOT git commit — stage your work; orchestrator commits.
G1. Modal open animation: extend spawn_modal in ui_modal.rs with a
ModalEntering component + advance_modal_enter system that animates
scrim alpha 0 → SCRIM and card scale 0.96 → 1.0 over MOTION_MODAL_SECS.
Use scaled_duration for AnimSpeed respect; ease-out curve t*(2-t).
Register the system in UiModalPlugin::build. Animate-OUT is OUT of
scope. Add ≥2 tests covering ModalEntering presence on spawn and
removal after duration elapses.
G2. Score-change feedback in hud_plugin.rs: ScorePulse component that
scales the score Text 1.0→1.1→1.0 over MOTION_SCORE_PULSE_SECS using
triangular curve. Plus a floating "+N" Text2d (only for ≥ +50 jumps)
in ACCENT_PRIMARY that drifts up 40 px and fades over
MOTION_SCORE_PULSE_SECS * 2. Add ≥2 tests for floater spawn on +50
and despawn after lifetime, plus ≥1 test that +5 does NOT spawn.
Hard requirements: workspace build + clippy --workspace -- -D warnings
+ test --workspace all green. Touch ONLY ui_modal.rs, hud_plugin.rs,
optionally ui_theme.rs for new tokens (don't think you'll need any).
DO NOT touch solitaire_app/src/main.rs (parallel work).
```
---
## Where we are (Phase 3)
Phase 3 of the UX overhaul brief is **done**. The whole engine has been migrated to the `ui_theme` design-token system + `ui_modal` scaffold. Animation system upgraded. Final literal sweep landed. The work spans 17 commits this session, from the foundation (`e14852c`) through to the final sweep (`54e024c`).
### Design direction (already saved as project memory)
### Design direction (unchanged)
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
- **Palette:** Midnight Purple base (`BG_BASE` `#1A0F2E` → `BG_ELEVATED` `#2D1B69` → `BG_ELEVATED_HI` `#3A2580` → `BG_ELEVATED_TOP` `#482F97`) + Balatro yellow primary accent (`ACCENT_PRIMARY` `#FFD23F`) + warm magenta secondary (`ACCENT_SECONDARY` `#FF6B9D`).
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for the full direction.
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
- See [memory/project_ux_overhaul_2026-04.md](.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md) for full direction.
### Top complaints from the original smoke test — all closed
## Phase 3 (shipped)
1. **HUD too cluttered.** ✅ Closed by `73cad7e` — readouts now sit in a 4-tier vertical stack with progressive disclosure of penalty/bonus tiers.
2. **Y/N keyboard prompts feel like debug panels.** ✅ Closed across Confirm, GameOver, Pause, Forfeit, and Settings modals — every prompt now has real Primary/Secondary/Tertiary buttons with hover/press feedback.
- `solitaire_engine/src/ui_theme.rs` — every design token: colours, type scale, spacing scale, radius rungs, z-index hierarchy, motion durations.
- `solitaire_engine/src/ui_modal.rs``spawn_modal` scaffold + button-variant helpers + `paint_modal_buttons` system.
- All 12 overlays migrated to the modal scaffold with real Primary/Secondary/Tertiary buttons (no more Y/N debug prompts).
- HUD restructured into a 4-tier vertical stack with progressive disclosure.
- Animation upgrades: `SmoothSnap` slide curves, scoped settle bounce, deal jitter, win-cascade rotation.
## Foundation (done)
## Phase 4 (shipped this session)
- **`solitaire_engine/src/ui_theme.rs`** — every design token: colours, 5-rung typography scale, 4-multiple spacing scale, three radius rungs, monotonically-ordered z-index hierarchy, motion durations with `scaled_duration(speed)` helper.
- **`solitaire_engine/src/ui_modal.rs`** — `spawn_modal` scaffold + `spawn_modal_header` / `spawn_modal_body_text` / `spawn_modal_actions` / `spawn_modal_button` helpers + `ButtonVariant` enum (Primary / Secondary / Tertiary) + `paint_modal_buttons` system. `UiModalPlugin` registered in `solitaire_app/src/main.rs`.
| Area | Commit | What landed |
|---|---|---|
| Workspace lint | `9bfca92` | Test-only clippy warnings under `--all-targets` resolved. |
| App / window | `5f5aba8` | WM_CLASS, centered-on-primary window, panic hook → `crash.log`. |
| Modal animation | `71999e1` | `ModalEntering` + ease-out scrim fade and 0.96→1.0 card scale over `MOTION_MODAL_SECS`; `Instant` collapses to zero. |
| Score feedback | `dcfa976` | `ScorePulse` triangular 1.0→1.1→1.0; floating "+N" for jumps ≥ `SCORE_FLOATER_THRESHOLD`. |
| Hit targets | `b082bd6` | `ICON_BUTTON_PX` 28 → 32; settings sync status reads "local only" not "not configured". |
| Microcopy | `abeb4e5` | Help "Close" → "Done"; final onboarding CTA → "Let's play". |
| Empty states | `65d595a` | First-launch em-dash zero-stats grid + welcome line on Profile. |
| Leaderboard | `1384365` | Idle/Loaded/Error enum; local-only guard replaces opt-in/out buttons. |
| Credits | `fd7fb7b`, `f866299` | CREDITS.md added (xCards, FiraMono, Bevy, kira, Rust deps); README links it. |
| Home | `c1bde18` | Home repurposed as Mode Launcher: 5 mode cards, level-5 lock state, dispatches existing request events. |
| Focus rings (Phase 1) | `1278952` | Tab/Shift-Tab/Enter on every modal button; auto-focus primary; overlay tracks `GlobalTransform` above scrim. |
| Focus rings (Phase 2) | `51d3454` | HUD action bar (hover-gated) and Home mode cards. |
| Focus rings (Phase 3) | `b78a493` | Settings: icon buttons, swatches, toggles; arrow-key navigation in `FocusRow`; auto-scroll keeps focused control in viewport. |
| Achievement tests | `2e080d0` | Integration coverage for `draw_three_master` and `zen_winner` — every advertised achievement now has a full-flow unlock test. |
| Microcopy | `0c86cac` | Drop "Yes," prefix on destructive confirms — "New game" / "Forfeit" replace "Yes, abandon" / "Yes, forfeit". |
| Tooltip infra | `54d3497` | `Tooltip(Cow<'static, str>)` component, hover-delay overlay, `Z_TOOLTIP` rung. |
| Tooltip wiring | `220e3f0` | Tooltips on 10 HUD readouts + 6 action-bar buttons; `spawn_action_button` requires a tooltip parameter. |
| Splash | `5d57b67` | Branded splash overlay (fade-in 300ms / hold ~1s / fade-out 300ms); board deals behind; any keypress dismisses. |
| Doc-rot | `73e210b` | ARCHITECTURE.md `bevy_kira_audio` references → `kira` to match Cargo.toml. |
| Doc | `de52c8a` | Mid-session SESSION_HANDOFF refresh after first batch of Phase 4 landed. |
## Commits this session (Phase 3, latest first)
## Commits this session, chronological
```
54e024c chore(engine): final literal-to-token sweep
3a01318 feat(engine): upgrade animations — curves, scoped settle, deal jitter, cascade rotation
79d3917 chore(data): derive Copy on AnimSpeed
ba019c0 feat(engine): convert SettingsPanel to modal scaffold + Done button
18d7c12 feat(engine): convert OnboardingPlugin to 3-slide modal flow
cb93bd9 fix(engine): pin modals via GlobalZIndex and surface forfeit-no-op toast
6723416 feat(engine): convert PauseScreen to modal + add ForfeitConfirmScreen
afb0879 docs: add SESSION_HANDOFF.md mid-overhaul checkpoint
3b619b8 feat(engine): convert HomeScreen to modal scaffold + Done button
37681cf feat(engine): convert LeaderboardScreen to modal scaffold + Done button
99064ce feat(engine): convert ProfileScreen to modal scaffold + Done button
de4dba6 feat(engine): convert AchievementsScreen to modal scaffold + Done button
75fc3aa feat(engine): convert StatsScreen to modal scaffold + Done button
deb034c feat(engine): convert HelpScreen to real-button modal with kbd-chip rows
242b5fe feat(engine): convert GameOverScreen to real-button modal
3f922ed feat(engine): convert ConfirmNewGameScreen to real-button modal
8da62bd feat(engine): add ui_modal primitive (scaffold + button variants)
73cad7e feat(engine): restructure HUD into 4-tier layout, adopt design tokens
e14852c feat(engine): add ui_theme.rs design-token module
9bfca92 chore(workspace): satisfy clippy --all-targets in test code
5f5aba8 feat(app): window polish — WM_CLASS, centered window, crash log hook
71999e1 feat(engine): modal open animation — fade + scale with ease-out
dcfa976 feat(engine): score change feedback — pulse and floating delta
de52c8a docs: update SESSION_HANDOFF for completed phase-4 polish tracks
b082bd6 feat(engine): bump icon-button hit target to 32px and clarify local-only sync status
abeb4e5 feat(engine): unify dismiss verb to Done and warm onboarding CTA to Let's play
65d595a feat(engine): first-launch polish — em-dash zero stats and welcome line on profile
1384365 feat(engine): leaderboard error and idle states plus local-only guard
fd7fb7b docs: add CREDITS.md and link from README
c1bde18 feat(engine): repurpose Home as mode launcher
1278952 feat(engine): keyboard focus rings on modal buttons (Phase 1)
51d3454 feat(engine): keyboard focus on HUD action bar and Home mode cards (Phase 2)
b78a493 feat(engine): keyboard focus on Settings panel with arrow-key pickers (Phase 3)
f866299 docs: drop xCards URL placeholder from CREDITS.md
73e210b docs: replace bevy_kira_audio references with kira in ARCHITECTURE.md
2e080d0 test(engine): integration coverage for draw_three_master and zen_winner
0c86cac feat(engine): unify destructive-confirm verbs — drop "Yes," prefix
54d3497 feat(engine): tooltip infrastructure with hover delay (foundation only)
220e3f0 feat(engine): tooltips on every HUD readout and action button
5d57b67 feat(engine): branded splash screen on launch
```
**Test status:** `cargo build --workspace` clean, `cargo clippy --workspace -- -D warnings` clean, **819 tests pass / 0 failed / 8 ignored**.
(Phase 3 commits `e14852c` through `54e024c` and the prior handoff update `0066ca6` are already pushed — see git history for full audit trail.)
## Smoke-test checklist
## Open punch list for v1
The whole overhaul is on disk. Worth running through once end-to-end:
Polish is essentially complete. Concretely scoped follow-ups:
1. **Run the game.** `cargo run -p solitaire_app --features bevy/dynamic_linking`.
2. **HUD layout** reads as 4 stacked tiers (Score / Mode / Penalty / Selection) with the new midnight-purple palette.
3. **Open every overlay** — `S` (Stats), `A` (Achievements), `P` (Profile), `O` (Settings), `L` (Leaderboard), `M` (Home), `F1` (Help). Each is a centred card on a uniform scrim with a yellow `Done` / `Close` primary button. Hover/press states on every button.
4. **Settings.** Four sections (Audio / Gameplay / Cosmetic / Sync). Body scrolls within the modal on small windows; `Done` button stays fixed at the bottom regardless of scroll. Card-back / Background pickers tint the selected swatch with `STATE_SUCCESS`.
5. **Confirm flow.** Click `New Game` while a game is in progress — the abandon-current-game modal has real Cancel/Confirm buttons. `Y/Enter` and the yellow primary button start a new game; `N/Esc` and the secondary button cancel.
6. **Pause + Forfeit.** Press `Esc` — pause modal shows real Resume / Forfeit buttons. Forfeit button opens a Cancel/Forfeit confirmation modal stacked above the pause modal (z-index ordered correctly via `GlobalZIndex`).
7. **First-run onboarding.** Delete `settings.json` (or set `first_run_complete = false`) — three-slide flow shows: Welcome → How to play → Keyboard shortcuts. Navigate with `Next` / `Back` buttons or `` / `` accelerators. `Esc` skips on slide 0.
8. **Animations.**
- Slide a card to a pile — motion curves through `SmoothSnap` (slight overshoot + settle), not linear lerp.
- Drop a card on a valid destination — only the moved cards bounce; the rest of the table stays still.
- Start a new game — deal stagger is no longer mechanically uniform; cards land with subtle ±10% timing variation.
- Win a game — cascade now uses `Expressive` curve with per-card ±15° Z-rotation, screen shake driven by the new `MOTION_WIN_SHAKE_*` tokens.
9. **Resize the window** — cards still snap, no "snap-back-and-forth" jitter.
10. **Win modal** — restyled with the design tokens: midnight-purple card, yellow `Play Again` button.
1. **Smoke-test pass.** Run the game end-to-end with the original Phase 3 checklist plus the Phase 4 additions (splash dismiss, focus rings on every screen, tooltip hover, mode launcher, leaderboard error state, first-launch em-dashes).
2. **xCards upstream URL** in CREDITS.md is intentionally absent (`f866299`). One-line fill-in when the project owner picks a canonical mirror/fork; LGPL notice obligations are already satisfied without it.
3. **Push to origin.** Local master is 16 commits ahead of `origin/master`. `git push origin master` (interactive credentials on `git.aleshym.co`).
4. **Tag `v0.1.0`** once the smoke test passes and the push lands.
5. **Release packaging** per ARCHITECTURE.md §17 — Docker compose for the server is documented; desktop client packaging (icon, .ico/.icns, signing, AppImage) is not yet done.
## Open follow-ups (not blockers)
### Optional, deferred
- **Home / Help redundancy.** Home is still a kbd-reference modal that mostly duplicates Help. Three options: (1) keep as-is, (2) convert into a true mode launcher (Classic / Daily / Zen / Challenge / Time Attack cards, locked options visibly disabled below level 5), (3) drop entirely now that the action bar covers everything Home does. Worth asking the user which direction they want.
- **Forfeit countdown toast** is now superseded by the Forfeit modal (`6723416`). Confirm the toast path is no longer reachable when smoke-testing.
- **Sub-rung pixel sizes** (1 px borders, 64/80/110/150/160 px fixed widths, 28/36/50 px specific spacings) were intentionally left as literals during the step-10 sweep — they're below the smallest `SPACE_*` rung. If the design system grows a "fine" spacing tier in the future, those become candidates for migration.
- Animated focus ring (currently a static overlay; could pulse on focus change).
- Splash skip-on-subsequent-launches — currently every launch shows the full ~1.6s splash.
- Achievement onboarding pass — show first-time players the achievement panel after their first win.
- In-flight Settings tooltip popovers in the working tree — finish or revert.
## Resume prompt for the next session
## Resume prompt
```
You are a senior Rust + Bevy developer working toward a public release
of Solitaire Quest. Working directory: /home/manage/Rusty_Solitare.
Branch: master. Apply that lens to every decision: prefer shipping
quality (polish, packaging, defaults, credits, crash safety) over
greenfield features. If something is half-done, the question is
"finish for v1 or cut for v1?" not "what else can we add?".
You are a senior Rust + Bevy developer finishing v1 of Solitaire
Quest. Working directory: /home/manage/Rusty_Solitare. Branch:
master. Polish phase is complete; the remaining work is release prep,
not new features.
State: HEAD=0066ca6. Phase 3 of the UX overhaul is shipped. cargo
build / clippy --workspace -- -D warnings / test --workspace all
green — 819 tests pass / 0 fail / 8 ignored.
State: HEAD=5d57b67. Local master is 16 commits ahead of
origin/master and unpushed. Working tree has uncommitted in-flight
tooltip work in solitaire_engine/src/hud_plugin.rs and
solitaire_engine/src/settings_plugin.rs — review and finish or revert
before opening anything new.
Build: cargo build / clippy --workspace -- -D warnings clean as of
HEAD. Tests: 872 passed / 0 failed / 9 ignored.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — full state, smoke-test checklist, follow-ups
1. SESSION_HANDOFF.md — full state and punch list
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
3. ARCHITECTURE.md §1, §15, §17 — design principles, platform
targets, deployment guide
3. ARCHITECTURE.md §15, §17 — platform targets, deployment guide
4. ~/.claude/projects/-home-manage-Rusty-Solitare/memory/MEMORY.md
— saved feedback / project context
GATING SIGNAL — ASK FIRST, DON'T ASSUME:
Before proposing new work, ask: "Did the smoke-test (items 1-10 in
SESSION_HANDOFF.md) pass, or did anything regress?" If a regression
exists, fix it before opening any new thread.
LIKELY NEXT DIRECTIONS — surface for the user to choose, don't pick
unilaterally. All framed through "what does v1 release need?":
A. Home modal decision (open in SESSION_HANDOFF.md).
- keep as kbd-reference (duplicates Help — release-blocking
confusion?)
- repurpose as mode launcher (Classic / Daily / Zen / Challenge /
Time Attack cards, locked options below level 5)
- drop (action bar already covers every action)
B. Window + release polish — `solitaire_app/src/main.rs:34-48`
currently sets only title + resolution + min size. For public
release the window needs:
- app icon (taskbar / dock / alt-tab) — Bevy `Window::window_icon`
or platform `set_window_icon`; ship a .png/.ico asset.
- window class / app id (`Window::name`) so X11/Wayland and
Windows group taskbar entries correctly.
- persist size + position across launches (Settings already
saves to JSON; add `window_geometry` field).
- F11 (or a Settings toggle) wired to real fullscreen mode.
- centered default position on first launch (Bevy supports
`WindowPosition::Centered`).
- present_mode + vsync verification — make sure Linux/macOS
don't ship at uncapped 4000 fps.
- panic hook (`std::panic::set_hook`) that writes a crash
report next to the save files instead of silently exiting.
- macOS Info.plist / Windows .ico bundling — ARCHITECTURE.md
§17 currently only covers server deploy.
C. Sound-design audit. The scoped settle bounce (3a01318) means
audio_plugin.rs trigger sites may fire less often than before;
verify card_place / card_flip / card_invalid still feel right.
D. Sync flow end-to-end on a real second machine. Server
scaffolding exists but the register → push → pull → restore-on-
other-device round trip hasn't been exercised against the new
Settings sync section.
E. Achievement unlock completeness. ARCHITECTURE.md §11 lists 18.
The three hidden ones (speed_and_skill, comeback, zen_winner)
are most likely to be untested. For release, every advertised
achievement needs to actually fire.
F. Release-readiness backlog:
- README / store-page copy / screenshots
- LICENSE + third-party credits (xCards art, FiraMono, Bevy)
- SemVer + a v0.1.0 git tag
- itch.io / Steam packaging per platform (ARCHITECTURE.md §15)
- App signing — macOS notarization, Windows Authenticode,
Linux AppImage
- Telemetry / crash reporting — opt-in, off by default; or
confirm we ship without and rely on player reports
G. UI/UX professional polish — Phase 3 shipped the design system;
v1 wants the difference between "consistent" and "feels
intentional":
- Microcopy pass: every button label, empty state, error
message, and onboarding line reviewed for voice + clarity.
Pick one verb per concept ("Done" vs "Close" vs "OK") and
apply it everywhere.
- Empty / loading / error states: Leaderboard before any
scores, Stats before any games, Sync UI before login.
Today these are likely blank panels.
- Modal open/close animation: `MOTION_MODAL_SECS` token exists
in `ui_theme.rs:255` but isn't wired up — modals
appear/disappear instantly. Add scale-from-0.96 + scrim fade
per the token's doc comment.
- Tooltips on HUD readouts and settings labels. Bevy has no
built-in tooltip; build a small one. Hover a number to learn
what it counts.
- Accessibility: verify the AAA-contrast claim on
`ACCENT_PRIMARY` over `BG_BASE` (ui_theme.rs:65). Confirm
`AnimSpeed::Instant` disables every new animation (slide
curve, scoped settle, deal jitter, cascade rotation). Add
focus rings on `Button` entities for keyboard navigation.
- Typography choice: FiraMono is one weight, monospace for
everything. Consider shipping a second proportional face for
body + headings, keep mono for numerics (HUD score, timer).
Or commit to mono and lean into the "calm coder" feel — pick
deliberately and document the decision.
- Onboarding artwork: the 3 slides are text + buttons. For
release, stylised illustrations (or simple animated card
props on each slide) elevate the first-launch feel.
- Score-change feedback: floating "+N" numbers when score
jumps; pulse on the readout when value crosses a milestone.
`MOTION_SCORE_PULSE_SECS` is already a token.
- Splash / loading screen: today the window goes straight to
gameplay. A 1-2 second branded splash signals "real game"
vs "rust prototype".
- Hit-target audit: every interactive element ≥ 32 px on
desktop. Settings has 28 px icon buttons (`ICON_BUTTON_PX`
in settings_plugin.rs); revisit.
- Win-moment design: the cascade is good; consider a score-
breakdown reveal, streak callout, "share your time"
affordance for v1.
PUNCH LIST (resolve in roughly this order):
1. Decide on the in-flight settings_plugin/hud_plugin tooltip work.
2. Smoke-test the binary end-to-end. If anything regresses, fix it
before opening anything new.
3. Confirm or fill the xCards upstream URL in CREDITS.md.
4. git push origin master (16 commits unpushed; interactive creds).
5. Tag v0.1.0.
6. Release packaging per ARCHITECTURE.md §17 — desktop client icon,
bundling, signing are not yet wired.
WORKFLOW NOTES:
- Commits use:
git -c user.name=funman300 -c user.email=root@vscode.infinity commit -m "..."
- Sub-agents can Edit/Write but CANNOT `git commit`. Brief them to
stage + verify only; orchestrator commits on their behalf.
See memory/feedback_agent_commit_limit.md.
- Remote push needs interactive credentials on git.aleshym.co; the
user runs `git push origin master` themselves.
- Every commit must pass build / clippy / test. Pause-and-verify
is the user's preferred cadence — one feature per commit.
- Sub-agents stage + verify only; orchestrator commits.
- Every commit must pass build / clippy / test.
OPEN AT THE START: ask (1) did smoke-test pass, (2) which of AG to
pursue first. Do not assume.
OPEN AT THE START: ask which punch-list item to start on. Don't pick
unilaterally — release-readiness ordering is the user's call.
```
+5 -2
View File
@@ -10,8 +10,8 @@ use solitaire_engine::{
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiModalPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
fn main() {
@@ -99,6 +99,9 @@ fn main() {
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.run();
}
+167
View File
@@ -509,6 +509,173 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// draw_three_master integration
// -----------------------------------------------------------------------
#[test]
fn draw_three_master_fires_on_tenth_draw_three_win() {
let mut app = headless_app();
// Pre-seed nine prior Draw-Three wins. The pending GameWonEvent will
// trigger update_stats_on_win first (StatsUpdate runs before
// evaluate_on_win), bumping draw_three_wins to 10 — the unlock
// threshold for the draw_three_master achievement.
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 9;
// The current game must be in DrawThree mode so update_on_win
// increments draw_three_wins (and not draw_one_wins).
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 240,
});
app.update();
// Sanity-check that the win was actually attributed to Draw-Three so
// the achievement reads the correct counter.
let stats = &app.world().resource::<StatsResource>().0;
assert_eq!(stats.draw_three_wins, 10);
let unlocked = app
.world()
.resource::<AchievementsResource>()
.0
.iter()
.find(|r| r.id == "draw_three_master")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(unlocked, "draw_three_master must unlock at the 10th Draw-Three win");
// Verify the AchievementUnlockedEvent fired for this id.
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(
fired.contains(&"draw_three_master".to_string()),
"AchievementUnlockedEvent for draw_three_master must fire; got {fired:?}"
);
}
#[test]
fn draw_three_master_does_not_fire_at_nine_wins() {
let mut app = headless_app();
// Pre-seed eight prior Draw-Three wins. The pending GameWonEvent
// brings draw_three_wins to 9 — one short of the threshold.
app.world_mut().resource_mut::<StatsResource>().0.draw_three_wins = 8;
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.draw_mode = solitaire_core::game_state::DrawMode::DrawThree;
app.world_mut().write_message(GameWonEvent {
score: 500,
time_seconds: 240,
});
app.update();
let stats = &app.world().resource::<StatsResource>().0;
assert_eq!(stats.draw_three_wins, 9);
let unlocked = app
.world()
.resource::<AchievementsResource>()
.0
.iter()
.find(|r| r.id == "draw_three_master")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(!unlocked, "draw_three_master must remain locked at 9 Draw-Three wins");
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(
!fired.contains(&"draw_three_master".to_string()),
"draw_three_master must not fire below threshold; got {fired:?}"
);
}
// -----------------------------------------------------------------------
// zen_winner integration
// -----------------------------------------------------------------------
#[test]
fn zen_winner_fires_on_zen_mode_win() {
let mut app = headless_app();
// Put the active game in Zen mode. evaluate_on_win reads
// GameStateResource.mode directly to populate last_win_is_zen.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.mode = solitaire_core::game_state::GameMode::Zen;
app.world_mut().write_message(GameWonEvent {
score: 0,
time_seconds: 600,
});
app.update();
let unlocked = app
.world()
.resource::<AchievementsResource>()
.0
.iter()
.find(|r| r.id == "zen_winner")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(unlocked, "zen_winner must unlock when the game mode is Zen");
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(
fired.contains(&"zen_winner".to_string()),
"AchievementUnlockedEvent for zen_winner must fire; got {fired:?}"
);
}
#[test]
fn zen_winner_does_not_fire_for_classic_win() {
let mut app = headless_app();
// Default GameMode is Classic; assert and rely on it.
assert_eq!(
app.world().resource::<GameStateResource>().0.mode,
solitaire_core::game_state::GameMode::Classic
);
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let unlocked = app
.world()
.resource::<AchievementsResource>()
.0
.iter()
.find(|r| r.id == "zen_winner")
.map(|r| r.unlocked)
.unwrap_or(false);
assert!(!unlocked, "zen_winner must remain locked outside Zen mode");
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<String> = cursor.read(events).map(|e| e.0.id.clone()).collect();
assert!(
!fired.contains(&"zen_winner".to_string()),
"zen_winner must not fire on a Classic-mode win; got {fired:?}"
);
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
+2 -2
View File
@@ -214,7 +214,7 @@ fn handle_new_game(
}
}
/// Marker on the primary "Yes, abandon" button inside the confirm modal.
/// Marker on the primary "New game" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmYesButton;
@@ -265,7 +265,7 @@ fn spawn_confirm_dialog(
spawn_modal_button(
actions,
ConfirmYesButton,
"Yes, abandon",
"New game",
Some("Y"),
ButtonVariant::Primary,
font_res,
+3 -3
View File
@@ -20,7 +20,7 @@ use crate::ui_theme::{
#[derive(Component, Debug)]
pub struct HelpScreen;
/// Marker on the "Close" button inside the Help modal.
/// Marker on the "Done" button inside the Help modal.
#[derive(Component, Debug)]
pub struct HelpCloseButton;
@@ -56,7 +56,7 @@ fn toggle_help_screen(
}
}
/// Click handler for the modal's "Close" button. F1 toggles the overlay
/// Click handler for the modal's "Done" button. F1 toggles the overlay
/// the same way; this just exposes the close action to mouse / touch.
fn handle_help_close_button(
mut commands: Commands,
@@ -194,7 +194,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal_button(
actions,
HelpCloseButton,
"Close",
"Done",
Some("F1"),
ButtonVariant::Primary,
font_res,
+735 -129
View File
@@ -1,43 +1,160 @@
//! Toggleable main menu overlay showing the current game mode and a full
//! keyboard shortcut reference.
//! Mode-launcher overlay shown when the player presses **M** or clicks the
//! Modes affordance.
//!
//! Press **M** to open or close the overlay.
//! Replaces the prior "keyboard shortcut reference" Home modal with a
//! vertical stack of five mode cards — Classic, Daily Challenge, Zen,
//! Challenge, Time Attack. Clicking a card fires the same launch event
//! the corresponding hotkey does, then closes the overlay. The shortcut
//! reference now lives only in Help (`F1`), which is the canonical place
//! for that information.
//!
//! Level-gated modes (Zen, Challenge, Time Attack) are disabled below
//! `CHALLENGE_UNLOCK_LEVEL`; clicking a locked card fires an
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
//! or close the overlay.
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::game_state::GameMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::resources::GameStateResource;
use crate::progress_plugin::ProgressResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO,
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
/// Marker component on the home-menu overlay root node.
// ---------------------------------------------------------------------------
// Public marker components
// ---------------------------------------------------------------------------
/// Marker component on the Home overlay root entity (the modal scrim).
#[derive(Component, Debug)]
pub struct HomeScreen;
/// Marker on the "Done" button inside the Home modal.
/// Marker on the bottom-row "Cancel" button that dismisses the Home modal
/// without launching a mode.
#[derive(Component, Debug)]
pub struct HomeCloseButton;
pub struct HomeCancelButton;
/// Registers the M-key toggle and the overlay spawn/despawn logic.
// ---------------------------------------------------------------------------
// Private mode-card data shape
// ---------------------------------------------------------------------------
/// Which game mode a [`HomeModeCard`] represents.
///
/// Kept private — external consumers should write the corresponding
/// `Start*RequestEvent` (or [`NewGameRequestEvent`] for Classic) directly.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
enum HomeMode {
Classic,
Daily,
Zen,
Challenge,
TimeAttack,
}
impl HomeMode {
/// Display title shown on the card.
fn title(self) -> &'static str {
match self {
HomeMode::Classic => "Classic",
HomeMode::Daily => "Daily Challenge",
HomeMode::Zen => "Zen Mode",
HomeMode::Challenge => "Challenge",
HomeMode::TimeAttack => "Time Attack",
}
}
/// One-line description shown below the title.
fn description(self) -> &'static str {
match self {
HomeMode::Classic => "The standard Klondike deal — score, time, and a fresh shuffle.",
HomeMode::Daily => "Today's seed, same for everyone. Build a streak.",
HomeMode::Zen => "No timer, no score. Just the cards.",
HomeMode::Challenge => "Hand-picked hard deals. No undo. Win to advance.",
HomeMode::TimeAttack => "How many can you finish in ten minutes?",
}
}
/// The keyboard accelerator that dispatches the same launch event,
/// shown in a small chip on the card.
fn hotkey(self) -> &'static str {
match self {
HomeMode::Classic => "N",
HomeMode::Daily => "C",
HomeMode::Zen => "Z",
HomeMode::Challenge => "X",
HomeMode::TimeAttack => "T",
}
}
/// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`.
fn requires_unlock(self) -> bool {
matches!(self, HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack)
}
/// `true` if the player at `level` is allowed to launch the mode.
fn is_unlocked(self, level: u32) -> bool {
!self.requires_unlock() || level >= CHALLENGE_UNLOCK_LEVEL
}
}
/// Marker component placed on each mode-card `Button` so the click
/// handler can identify which mode was pressed.
#[derive(Component, Debug)]
struct HomeModeCard(HomeMode);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers the M-key toggle, the mode-card click handler, and the
/// Cancel-button handler.
pub struct HomePlugin;
impl Plugin for HomePlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, (toggle_home_screen, handle_home_close_button));
// Be defensive about message registration so HomePlugin works
// standalone in tests (the actual handlers live in
// input_plugin / challenge_plugin / time_attack_plugin /
// daily_challenge_plugin, but those plugins might not be
// installed in a tightly-scoped headless app).
app.add_message::<NewGameRequestEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
(
toggle_home_screen,
attach_focusable_to_home_mode_cards,
handle_home_card_click,
handle_home_cancel_button,
),
);
}
}
// ---------------------------------------------------------------------------
// M-key toggle
// ---------------------------------------------------------------------------
fn toggle_home_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
game: Res<GameStateResource>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
) {
@@ -47,16 +164,86 @@ fn toggle_home_screen(
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_home_screen(&mut commands, &game, font_res.as_deref());
let level = progress.as_ref().map_or(0, |p| p.0.level);
spawn_home_screen(&mut commands, level, font_res.as_deref());
}
}
fn handle_home_close_button(
// ---------------------------------------------------------------------------
// Card click handler
// ---------------------------------------------------------------------------
/// Dispatches a click on a mode card.
///
/// - **Unlocked** modes fire the matching `Start*RequestEvent` (or
/// [`NewGameRequestEvent`] for Classic) and despawn the modal.
/// - **Locked** modes (level below [`CHALLENGE_UNLOCK_LEVEL`]) fire only
/// an [`InfoToastEvent`] and leave the modal open so the player can
/// pick another mode.
#[allow(clippy::too_many_arguments)]
fn handle_home_card_click(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<HomeCloseButton>, Changed<Interaction>)>,
cards: Query<(&Interaction, &HomeModeCard), Changed<Interaction>>,
progress: Option<Res<ProgressResource>>,
screens: Query<Entity, With<HomeScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut zen: MessageWriter<StartZenRequestEvent>,
mut challenge: MessageWriter<StartChallengeRequestEvent>,
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
let level = progress.as_ref().map_or(0, |p| p.0.level);
for (interaction, card) in &cards {
if *interaction != Interaction::Pressed {
continue;
}
if !card.0.is_unlocked(level) {
info_toast.write(InfoToastEvent(format!(
"{} unlocks at level {CHALLENGE_UNLOCK_LEVEL}",
card.0.title()
)));
// Leave the modal open so the player can pick another mode.
continue;
}
match card.0 {
HomeMode::Classic => {
new_game.write(NewGameRequestEvent::default());
}
HomeMode::Daily => {
daily.write(StartDailyChallengeRequestEvent);
}
HomeMode::Zen => {
zen.write(StartZenRequestEvent);
}
HomeMode::Challenge => {
challenge.write(StartChallengeRequestEvent);
}
HomeMode::TimeAttack => {
time_attack.write(StartTimeAttackRequestEvent);
}
}
// Close the modal after dispatching the launch event.
for entity in &screens {
commands.entity(entity).despawn();
}
}
}
// ---------------------------------------------------------------------------
// Cancel button handler
// ---------------------------------------------------------------------------
fn handle_home_cancel_button(
mut commands: Commands,
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
if !cancel_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
@@ -64,139 +251,230 @@ fn handle_home_close_button(
}
}
/// Spawns the home-menu modal — a hotkey reference grouped into "Game
/// Controls" and "Screens" sections plus the current game mode badge.
/// A future pass can pivot Home into a true mode launcher (the
/// Modes-popover already covers that path from the action bar).
fn spawn_home_screen(
commands: &mut Commands,
game: &GameStateResource,
font_res: Option<&FontResource>,
) {
let mode_label = match game.0.mode {
GameMode::Classic => "Classic",
GameMode::Zen => "Zen",
GameMode::Challenge => "Challenge",
GameMode::TimeAttack => "Time Attack",
};
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_section = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_row = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_kbd = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
// ---------------------------------------------------------------------------
// Spawn helpers
// ---------------------------------------------------------------------------
/// Spawns the Home modal with five mode cards plus a Cancel button.
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Solitaire Quest", font_res);
spawn_modal_header(card, "Choose a Mode", font_res);
// Mode badge — current game's mode, ACCENT_PRIMARY so it pops.
card.spawn((
Text::new(format!("Current mode: {mode_label}")),
font_section.clone(),
TextColor(ACCENT_PRIMARY),
));
// Game controls section.
card.spawn((
Text::new("Game Controls"),
font_section.clone(),
TextColor(STATE_INFO),
));
for (key, action) in [
("N", "New game (N again confirms)"),
("U", "Undo last move"),
("Space / D", "Draw from stock"),
("G", "Forfeit current game"),
("Tab", "Cycle hint highlight"),
("Enter", "Auto-complete if available"),
for mode in [
HomeMode::Classic,
HomeMode::Daily,
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
] {
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
}
// Screens section.
card.spawn((
Text::new("Screens"),
font_section.clone(),
TextColor(STATE_INFO),
));
for (key, action) in [
("M", "Main menu (this screen)"),
("S", "Statistics"),
("A", "Achievements"),
("O", "Settings"),
("P", "Profile"),
("L", "Leaderboard"),
("F1", "Help"),
("F11", "Toggle fullscreen"),
("Esc", "Pause / Resume"),
] {
spawn_shortcut_row(card, key, action, &font_row, &font_kbd);
spawn_mode_card(card, mode, level, font_res);
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
HomeCloseButton,
"Done",
HomeCancelButton,
"Cancel",
Some("M"),
ButtonVariant::Primary,
ButtonVariant::Tertiary,
font_res,
);
});
});
}
/// One row inside Home's controls reference: a kbd-chip + description.
/// Same look as Help's rows so the two screens read consistently.
fn spawn_shortcut_row(
parent: &mut ChildSpawnerCommands,
key: &str,
action: &str,
font_row: &TextFont,
font_kbd: &TextFont,
/// Tab-walk order for each mode card, matching the visual top-to-bottom
/// stack inside the Home modal. Lower numbers receive focus first under
/// `Focusable`'s sort.
fn home_mode_focus_order(mode: HomeMode) -> i32 {
match mode {
HomeMode::Classic => 0,
HomeMode::Daily => 1,
HomeMode::Zen => 2,
HomeMode::Challenge => 3,
HomeMode::TimeAttack => 4,
}
}
/// Auto-attaches [`Focusable`] (and [`Disabled`] when locked) to every
/// newly-spawned [`HomeModeCard`]. Walks ancestors to find the
/// [`crate::ui_modal::ModalScrim`] so each card's focus group is bound
/// to its parent modal — mirrors the convention that
/// `attach_focusable_to_modal_buttons` uses for `ModalButton`s.
///
/// Doing this in a system (instead of inline at spawn time) lets
/// `spawn_home_screen` keep using the existing `spawn_modal`'s
/// build-closure shape; the scrim entity isn't visible inside that
/// closure, only after the call returns. The system runs every frame
/// and is a no-op once every card has been tagged.
fn attach_focusable_to_home_mode_cards(
mut commands: Commands,
new_cards: Query<(Entity, &HomeModeCard), Without<Focusable>>,
parents: Query<&ChildOf>,
scrims: Query<(), With<crate::ui_modal::ModalScrim>>,
progress: Option<Res<ProgressResource>>,
) {
let level = progress.as_ref().map_or(0, |p| p.0.level);
for (card_entity, card) in &new_cards {
// Walk ancestors until we find the ModalScrim. Bounded loop so a
// malformed hierarchy can't hang the system — same defensive
// shape as `attach_focusable_to_modal_buttons`.
let mut current = card_entity;
let mut scrim_entity: Option<Entity> = None;
for _ in 0..32 {
if scrims.get(current).is_ok() {
scrim_entity = Some(current);
break;
}
match parents.get(current) {
Ok(parent) => current = parent.parent(),
Err(_) => break,
}
}
let Some(scrim) = scrim_entity else { continue };
commands.entity(card_entity).insert(Focusable {
group: FocusGroup::Modal(scrim),
order: home_mode_focus_order(card.0),
});
if !card.0.is_unlocked(level) {
commands.entity(card_entity).insert(Disabled);
}
}
}
/// Spawns one mode card — a `Button` whose children are a title row, a
/// description line, and (when locked) a "Reach level N" hint.
///
/// The visual deliberately diverges from `spawn_modal_button` because a
/// mode card is a wide, two-line tile rather than a compact action; the
/// `ButtonVariant` palette would not apply cleanly here. Hover/press
/// feedback is supplied by `paint_modal_buttons` via the `ModalButton`
/// component, which we attach with `ButtonVariant::Secondary` so the card
/// reads as a standard interactive surface.
fn spawn_mode_card(
parent: &mut ChildSpawnerCommands,
mode: HomeMode,
level: u32,
font_res: Option<&FontResource>,
) {
let unlocked = mode.is_unlocked(level);
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_title = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_desc = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_chip = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
// Locked cards mute their text to communicate the disabled state at
// a glance; the explicit "Unlocks at level N" caption underneath
// backs that up with copy.
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
row.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(80.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(key.to_string()),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
.spawn((
HomeModeCard(mode),
// Keep this a real Button entity so clicks resolve through
// bevy::ui — the click handler queries on `&Interaction`
// which Button drives.
Button,
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_3),
width: Val::Percent(100.0),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(border_color),
))
.with_children(|c| {
// Title row — title text on the left, hotkey chip on the right.
c.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
row.spawn((
Text::new(mode.title().to_string()),
font_title.clone(),
TextColor(title_color),
));
if unlocked {
// Hotkey chip — same look as the kbd-chip rows used
// elsewhere so accelerators read consistently.
row.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(32.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(mode.hotkey().to_string()),
font_chip.clone(),
TextColor(TEXT_SECONDARY),
));
});
} else {
// Lock icon stand-in — text glyph keeps the layout
// dependency-free (no asset loader required) and
// reads at every supported font size.
row.spawn((
Text::new("LOCKED".to_string()),
font_chip.clone(),
TextColor(STATE_INFO),
));
}
});
row.spawn((
Text::new(action.to_string()),
font_row.clone(),
TextColor(TEXT_SECONDARY),
// Description line.
c.spawn((
Text::new(mode.description().to_string()),
font_desc.clone(),
TextColor(desc_color),
));
// Locked footnote — explicit copy so the gate is unambiguous.
if !unlocked {
c.spawn((
Text::new(format!(
"Unlocks at level {CHALLENGE_UNLOCK_LEVEL}"
)),
TextFont {
font: font_desc.font.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
}
});
}
@@ -204,19 +482,75 @@ fn spawn_shortcut_row(
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use crate::progress_plugin::ProgressPlugin;
use crate::table_plugin::TablePlugin;
use bevy::ecs::message::Messages;
/// Builds a headless `App` with just the plugins Home actually
/// reaches into. We deliberately skip input_plugin /
/// challenge_plugin / time_attack_plugin / daily_challenge_plugin —
/// Home only needs to dispatch their request events; the events
/// themselves are registered defensively by `HomePlugin::build`.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(HomePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
/// Press M, run a tick, and return the resulting screen entity.
/// Panics if the modal does not appear (failure mode that any later
/// assertion would mask anyway). The keyboard input is cleared after
/// the press so the next `app.update()` doesn't re-toggle the modal
/// closed — `MinimalPlugins` doesn't run the bevy_input update system
/// that would normally clear `just_pressed` between frames.
fn open_home(app: &mut App) -> Entity {
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.press(KeyCode::KeyM);
}
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyM);
input.clear();
}
app.world_mut()
.query::<(Entity, &HomeScreen)>()
.single(app.world())
.map(|(e, _)| e)
.expect("HomeScreen must spawn after M press")
}
/// Pump a button-press synthetic interaction onto the entity. Bevy
/// 0.18 surfaces interactions through the `Interaction` component
/// driven by the UI input pipeline, but MinimalPlugins does not run
/// that pipeline — so we insert `Interaction::Pressed` directly,
/// which triggers `Changed<Interaction>` on the next update tick.
/// Pattern is borrowed verbatim from `pause_plugin`'s tests.
fn press_button(app: &mut App, entity: Entity) {
app.world_mut()
.entity_mut(entity)
.insert(Interaction::Pressed);
app.update();
}
/// Find the unique `HomeModeCard` entity for a specific mode. Used
/// by the click-handler tests to target the right card.
fn find_card(app: &mut App, mode: HomeMode) -> Entity {
app.world_mut()
.query::<(Entity, &HomeModeCard)>()
.iter(app.world())
.find(|(_, c)| c.0 == mode)
.map(|(e, _)| e)
.unwrap_or_else(|| panic!("no HomeModeCard for {mode:?}"))
}
#[test]
fn pressing_m_spawns_home_screen() {
let mut app = headless_app();
@@ -267,4 +601,276 @@ mod tests {
0
);
}
#[test]
fn modal_contains_a_card_for_each_mode() {
let mut app = headless_app();
let _ = open_home(&mut app);
let modes: Vec<HomeMode> = app
.world_mut()
.query::<&HomeModeCard>()
.iter(app.world())
.map(|c| c.0)
.collect();
for expected in [
HomeMode::Classic,
HomeMode::Daily,
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
] {
assert!(
modes.contains(&expected),
"missing card for {expected:?}; found {modes:?}"
);
}
assert_eq!(modes.len(), 5, "exactly five cards expected");
}
#[test]
fn classic_click_fires_new_game_event_and_closes_modal() {
let mut app = headless_app();
let _ = open_home(&mut app);
// Drain any pre-existing NewGameRequestEvent so the assertion
// only sees the click-driven write.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Classic);
press_button(&mut app, card);
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).copied().collect();
assert_eq!(fired.len(), 1, "one NewGameRequestEvent must fire");
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Classic"
);
}
#[test]
fn locked_zen_click_is_a_noop_below_unlock_level() {
let mut app = headless_app();
// Default level is 0 — Zen is locked.
let _ = open_home(&mut app);
// Reset event queues so the assertion is clean.
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Zen);
press_button(&mut app, card);
// No launch events should have fired.
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"locked Zen click must not fire NewGameRequestEvent"
);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert!(
zc.read(zen).next().is_none(),
"locked Zen click must not fire StartZenRequestEvent"
);
// Modal must still be open so the player can pick another mode.
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"Home modal must remain open after a locked-mode click"
);
}
#[test]
fn unlocked_zen_click_fires_start_zen_event_and_closes_modal() {
let mut app = headless_app();
// Bump the player to the unlock level.
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.level = CHALLENGE_UNLOCK_LEVEL;
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<StartZenRequestEvent>>()
.clear();
let card = find_card(&mut app, HomeMode::Zen);
press_button(&mut app, card);
let zen = app.world().resource::<Messages<StartZenRequestEvent>>();
let mut zc = zen.get_cursor();
assert_eq!(
zc.read(zen).count(),
1,
"unlocked Zen click must fire exactly one StartZenRequestEvent"
);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Home modal must close after launching Zen"
);
}
#[test]
fn cancel_button_closes_modal_without_launching_anything() {
let mut app = headless_app();
let _ = open_home(&mut app);
app.world_mut()
.resource_mut::<Messages<NewGameRequestEvent>>()
.clear();
let cancel = app
.world_mut()
.query::<(Entity, &HomeCancelButton)>()
.single(app.world())
.map(|(e, _)| e)
.expect("HomeCancelButton must exist when modal is open");
press_button(&mut app, cancel);
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"Cancel must despawn the modal"
);
let new_game = app.world().resource::<Messages<NewGameRequestEvent>>();
let mut nc = new_game.get_cursor();
assert!(
nc.read(new_game).next().is_none(),
"Cancel must not fire NewGameRequestEvent"
);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — Home mode cards
// -----------------------------------------------------------------------
/// Headless app variant that also installs the focus and modal
/// plugins so `attach_focusable_to_modal_buttons` and Phase 2's
/// `attach_focusable_to_home_mode_cards` can run.
fn headless_app_with_focus() -> App {
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(HomePlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
/// Open the Home modal at the given player level. Tags the cards
/// with `Focusable` (and, when locked, `Disabled`) by running an
/// extra tick after the M press so the focus-attach system fires.
fn open_home_at_level(app: &mut App, level: u32) -> Entity {
app.world_mut().resource_mut::<ProgressResource>().0.level = level;
let entity = open_home(app);
// One more tick so `attach_focusable_to_home_mode_cards` runs
// on the freshly-spawned cards.
app.update();
entity
}
#[test]
fn home_mode_cards_get_focusable_marker() {
let mut app = headless_app_with_focus();
let scrim = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL);
// Every card carries `Focusable` in `FocusGroup::Modal(scrim)`.
let cards: Vec<(HomeMode, Focusable)> = app
.world_mut()
.query::<(&HomeModeCard, &Focusable)>()
.iter(app.world())
.map(|(c, f)| (c.0, *f))
.collect();
assert_eq!(cards.len(), 5, "all five cards must carry a Focusable");
for (mode, focusable) in &cards {
assert_eq!(
focusable.group,
FocusGroup::Modal(scrim),
"{mode:?} card must be in the Home scrim's focus group"
);
}
}
#[test]
fn home_locked_cards_get_disabled_marker() {
let mut app = headless_app_with_focus();
// Level 0: Zen, Challenge, Time Attack are locked; Classic and
// Daily are not.
let _ = open_home_at_level(&mut app, 0);
let states: Vec<(HomeMode, bool)> = app
.world_mut()
.query::<(&HomeModeCard, bevy::ecs::query::Has<Disabled>)>()
.iter(app.world())
.map(|(c, d)| (c.0, d))
.collect();
for (mode, disabled) in states {
match mode {
HomeMode::Classic | HomeMode::Daily => assert!(
!disabled,
"{mode:?} must not be Disabled at level 0 (it's never locked)"
),
HomeMode::Zen | HomeMode::Challenge | HomeMode::TimeAttack => assert!(
disabled,
"{mode:?} must carry the Disabled marker at level 0 so Tab skips it"
),
}
}
}
#[test]
fn home_unlocked_cards_no_disabled_marker() {
let mut app = headless_app_with_focus();
let _ = open_home_at_level(&mut app, CHALLENGE_UNLOCK_LEVEL);
let any_disabled = app
.world_mut()
.query_filtered::<&HomeModeCard, With<Disabled>>()
.iter(app.world())
.next()
.is_some();
assert!(
!any_disabled,
"no card may be Disabled when the player is at the unlock level"
);
}
}
+505 -19
View File
@@ -34,6 +34,8 @@ use crate::game_plugin::GameMutation;
use crate::resources::GameStateResource;
use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_tooltip::Tooltip;
/// Marker on the score text node.
#[derive(Component, Debug)]
@@ -342,18 +344,23 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
hud.spawn(row_node()).with_children(|t1| {
t1.spawn((
HudScore,
Tooltip::new("Points earned this game. Hidden in Zen mode."),
Text::new("Score: 0"),
font_score.clone(),
TextColor(TEXT_PRIMARY),
));
t1.spawn((
HudMoves,
Tooltip::new(
"Moves you've made this game. Counts placements and stock draws.",
),
Text::new("Moves: 0"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
));
t1.spawn((
HudTime,
Tooltip::new("Time on this game. Counts down in Time Attack."),
Text::new("0:00"),
font_lg.clone(),
TextColor(TEXT_SECONDARY),
@@ -366,18 +373,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
hud.spawn(row_node()).with_children(|t2| {
t2.spawn((
HudMode,
Tooltip::new("Active game mode. Click Modes to switch."),
Text::new(""),
font_body.clone(),
TextColor(ACCENT_PRIMARY),
));
t2.spawn((
HudChallenge,
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
Text::new(""),
font_body.clone(),
TextColor(STATE_INFO),
));
t2.spawn((
HudDrawCycle,
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
Text::new(""),
font_body.clone(),
TextColor(STATE_INFO),
@@ -390,18 +400,25 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
hud.spawn(row_node()).with_children(|t3| {
t3.spawn((
HudUndos,
Tooltip::new(
"Undos used this game. Any undo blocks the No Undo achievement.",
),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudRecycles,
Tooltip::new(
"Times you've recycled the stock. Three or more unlocks Comeback.",
),
Text::new(""),
font_body.clone(),
TextColor(STATE_WARNING),
));
t3.spawn((
HudAutoComplete,
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
@@ -413,6 +430,7 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
hud.spawn(row_node()).with_children(|t4| {
t4.spawn((
HudSelection,
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
Text::new(""),
font_body,
TextColor(ACCENT_SECONDARY),
@@ -452,24 +470,89 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
// Menu and Modes don't have a single hotkey accelerator
// (each row inside their popover has its own); their button
// labels carry the dropdown chevron in lieu of a key chip.
spawn_action_button(row, MenuButton, "Menu \u{25BE}", None, &font);
spawn_action_button(row, UndoButton, "Undo", Some("U"), &font);
spawn_action_button(row, PauseButton, "Pause", Some("Esc"), &font);
spawn_action_button(row, HelpButton, "Help", Some("F1"), &font);
spawn_action_button(row, ModesButton, "Modes \u{25BE}", None, &font);
spawn_action_button(row, NewGameButton, "New Game", Some("N"), &font);
//
// The trailing `order` argument is the per-button index in
// visual reading order (left → right). It feeds
// `Focusable { group: Hud, order }` so Tab cycles the action
// bar in the same order the eye scans it.
spawn_action_button(
row,
MenuButton,
"Menu \u{25BE}",
None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
&font,
0,
);
spawn_action_button(
row,
UndoButton,
"Undo",
Some("U"),
"Take back your last move. Costs points and blocks No Undo.",
&font,
1,
);
spawn_action_button(
row,
PauseButton,
"Pause",
Some("Esc"),
"Pause the game and freeze the timer.",
&font,
2,
);
spawn_action_button(
row,
HelpButton,
"Help",
Some("F1"),
"Show controls, rules, and keyboard shortcuts.",
&font,
3,
);
spawn_action_button(
row,
ModesButton,
"Modes \u{25BE}",
None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
&font,
4,
);
spawn_action_button(
row,
NewGameButton,
"New Game",
Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.",
&font,
5,
);
});
}
/// Spawns a single action button as a child of `row`. Each button shares
/// the same node geometry, idle colour, and `ActionButton` marker so
/// `paint_action_buttons` can recolour all of them with one query.
///
/// `order` is the button's index inside the action bar (0 for the
/// leftmost). It propagates into the [`Focusable`] this function inserts
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
///
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
/// action button ships with one — there is no opt-out — because each button
/// represents a player-triggered action and benefits from a one-line
/// reminder of what it does.
#[allow(clippy::too_many_arguments)]
fn spawn_action_button<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
hotkey: Option<&'static str>,
tooltip: &'static str,
font: &TextFont,
order: i32,
) {
let hotkey_font = TextFont {
font: font.font.clone(),
@@ -480,6 +563,16 @@ fn spawn_action_button<M: Component>(
marker,
ActionButton,
Button,
Tooltip::new(tooltip),
// Joins the `Hud` focus group at the supplied order so Tab
// cycles HUD buttons left-to-right under Phase 2. The HUD focus
// ring still only engages when a HUD button is hovered (or in
// future phases, when the player explicitly switches groups);
// the marker just declares membership.
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
@@ -597,14 +690,37 @@ fn spawn_modes_popover(
..default()
};
let mut rows: Vec<(ModeOption, &'static str)> = vec![(ModeOption::Classic, "Classic")];
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what the mode does — mirroring the
// tooltips on the action-bar buttons that opened this popover.
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
ModeOption::Classic,
"Classic",
"Standard Klondike. Score, timer, and full progression.",
)];
if daily.is_some() {
rows.push((ModeOption::DailyChallenge, "Daily Challenge"));
rows.push((
ModeOption::DailyChallenge,
"Daily Challenge",
"Today's seeded deal. Same for every player worldwide.",
));
}
if level >= CHALLENGE_UNLOCK_LEVEL {
rows.push((ModeOption::Zen, "Zen"));
rows.push((ModeOption::Challenge, "Challenge"));
rows.push((ModeOption::TimeAttack, "Time Attack"));
rows.push((
ModeOption::Zen,
"Zen",
"No timer, no score, no penalties. Just play.",
));
rows.push((
ModeOption::Challenge,
"Challenge",
"Hand-picked hard seeds. No undo allowed.",
));
rows.push((
ModeOption::TimeAttack,
"Time Attack",
"Win as many games as you can in ten minutes.",
));
}
commands
@@ -624,12 +740,13 @@ fn spawn_modes_popover(
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
for (option, label) in rows {
for (option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
@@ -728,12 +845,35 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
..default()
};
let rows: [(MenuOption, &'static str); 5] = [
(MenuOption::Stats, "Stats"),
(MenuOption::Achievements, "Achievements"),
(MenuOption::Profile, "Profile"),
(MenuOption::Settings, "Settings"),
(MenuOption::Leaderboard, "Leaderboard"),
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover.
let rows: [(MenuOption, &'static str, &'static str); 5] = [
(
MenuOption::Stats,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
(
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
),
(
MenuOption::Settings,
"Settings",
"Audio, animations, theme, draw mode, and sync.",
),
(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
),
];
commands
@@ -753,12 +893,13 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
ZIndex(Z_HUD + 5),
))
.with_children(|panel| {
for (option, label) in rows {
for (option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
@@ -1804,4 +1945,349 @@ mod tests {
assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6);
assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6);
}
// -----------------------------------------------------------------------
// Phase 2: keyboard focus ring — HUD action bar
// -----------------------------------------------------------------------
/// Returns the `Focusable` carried by the unique entity matching
/// marker `M`. Helper for the HUD focus tests.
fn focusable_for<M: Component>(app: &mut App) -> Focusable {
app.world_mut()
.query_filtered::<&Focusable, With<M>>()
.iter(app.world())
.next()
.copied()
.unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::<M>()))
}
#[test]
fn hud_buttons_get_focusable_marker() {
let mut app = headless_app();
// Every action-bar button is in `FocusGroup::Hud`.
for f in [
focusable_for::<MenuButton>(&mut app),
focusable_for::<UndoButton>(&mut app),
focusable_for::<PauseButton>(&mut app),
focusable_for::<HelpButton>(&mut app),
focusable_for::<ModesButton>(&mut app),
focusable_for::<NewGameButton>(&mut app),
] {
assert_eq!(
f.group,
FocusGroup::Hud,
"every HUD action button must be in FocusGroup::Hud"
);
}
}
/// Returns the tooltip string carried by the unique entity matching
/// marker `M`. Panics if zero or more than one such entity exists,
/// which is the invariant we want to enforce for HUD readouts and
/// action buttons (each marker is spawned exactly once).
fn tooltip_for<M: Component>(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Tooltip, With<M>>();
let world = app.world();
let mut iter = q.iter(world);
let first = iter
.next()
.unwrap_or_else(|| {
panic!(
"expected a Tooltip on the {} entity",
std::any::type_name::<M>()
)
})
.0
.clone()
.into_owned();
assert!(
iter.next().is_none(),
"expected exactly one Tooltip-bearing entity for {}",
std::any::type_name::<M>()
);
first
}
/// Every HUD readout and action button must spawn with a `Tooltip`
/// carrying the approved canonical microcopy. Mirrors the structure
/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the
/// invariant — one marker entity, one tooltip, exact text — is
/// asserted consistently across every element.
#[test]
fn hud_elements_carry_expected_tooltip_strings() {
let mut app = headless_app();
// HUD readouts (left column, top to bottom).
assert_eq!(
tooltip_for::<HudScore>(&mut app),
"Points earned this game. Hidden in Zen mode."
);
assert_eq!(
tooltip_for::<HudMoves>(&mut app),
"Moves you've made this game. Counts placements and stock draws."
);
assert_eq!(
tooltip_for::<HudTime>(&mut app),
"Time on this game. Counts down in Time Attack."
);
assert_eq!(
tooltip_for::<HudMode>(&mut app),
"Active game mode. Click Modes to switch."
);
assert_eq!(
tooltip_for::<HudChallenge>(&mut app),
"Today's daily challenge target. Beat it for bonus XP."
);
assert_eq!(
tooltip_for::<HudDrawCycle>(&mut app),
"Cards drawn on the next stock click in Draw-Three."
);
assert_eq!(
tooltip_for::<HudUndos>(&mut app),
"Undos used this game. Any undo blocks the No Undo achievement."
);
assert_eq!(
tooltip_for::<HudRecycles>(&mut app),
"Times you've recycled the stock. Three or more unlocks Comeback."
);
assert_eq!(
tooltip_for::<HudAutoComplete>(&mut app),
"Board is solvable from here. Press Enter to auto-finish."
);
assert_eq!(
tooltip_for::<HudSelection>(&mut app),
"Pile selected with Tab. Use arrows or Enter to act."
);
// Action bar (left to right).
assert_eq!(
tooltip_for::<MenuButton>(&mut app),
"Open Stats, Achievements, Profile, Settings, or Leaderboard."
);
assert_eq!(
tooltip_for::<UndoButton>(&mut app),
"Take back your last move. Costs points and blocks No Undo."
);
assert_eq!(
tooltip_for::<PauseButton>(&mut app),
"Pause the game and freeze the timer."
);
assert_eq!(
tooltip_for::<HelpButton>(&mut app),
"Show controls, rules, and keyboard shortcuts."
);
assert_eq!(
tooltip_for::<ModesButton>(&mut app),
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack."
);
assert_eq!(
tooltip_for::<NewGameButton>(&mut app),
"Start a fresh deal. Confirms first if a game is in progress."
);
}
/// Every interior row of the Modes and Menu popovers must carry a
/// `Tooltip`. The popovers open from action-bar buttons whose own
/// tooltips are already covered above; this test extends the
/// invariant inward so hover discoverability is uniform across the
/// HUD's nested controls.
///
/// We invoke the popover spawn helpers directly with a maxed-out
/// `ProgressResource` and a `DailyChallengeResource` so every row
/// branch fires (Classic, Daily, Zen, Challenge, Time Attack).
/// Headless click simulation isn't needed — the contract under
/// test is "every popover row spawns with a tooltip", which is a
/// property of the spawn helpers themselves.
#[test]
fn popover_rows_carry_tooltip_strings() {
use crate::progress_plugin::ProgressResource;
use solitaire_sync::progress::PlayerProgress;
let mut app = headless_app();
// Force every mode row to render: level past the challenge
// unlock threshold, plus a daily challenge resource so the
// Daily row appears.
let progress = ProgressResource(PlayerProgress {
level: CHALLENGE_UNLOCK_LEVEL,
..Default::default()
});
let daily = DailyChallengeResource {
date: Local::now().date_naive(),
seed: 1,
goal_description: None,
target_score: None,
max_time_secs: None,
};
// Spawn both popovers via their helpers. Mirrors how the click
// handlers invoke them in production — we just skip the click.
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None);
spawn_menu_popover(&mut commands, None);
world.flush();
}
app.update();
// Every ModeOption-tagged entity must also carry a Tooltip,
// and the count must match the five canonical modes.
let mut mode_q = app
.world_mut()
.query_filtered::<&Tooltip, With<ModeOption>>();
let mode_tooltips: Vec<String> = mode_q
.iter(app.world())
.map(|t| t.0.clone().into_owned())
.collect();
assert_eq!(
mode_tooltips.len(),
5,
"expected a tooltip on each of the 5 mode rows, got {}",
mode_tooltips.len()
);
// Every approved mode tooltip string must be present somewhere
// among the ModeOption rows. Order isn't asserted — the spawn
// order test elsewhere already covers that.
for expected in [
"Standard Klondike. Score, timer, and full progression.",
"Today's seeded deal. Same for every player worldwide.",
"No timer, no score, no penalties. Just play.",
"Hand-picked hard seeds. No undo allowed.",
"Win as many games as you can in ten minutes.",
] {
assert!(
mode_tooltips.iter().any(|s| s == expected),
"missing mode tooltip: {expected:?}"
);
}
// Same contract for MenuOption rows: five entries, each with a
// tooltip, exact strings matching the approved microcopy.
let mut menu_q = app
.world_mut()
.query_filtered::<&Tooltip, With<MenuOption>>();
let menu_tooltips: Vec<String> = menu_q
.iter(app.world())
.map(|t| t.0.clone().into_owned())
.collect();
assert_eq!(
menu_tooltips.len(),
5,
"expected a tooltip on each of the 5 menu rows, got {}",
menu_tooltips.len()
);
for expected in [
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
"Audio, animations, theme, draw mode, and sync.",
"Top players from your sync server. Opt in from Profile.",
] {
assert!(
menu_tooltips.iter().any(|s| s == expected),
"missing menu tooltip: {expected:?}"
);
}
}
#[test]
fn hud_button_order_matches_spawn_order() {
let mut app = headless_app();
// Visual reading order (left → right): Menu, Undo, Pause, Help,
// Modes, New Game. Their `order` fields must be 0..=5 in that
// order so Tab cycles them as the player reads them.
assert_eq!(focusable_for::<MenuButton>(&mut app).order, 0);
assert_eq!(focusable_for::<UndoButton>(&mut app).order, 1);
assert_eq!(focusable_for::<PauseButton>(&mut app).order, 2);
assert_eq!(focusable_for::<HelpButton>(&mut app).order, 3);
assert_eq!(focusable_for::<ModesButton>(&mut app).order, 4);
assert_eq!(focusable_for::<NewGameButton>(&mut app).order, 5);
}
#[test]
fn hud_focus_only_engages_when_button_hovered() {
// Phase 2 declares membership in `FocusGroup::Hud`; the
// engagement rule lives in `handle_focus_keys`. Two halves to
// this test:
// (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract
// still holds when nothing is hovered).
// (b) no modal + a HUD button hovered ⇒ Tab advances
// `FocusedButton` to a Hud-grouped entity.
use crate::ui_focus::{FocusedButton, UiFocusPlugin};
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(HudPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
// (a) Sanity: HUD buttons exist and are focusable, but no
// modal open and no hover ⇒ FocusedButton stays None.
assert!(
app.world().resource::<FocusedButton>().0.is_none(),
"no modal open, no auto-focus"
);
// Press Tab. With no modal and no hover, `handle_focus_keys`
// resolves no active group and returns early — Tab must not
// advance the HUD focus ring on its own.
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release_all();
input.clear();
input.press(KeyCode::Tab);
}
app.update();
assert!(
app.world().resource::<FocusedButton>().0.is_none(),
"Tab with no modal and no Hud hover must not engage the HUD focus ring"
);
// (b) Hover the Menu button — the leftmost HUD action — and
// Tab. The Hud-group cycle should pick a Hud-tagged entity.
let menu_entity = app
.world_mut()
.query_filtered::<Entity, With<MenuButton>>()
.iter(app.world())
.next()
.expect("MenuButton entity should exist");
app.world_mut()
.entity_mut(menu_entity)
.insert(Interaction::Hovered);
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release_all();
input.clear();
input.press(KeyCode::Tab);
}
app.update();
let focused = app
.world()
.resource::<FocusedButton>()
.0
.expect("Tab with a HUD button hovered must engage the HUD focus ring");
// The focused entity must itself be Hud-grouped (i.e. one of
// the action-bar buttons), not anything else in the world.
let focusable = app
.world()
.entity(focused)
.get::<Focusable>()
.expect("focused entity must carry Focusable");
assert_eq!(
focusable.group,
FocusGroup::Hud,
"Hud-engaged Tab must focus a Hud-grouped entity"
);
}
}
+90 -39
View File
@@ -30,9 +30,25 @@ use crate::ui_theme::{
// Resources
// ---------------------------------------------------------------------------
/// Cached leaderboard data. `None` means no fetch has completed yet.
/// State of the cached leaderboard fetch.
///
/// Distinguishes "fetch hasn't completed yet" from "fetch failed" from
/// "fetch succeeded but the leaderboard is empty" so the UI can show
/// targeted copy for each case rather than a single ambiguous "no
/// entries" line that hid network errors from the player.
#[derive(Resource, Default, Debug, Clone)]
pub struct LeaderboardResource(pub Option<Vec<LeaderboardEntry>>);
pub enum LeaderboardResource {
/// No fetch has completed yet — show "Fetching..." in the panel.
#[default]
Idle,
/// Last fetch failed (network, auth, etc.) — show error copy.
/// The wrapped string is the underlying error for logging only;
/// the UI shows a fixed user-friendly message.
Error(String),
/// Fetch succeeded — wrapped Vec may be empty (legitimately empty
/// leaderboard) or populated.
Loaded(Vec<LeaderboardEntry>),
}
/// Set to `true` in the frame the user explicitly closes the panel so that a
/// fetch completing in the same frame doesn't immediately reopen it.
@@ -134,8 +150,12 @@ fn toggle_leaderboard_screen(
return;
}
// Spawn the panel immediately with whatever data we have (may be None).
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
// Spawn the panel immediately with whatever data we have so far.
let remote_available = provider
.as_ref()
.map(|p| p.0.backend_name() != "local")
.unwrap_or(false);
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
// Start a background fetch if not already in flight.
if task_res.0.is_none()
@@ -167,6 +187,7 @@ fn update_leaderboard_panel(
mut result_res: ResMut<LeaderboardFetchResult>,
mut data: ResMut<LeaderboardResource>,
screens: Query<Entity, With<LeaderboardScreen>>,
provider: Option<Res<SyncProviderResource>>,
font_res: Option<Res<FontResource>>,
closed_flag: Res<ClosedThisFrame>,
) {
@@ -174,12 +195,15 @@ fn update_leaderboard_panel(
match result {
Ok(entries) => {
data.0 = Some(entries);
*data = LeaderboardResource::Loaded(entries);
}
Err(e) => {
warn!("leaderboard fetch failed: {e}");
if data.0.is_none() {
data.0 = Some(vec![]); // show empty rather than spinner forever
// Preserve previously-loaded data on a transient failure so a
// momentary network blip doesn't wipe a populated list. Only
// surface an Error state when we have nothing better to show.
if !matches!(*data, LeaderboardResource::Loaded(_)) {
*data = LeaderboardResource::Error(e);
}
}
}
@@ -189,9 +213,13 @@ fn update_leaderboard_panel(
if closed_flag.0 {
return;
}
let remote_available = provider
.as_ref()
.map(|p| p.0.backend_name() != "local")
.unwrap_or(false);
for entity in &screens {
commands.entity(entity).despawn();
spawn_leaderboard_screen(&mut commands, data.0.as_deref(), font_res.as_deref());
spawn_leaderboard_screen(&mut commands, &data, remote_available, font_res.as_deref());
}
}
@@ -316,7 +344,8 @@ pub struct LeaderboardCloseButton;
fn spawn_leaderboard_screen(
commands: &mut Commands,
entries: Option<&[LeaderboardEntry]>,
data: &LeaderboardResource,
remote_available: bool,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
@@ -345,32 +374,44 @@ fn spawn_leaderboard_screen(
..default()
};
card.spawn((
Text::new("Use Opt In / Opt Out to control your visibility on the server."),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
));
if remote_available {
card.spawn((
Text::new("Use Opt In / Opt Out to control your visibility on the server."),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
));
// Opt In / Opt Out row uses the same modal-button helpers as
// the rest of the UI for consistent hover / press feedback.
spawn_modal_actions(card, |row| {
spawn_modal_button(
row,
LeaderboardOptInButton,
"Opt In",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
row,
LeaderboardOptOutButton,
"Opt Out",
None,
ButtonVariant::Tertiary,
font_res,
);
});
// Opt In / Opt Out row uses the same modal-button helpers as
// the rest of the UI for consistent hover / press feedback.
spawn_modal_actions(card, |row| {
spawn_modal_button(
row,
LeaderboardOptInButton,
"Opt In",
None,
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
row,
LeaderboardOptOutButton,
"Opt Out",
None,
ButtonVariant::Tertiary,
font_res,
);
});
} else {
// No remote sync provider configured — opt-in/out would be a
// silent no-op, so show a single explanatory line instead.
card.spawn((
Text::new(
"Leaderboards require cloud sync. Configure a server in Settings to participate.",
),
font_caption.clone(),
TextColor(TEXT_SECONDARY),
));
}
// Subtle separator between the controls and the data area.
card.spawn((
@@ -381,22 +422,29 @@ fn spawn_leaderboard_screen(
BackgroundColor(BORDER_SUBTLE),
));
match entries {
None => {
match data {
LeaderboardResource::Idle => {
card.spawn((
Text::new("Fetching\u{2026}"),
font_status.clone(),
TextColor(STATE_INFO),
));
}
Some([]) => {
LeaderboardResource::Error(_) => {
card.spawn((
Text::new("Couldn't reach the leaderboard. Try again later."),
font_status.clone(),
TextColor(TEXT_SECONDARY),
));
}
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
card.spawn((
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
Some(rows) => {
LeaderboardResource::Loaded(rows) => {
// Column headers
card.spawn(Node {
flex_direction: FlexDirection::Row,
@@ -583,7 +631,10 @@ mod tests {
#[test]
fn resource_starts_empty() {
let app = headless_app();
assert!(app.world().resource::<LeaderboardResource>().0.is_none());
assert!(matches!(
app.world().resource::<LeaderboardResource>(),
LeaderboardResource::Idle
));
}
#[test]
+6
View File
@@ -26,12 +26,15 @@ pub mod settings_plugin;
pub mod progress_plugin;
pub mod resources;
pub mod selection_plugin;
pub mod splash_plugin;
pub mod stats_plugin;
pub mod sync_plugin;
pub mod table_plugin;
pub mod time_attack_plugin;
pub mod ui_focus;
pub mod ui_modal;
pub mod ui_theme;
pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod win_summary_plugin;
@@ -95,13 +98,16 @@ pub use settings_plugin::{
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard,
ModalHeader, ModalScrim, UiModalPlugin,
};
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use table_plugin::{
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
};
+2 -2
View File
@@ -166,7 +166,7 @@ fn handle_onboarding_buttons(
}
if skip_pressed || (next_pressed && slide_index.0 == SLIDE_COUNT - 1) {
// Skip or final-slide "Start playing" — complete onboarding.
// Skip or final-slide "Let's play" — complete onboarding.
complete_onboarding(
&mut commands,
&screens,
@@ -412,7 +412,7 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
spawn_modal_button(
actions,
OnboardingNextButton,
"Start playing",
"Let's play",
Some(""),
ButtonVariant::Primary,
font_res,
+3 -3
View File
@@ -72,7 +72,7 @@ pub struct ForfeitConfirmScreen;
#[derive(Component, Debug)]
struct ForfeitCancelButton;
/// Marker on the "Yes, forfeit" primary button inside the forfeit-confirm modal.
/// Marker on the "Forfeit" primary button inside the forfeit-confirm modal.
#[derive(Component, Debug)]
struct ForfeitConfirmButton;
@@ -468,7 +468,7 @@ fn spawn_draw_mode_row(
));
}
/// Spawns `ForfeitConfirmScreen` — a Cancel / "Yes, forfeit" modal
/// Spawns `ForfeitConfirmScreen` — a Cancel / "Forfeit" modal
/// stacked above the pause modal at `Z_PAUSE_DIALOG`.
fn spawn_forfeit_confirm_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(
@@ -495,7 +495,7 @@ fn spawn_forfeit_confirm_screen(commands: &mut Commands, font_res: Option<&FontR
spawn_modal_button(
actions,
ForfeitConfirmButton,
"Yes, forfeit",
"Forfeit",
Some("Y"),
ButtonVariant::Primary,
font_res,
+21
View File
@@ -111,6 +111,27 @@ fn spawn_profile_screen(
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
// First-launch welcome — only when the player has zero XP and
// zero daily streak, so the profile doesn't read as a wall of
// zeros to a brand-new player.
if let Some(p) = progress
&& p.0.total_xp == 0
&& p.0.daily_challenge_streak == 0
{
card.spawn((
Text::new("Welcome! Play games to earn XP and unlock achievements."),
font_section.clone(),
TextColor(ACCENT_PRIMARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default()
},
));
}
// ── Sync section ────────────────────────────────────────────
card.spawn((
Text::new("Sync"),
+387 -15
View File
@@ -13,6 +13,7 @@ use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use solitaire_core::game_state::DrawMode;
use solitaire_data::{load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings};
@@ -20,12 +21,15 @@ use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ModalButton, ModalScrim,
};
use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
/// Side length of a swatch button in the card-back / background pickers.
@@ -33,8 +37,9 @@ use crate::ui_theme::{
const SWATCH_PX: f32 = 40.0;
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
/// Sub-rung sizing — kept as a literal, see SWATCH_PX.
const ICON_BUTTON_PX: f32 = 28.0;
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
const ICON_BUTTON_PX: f32 = 32.0;
/// Volume adjustment step applied by the `[` / `]` hotkeys.
pub const SFX_STEP: f32 = 0.1;
@@ -122,6 +127,39 @@ enum SettingsButton {
SelectBackground(usize),
}
impl SettingsButton {
/// Tab-walk priority — lower numbers visited first. Visual reading
/// order is top-to-bottom by section, left-to-right inside each row.
/// Two buttons in the same picker row receive the same `order`;
/// `handle_focus_keys` then breaks ties by entity index, which
/// matches `Children` spawn order inside each row.
fn focus_order(&self) -> i32 {
match self {
// Audio section
SettingsButton::SfxDown => 10,
SettingsButton::SfxUp => 11,
SettingsButton::MusicDown => 20,
SettingsButton::MusicUp => 21,
// Gameplay section
SettingsButton::ToggleDrawMode => 30,
SettingsButton::CycleAnimSpeed => 40,
// Cosmetic section
SettingsButton::ToggleTheme => 50,
SettingsButton::ToggleColorBlind => 60,
// Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80,
// Sync section
SettingsButton::SyncNow => 90,
// Done is tagged by `attach_focusable_to_modal_buttons` and
// never reaches `attach_focusable_to_settings_buttons`; the
// value here is only a fallback for completeness.
SettingsButton::Done => 100,
}
}
}
/// Plugin that owns the settings lifecycle.
pub struct SettingsPlugin {
/// Path to `settings.json`. `None` in headless/test mode.
@@ -177,6 +215,8 @@ impl Plugin for SettingsPlugin {
update_background_text,
update_anim_speed_text,
update_color_blind_text,
attach_focusable_to_settings_buttons,
scroll_focus_into_view,
),
);
}
@@ -257,7 +297,7 @@ fn sync_settings_panel_visibility(
if panels.is_empty() {
let status_label = sync_status
.map(|s| sync_status_label(&s.0))
.unwrap_or_else(|| "Status: not configured".to_string());
.unwrap_or_else(|| "Status: local only".to_string());
let unlocked_backs = progress
.as_ref()
.map(|p| p.0.unlocked_card_backs.as_slice())
@@ -553,6 +593,148 @@ fn color_blind_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() }
}
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
/// buttons (volume +/, toggle, cycle), swatch buttons (card-back,
/// background pickers), and the "Sync Now" button. The "Done" button is
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
/// [`ModalButton`]) and is filtered out here.
///
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
/// same defensive shape as the Phase 1 / 2 attach systems.
#[allow(clippy::type_complexity)]
fn attach_focusable_to_settings_buttons(
mut commands: Commands,
new_buttons: Query<
(Entity, &SettingsButton),
(With<Button>, Without<Focusable>, Without<ModalButton>),
>,
parents: Query<&ChildOf>,
scrims: Query<(), With<ModalScrim>>,
) {
for (button, settings_button) in &new_buttons {
let mut current = button;
let mut scrim_entity: Option<Entity> = None;
for _ in 0..32 {
if scrims.get(current).is_ok() {
scrim_entity = Some(current);
break;
}
match parents.get(current) {
Ok(parent) => current = parent.parent(),
Err(_) => break,
}
}
if let Some(scrim) = scrim_entity {
commands.entity(button).insert(Focusable {
group: FocusGroup::Modal(scrim),
order: settings_button.focus_order(),
});
}
}
}
/// Vertical padding (logical px) added around the focused button when
/// scrolling it into view. Keeps the focus ring's halo visible above /
/// below the viewport edge.
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
/// When the focused entity sits outside the visible Settings scroll
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
/// fully visible. No-op when:
///
/// - `FocusedButton` is `None`
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
/// (e.g. a freshly-spawned modal hasn't laid out yet)
/// - the focused entity is not a descendant of the
/// [`SettingsPanelScrollable`] container
///
/// The viewport's visible Y range is `[scroll_y, scroll_y +
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
/// The focused button's vertical extent is computed from its
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
/// `ComputedNode.size.y`. Because the scroll container's local
/// coordinates run [0, content_height] and the visible window is
/// [scroll_y, scroll_y + viewport], we convert the button's window-
/// space Y to container-local Y by subtracting the container's window-
/// space top and adding the current scroll offset.
#[allow(clippy::type_complexity)]
fn scroll_focus_into_view(
focused: Res<FocusedButton>,
parents: Query<&ChildOf>,
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
mut containers: Query<
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
With<SettingsPanelScrollable>,
>,
) {
let Some(target) = focused.0 else { return };
// Gather button geometry.
let Ok((target_transform, target_node)) = nodes.get(target) else {
return;
};
// Walk ancestors looking for the scroll container. Bounded to keep
// a malformed hierarchy from hanging the system.
let mut current = target;
let mut container_entity: Option<Entity> = None;
for _ in 0..32 {
if containers.get(current).is_ok() {
container_entity = Some(current);
break;
}
match parents.get(current) {
Ok(parent) => current = parent.parent(),
Err(_) => break,
}
}
let Some(container) = container_entity else { return };
let Ok((mut scroll, container_transform, container_node)) =
containers.get_mut(container)
else {
return;
};
// Geometry is reported in physical pixels by `ComputedNode.size` and
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
// so convert via `inverse_scale_factor` before we write.
let inv = target_node.inverse_scale_factor;
let target_height = target_node.size().y;
let target_centre_y = target_transform.translation.y;
let target_top = target_centre_y - target_height * 0.5;
let target_bottom = target_centre_y + target_height * 0.5;
let container_height = container_node.size().y;
let container_top = container_transform.translation.y - container_height * 0.5;
// Convert button window-space Y to container-local Y. The container
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
// by physical-per-logical to compare with physical pixel extents.
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
let viewport_top = container_top + scroll_phys;
let viewport_bottom = viewport_top + container_height;
// Layout may not have run yet (zero size on first frame) — no
// sensible scroll target until the container has dimensions.
if container_height <= 0.0 {
return;
}
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
if target_top < viewport_top {
// Button extends above the viewport — scroll up.
let new_top = target_top - pad_phys;
let delta = new_top - viewport_top;
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
} else if target_bottom > viewport_bottom {
// Button extends below the viewport — scroll down.
let new_bottom = target_bottom + pad_phys;
let delta = new_bottom - viewport_bottom;
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
}
}
/// Scrolls the settings panel inner card in response to mouse-wheel events.
///
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
@@ -623,6 +805,8 @@ fn spawn_settings_panel(
SfxVolumeText,
SettingsButton::SfxDown,
SettingsButton::SfxUp,
"Lower sound effects volume.",
"Raise sound effects volume.",
font_res,
);
volume_row(
@@ -632,6 +816,8 @@ fn spawn_settings_panel(
MusicVolumeText,
SettingsButton::MusicDown,
SettingsButton::MusicUp,
"Lower music and ambience volume.",
"Raise music and ambience volume.",
font_res,
);
@@ -643,6 +829,7 @@ fn spawn_settings_panel(
DrawModeText,
draw_mode_label(&settings.draw_mode),
SettingsButton::ToggleDrawMode,
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
font_res,
);
toggle_row(
@@ -651,6 +838,7 @@ fn spawn_settings_panel(
AnimSpeedText,
anim_speed_label(&settings.animation_speed),
SettingsButton::CycleAnimSpeed,
"Cycle animation speed: Normal, Fast, Instant.",
font_res,
);
@@ -662,6 +850,7 @@ fn spawn_settings_panel(
ThemeText,
theme_label(&settings.theme),
SettingsButton::ToggleTheme,
"Cycle felt color: Green, Blue, Dark.",
font_res,
);
toggle_row(
@@ -670,6 +859,7 @@ fn spawn_settings_panel(
ColorBlindText,
color_blind_label(settings.color_blind_mode),
SettingsButton::ToggleColorBlind,
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
font_res,
);
picker_row(
@@ -678,6 +868,7 @@ fn spawn_settings_panel(
unlocked_card_backs,
settings.selected_card_back,
SettingsButton::SelectCardBack,
"Choose your deck art. New backs unlock at higher levels.",
font_res,
);
picker_row(
@@ -686,6 +877,7 @@ fn spawn_settings_panel(
unlocked_backgrounds,
settings.selected_background,
SettingsButton::SelectBackground,
"Choose your felt art. New felts unlock at higher levels.",
font_res,
);
@@ -720,6 +912,10 @@ fn section_label(parent: &mut ChildSpawnerCommands, title: &str, font_res: Optio
}
/// `Label 0.80 [] [+]` — used for SFX and Music volume rows.
///
/// `tooltip_down` / `tooltip_up` are attached to the `` / `+` buttons
/// respectively so each glyph carries a one-line reminder of which channel
/// it adjusts.
#[allow(clippy::too_many_arguments)]
fn volume_row<Marker: Component>(
parent: &mut ChildSpawnerCommands,
@@ -728,6 +924,8 @@ fn volume_row<Marker: Component>(
marker: Marker,
btn_down: SettingsButton,
btn_up: SettingsButton,
tooltip_down: &'static str,
tooltip_up: &'static str,
font_res: Option<&FontResource>,
) {
let label_font = label_text_font(font_res);
@@ -751,19 +949,24 @@ fn volume_row<Marker: Component>(
value_font,
TextColor(TEXT_PRIMARY),
));
icon_button(row, "", btn_down, font_res);
icon_button(row, "+", btn_up, font_res);
icon_button(row, "", btn_down, tooltip_down, font_res);
icon_button(row, "+", btn_up, tooltip_up, font_res);
});
}
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
/// anim speed, colour-blind).
///
/// `tooltip` is attached to the `⇄` button so the cycle glyph carries a
/// one-line reminder of what it iterates through.
#[allow(clippy::too_many_arguments)]
fn toggle_row<Marker: Component>(
parent: &mut ChildSpawnerCommands,
label: &str,
marker: Marker,
value: String,
action: SettingsButton,
tooltip: &'static str,
font_res: Option<&FontResource>,
) {
let label_font = label_text_font(font_res);
@@ -782,19 +985,24 @@ fn toggle_row<Marker: Component>(
TextColor(TEXT_SECONDARY),
));
row.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
icon_button(row, "", action, font_res);
icon_button(row, "", action, tooltip, font_res);
});
}
/// Wrapping row of indexed swatch buttons — used for card-back and
/// background pickers. The currently-selected swatch is tinted with
/// `STATE_SUCCESS` so the user can see it without reading a label.
///
/// `tooltip` is attached to every swatch in the row so hovering any chip
/// reveals what the picker controls and how new entries unlock.
#[allow(clippy::too_many_arguments)]
fn picker_row(
parent: &mut ChildSpawnerCommands,
label: &str,
unlocked: &[usize],
selected: usize,
make_button: impl Fn(usize) -> SettingsButton,
tooltip: &'static str,
font_res: Option<&FontResource>,
) {
let label_font = label_text_font(font_res);
@@ -804,13 +1012,19 @@ fn picker_row(
..default()
};
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
flex_wrap: FlexWrap::Wrap,
..default()
})
.spawn((
// The row container is a `FocusRow` so Left / Right arrow
// keys cycle within its swatch children. Tab still escapes
// the row to the next focusable in the modal.
FocusRow,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
flex_wrap: FlexWrap::Wrap,
..default()
},
))
.with_children(|row| {
row.spawn((
Text::new(label.to_string()),
@@ -825,6 +1039,7 @@ fn picker_row(
row.spawn((
make_button(idx),
Button,
Tooltip::new(tooltip),
Node {
width: Val::Px(SWATCH_PX),
height: Val::Px(SWATCH_PX),
@@ -880,6 +1095,9 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
row.spawn((
SettingsButton::SyncNow,
Button,
Tooltip::new(
"Push and pull stats now. Runs automatically on launch and exit.",
),
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
@@ -916,10 +1134,16 @@ fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
}
}
/// Spawns a small square icon button (volume +/, toggle, cycle).
///
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
/// Settings icon button ships with one because the glyph alone (`+`, ``,
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
fn icon_button(
parent: &mut ChildSpawnerCommands,
label: &str,
action: SettingsButton,
tooltip: &'static str,
font_res: Option<&FontResource>,
) {
let glyph_font = TextFont {
@@ -931,6 +1155,7 @@ fn icon_button(
.spawn((
action,
Button,
Tooltip::new(tooltip),
Node {
width: Val::Px(ICON_BUTTON_PX),
height: Val::Px(ICON_BUTTON_PX),
@@ -1140,6 +1365,153 @@ mod tests {
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
}
// -----------------------------------------------------------------------
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
// -----------------------------------------------------------------------
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
/// auto-tag systems fire end-to-end without writing to disk.
fn headless_app_with_focus() -> App {
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin {
// No persistence — keep the test isolated.
storage_path: None,
ui_enabled: true,
});
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
#[test]
fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
// Two more ticks: the first runs `sync_settings_panel_visibility`
// and queues the spawn commands; the second flushes them and
// runs `attach_focusable_to_settings_buttons`.
app.update();
app.update();
// Every bespoke `SettingsButton` (not `Done`, which is also a
// `ModalButton`) must carry a `Focusable`.
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untagged.is_empty(),
"every bespoke Settings button must carry Focusable; missing: {:?}",
untagged
);
// And there must be at least one tagged `SettingsButton` so the
// assertion above isn't vacuously true (the panel really did
// spawn).
let tagged_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
assert!(
tagged_count >= 6,
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
);
}
/// Every bespoke `SettingsButton` (volume +/, toggles, swatches,
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
/// indexed swatches carry hover-reveal context. Mirrors
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
/// the invariant — every interactive Settings element except the
/// `Done` modal button has a tooltip — is asserted consistently.
#[test]
fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus();
// Open the panel and let spawn + child-flush run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
// also a `ModalButton`) may be missing a `Tooltip`.
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untipped.is_empty(),
"every bespoke Settings button must carry Tooltip; missing: {:?}",
untipped
);
// And there must be at least 6 tipped buttons so the assertion
// above isn't vacuously true: SFX +/, Music +/, Draw Mode,
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
// card-back and one background swatch — well over the floor.
let tipped_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
assert!(
tipped_count >= 6,
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
);
// Spot-check: the Sync Now button's tooltip text is the
// canonical microcopy. We find it via the `SettingsButton`
// discriminant — there is exactly one Sync Now entity per panel.
let sync_tip = app
.world_mut()
.query::<(&SettingsButton, &Tooltip)>()
.iter(app.world())
.find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone()))
.expect("Sync Now button should spawn with a Tooltip");
assert_eq!(
sync_tip.as_ref(),
"Push and pull stats now. Runs automatically on launch and exit.",
"Sync Now tooltip must use the canonical microcopy"
);
}
#[test]
fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// Two picker rows are spawned (card-back + background); each
// must carry the FocusRow marker.
let row_count = app
.world_mut()
.query_filtered::<Entity, With<FocusRow>>()
.iter(app.world())
.count();
assert!(
row_count >= 2,
"expected at least two FocusRow containers (card-back + background); got {row_count}"
);
}
#[test]
fn scroll_clamps_offset_to_zero_at_top() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
+516
View File
@@ -0,0 +1,516 @@
//! Launch splash overlay.
//!
//! On app start the engine spawns a fullscreen, high-Z overlay that
//! reads "Solitaire Quest" in the project font for ~1.6 s
//! (300 ms fade-in, ~1 s hold, 300 ms fade-out), then despawns. The
//! existing deal animation plays *behind* the splash during the hold —
//! the user sees the dealt board appear as the splash dissolves.
//!
//! ## Why an overlay instead of an `AppState`
//!
//! Every existing plugin in this engine runs unconditionally on
//! `Startup`/`Update`; gating them with `run_if(in_state(...))` would be
//! a sweeping refactor for a one-off brand beat. The splash instead
//! sits on top of `Z_SPLASH` (above tooltips, focus ring, and toasts)
//! while the rest of the game runs normally beneath it. The handoff is
//! intentional: the user finishes the splash and the dealt board is
//! already there.
//!
//! ## Dismissal
//!
//! Any keypress, mouse click, or touch begin shortcuts the splash to its
//! fade-out window — never to an instant despawn, so the dissolve still
//! plays for visual continuity. The dismiss input is **not** consumed,
//! so a player who instinctively taps Space to "skip the intro" still
//! gets their stock draw the moment the splash clears (Space and most
//! other gameplay keys read `just_pressed`, which by the next tick is
//! already false — splash dismissal happens on the same tick as the
//! press, so downstream gameplay handlers see exactly the keystroke
//! they would have seen with no splash).
//!
//! ## Headless tests
//!
//! Under `MinimalPlugins + SplashPlugin`, the `Time<Virtual>` clock
//! clamps each tick to `max_delta` (default 250 ms) regardless of the
//! `TimeUpdateStrategy::ManualDuration` value, so tests advance time in
//! 200 ms ticks and call `app.update()` enough times to cross the
//! desired threshold (same approach used by `ui_tooltip::tests`).
use std::time::Duration;
use bevy::input::touch::Touches;
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_BASE, MOTION_SPLASH_FADE_SECS, MOTION_SPLASH_TOTAL_SECS, TEXT_SECONDARY,
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_2, Z_SPLASH,
};
// ---------------------------------------------------------------------------
// Public plugin
// ---------------------------------------------------------------------------
/// Drives the launch splash overlay. Add this plugin once at app start;
/// the splash spawns during `Startup`, fades in/out over
/// [`MOTION_SPLASH_TOTAL_SECS`], and despawns itself.
///
/// The overlay is a sibling of every other UI surface — it never
/// becomes a parent of game systems, and the deal animation runs
/// underneath it during the hold window. Dismissal on any keypress /
/// click / touch shortcuts the timeline into the fade-out phase rather
/// than despawning instantly, so the dissolve always plays.
pub struct SplashPlugin;
impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_splash).add_systems(
Update,
(dismiss_splash_on_input, advance_splash).chain(),
);
}
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Marker on the splash overlay scrim (root entity for the launch beat).
/// Despawned with descendants once [`MOTION_SPLASH_TOTAL_SECS`] elapses
/// or once a user-input dismissal advances the timeline past the hold.
#[derive(Component, Debug)]
pub struct SplashRoot;
/// Tracks the splash's elapsed visible duration. Stored as a component
/// on the splash root rather than a global resource so despawning the
/// splash root removes its state along with it — there's no second-run
/// concern (the splash is one-shot at app start) and a component keeps
/// the splash data co-located with its entity.
#[derive(Component, Debug, Default)]
pub struct SplashAge(pub Duration);
/// Marker on the splash title text. Used by [`advance_splash`] to write
/// the per-frame alpha into the text colour without walking arbitrary
/// children.
#[derive(Component, Debug)]
struct SplashTitle;
/// Marker on the splash subtitle text (build version). Faded together
/// with the title so the brand beat dissolves as a single layer.
#[derive(Component, Debug)]
struct SplashSubtitle;
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Spawns the splash overlay at `Startup`. Builds a fullscreen scrim
/// at full alpha (the first `advance_splash` tick will overwrite the
/// alpha based on age), centres a "Solitaire Quest" title in
/// [`ACCENT_PRIMARY`], and pins a small build-version line below.
fn spawn_splash(mut commands: Commands, font_res: Option<Res<FontResource>>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let title_font = TextFont {
font: font_handle.clone(),
font_size: TYPE_DISPLAY,
..default()
};
let subtitle_font = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
// Initial alpha is 0 (fade-in starts at 0 and grows). Without this
// the first frame would flash full-opacity scrim before the
// `advance_splash` tick lerped it down — visually a pop on slower
// start-ups.
let mut initial_bg = BG_BASE;
initial_bg.set_alpha(0.0);
let mut initial_title = ACCENT_PRIMARY;
initial_title.set_alpha(0.0);
let mut initial_subtitle = TEXT_SECONDARY;
initial_subtitle.set_alpha(0.0);
commands
.spawn((
SplashRoot,
SplashAge(Duration::ZERO),
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
..default()
},
BackgroundColor(initial_bg),
GlobalZIndex(Z_SPLASH),
))
.with_children(|root| {
root.spawn((
SplashTitle,
Text::new("Solitaire Quest"),
title_font,
TextColor(initial_title),
));
root.spawn((
SplashSubtitle,
Text::new(format!("v{}", env!("CARGO_PKG_VERSION"))),
subtitle_font,
TextColor(initial_subtitle),
));
});
}
/// Computes the splash's per-frame alpha from its age. Three phases:
///
/// * `0..fade` — fade-in: `alpha = age / fade`.
/// * `fade..total - fade` — hold: `alpha = 1.0`.
/// * `total - fade..total` — fade-out: `alpha = (total - age) / fade`.
/// * `>= total` — splash is complete; caller despawns the root.
///
/// Returns `None` once the timeline is finished, signalling the splash
/// should be despawned.
fn splash_alpha(age: Duration) -> Option<f32> {
let age_s = age.as_secs_f32();
let total = MOTION_SPLASH_TOTAL_SECS;
let fade = MOTION_SPLASH_FADE_SECS;
if age_s >= total {
return None;
}
if age_s < fade {
// Fade-in.
return Some((age_s / fade).clamp(0.0, 1.0));
}
if age_s < total - fade {
// Hold.
return Some(1.0);
}
// Fade-out.
Some(((total - age_s) / fade).clamp(0.0, 1.0))
}
/// Advances every splash root's age by `time.delta()` and updates the
/// scrim + text alpha, despawning the splash once the timeline
/// finishes. Despawns with descendants so the title and subtitle leave
/// the world together.
fn advance_splash(
mut commands: Commands,
time: Res<Time>,
mut roots: Query<(Entity, &mut SplashAge, &mut BackgroundColor, &Children), With<SplashRoot>>,
mut titles: Query<&mut TextColor, (With<SplashTitle>, Without<SplashSubtitle>)>,
mut subtitles: Query<&mut TextColor, (With<SplashSubtitle>, Without<SplashTitle>)>,
) {
for (entity, mut age, mut bg, children) in &mut roots {
age.0 = age.0.saturating_add(time.delta());
let Some(alpha) = splash_alpha(age.0) else {
commands.entity(entity).despawn();
continue;
};
// Scrim alpha — keeps BG_BASE's RGB and just rewrites alpha.
let mut scrim = BG_BASE;
scrim.set_alpha(alpha);
bg.0 = scrim;
// Walk the splash root's direct children for the title /
// subtitle markers and update their alpha. The hierarchy is
// shallow (root → 2 text children) so a small loop is fine.
for child in children.iter() {
if let Ok(mut color) = titles.get_mut(child) {
let mut c = ACCENT_PRIMARY;
c.set_alpha(alpha);
color.0 = c;
continue;
}
if let Ok(mut color) = subtitles.get_mut(child) {
let mut c = TEXT_SECONDARY;
c.set_alpha(alpha);
color.0 = c;
}
}
}
}
/// Dismisses the splash on any user input. Accelerates each splash
/// root's age into the fade-out window so the dissolve still plays
/// (despawning instantly would feel abrupt). If the timeline is
/// already inside fade-out, the splash is left to finish on its own.
///
/// **Input is not consumed.** The splash neither calls
/// `clear_just_pressed` nor drains the touch / mouse buffers, so a
/// keystroke that dismissed the splash also reaches downstream
/// systems on the same tick (e.g. Space → `DrawRequestEvent`). This
/// matches what the user expects — the splash is a brand beat, not a
/// modal stop sign.
fn dismiss_splash_on_input(
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
touches: Option<Res<Touches>>,
mut roots: Query<&mut SplashAge, With<SplashRoot>>,
) {
if roots.is_empty() {
return;
}
let touch_pressed = touches
.map(|t| t.iter_just_pressed().next().is_some())
.unwrap_or(false);
let dismissed = keys.get_just_pressed().next().is_some()
|| mouse.get_just_pressed().next().is_some()
|| touch_pressed;
if !dismissed {
return;
}
// Jump the age forward to the start of the fade-out so the
// overlay dissolves cleanly. Saturating arithmetic on Duration
// means an already-past-fade-out splash stays past fade-out.
let fade_out_start = Duration::from_secs_f32(
(MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS).max(0.0),
);
for mut age in &mut roots {
if age.0 < fade_out_start {
age.0 = fade_out_start;
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bevy::time::TimeUpdateStrategy;
/// Builds a headless `App` with `MinimalPlugins + SplashPlugin` and
/// runs one tick so `spawn_splash` (Startup) has executed before
/// the first asserting `update`.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
app.update();
app
}
/// Tells `TimePlugin` to advance the virtual clock by `secs` on the
/// next `app.update()`. Mirrors the helper in `ui_tooltip::tests`.
fn set_manual_time_step(app: &mut App, secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
}
/// `Time<Virtual>` clamps per-tick deltas to `max_delta` (default
/// 250 ms) regardless of the requested manual step, so we drive
/// 200 ms ticks and call `update` enough times to exceed the target
/// duration. Returns the splash root's recorded age after the
/// stepping completes (or `None` if the splash was despawned).
fn advance_by(app: &mut App, total_secs: f32) -> Option<Duration> {
set_manual_time_step(app, 0.2);
let ticks = (total_secs / 0.2).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
}
let mut q = app
.world_mut()
.query_filtered::<&SplashAge, With<SplashRoot>>();
q.iter(app.world()).next().map(|a| a.0)
}
fn count_splash_roots(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<Entity, With<SplashRoot>>()
.iter(app.world())
.count()
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release_all();
input.clear();
input.press(key);
}
fn press_mouse(app: &mut App, button: MouseButton) {
let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
input.release_all();
input.clear();
input.press(button);
}
/// Reads the splash scrim's `BackgroundColor` alpha. Panics if the
/// splash root is missing — that's a regression in `spawn_splash`.
fn scrim_alpha(app: &mut App) -> f32 {
let mut q = app
.world_mut()
.query_filtered::<&BackgroundColor, With<SplashRoot>>();
q.iter(app.world())
.next()
.expect("SplashRoot should exist")
.0
.alpha()
}
#[test]
fn splash_spawns_on_startup() {
let mut app = headless_app();
assert_eq!(
count_splash_roots(&mut app),
1,
"SplashRoot must exist after Startup"
);
}
#[test]
fn splash_despawns_after_total_duration() {
let mut app = headless_app();
// Comfortably past the total duration to absorb the
// ManualDuration → Virtual-clock clamp + the despawn lag of
// one extra tick.
let _ = advance_by(&mut app, MOTION_SPLASH_TOTAL_SECS + 0.5);
assert_eq!(
count_splash_roots(&mut app),
0,
"SplashRoot must be despawned after MOTION_SPLASH_TOTAL_SECS"
);
}
#[test]
fn splash_alpha_curves_through_fade_hold_fade() {
// Pure-function test on the curve so we don't need to wrangle
// the virtual-clock clamp here. The integration assertion below
// (`splash_dismisses_immediately_on_keypress`) covers the
// wired-up version.
// Start of fade-in.
assert!(
splash_alpha(Duration::ZERO).unwrap() < 0.05,
"alpha at t=0 must be near 0 (fade-in start)"
);
// End of fade-in.
let after_fade_in = Duration::from_secs_f32(MOTION_SPLASH_FADE_SECS);
assert!(
(splash_alpha(after_fade_in).unwrap() - 1.0).abs() < 0.001,
"alpha at end of fade-in must be ~1.0"
);
// Mid-hold.
let mid_hold = Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS / 2.0);
assert!(
(splash_alpha(mid_hold).unwrap() - 1.0).abs() < f32::EPSILON,
"alpha mid-hold must be exactly 1.0"
);
// Inside fade-out.
let mid_fade_out = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS / 2.0,
);
let mid_out_alpha = splash_alpha(mid_fade_out).unwrap();
assert!(
mid_out_alpha < 0.6 && mid_out_alpha > 0.4,
"alpha mid-fade-out should be ~0.5, got {mid_out_alpha}"
);
// Past total.
let past_total = Duration::from_secs_f32(MOTION_SPLASH_TOTAL_SECS + 0.1);
assert!(
splash_alpha(past_total).is_none(),
"alpha past total duration must be None (signal: despawn)"
);
}
#[test]
fn splash_dismisses_immediately_on_keypress() {
let mut app = headless_app();
// Run one fast tick under the fade-in window so the splash is
// unambiguously not yet in fade-out before the dismiss.
set_manual_time_step(&mut app, 0.05);
app.update();
let pre_alpha = scrim_alpha(&mut app);
assert!(
pre_alpha < 1.0,
"precondition: splash should be inside fade-in, not yet at full alpha (got {pre_alpha})"
);
// Press any key. The dismissal system should bump the age into
// the fade-out window on this tick.
press_key(&mut app, KeyCode::Space);
app.update();
// Either still alive in fade-out, or already despawned (the
// 200 ms test-clock clamp can shave the fade-out window
// depending on how many ticks `app.update()` has accrued).
if count_splash_roots(&mut app) == 0 {
return; // already past fade-out — that's fine.
}
let mut q = app
.world_mut()
.query_filtered::<&SplashAge, With<SplashRoot>>();
let age = q
.iter(app.world())
.next()
.expect("splash should exist after one post-dismiss tick")
.0;
let fade_out_start = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
);
assert!(
age >= fade_out_start,
"after a keypress dismiss the splash must be in fade-out (age >= {fade_out_start:?}); got {age:?}"
);
}
#[test]
fn splash_dismisses_on_mouse_click() {
let mut app = headless_app();
set_manual_time_step(&mut app, 0.05);
app.update();
assert!(scrim_alpha(&mut app) < 1.0);
press_mouse(&mut app, MouseButton::Left);
app.update();
if count_splash_roots(&mut app) == 0 {
return;
}
let mut q = app
.world_mut()
.query_filtered::<&SplashAge, With<SplashRoot>>();
let age = q
.iter(app.world())
.next()
.expect("splash should exist after one post-dismiss tick")
.0;
let fade_out_start = Duration::from_secs_f32(
MOTION_SPLASH_TOTAL_SECS - MOTION_SPLASH_FADE_SECS,
);
assert!(
age >= fade_out_start,
"after a left-click dismiss the splash must be in fade-out; got {age:?}"
);
}
/// Bonus test: dismissing the splash with a keypress does NOT clear
/// that key's `just_pressed` flag — downstream systems still see
/// the keystroke that dismissed the splash. Important for parity
/// with "no splash" behaviour where Space draws a card.
#[test]
fn dismissal_keypress_is_visible_to_other_systems() {
let mut app = headless_app();
press_key(&mut app, KeyCode::Space);
app.update();
let keys = app.world().resource::<ButtonInput<KeyCode>>();
assert!(
keys.just_pressed(KeyCode::Space),
"Splash dismissal must NOT consume the input — downstream gameplay still needs it"
);
}
}
+36 -10
View File
@@ -30,8 +30,8 @@ use crate::ui_modal::{
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_MODAL_PANEL,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, Z_MODAL_PANEL,
};
/// Bevy resource wrapping the current stats.
@@ -247,14 +247,19 @@ fn spawn_stats_screen(
font_res: Option<&FontResource>,
) {
// --- primary stat cells ---
let win_rate_str = format_win_rate(stats);
let played_str = format_stat_value(stats.games_played);
let won_str = format_stat_value(stats.games_won);
let lost_str = format_stat_value(stats.games_lost);
let fastest_str = format_fastest_win(stats.fastest_win_seconds);
let avg_time_str = format_avg_time(stats);
let best_score_str = format_optional_u32(stats.best_single_score);
let best_streak_str = format_stat_value(stats.win_streak_best);
// First-launch zero-state: when no games have been played yet, render
// every top-level cell as an em-dash so the panel doesn't read as a
// mix of "0" counters and "—" sentinels (which feels buggy).
let is_first_launch = stats.games_played == 0;
let dash = "\u{2014}".to_string();
let win_rate_str = if is_first_launch { dash.clone() } else { format_win_rate(stats) };
let played_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_played) };
let won_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_won) };
let lost_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.games_lost) };
let fastest_str = if is_first_launch { dash.clone() } else { format_fastest_win(stats.fastest_win_seconds) };
let avg_time_str = if is_first_launch { dash.clone() } else { format_avg_time(stats) };
let best_score_str = if is_first_launch { dash.clone() } else { format_optional_u32(stats.best_single_score) };
let best_streak_str = if is_first_launch { dash.clone() } else { format_stat_value(stats.win_streak_best) };
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_section = TextFont {
@@ -271,6 +276,27 @@ fn spawn_stats_screen(
spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Statistics", font_res);
// First-launch caption — sits above the grid as gentle nudge so
// the wall of em-dashes reads as "nothing to track yet" rather
// than as broken state.
if is_first_launch {
card.spawn((
Text::new("Play a game to start tracking stats."),
TextFont {
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default()
},
));
}
// --- primary stat cells grid ---
card.spawn(Node {
flex_direction: FlexDirection::Row,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -38,7 +38,7 @@
//! spawn_modal_button(
//! actions,
//! ConfirmButton,
//! "Yes, abandon",
//! "New game",
//! Some("Y"),
//! ButtonVariant::Primary,
//! font_res,
+52
View File
@@ -94,6 +94,11 @@ pub const BORDER_SUBTLE: Color = Color::srgba(0.647, 0.549, 1.000, 0.12);
/// Strong border — hover outline, focused button, active popover.
pub const BORDER_STRONG: Color = Color::srgba(0.647, 0.549, 1.000, 0.30);
/// 2 px ring drawn around the focused interactive element. Balatro yellow
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
/// against both elevated surfaces and the modal scrim backdrop.
pub const FOCUS_RING: Color = Color::srgba(1.0, 0.823, 0.247, 0.85);
// ---------------------------------------------------------------------------
// Typography scale (px) — 5 rungs replace the prior
// 14/15/16/17/18/22/26/28/30/32/40/48 jungle. All UI uses FiraMono via
@@ -187,6 +192,11 @@ pub const Z_PAUSE: i32 = 220;
/// `Z_PAUSE` so the dialog is always visible over the paused state.
pub const Z_PAUSE_DIALOG: i32 = 225;
pub const Z_ONBOARDING: i32 = 230;
/// Z-layer for the keyboard focus indicator. Sits one rung above the
/// topmost modal layer (`Z_ONBOARDING`) so the ring is never occluded by
/// a modal card's hover state, while staying below the win cascade and
/// transient toasts that are allowed to overlay everything else.
pub const Z_FOCUS_RING: i32 = 240;
/// Win cascade sits between modals and toasts so the celebration plays
/// over a paused / mid-modal screen.
pub const Z_WIN_CASCADE: i32 = 300;
@@ -265,6 +275,45 @@ pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
/// 400 ms.
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
/// Hover delay before a tooltip appears, in seconds. Long enough that
/// players gliding the cursor across the HUD don't see flicker; short
/// enough that "stop and read" feels responsive. Not run through
/// [`scaled_duration`] — `AnimSpeed` controls gameplay motion, not the
/// hover-discoverability budget for help text.
pub const MOTION_TOOLTIP_DELAY_SECS: f32 = 0.5;
/// Total visible duration of the splash screen overlay, in seconds.
/// Composed of a fade-in, a hold, and a fade-out — see
/// [`MOTION_SPLASH_FADE_SECS`] for the per-edge fade budget. Not run
/// through [`scaled_duration`]: the splash is a one-shot brand beat at
/// app start, not gameplay motion that should track `AnimSpeed`.
pub const MOTION_SPLASH_TOTAL_SECS: f32 = 1.6;
/// Fade-in and fade-out duration of the splash overlay, in seconds.
/// The hold time is `MOTION_SPLASH_TOTAL_SECS - 2 * MOTION_SPLASH_FADE_SECS`.
/// Mirroring fade-in and fade-out keeps the curve symmetric so the brand
/// beat reads as a single dissolve instead of two separate animations.
pub const MOTION_SPLASH_FADE_SECS: f32 = 0.3;
// ---------------------------------------------------------------------------
// Z-index — tooltip layer
// ---------------------------------------------------------------------------
/// Z-layer for tooltips. Sits one rung above the focus ring so a
/// tooltip rendered over a focused button is never occluded by the
/// button's outline. Still below `Z_WIN_CASCADE` and `Z_TOAST` so the
/// celebration and notification layers stay on top.
pub const Z_TOOLTIP: i32 = Z_FOCUS_RING + 10;
/// Z-layer for the launch splash overlay. The splash owns the entire
/// viewport for ~1.6 s before fading out, so it sits above every other
/// UI rung — including `Z_TOAST` — to guarantee the brand beat is
/// never occluded by a stray toast or tooltip. Neither toasts nor the
/// win cascade can fire during the splash window in practice (no game
/// has run yet, no toast queue has dispatched), but the relative order
/// is kept tidy in case a future feature schedules either at startup.
pub const Z_SPLASH: i32 = Z_TOAST + 100;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -331,8 +380,11 @@ mod tests {
Z_PAUSE,
Z_PAUSE_DIALOG,
Z_ONBOARDING,
Z_FOCUS_RING,
Z_TOOLTIP,
Z_WIN_CASCADE,
Z_TOAST,
Z_SPLASH,
];
for window in layers.windows(2) {
assert!(
+553
View File
@@ -0,0 +1,553 @@
//! Hover-tooltip infrastructure. Adds a one-shot, design-token-styled
//! popover that appears over any UI element carrying a [`Tooltip`]
//! component once the cursor has lingered for
//! [`crate::ui_theme::MOTION_TOOLTIP_DELAY_SECS`] seconds.
//!
//! ## Why a sibling overlay
//!
//! Like [`crate::ui_focus`], this module uses a single absolute-positioned
//! overlay entity that is never a descendant of any modal or HUD card. On
//! every frame, [`show_or_hide_tooltip`] reads the hovered target's
//! [`bevy::ui::UiGlobalTransform`] + [`bevy::ui::ComputedNode`] and writes
//! an absolute `Node.left` / `Node.top` so the overlay tracks the target
//! without inheriting modal scale-in or scroll-clipping. The pattern
//! mirrors [`crate::ui_focus::update_focus_overlay`] one-for-one.
//!
//! ## Public surface
//!
//! - [`Tooltip`] — component carrying the hover text. Add it to any
//! interactive node and the rest is automatic.
//! - [`UiTooltipPlugin`] — registers the resource, startup spawn, and the
//! per-frame tracking + display systems.
//!
//! ## Scope
//!
//! Phase 1 of the tooltip rollout — *infrastructure only*. No HUD or
//! Settings entity carries [`Tooltip`] yet; a follow-up commit applies
//! tooltips to specific readouts and buttons. Treat this module as the
//! library half of the feature.
use std::borrow::Cow;
use std::time::Duration;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::font_plugin::FontResource;
use crate::ui_theme::{
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
};
// ---------------------------------------------------------------------------
// Public component / plugin
// ---------------------------------------------------------------------------
/// Marker on a UI element that should display a tooltip when the cursor
/// hovers over it. The component carries the tooltip text — typically a
/// short caption explaining what the element does or what its number
/// represents.
///
/// Bevy UI hover detection requires the [`Interaction`] component (the
/// picking system writes `Interaction::Hovered` only on entities that
/// have it), so [`Tooltip`] declares it as a required component. Adding
/// `Tooltip` to a node automatically inserts a default [`Interaction`].
///
/// The owning entity must also be a UI [`Node`] for picking to pick it
/// up; that's a layout concern handled at the call site. Every interactive
/// HUD readout and modal button in this codebase already carries `Node`,
/// so in practice callers just attach `Tooltip::new("…")` and move on.
///
/// # Example
///
/// ```ignore
/// use solitaire_engine::ui_tooltip::Tooltip;
///
/// commands.spawn((
/// Node { /* ... */ ..default() },
/// Tooltip::new("Cards left in the stock"),
/// ));
/// ```
#[derive(Component, Debug, Clone)]
#[require(Interaction)]
pub struct Tooltip(pub Cow<'static, str>);
impl Tooltip {
/// Builds a [`Tooltip`] from any string-like value. Prefer passing a
/// `&'static str` for static labels — the underlying `Cow` keeps the
/// allocation-free path open for the common case while still
/// accepting owned `String`s for runtime-formatted text.
pub fn new(text: impl Into<Cow<'static, str>>) -> Self {
Self(text.into())
}
}
/// Registers the tooltip overlay and the systems that drive it. Add this
/// plugin once, immediately after [`crate::ui_focus::UiFocusPlugin`], and
/// every entity carrying a [`Tooltip`] component gains hover-to-reveal
/// behaviour with no per-plugin wiring.
pub struct UiTooltipPlugin;
impl Plugin for UiTooltipPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TooltipState>()
.add_systems(Startup, spawn_tooltip_overlay)
.add_systems(
Update,
(track_tooltip_hover, show_or_hide_tooltip).chain(),
);
}
}
// ---------------------------------------------------------------------------
// Private resource + markers
// ---------------------------------------------------------------------------
/// Internal state for the singleton tooltip overlay. Tracks which
/// [`Tooltip`]-bearing entity the cursor is currently hovering and the
/// `Time::elapsed()` timestamp at which the hover started, so the display
/// system can fire only once the dwell threshold has elapsed.
#[derive(Resource, Debug, Default)]
struct TooltipState {
/// `(target_entity, hover_started_at)` — populated by
/// [`track_tooltip_hover`] when an entity transitions to
/// [`Interaction::Hovered`], cleared when the cursor leaves.
hovered: Option<(Entity, Duration)>,
/// The singleton overlay entity, populated by
/// [`spawn_tooltip_overlay`] on Startup. Read by
/// [`show_or_hide_tooltip`] to skip a `single_mut` query.
overlay: Option<Entity>,
}
/// Marker on the singleton tooltip-overlay container.
#[derive(Component, Debug)]
struct TooltipOverlay;
/// Marker on the overlay's [`Text`] child, so the display system can
/// rewrite the tooltip string without despawning the whole overlay.
#[derive(Component, Debug)]
struct TooltipText;
// ---------------------------------------------------------------------------
// Tunables
// ---------------------------------------------------------------------------
/// Vertical gap between the target and the tooltip overlay, in logical
/// pixels. Small enough to read as "attached"; big enough to clear the
/// target's own border.
const TOOLTIP_GAP_PX: f32 = 4.0;
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Spawns the singleton tooltip-overlay entity at Startup. Hidden until a
/// [`Tooltip`]-bearing target is hovered for [`MOTION_TOOLTIP_DELAY_SECS`]
/// seconds, then repositioned and revealed by [`show_or_hide_tooltip`].
fn spawn_tooltip_overlay(
mut commands: Commands,
mut state: ResMut<TooltipState>,
font_res: Option<Res<FontResource>>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
let overlay = commands
.spawn((
TooltipOverlay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
// Auto width/height so the overlay tracks its text content.
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
Visibility::Hidden,
// Pin above the focus ring so a tooltip on a focused element
// is never occluded by the focus outline.
GlobalZIndex(Z_TOOLTIP),
))
.with_children(|root| {
root.spawn((
TooltipText,
Text::new(String::new()),
font,
TextColor(TEXT_PRIMARY),
));
})
.id();
state.overlay = Some(overlay);
}
/// Watches every interactive entity for `Changed<Interaction>` and
/// updates [`TooltipState::hovered`] accordingly:
///
/// * Hovering a [`Tooltip`]-bearing entity records the start time so the
/// display system can apply the dwell delay.
/// * Leaving the currently-hovered entity (transition away from
/// `Hovered`) clears the state so the overlay hides on the next tick.
///
/// Hovering a different `Tooltip` entity simply replaces the prior
/// `(entity, t0)` pair — the dwell timer restarts, matching native
/// tooltip behaviour where moving across multiple targets resets the
/// reveal delay.
fn track_tooltip_hover(
time: Res<Time>,
interactions: Query<
(Entity, &Interaction, Option<&Tooltip>),
Changed<Interaction>,
>,
mut state: ResMut<TooltipState>,
) {
for (entity, interaction, tooltip) in &interactions {
match interaction {
Interaction::Hovered => {
if tooltip.is_some() {
// Record the hover start. If the same entity is
// already recorded, leave the original timestamp so
// a re-emitted Hovered (e.g. pointer wiggle) doesn't
// reset the dwell timer.
let already = matches!(state.hovered, Some((e, _)) if e == entity);
if !already {
state.hovered = Some((entity, time.elapsed()));
}
}
}
Interaction::Pressed | Interaction::None => {
// Clear iff this is the entity we were tracking. Other
// changed-interaction events on unrelated entities must
// not blow away an in-flight hover.
if matches!(state.hovered, Some((e, _)) if e == entity) {
state.hovered = None;
}
}
}
}
}
/// Per-frame display driver. Reads [`TooltipState::hovered`] and:
///
/// * If `None`, hides the overlay.
/// * If `Some((entity, t0))` and `time.elapsed() - t0 < delay`, hides the
/// overlay (still in the dwell window).
/// * If `Some((entity, t0))` and the dwell has elapsed, copies the
/// target's [`Tooltip`] string into the overlay's [`TooltipText`] child,
/// positions the overlay above the target (or below, if above would
/// clip the screen top), and reveals it.
///
/// Positioning math mirrors
/// [`crate::ui_focus::update_focus_overlay`]: `ComputedNode.size` and
/// `UiGlobalTransform.translation` are converted from physical to
/// logical pixels via `inverse_scale_factor` before being written into
/// `Val::Px` slots on the overlay's `Node`. Headless tests run under
/// `MinimalPlugins` and don't execute the layout schedule, so
/// `ComputedNode` is `Vec2::ZERO` there — the test asserts the
/// visibility-and-text invariant rather than position.
#[allow(clippy::type_complexity)]
fn show_or_hide_tooltip(
time: Res<Time>,
state: Res<TooltipState>,
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
tooltip_text_only: Query<&Tooltip>,
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
mut text_q: Query<&mut Text, With<TooltipText>>,
) {
let Ok((mut node, mut visibility, children)) = overlay_q.single_mut() else {
// Overlay not yet spawned — first frame before Startup ran, or a
// test harness without Startup. Nothing to do.
return;
};
// Helper: hide the overlay if not already hidden.
let hide = |visibility: &mut Visibility| {
if !matches!(*visibility, Visibility::Hidden) {
*visibility = Visibility::Hidden;
}
};
let Some((target, started_at)) = state.hovered else {
hide(&mut visibility);
return;
};
let elapsed = time.elapsed().saturating_sub(started_at);
let delay = Duration::from_secs_f32(MOTION_TOOLTIP_DELAY_SECS);
if elapsed < delay {
hide(&mut visibility);
return;
}
// Past the dwell threshold. Pull the target's tooltip text and write
// it into the overlay's Text child. The wider query
// (`UiGlobalTransform + ComputedNode`) may miss in headless tests
// where layout doesn't run; fall back to the text-only query so test
// assertions on visibility + text content still pass even when
// positioning data is unavailable.
let label: Option<Cow<'static, str>> = tooltips
.get(target)
.ok()
.map(|(t, _, _)| t.0.clone())
.or_else(|| tooltip_text_only.get(target).ok().map(|t| t.0.clone()));
let Some(text) = label else {
// Target despawned or no longer carries Tooltip — hide and bail.
// We don't write back to the resource here because it's `Res`,
// not `ResMut`; `track_tooltip_hover` will clear it the next
// frame the entity changes interaction.
hide(&mut visibility);
return;
};
// Update the visible text. Skip the write if it already matches so
// we don't churn the change-detection flag every frame.
for child in children.iter() {
if let Ok(mut t) = text_q.get_mut(child)
&& t.0 != text
{
t.0 = text.clone().into_owned();
}
}
// Compute placement. ComputedNode.size is in physical pixels;
// inverse_scale_factor multiplies physical → logical so the result
// matches the Val::Px logical-pixel coordinate space every other
// Node uses.
if let Ok((_, transform, computed)) = tooltips.get(target) {
let inv = computed.inverse_scale_factor;
let size_logical = computed.size() * inv;
let center_logical = transform.translation * inv;
// Default placement: above the target, centered horizontally.
// Tooltip width isn't known until layout — use a small assumed
// width via auto sizing; we centre on the target's centre and
// let the overlay's auto Node width do the rest. For the X
// coordinate we still need to anchor *something*: place the
// overlay's left edge at the target's centre minus half of the
// target's width, then rely on auto-Node sizing. That's a small
// approximation; the follow-up phase that wires real entities
// will measure overlay width via ComputedNode and re-centre.
let half = size_logical * 0.5;
let left_above = center_logical.x - half.x;
let top_above = center_logical.y - half.y - TOOLTIP_GAP_PX;
// If the tooltip would render above the screen top (top < 0),
// flip below the target. We don't know overlay height yet, so
// use the target's bottom edge plus the gap.
let (left, top) = if top_above < 0.0 {
(left_above, center_logical.y + half.y + TOOLTIP_GAP_PX)
} else {
(left_above, top_above)
};
node.left = Val::Px(left);
node.top = Val::Px(top);
}
if !matches!(*visibility, Visibility::Visible) {
*visibility = Visibility::Visible;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bevy::time::TimeUpdateStrategy;
/// Builds a headless `App` with `MinimalPlugins + UiTooltipPlugin`.
/// Ticks once so the Startup spawn system has run and the singleton
/// overlay exists in the world before the first asserting `update`.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(UiTooltipPlugin);
app.update();
app
}
/// Tells `TimePlugin` to advance the clock by `secs` on the next
/// `app.update()`. Mirrors the helper in `ui_modal::tests` and
/// `hud_plugin::tests`.
fn set_manual_time_step(app: &mut App, secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
}
/// Reads the current overlay visibility. Panics if the singleton is
/// missing — that would indicate a bug in `spawn_tooltip_overlay`.
fn overlay_visibility(app: &mut App) -> Visibility {
let mut q = app
.world_mut()
.query_filtered::<&Visibility, With<TooltipOverlay>>();
*q.iter(app.world())
.next()
.expect("TooltipOverlay singleton should exist")
}
/// Reads the current tooltip text content from the overlay's Text
/// child.
fn overlay_text(app: &mut App) -> String {
let mut q = app.world_mut().query_filtered::<&Text, With<TooltipText>>();
q.iter(app.world())
.next()
.expect("TooltipText child should exist")
.0
.clone()
}
/// Spawns a synthetic interactive node with a `Tooltip` component,
/// pre-set to `Interaction::Hovered`. The picking pipeline doesn't
/// run under `MinimalPlugins`, so we write `Hovered` directly.
fn spawn_hovered_tooltip(app: &mut App, label: &'static str) -> Entity {
let id = app
.world_mut()
.spawn((
Node::default(),
Interaction::Hovered,
Tooltip::new(label),
))
.id();
// Mark the Interaction Changed by re-inserting it. `Changed`
// requires component mutation since the previous tick; spawn
// already counts, but a follow-up insert is the explicit signal.
app.world_mut()
.entity_mut(id)
.insert(Interaction::Hovered);
id
}
/// Test 1: nothing is shown before the dwell delay elapses.
#[test]
fn tooltip_does_not_show_before_delay() {
let mut app = headless_app();
// Manual step well under the dwell delay. A handful of ticks
// accumulates to far less than `MOTION_TOOLTIP_DELAY_SECS` so
// the overlay must stay hidden the whole time.
set_manual_time_step(&mut app, MOTION_TOOLTIP_DELAY_SECS * 0.1);
spawn_hovered_tooltip(&mut app, "Test");
// Two ticks: track_tooltip_hover records the hover start on
// tick #1; show_or_hide_tooltip on tick #2 sees a non-zero but
// sub-threshold elapsed. Both must keep the overlay hidden.
app.update();
app.update();
assert!(
matches!(overlay_visibility(&mut app), Visibility::Hidden),
"overlay must stay hidden before MOTION_TOOLTIP_DELAY_SECS elapses"
);
}
/// Advances Bevy's virtual clock far enough that any
/// `Time::elapsed()` reader observes more than
/// `MOTION_TOOLTIP_DELAY_SECS` of progress since the last
/// `track_tooltip_hover` recorded a hover start.
///
/// `Time<Virtual>` clamps each tick's delta to `max_delta`
/// (default 250 ms) regardless of how big the underlying
/// `TimeUpdateStrategy::ManualDuration` is, so a single oversized
/// step doesn't actually advance virtual time by that much. We
/// instead set a small per-tick step (200 ms — well under the
/// 250 ms clamp) and call `app.update()` enough times to exceed
/// the dwell threshold by a comfortable margin.
fn advance_past_tooltip_delay(app: &mut App) {
set_manual_time_step(app, 0.2);
// 5 ticks × 200 ms = 1.0 s — comfortably past the 0.5 s delay
// even after subtracting the first tick (when the hover gets
// recorded; that tick's elapsed-since-hover is zero).
for _ in 0..5 {
app.update();
}
}
/// Test 2: after the dwell delay, the overlay reveals and the
/// tooltip text matches the hovered entity's `Tooltip` string.
/// Position is intentionally not asserted: layout doesn't run under
/// `MinimalPlugins`, so `ComputedNode.size` is `Vec2::ZERO`. The
/// invariants we *can* check headlessly are visibility and text.
#[test]
fn tooltip_shows_after_delay() {
let mut app = headless_app();
spawn_hovered_tooltip(&mut app, "Test");
advance_past_tooltip_delay(&mut app);
assert!(
matches!(overlay_visibility(&mut app), Visibility::Visible),
"overlay must be visible after the dwell delay"
);
assert_eq!(
overlay_text(&mut app),
"Test",
"overlay text must reflect the hovered entity's Tooltip string"
);
}
/// Test 3: after the tooltip is shown, transitioning the target's
/// `Interaction` away from `Hovered` hides the overlay on the next
/// tick.
#[test]
fn tooltip_hides_on_unhover() {
let mut app = headless_app();
let target = spawn_hovered_tooltip(&mut app, "Test");
advance_past_tooltip_delay(&mut app);
assert!(
matches!(overlay_visibility(&mut app), Visibility::Visible),
"precondition: tooltip should be visible before un-hover"
);
// Unhover. `track_tooltip_hover` clears the state on the next
// tick because the entity transitions Hovered → None.
app.world_mut()
.entity_mut(target)
.insert(Interaction::None);
app.update();
assert!(
matches!(overlay_visibility(&mut app), Visibility::Hidden),
"overlay must hide once the target is no longer hovered"
);
}
/// Test 4: when the cursor switches from one tooltip entity to
/// another with different text, the overlay's text updates to match
/// the new target's string after the dwell delay.
#[test]
fn tooltip_text_updates_when_hovered_target_changes() {
let mut app = headless_app();
// Phase A: hover entity A and let its tooltip appear.
let a = spawn_hovered_tooltip(&mut app, "A label");
advance_past_tooltip_delay(&mut app);
assert_eq!(overlay_text(&mut app), "A label");
// Phase B: unhover A, hover B with a different label. Then
// advance time past the dwell delay again so B's tooltip can
// take over the overlay.
app.world_mut().entity_mut(a).insert(Interaction::None);
let _b = spawn_hovered_tooltip(&mut app, "B label");
advance_past_tooltip_delay(&mut app);
assert!(
matches!(overlay_visibility(&mut app), Visibility::Visible),
"B's tooltip must be visible after switching hover"
);
assert_eq!(
overlay_text(&mut app),
"B label",
"overlay text must update to the new hovered entity's Tooltip string"
);
}
}