Replace all display-name occurrences across web pages, Rust source, docs, and Cargo metadata. Update localStorage token key from sq_token to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
123 KiB
Changelog
All notable changes to Ferrous Solitaire are documented here. The format is based on Keep a Changelog and this project follows Semantic Versioning.
Unreleased
Fixed
- BUG-3: Multi-modal stacking (
hud_plugin.rs).handle_menu_buttonnow checksscrims.is_empty()— aQuery<(), With<ModalScrim>>guard — before callingspawn_menu_popover. Tapping ≡ while any modal (Stats, Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile could be open simultaneously. - UX-7: Help text single-line overflow (
help_plugin.rs). The HUD menu button description "Menu: Stats, Settings, Profile, Achievements" wrapped to two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)" which fits on one line. Verified on device. - UX-5b: Home mode glyph corruption (
home_plugin.rs). Mode selector icons were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono font — rendered as missing-glyph rectangles on Android. Replaced with card suits (U+2660–2666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge. - UX-1: Modal Done button in gesture zone (
safe_area.rs). Newapply_safe_area_to_modal_scrimsBevy system pads everyModalScrimbottom bySafeAreaInsets.bottom / scale_factor. Modal cards are now centred over the safe area, not the full physical screen. The Settings / Help / Stats Done buttons are reachable on gesture-nav Android devices. Verified on device.
[0.23.0] — 2026-05-12
Phase 8 sync UI: the self-hosted-server connection flow is now fully playable end-to-end. Players can open a Connect modal from Settings, enter a server URL + credentials, log in or register, and see the sync-status section update live. Token expiry auto-reopens the modal. Account deletion ships a two-click destroy flow. Server deployment artifacts (Dockerfile + docker-compose) let self-hosters spin up in one command.
Added
- Sync setup modal — Connect / Disconnect flow (
432061c). NewSyncSetupPlugin(solitaire_engine/src/sync_setup_plugin.rs) provides the full server-connection UI. Three tab-stopped text fields (URL, Username, Password) handle keyboard input viaMessageReader<KeyboardInput>with focus cycling on Tab. "Log In" and "Register" buttons each spawn an asyncAsyncComputeTaskPooltask that calls the newSolitaireServerClient::login()/::register()methods;poll_auth_taskharvests the result, stores tokens viastore_tokens(), hot-swapsSyncProviderResourceto the new server backend, firesManualSyncRequestEventto pull immediately, and closes the modal. An inlineSyncAuthErrorlabel displays credential errors without a toast. The modal is idempotent (existing.is_empty()guard) — safe to open programmatically. SyncConfigureRequestEvent,SyncLogoutRequestEvent,DeleteAccountRequestEvent(432061c). Three new engine events wire the Settings buttons → plugin handlers.SyncConfigureRequestEventopens the setup modal;SyncLogoutRequestEventdisconnects and resetsSyncProviderResourcetoLocalOnlyProvider;DeleteAccountRequestEventopens the deletion confirmation modal.- Settings sync section — dynamic backend UI (
432061c).sync_row()inSettingsPluginnow takesbackend: &SyncBackendand renders conditionally:Local→ "Connect" button;SolitaireServer→ username label + "Sync Now" + "Disconnect" + "Delete Account". Three newSettingsButtondiscriminants (ConnectSynctab 91,DisconnectSynctab 92,DeleteAccounttab 93) feed into a newhandle_sync_buttonssystem extracted fromhandle_settings_buttonsto stay within Bevy's 16-parameter system limit. SolitaireServerClient::login()and::register()(432061c). Both POST to/api/auth/loginand/api/auth/registerrespectively. Private helperextract_auth_tokensparses{ access_token, refresh_token }. 409 CONFLICT → "username already taken"; 401/403 → "invalid credentials"; 400 → server message echoed to the player.- Re-auth prompt on token expiry (
6ce5564).poll_pull_resultinSyncPluginnow firesInfoToastEvent("Session expired — please reconnect")+SyncConfigureRequestEventwhen the pull task resolves toSyncError::Auth(_). Because the modal is idempotent the re-open is safe to trigger from any system path. - Server deployment artifacts (
6ce5564).solitaire_server/Dockerfile: multi-stage build (rust:1.95-slim→debian:bookworm-slim); copies.sqlxoffline cache soSQLX_OFFLINE=truesucceeds without a live database at build time; exposes port 8080.solitaire_server/docker-compose.yml: single-service compose file;db-datavolume at/app/data;DATABASE_URLandJWT_SECRETfrom environment; HTTP health-check viawget.solitaire_server/.env.example: documents all required variables with generation hint (openssl rand -hex 32). - Account deletion flow (
272d31f). "Delete Account" in Settings firesDeleteAccountRequestEvent→SyncSetupPlugin::open_delete_confirm_modalspawns a danger-red confirmation modal with "Cancel" and "Delete Forever" buttons. "Delete Forever" submits an asyncPendingDeleteTaskthat callsSyncProvider::delete_account();poll_delete_taskon Ok firesSyncLogoutRequestEvent+ a success toast; on Err shows an error toast and leaves the modal open. Two-click destroy pattern — no accidental account deletion possible.
Removed
SyncAuthResultEvent(432061c). Defined but never emitted or consumed; removed as dead code.
Stats
- Tests: 1300+ passing / 0 failing
- Clippy: clean
- Crates touched:
solitaire_data(sync_client),solitaire_engine(events, settings_plugin, sync_plugin, sync_setup_plugin [new], lib),solitaire_app(lib.rs),solitaire_server(Dockerfile, docker-compose.yml, .env.example [new])
[0.22.0] — 2026-05-08
Adds difficulty-tier game selection, Android JNI bridges for keystore and clipboard, Play-by-Seed dialog, and double-tap auto-move on touch screens. Also closes the Prev/Next replay-selector spawn-site item carried since v0.19.0.
Added
- Difficulty-tier game mode (this release).
DifficultyLevelenum (Easy / Medium / Hard / Expert / Grandmaster / Random) added tosolitaire_core::game_statealongside a newGameMode::Difficulty(DifficultyLevel)variant. Five pre-verified seed catalogs (40 seeds each, 200 total) are generated by the newgen_difficulty_seedsbinary insolitaire_assetgen; each catalog contains seeds proven winnable at progressively larger solver budgets (1 K → 200 K moves).DifficultyPluginresolvesStartDifficultyRequestEvent→ catalog seed →NewGameRequestEvent; theRandomtier uses a system-time seed and intentionally bypasses the winnable-only filter. The home overlay gains an expandable▶ Difficultysection between the Draw Mode row and the mode-card grid; the last-played tier is persisted inSettings::last_difficultyand pre-expands/highlights on re-open. Difficulty wins pool into Classic stats (no separate buckets). - Prev/Next replay selector in the Stats overlay (
a449f60).ReplayPrevButton,ReplayNextButton,ReplaySelectorCaption, andReplaySelectorDetailnodes now spawn insidespawn_stats_screenas a flex row of two bordered chips flanking a"Replay N / M"caption, with a detail line below showing the selected replay's duration + date and an optional"· Shareable"badge. Both chips carryModalButton(Secondary)so the existingrepaint_modal_buttonspaint loop gives them hover/press feedback at zero extra cost.repaint_replay_selector_detailis wired into the existing.chain()alongsidehandle_replay_selector_buttonsandrepaint_replay_selector_caption. The click handler and repaint systems have been registered (and dormant) since v0.19.0; this commit is purely the missing spawn site. - 6 new selector unit tests (
a449f60). Covers: spawn-site presence (Prev, Next, Caption, Detail all spawn with the screen), caption initial text ("Replay 1 / 1"), detail initial text ("{dur} win on {date}"), Shareable badge whenshare_urlis set, empty-history "No replays" caption, and ordinal wrapping.make_test_replay(time_seconds, share_url)helper encapsulatesReplay::new(...)+chrono::NaiveDate.
Fixed
const { assert!() }for dim-layer z-order test (a449f60). Convertedassert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …)inreplay_overlaytests toconst { assert!(…) }to satisfyclippy::assertions_on_constants(constant-fold at compile time rather than a runtime no-op).
Added (post-cut, same pending release)
- Double-tap auto-move on touch screens (
395a322).handle_double_tapfiresMoveRequestEvent(single card to foundation/tableau, or a whole face-up stack viabest_tableau_destination_for_stack) when twoTouchPhase::Endedevents on the same card arrive withinDOUBLE_TAP_WINDOW(0.5 s, slightly wider than the mouseDOUBLE_CLICK_WINDOWto account for touch latency). If no legal destination exists, firesMoveRejectedEvent(audio + visual rejection feedback). The system is inserted into the touch drag chain immediately beforetouch_end_dragsoDragState.active_touch_idandcommittedare still readable; the tap timestamp is tracked in aLocal<HashMap<u32, f32>>keyed by card ID. - Play-by-Seed dialog (
0cb1587).PlayBySeedPluginadds a numeric-input modal that accepts a decimal seed, runs a solver preview in the background (debounced 500 ms viaAsyncComputeTaskPool), and shows a win/no-win verdict before dealing. A newHomeMode::PlayBySeedcard in the home overlay firesStartPlayBySeedRequestEvent; the handler inPlayBySeedPluginspawns the dialog. Digit, Backspace, Enter (confirm), and Escape (cancel) are handled viaButtonInput<KeyCode>. Five unit tests cover spawn, digit append, buffer read, confirm, and cancel paths. - 75 new challenge seeds (
2062bd0). Newgen_seedsbinary insolitaire_assetgenbrute-searches seeds in the0xCAFEBABE…namespace and filters for hands solvable in ≤250 moves via the core solver. The 75 confirmed-win seeds are appended toCHALLENGE_SEEDSinsolitaire_data::challenge.
Fixed (post-cut, same pending release)
- Gate
handle_fullscreento non-Android (45436d0). F11 fullscreen toggle makes no sense on Android (the OS owns window sizing); the fn and itsMonitorSelection/WindowModeimports are now#[cfg(not(target_os = "android"))]-gated. Theadd_systemscall is extracted as a separate statement so#[cfg]can annotate it (attributes cannot appear mid-chain in Rust). - Android APK launch: export
android_main(202a64d).NativeActivitydlopen-slibsolitaire_app.soand callsandroid_mainas its entry point. Without the symbol the app crashed immediately withUnsatisfiedLinkError. The new function setsbevy::android::ANDROID_APP(required byWinitPlugin) then delegates torun()— equivalent to what#[bevy_main]would generate, but usable on an arbitrary entry point name. - Android APK launch: gate
resize_constraintsto non-Android (202a64d). On Androidmax_width/max_heightdefault to0.0; Bevy's clamp panicked withmin=800 > max=0. - Android APK launch: gate
apply_smart_default_window_sizeto non-Android (202a64d). The system calls.clamp(800.0, logical_w)which panics when the emulator reports zero window dimensions during early Android lifecycle events. The OS controls window size on Android; the system is irrelevant there. - Ignore
.idea/IDE project files (16242e6). Android Studio created.idea/when the project was opened during APK verification; added to.gitignoreand removed the accidentally- committed files.
Android verification result
APK boots on x86_64-linux-android in a Pixel_7 AVD (Android 14 /
API 34, SwiftShader Vulkan). App runs for 2+ minutes without crashing.
Bevy renderer initialises, splash screen loads. This is the first
confirmed end-to-end device run.
Stats
- Tests: 1300+ passing / 0 failing
- Clippy: clean
- Crates touched:
solitaire_core(game_state),solitaire_data(settings, stats, difficulty_seeds, challenge),solitaire_engine(events, difficulty_plugin, home_plugin, hud_plugin, win_summary_plugin, input_plugin, play_by_seed_plugin, lib),solitaire_app(lib.rs),solitaire_assetgen(gen_difficulty_seeds + gen_seeds binaries)
[0.21.8] — 2026-05-08
Patch release for replay-overlay polish. Through-line: notch-label centering + WIN MOVE HC legibility + HC system extension. All three items were "optional polish" flagged in the v0.21.7 handoff; all three ship in two commits.
Added
STATE_SUCCESS_HCconstant (c50eaf8). Brighter lime (#c8e862, L≈0.73) inui_themefor use wherever the standardSTATE_SUCCESS(#acc267, L≈0.51) needs extra luminance under HC mode. Sits above the bumped notch ticks (BORDER_SUBTLE_HCgray, L≈0.60) so a WIN MOVE marker at this colour is unambiguous.HighContrastBackground::with_hc(default, hc)constructor (c50eaf8). ExtendsHighContrastBackgroundwith anhc_color: Colorfield (default =BORDER_SUBTLE_HCviawith_default()).update_high_contrast_backgroundsnow readsmarker.hc_colorinstead of the hardcoded constant — backwards-compatible; all existingwith_default()usages continue to bump to gray.- WIN MOVE scrub-bar marker HC bump (
c50eaf8). Marker now carriesHighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC)so the lime stays lime under HC (brighter lime rather than gray). Pin test locks both the default and HC colour fields on the spawned entity.
Fixed
- Scrub-bar notch-label centering (
b44d277). Middle three labels ("25%", "50%", "75%") previously had their left edge at the notch; now their text centre coincides with the notch tick. Implemented using the CSStranslateX(-50%)pattern for Bevy 0.18 UI: a fixedSCRUB_LABEL_CENTER_WIDTH = 36 pxcontainer withmargin.left = -18 pxis placed atleft: Percent(pct), andJustify::Centercentres the text within it. Endpoint labels ("0%", "100%") keep their flush-left / flush-right anchoring.with_default()remains one-argument.
Stats
- Tests: 1276 passing / 0 failing (engine: 831)
- Clippy: clean
- Crates touched:
solitaire_engine(replay_overlay.rs, ui_theme.rs, settings_plugin.rs)
[0.21.7] — 2026-05-08
Patch release closing the last major B-2 sub-piece. Through-line: mini-tableau preview dim layer. The mockup's "Game Peek Band at 50 % opacity" is now implemented as a full-screen UI scrim that darkens the card world during replay so the chrome (banner + move-log panel) reads clearly against the scene.
Added
- Full-screen tableau dim layer (
da3e542). Spawns aReplayTableauDimLayerUI node (100 % × 100 %, 50 % opacity black) atZ_REPLAY_DIM = Z_REPLAY_OVERLAY − 1 = 54whenever a replay starts; despawned alongside the banner and move-log panel when the replay ends. Bevy's UI/world compositor means no changes tocard_pluginare needed — UI nodes always render above world-space sprites regardless ofTransform.z. The dim layer carries noInteractioncomponent (purely visual; pointer events pass through). AddsZ_REPLAY_DIMandTABLEAU_DIM_ALPHAconstants plus two new tests: lifecycle (spawn/despawn mirrors the floating-chip pattern) and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAYpinned). 1275 tests pass / 0 failing.
Stats
- Tests: 1275 passing / 0 failing
- Clippy: clean
- Crates touched:
solitaire_engine(replay_overlay.rs)
[0.21.6] — 2026-05-08
Patch release for the post-v0.21.5 work. Through-line: Move Log panel + scrub-UX polish. v0.21.5 closed out the keyboard-accelerator surface (Space / Esc / ← / →) and the keybind footer; v0.21.6 builds on that with two parallel threads — accessibility + scrub-on-hold polish for the v0.21.5 surfaces, plus a brand-new Move Log panel anchored to the viewport's bottom edge that gives players a 5-row recent-and- upcoming move history alongside the existing top-edge banner.
The Move Log panel is the first replay-overlay surface that isn't attached to the banner — it lives at a separate screen anchor (bottom: 0) with its own spawn/despawn lifecycle. Establishes the pattern for "multi-anchor replay UI" that the remaining B-2 sub-piece (mini-tableau preview) will inherit.
Added
- HC-mode coverage for the scrub track + quarter-mark notch
ticks (
d3cb1a5). Adds parallel primitiveHighContrastBackgroundtoui_themeand a paint systemupdate_high_contrast_backgroundsinsettings_pluginthat mirrors the existing border-marker pattern but targetsBackgroundColorinstead ofBorderColor. Tags the 1 px scrub track Node and all five quarter-mark notch ticks so they bump fromBORDER_SUBTLE(#505050) →BORDER_SUBTLE_HC(#a0a0a0) under HC mode. Scrub fill (ACCENT_PRIMARY) and WIN MOVE marker (STATE_SUCCESS) don't get the marker — accent and state colours are already saturated and don't need an HC luminance variant. - Continuous scrub on key-held arrow keys (
2e25476). Holding ← or → triggers continuous step at 100 ms cadence (10 steps/sec) — matches the mockup's[← →] scrubterminology while keeping single-press = single-step semantics. Per-key accumulators in a newReplayScrubKeyHoldresource;just_pressedevents bypass the accumulator and fire immediately. Release resets to 0 so the next fresh press fires immediately rather than at half-interval. - Move Log panel (
d6f32d3+140251b+e7345ae+4437a1a). New bottom-edge UI panel showing a 5-row window onto recent + upcoming moves: 2 prev rows above the active row + active row highlighted inACCENT_PRIMARY+ 2 next rows below. Header reads▌ MOVE LOG · N/M(or▌ MOVE LOG · COMPLETEwhen finished). Active row carries a▶focus prefix andTEXT_PRIMARY_HCtext colour for legible contrast against the brick-red highlight. Prev / next rows render inTEXT_SECONDARYso the active row stays the focal point.- Sibling-of-banner pattern (separate root entity anchored
at viewport bottom, not a banner child) — same
spawn/despawn lifecycle as
ReplayFloatingProgressChip, different screen anchor. - Five pure helpers handle the formatting:
format_pile,format_move_body,format_move_log_header,format_kth_recent_row(active- prev),
format_kth_next_row(next). 1-indexed display numbers throughout (Foundation(2)reads as "foundation 3" rather than the enum's 0-index).
- prev),
- Panel grows from 56 → 84 → 112 px across the four
move-log commits.
MOVE_LOG_PREV_ROWSandMOVE_LOG_NEXT_ROWSconstants (both = 2) parameterise the row count;format_kth_recent_rowandformat_kth_next_rowreturn empty for out-of-range k so panels gracefully under-fill at the start (cursor=1) and end (cursor=N-1) of a replay. - HC marker on the panel's top border so the 1 px edge bumps under HC mode (same pattern as the keybind footer).
- Sibling-of-banner pattern (separate root entity anchored
at viewport bottom, not a banner child) — same
spawn/despawn lifecycle as
Changed
react_to_state_changedespawns the Move Log panel onPlaying → Inactivealongside the banner root and floating progress chip. Third query in the same defer-and-despawn cycle.- Move Log panel height grew 56 → 84 → 112 px across the
prev-rows and next-rows commits. The panel is sized to fit
the chosen row count + header + padding; tunable via the
MOVE_LOG_PANEL_HEIGHTconst. format_active_move_rownow prefixes the▶focus marker (e7345ae). Wrapsformat_kth_recent_row(state, 1)and prepends the prefix when the body is non-empty. Empty case still returns empty — cursor=0 doesn't paint a stray▶on an otherwise-empty row.
Documentation
SESSION_HANDOFF.mdrefreshed twice this cycle — once recording the HC paint + continuous-scrub polish, then again as the Move Log arc shipped commit-by-commit. The Resume menu's B option now traces the full arc: notches → labels → footer → ESC → HC → arrow keys → HC paint → continuous scrub → move log.
Stats
- 1273 passing tests / 0 failing across the workspace
(net +23 from v0.21.5's 1250 baseline):
- 2 from
d3cb1a5(HC marker on track + notches). - 2 from
2e25476(continuous-scrub repeat-while-held + release-resets-accumulator). - 8 from
d6f32d3(move-log panel init + 5 helpers + 3 spawn / lifecycle scenarios). - 4 from
140251b(prev rows: helper k coverage + spawn cardinality + spawn texts + repaint on cursor advance). - 3 from
e7345ae(active row highlight: wrapper bg + text colour + focus prefix + cursor=0 stays empty). - 4 from
4437a1a(next rows: helper k coverage + spawn cardinality + spawn texts + under-fill at replay end).
- 2 from
- Clippy clean across the workspace.
[0.21.5] — 2026-05-08
Patch release for the post-v0.21.4 work. One through-line: replay-overlay scrubbing affordances + accessibility. v0.21.4 shipped pause / resume / step + the WIN MOVE marker as the first scrubbing-shaped additions to the replay overlay; v0.21.5 fills out the rest of the scrubbing UX so the player has both visual anchor points (notches + labels) and a complete keyboard control surface (Space / Esc / ← / →) for navigating a paused replay.
Two of the six commits in this cycle are layout-changing — they grow the banner height from 60 px → 76 px → 92 px to make room for the notch labels and keybind footer. Banner geometry was fixed for every prior B-2 commit; this release establishes the "grow the container, add a flex-column child" pattern that the remaining B-2 sub-pieces (move-log scroller, mini-tableau preview) will inherit when they land.
Added
- Quarter-mark scrub-bar notches (
fe68861). Five 1 px vertical ticks at 0 / 25 / 50 / 75 / 100 % give the player visual anchor points without needing to mentally bisect the bar. Pure helperscrub_notch_positions()returns the fixed array; spawn loop sits next to the WIN MOVE marker spawn so the lifecycles match. Notches paint inBORDER_SUBTLE(same as the unfilled track) and rely on extending past the 1 px track (5 px tall, anchored 2 px above the track top) for visibility — same trick the WIN MOVE marker uses. Spawned after the WIN MOVE marker so a notch and the marker landing on the same percentage paint the marker on top. - Percentage labels under each notch (
d322abf). Five0%/25%/50%/75%/100%labels in a new 16 px row beneath the 1 px scrub track give the player explicit quarter-mark readouts. Banner grew from 60 → 76 px to accommodate the row — first layout-changing commit in the B-2 arc. Pure helperscrub_notch_labels()returns the fixed array, paired index-for-index withscrub_notch_positions(). Spawn loop applies an "endpoints flush, middle three percent-anchored" positioning pattern: leftmost label getsleft: 0, rightmost getsright: 0, middle three anchor atleft: Val::Percent(p)since Bevy 0.18 UI lacks a clean CSS-styletranslate-x: -50%centering primitive. Label colour isTEXT_SECONDARYrather than the mockup'sBORDER_SUBTLE(the latter would match the notches but is too low-contrast againstBG_ELEVATED_HIto read at 12 px). - Keybind-hint footer (
1873b3f). Vim-style mode line on the left (▌ NORMAL │ replay) plus a keybind hint on the right at the bottom edge of the banner. Banner grew from 76 → 92 px to fit the 16 px footer row. Surfaces every wired keyboard accelerator visually so CLAUDE.md §3.3's UI-first contract holds for keyboard accelerators too. The footer lists only keybinds that are actually wired — the only-wired-keybinds discipline means each release cycle's hint string is a precise honest contract with the player. Two pure helpers (keybind_footer_mode_text,keybind_footer_hint_text) keep the static text testable. 1 px top border inBORDER_SUBTLEseparates the footer from the labels row. - ESC keyboard accelerator for replay-stop (
90e24d9). Newhandle_stop_keyboardsystem parallelshandle_pause_keyboardin shape — fires only when state isPlaying, callsstop_replay_playback. Cross-plugin coordination viapause_plugin::toggle_pause: added a fourth defer-if check (replay_state.is_some_and(|s| s.is_playing())) right after the existingother_modal_scrimscheck so ESC during active replay belongs to the replay overlay, not the pause modal. - HC-mode coverage for the keybind-footer top border
(
23902cd).HighContrastBorder::with_default(BORDER_SUBTLE)marker on the footer's border-carrying Node so the existingapply_high_contrast_borderssystem bumps the 1 px top border from#505050→#a0a0a0whenSettings::high_contrast_modeis on. Without the marker the footer reads as floating loose under HC because the border that anchors it to the labels row is near-invisible. - ← / → keyboard accelerators for paused stepping
(
e5c4f51). Newstep_backwards_replay_playbackinreplay_playback.rsdecrements the cursor and dispatchesUndoRequestEvent; the game'shandle_undoreads it next frame to reverse its most-recent move. Hooks the existing undo system rather than replaying-forward-from- zero — every replay-applied move pushes to the undo stack the same way a player move would, so undo is the right reversal primitive. Both arrow keys are paused-only via the same destructure-gate pattern the forward step uses. The mockup labels these[← →] scrub; single-move step is the closest behaviour shippable today, so the footer hint reads[← →] step— only-wired-keybinds discipline.
Changed
- Banner height grew 60 → 76 → 92 px across two
layout-changing commits (
d322abfthen1873b3f). Top row'sflex_grow: 1.0still consumes 59 px so the existing content (label / progress chip / buttons) has the same vertical space; the new rows (16 px labels + 16 px footer) extend the banner downward into the gameplay area. Banner geometry is now mutable — every prior B-2 commit fit inside fixed 60 px space. - Keybind-footer hint text grew alongside the wirings:
[SPACE] pause/resume→[SPACE] pause/resume · [ESC] stop→[SPACE] pause/resume · [ESC] stop · [← →] step. pause_plugin::toggle_pausenow defers when a replay is active (90e24d9). Adds a fourth defer-if check to the existing modal-stack pattern.ReplayOverlayPluginregistersadd_message::<UndoRequestEvent>()(e5c4f51). Defensive registration so the plugin runs cleanly underMinimalPluginswithoutGamePluginattached.
Documentation
SESSION_HANDOFF.mdrefreshed five times this cycle. The B option in the Resume menu now traces the full arc: notches → labels → footer → ESC → HC → arrow keys.- The pre-existing
daily_challengewarning test that fails when wall-clock UTC is within 30 minutes of midnight is documented in this cycle's handoff. Same shape as the earlierwinnable_seed_searchflake — time-dependent, deterministically passes outside the trigger window.
Stats
- 1250 total tests / 1249 passing / 1 pre-existing
time-dependent flake across the workspace (net +22 from
v0.21.4's 1228 baseline):
- 4 from
fe68861(scrub-notch coverage) - 4 from
d322abf(notch-label coverage) - 4 from
1873b3f(keybind-footer coverage) - 3 from
90e24d9(ESC-accelerator coverage) - 1 from
23902cd(HC-marker coverage) - 6 from
e5c4f51(arrow-keyboard coverage)
- 4 from
- Pre-existing flake:
daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_dayfails when wall-clock UTC is within 30 minutes of midnight. Verified pre-existing by stash-and-retest before each commit. Will pass deterministically outside the trigger window. Not introduced by this release. - Clippy clean across the workspace.
[0.21.4] — 2026-05-08
Patch release for the post-v0.21.3 work. One through-line: replay-scrubbing accessibility. The replay overlay used to be pure-passive — the player started a replay, watched it execute, and waited for it to end. v0.21.4 adds the scaffolding for navigating within a replay: a WIN MOVE marker on the scrub bar so the player can see at a glance where the winning move sits, and pause / resume / step controls so they can stop on any move and inspect the board.
The work is also the first three commits on the B-2 replay screen-takeover redesign arc. The remaining pieces (screen- takeover layout, move-log scroller, mini-tableau preview) are deferred to a future cycle because they need a layout reflow that the existing banner-only overlay can't carry.
Added
Replay::win_move_index: Option<usize>data field (ab857bb). Additive optional field on the persistedReplayshape.#[serde(default)]keeps olderlatest_replay.json/replays.jsonfiles loadable without bumpingREPLAY_SCHEMA_VERSION— this is purely additive. Populated at the live recording site (game_plugin::handle_game_won) via a new builder-style setterReplay::with_win_move_index. For fresh recordings the value is alwaysSome(moves.len() - 1)because recording freezes on win, but storing it explicitly lets the playback UI read the WIN MOVE position directly without re-deriving on every render.- WIN MOVE scrub-bar marker (
52befa6). NewReplayOverlayWinMoveMarkercomponent spawned as a sibling toReplayOverlayScrubFillunder the 1px scrub track, absolute-positioned atreplay.win_move_index / total %of the bar. Painted inSTATE_SUCCESS(green) so the marker reads as "this is where the win lives." Pure helperwin_move_marker_pctreturnsNonefor any state where the marker shouldn't draw (Inactive, Completed, replay missing the field, empty move list); percentage clamps to[0, 100]defensively. Spawn-time only — the position never changes during a single playback because the underlyingReplayis immutable whilePlaying. - Pause / Resume / Step playback controls (
fbe48ac). Newpaused: boolfield onReplayPlaybackState::Playing.tick_replay_playbackskips thesecs_to_nextdecrement entirely while paused so cursor and timer freeze together; resuming starts the next move from a full interval. New public API:toggle_pause_replay_playbackandstep_replay_playback(the latter hard-gated toPlaying { paused: true }via the destructure pattern itself, so manual stepping can't race the tick loop). On-screen Pause and Step buttons sit alongside the existing Stop button;Spacekeyboard accelerator toggles pause / resume. Replay::with_win_move_indexbuilder (ab857bb). Chainable setter so the recording site can writeReplay::new(...).with_win_move_index(idx). KeepsReplay::new's signature stable across the 13+ existing test-fixture call sites that don't care about the field.
Changed
Replay::newwriteswin_move_index: None(ab857bb). Existing canonical constructor stays signature-compatible with all existing callers. The field is opt-in via the builder.game_plugin::handle_game_wonpopulates the new field (ab857bb). The recording site computesrecording.moves.len().checked_sub(1)as the win-move index.checked_subrather than direct subtraction guards the unreachable empty-recording branch (which is also guarded earlier in the function).tick_replay_playbackhonors the newpausedflag (fbe48ac). Skipping the timer decrement is the only behavior change; the loop body and Completed-detection are unchanged. Stepping fires moves directly viastep_replay_playback, bypassing the tick path entirely.- Pause / Resume button label is reactive (
fbe48ac).update_pause_button_labelwalksChildrenfrom the marked button to its innerTextand repaints the label wheneverReplayPlaybackStatechanges. Pure helperpause_button_labelcovers all four state arms (running, paused, inactive, completed). - 25 existing
Playing { ... }construction sites gainedpaused: false(fbe48ac). Mechanical edit acrossreplay_overlay,achievement_plugin, andreplay_playbacktests to satisfy the new field requirement. No behavioral change.
Documentation
SESSION_HANDOFF.mdrefreshed three times this cycle — once after each post-cut feature commit. The B-2 entry in the Visual-identity follow-ups list now points at the remaining sub-pieces (screen-takeover layout, move-log scroller, mini-tableau preview) as a single multi-session arc rather than three independent ones, since they share a layout-reflow prerequisite.
Stats
- 1228 passing tests / 0 failing across the workspace
(net +21 from v0.21.3's 1207 baseline):
- 5 from
ab857bb'swin_move_indexcoverage: default constructor, builder set / set-None, on-disk round-trip, legacy-JSON-loads-with-None backward-compat. The last test pins the no-schema-bump claim — if a future refactor drops the#[serde(default)], that test catches it. - 8 from
52befa6's WIN MOVE marker: pure-helper truth table (Inactive / Completed / no-field / correct-position / clamp) + spawn-presence-with-field / spawn-absence-without / despawn-with-overlay observables. - 8 from
fbe48ac's playback controls: label truth table, label repaint on state change, click-toggles-paused, step advances cursor by exactly one with paused preserved, step-while-running no-op, Space toggles paused.
- 5 from
- Zero clippy warnings under
cargo clippy --workspace --all-targets -- -D warnings. cargo test --workspaceclean.
[0.21.3] — 2026-05-08
Patch release for the post-v0.21.2 work. One through-line:
accessibility arc closure. v0.21.2 explicitly carved out
"dynamic-paint sites" (HUD action buttons, modal buttons, radial
menu rim) on the assumption that their existing paint cycles would
race the central update_high_contrast_borders system. v0.21.3
walks the actual code, finds the carve-out was over-cautious, and
closes it. Bonus: the first real consumer of ToastVariant::Warning
also lands here, making the ToastVariant enum fully load-bearing
(every variant has at least one driver).
Added
WarningToastEvent(String)— firstToastVariant::Warningconsumer (279e23d). Generic carrier message that any system can fire to spawn a 4 s amber-bordered fire-and-forget toast. Mirrors the v0.21.2MoveRejectedEvent→Errortoast wiring: domain message crosses the plugin boundary, the animation plugin'shandle_warning_toastsystem reads it and spawns. Not queued (Warning is alert-shaped, not info-shaped — should never block on a queue).- Daily-challenge-expiry warning (
279e23d). First in-engine driver ofWarningToastEvent. Newdaily_challenge_plugin::check_daily_expiry_warningsystem fires at most once perDailyChallengeResource::datewhen the player is within 30 min of UTC midnight reset and today's challenge isn't yet complete. Suppression decided by a pure helper (compute_expiry_warning_minutes) covering: already- completed-today, already-shown-for-this-date, outside the threshold window, post-midnight rollover. Pure-helper-plus- thin-system shape becauseUtc::now()can't be pinned without injecting a clock resource — overkill for one consumer. radial_rim_outlinepure helper (c153363). Decision logic for the radial-menu rim outline colour. Resting outlines always carryBORDER_SUBTLE; focused outlines carryBORDER_STRONGnormally andBORDER_SUBTLE_HCunder HC. Naive marker substitution would invert the focused-vs-resting hierarchy becauseBORDER_SUBTLE_HC(#a0a0a0) is lighter thanBORDER_STRONG(#505050); folding the choice in here keeps the focused rim more visible under HC, not less.
Changed
- HC marker pattern extended to HUD action buttons + modal
buttons (
c153363). Re-reading the code revealed both sites' paint systems (paint_action_buttons,paint_modal_buttons) only mutateBackgroundColor—BorderColoris set once at spawn and never touched. So the existingHighContrastBorder::with_default(BORDER_SUBTLE)marker pattern works cleanly for both, no race. v0.21.2's carve-out comment was based on assumed-but-not-actual race risk; this cycle treats it as the doc-vs-implementation drift pattern in the wild and verifies before trusting. - Radial menu rim folds HC into per-frame respawn
(
c153363). The rim is the only true dynamic-painter of the three carved-out sites —radial_redraw_overlaydespawns and respawns all rim sprites every frame the radial isActive. TheHighContrastBordermarker can't apply (entities don't persist across frames) so HC is read directly in the system viaOption<Res<SettingsResource>>and routed throughradial_rim_outline. TheOption<Res<...>>shape preserves test compatibility underMinimalPlugins. - Animation plugin registers
WarningToastEvent(279e23d). JoinsInfoToastEvent,MoveRejectedEventetc. inAnimationPlugin::build. Daily-challenge plugin also registers it (idempotent) so the message exists when running the daily plugin underMinimalPluginswithout the animation plugin attached.
Documentation
SESSION_HANDOFF.mdrefreshed twice this cycle — once after the Toast Warning wiring (menu trimmed 5 → 4 options), and again after the HC dynamic-paint rollout (menu trimmed 4 → 3, with all remaining options now flagged as multi-session). TheHigh-contrast accessibility modeentry in the Visual-identity follow-ups list is updated to reflect that no "un-tagged because race-risk" surfaces remain.
Stats
- 1207 passing tests / 0 failing across the workspace
(net +12 from v0.21.2's 1195 baseline):
- 7 tests for
compute_expiry_warning_minutes(279e23d) covering each suppression rule + the inclusive boundary at exactly 30 min remaining. - 1 in-Bevy test (
check_system_fires_warning_event_only_once_per_day) pinningDailyExpiryWarningShown's once-per-date suppression and the symmetric "already-completed-today" suppression. - 4 truth-table tests for
radial_rim_outline(c153363): focused × HC. The "resting stays subtle under HC" test explicitly documents why — it's the hierarchy-preservation invariant a future refactor might be tempted to break.
- 7 tests for
- Zero clippy warnings under
cargo clippy --workspace --all-targets -- -D warnings. cargo test --workspaceclean.
[0.21.2] — 2026-05-08
Patch release for the post-v0.21.1 polish work. Three through-
lines: accessibility extensions (reduce-motion gating for
splash animations, full HC chrome rollout across 8 surfaces),
replay polish (floating MOVE chip above the focused card
during playback), and the first real consumer of
ToastVariant::Error (invalid-move feedback as the third leg
of the existing audio + visual rejection-feedback stool).
The accessibility extensions close two threads v0.21.1 left
explicitly open: reduce-motion was previously gated only on card
slide_secs, and HC borders had BORDER_SUBTLE_HC defined but no
consumers. v0.21.2 finishes both — non-essential motion in the
splash boot screen now respects reduce-motion, and every static-
border chrome surface (modal scaffold, tooltip, help / stats /
home / settings panels) boosts to the HC variant under high-
contrast mode. Dynamic-paint sites (HUD action buttons, modal
buttons, radial menu rim) intentionally stay un-tagged because
their existing paint cycles would race the HC system; they
remain open for a future iteration that needs a different shape.
Added
sync_pile_marker_visibilitysystem precursor was v0.21.1's; this cycle adds:update_high_contrast_borderssystem insettings_plugin(c9af1ea). Walks all entities tagged withHighContrastBordereach Update tick, swapsBorderColortoBORDER_SUBTLE_HCwhen high-contrast mode is on. Compares current colour and only mutates when different so Bevy's change-detection doesn't trigger repaints every frame. NewHighContrastBorder { default_color: Color }component carries the off-state colour at each tagged site so the system can revert correctly.- HC chrome rollout — 8 tagged surfaces (
c9af1eamodal scaffold;d87761dtooltip + onboarding key chips + help panel key chips + stats panel cells;ec804d5home Level/XP/ Score row + home mode-selector buttons + home mode-hotkey chips + 4 settings panel surfaces). Each tagging is one line on the spawn tuple. The marker-component architecture pays back proportionally to the number of consumers — the per- commit cost dropped from ~75 lines (foundation + first surface) to ~13 lines (4 surfaces) to ~9 lines (7 surfaces). - Floating MOVE chip during replay (
2fb2d63). NewReplayFloatingProgressChipmarker on aText2dentity rendered in 2D world space above the destination pile of the most-recently-applied move. Sibling of the banner overlay (not a child) because it lives in world-space coordinates, not the UI tree. Lifecycle matches the banner:spawn_overlayspawns the chip alongside the banner when a replay starts;react_to_state_changedespawns it when the replay ends. World-space placement (rather than UI-space + camera projection) uses the sameLayoutResourcepile coordinates that drive every other piece of pile geometry — stays correctly positioned through window resizes for free. Hidden when cursor=0 (no moves applied yet) or when the last applied move was aStockClick(no destination pile to follow). handle_move_rejected_toastsystem + first realToastVariant::Errorconsumer (68d50b5). WhenMoveRejectedEventfires (illegal placement attempt), spawns a 2-second pink-bordered "Invalid move" toast. Joins the existingcard_invalid.wav(audio cue) and destination-pile shake (visual cue) as the accessibility-focused readable text channel — covers deaf players (no audio reliance) and reduce-motion players (no shake reliance) with a persistent ~2 s text cue. Drops the#[allow(dead_code)]fromToastVariant::Errorand updates its doc to point at the new consumer.
Changed
- Splash scanline overlay skipped under reduce-motion
(
ed152e2).spawn_splashreadsSettings::reduce_motion_modeand skips the scanline texture / overlay node entirely when on. Without the scanlines the boot screen still reads as terminal-themed (foreground content, borders, palette swatches unchanged); the scanlines are decorative. - Splash cursor pulse held under reduce-motion (
ed152e2).pulse_splash_cursorreadsSettings::reduce_motion_modeand skips the per-frame sine-pulse multiplier when on — the cursor still fades in / out with the global splash alpha (essential timing) but doesn't blink. Spec calls out non-essential motion as the reduce-motion target; the global fade is essential (otherwise the splash would hard-cut on/off, which is jarring), and the cursor blink is decorative. AnimationPlugin::buildregistersMoveRejectedEvent(68d50b5). Bevy'sadd_messageis idempotent, so the duplicate registration withfeedback_anim_plugin(which already registered the message) coexists cleanly. Required for the newhandle_move_rejected_toastsystem to run under MinimalPlugins (tests).
Documentation
docs/ui-mockups/design-system.mdandSESSION_HANDOFF.mdrefreshed in lockstep with the rollouts. The handoff's Resume-prompt menu trimmed twice this cycle as Options A and F closed in v0.21.1, then this commit cycle's accessibility extensions implicitly closed the "future scope" footnotes v0.21.1 left on F's documentation.
Stats
- 1195 passing tests / 0 failing across the workspace
(net +3 from v0.21.1's 1192 baseline). New tests added by
this cycle:
splash_skips_scanline_overlay_under_reduce_motion(ed152e2) pins the reduce-motion gate on the splash scanline overlay. Discovered an asset-fixture bootstrapping detail along the way: underMinimalPlugins,Assets<Image>isn't auto-inserted; the test had to addbevy::asset::AssetPlugin::default()andinit_asset::<bevy::image::Image>(). Pattern flagged for future asset-using tests.floating_chip_spawns_and_despawns_with_overlay(2fb2d63) pins the floating MOVE chip's lifecycle: absent on Inactive, exactly one on Playing, absent again on return to Inactive.move_rejected_event_spawns_error_toast(68d50b5) pins the new toast wiring: firing aMoveRejectedEventspawns exactly oneToastOverlayon the next tick.
- Zero clippy warnings under
cargo clippy --workspace --all-targets -- -D warnings. cargo test --workspaceclean.
[0.21.1] — 2026-05-08
Patch release for the post-v0.21.0 work — closes Resume-prompt Options A (app icon) and F (high-contrast + reduce-motion accessibility modes), plus a card-visual iteration cycle that moved through three states: the v0.21.0 Terminal pink/gray, a brief 4-colour-deck experiment (hearts pink, diamonds gold, clubs lime, spades gray), and a reversion to traditional 2-colour "Microsoft Solitaire on dark mode" pairing (saturated red + near-white). Two visible bugs surfaced and were fixed during the iteration: the suit-coloured border produced anti-aliasing artifacts at rounded card corners (border dropped entirely), and the pile-marker sprite bleed-through created visible "gray L" shapes where cards sat on markers (markers now hide when occupied — the documented but previously-not-enforced "remain visible only where a pile is empty" invariant).
Added
- Desktop window icon (
3eb3a26). RuntimeWindow::iconwired viaWinitWindows; embedded 256 px PNG decoded on startup viatiny_skiaand handed to winit. Plus a 9-size PNG hierarchy atassets/icon/icon_<size>.pngcovering Linux hicolor (16/24/32/48/64/128/256/512), Windows.icotargets (16/32/48/256), and macOS.icnstargets (16/32/64/128/256/512/1024). All sizes generated from a sharedicon_svgbuilder (Terminal▌RSmark on dark#151515with brick-red accent) by a newicon_generatorexample. Pin testicon_svg_pinguards rasterised RGBA bytes againstusvg/resvgdrift. Two newsolitaire_appdeps target-gated to non-Android: directwinit = "0.30"(forIconconstruction —bevy_winit0.18 doesn't re-export it) and directtiny-skia(for PNG → RGBA decode). Android draws its launcher icon from the APK manifest, so neither dep is needed there. Settings::high_contrast_modeflag (c5787c6). Boosts card text colours: hearts/diamonds →RED_SUIT_COLOUR_HC(#ff6868), clubs/spades →TEXT_PRIMARY_HC(#f5f5f5). Composes withcolor_blind_mode: CBM lime wins over HC red on red suits when both are on; HC still applies to dark suits independent of CBM. Six new tests pin the truth table.Settings::reduce_motion_modeflag (c5787c6). Forceseffective_slide_secsto0.0regardless of theAnimSpeedselection, making cards snap instantly to their target. Two new tests pin the gate behaviour and the fall-through toanim_speed_to_secswhen off. Future scope: gate splash scanline / cursor pulse / warning-chip pulse on the same flag.- Settings UI toggle rows (
07e0357). Two new rows in the Settings panel under Cosmetic (alongside Color-blind): "High Contrast" and "Reduce Motion".tab-walkorder visits all three accessibility flags in one vertical run. Same shape as the existingColorBlindTexttoggle scaffold with marker components, label updaters, click handlers, and disambiguator chains. sync_pile_marker_visibilitysystem (4d48cad). Implements the module-level doc invariant intable_plugin("pile markers ... remain visible only where a pile is empty") that was previously declared but not enforced. Hides the pile-marker sprite for any pile that has a card on top, shows it for empty piles. Closes the "gray L corners" artifact where the marker's translucent fill bled through the rounded card corners.
Changed
- Card-face suit colours (
62b61cc→ddb6540). Started the cycle at v0.21.0's Terminal pink (#fb9fb1) / gray (#d0d0d0), briefly experimented with a 4-colour deck (62b61cc— hearts pink, diamonds gold, clubs lime, spades gray) for faster suit recognition by hue alone, then reverted to traditional 2-colour pairing at the player's request (ddb6540). Final state:RED_SUIT_COLOUR = #e35353(saturated red, replacing the v0.21.0 pink) andBLACK_SUIT_COLOUR = #e8e8e8(near-white, brighter than the v0.21.0#d0d0d0foreground gray so the dark suits read as a chromatic-neutral counterpart to the saturated red rather than as "the same gray as body text"). Reads like Microsoft Solitaire on dark mode.RED_SUIT_COLOUR_HCrebumped to#ff6868(brighter saturated red) so HC stays more chromatic than the new default red rather than the previous pinker boost. The 4-colour experiment's commit history is preserved in the log; net delta vs. v0.21.0 is the new red + new near-white. - Card-face border dropped (
dd97021). The earlier 1 px suit-coloured stroke on the card body produced anti-aliasing artifacts at the rounded corners (the colored stroke faded through gray pixels into the play surface). Cards now have no border — body fill alone defines the shape against the play surface; the 5-unit brightness gap between#1a1a1abody and#151515surface is enough to read as a card edge without an explicit stroke.design-system.md§ Game Cards line 225 updated in lockstep. - Settings UI accessibility row count (
07e0357). Three toggles in Cosmetic now: Color-blind, High Contrast, Reduce Motion. Existing query-disambiguator chains inhandle_settings_buttonsextended withWithout<HighContrastText>andWithout<ReduceMotionText>so the new components don't ambiguate the existing mutations.
Fixed
- Bevy 0.18 system-param validation panic on icon startup
(
716a025).NonSend<WinitWindows>failed validation on the first few frames before winit'sResumedevent populated the resource. Bevy 0.18's stricter validation panics rather than skips when a non-send resource is absent; the error message itself spelled out the fix ("wrap the parameter inOption<T>and handleNonewhen it happens"). Wrapswinit_windowsasOption<NonSend<WinitWindows>>and early-returns onNone. - "Gray L corners" on cards (
4d48cad). Two artifacts were producing similar-looking grey at card corners: the SVG stroke fading through gray pixels (closed bydd97021) and the pile-marker sprite bleeding through the rounded cutouts (closed by4d48cad). Right test target, wrong visible-artifact target on the first attempt — the pin test correctly drifted 52 face hashes, but the visible gray came from a different layer. Two layers, two fixes; the second closed the player-visible complaint.
Documentation
docs/ui-mockups/design-system.md§ Suit Colors retitled through three states (Terminal 2-color → "Four-color deck" → final "Two-color traditional pairing"). Final table records the saturated red + near-white. § Game Cards border spec changed from "1px solid in suit color" to "Border: none" with the artifact-rationale audit trail. CBM section text updated through each colour-scheme iteration.SESSION_HANDOFF.mdrefreshed twice this cycle (0c1cc4031139ae) — the first reset the post-v0.21.0 narrative ("no threads in flight"), the second recorded Options A + F closures and trimmed the Resume-prompt menu.
- New module-level doc strings on the new constants
(
RED_SUIT_COLOUR_HC,TEXT_PRIMARY_HC,BORDER_SUBTLE_HC,RED_SUIT_COLOUR_CBMsemantic shift) record the composability rules between CBM and HC and the "what to use this for" rationale.
Stats
- 1192 passing tests / 0 failing across the workspace
(net +8 from v0.21.0's 1184 baseline). New tests added by
this release:
card_face_svg_pinintegration test rebaselined three times during the suit-colour iteration; final hashes pin the saturated-red + near-white + no-border state.- 4 high-contrast text_colour tests + 2 reduce-motion
effective_slide_secstests incard_plugin/animation_plugin(fromc5787c6). - 1
icon_svg_pinintegration test guarding the icon rasterisation pipeline (from48b28d2— actually landed in v0.21.0's accounting but worth noting for the cycle). - 1
pile_markers_hide_when_pile_is_occupiedtest pinning the new visibility-by-occupancy invariant (from4d48cad).
- Zero clippy warnings under
cargo clippy --workspace --all-targets -- -D warnings. cargo test --workspaceclean.
[0.21.0] — 2026-05-08
Closes the visual-identity arc opened in v0.20.0. Three through-lines
landed: the card-face / suit / card-back artwork migration that
v0.20.0 deliberately deferred, the splash boot-screen + replay-
overlay polish that closes Resume-prompt Options B and C, and a
late-cycle ACCENT_PRIMARY palette swap from cyan #6fc2ef to
brick red #a54242 after a quick stakeholder review on the
shipped art.
The card-face arc is the largest piece by commit count (10 of the
25 post-tag commits) and shape: it ports both rendering paths
production traverses — the PNG fallback at assets/cards/*.png
and the bundled-default theme SVGs at
solitaire_engine/assets/themes/default/*.svg that
include_bytes!()-embed into the binary and override the PNGs at
runtime — to identical Terminal-aesthetic art generated by the
same face_svg / back_svg builders. A new
card_face_svg_pin integration test pins rasteriser output via
FNV-1a on raw RGBA bytes, so future usvg/resvg upgrades or
intentional builder edits surface as test failures rather than
silent visual drift. The pin test fired three times during the
arc (text→path glyph fix, glyph orientation tweak, palette swap)
and rebaselined cleanly each time via the empty-then-paste
bootstrap pattern baked into the test.
Three sign-off follow-ups surfaced once a human booted the
running game and they all matched the same shape — "fallback
path the chrome migration walked past": the embedded default
theme overrode the new PNGs at runtime, the table backgrounds
were a separate PNG path that the v0.20.0 chrome migration
didn't touch, and the action-button row's font_size: 16.0
literal slipped through the typography migration audit. All
three are recorded under "Fixed" below.
Phase 8 (sync) and the Phase Android runtime gaps (JNI bridges, APK launch verification on device) remain open and roll forward.
Added
- Card-face SVG generator pipeline (
5623368plan doc,3a4bb63PoC,babe5ccfull generator,48b28d2pin test).solitaire_engine/examples/card_face_generator.rswrites 52 face PNGs + 5 back PNGs intoassets/cards/and 53 theme SVGs intosolitaire_engine/assets/themes/default/, all from the sharedface_svg/back_svgbuilders in the newsolitaire_engine::assets::card_face_svgmodule. Run withcargo run --example card_face_generator --release. The PoC (card_face_poc.rs) stays alongside as historical record of the per-card grain proof. Pin testcard_face_svg_pinguards rasterised output via inline FNV-1a so the arc has test-time coverage of both intentional builder edits (rebase via empty-then-paste) and unintentional dependency-upgrade drift. - Background generator example (in
8719f77).solitaire_engine/examples/background_generator.rsemits 5 flat Terminal-palette play-surface PNGs at 120 × 168, the same tile size the legacy felt textures used (the runtime stretches towindow_size * 2.0so source resolution is immaterial). All 5 slots stay in the near-black family —#151515canonical,#0a0a0adeeper,#1a1a1aelevated,#121820cool tint,#201812warm tint. - Splash boot-screen port (
cacb19c). Full mockup-spec splash: header, boot log, progress bar, palette swatches, version footer, plus theSplashFadablescaffold that lets any future overlay fadeN >> 3elements via one marker + one global lerp query (replaces theWithout<X>, Without<Y>exclusion pattern that the legacy splash hit at three siblings). - Splash trailing cursor pulse (
29136d8). Trailing 6×12 px Node, sine-pulsed, multiplied with the global splash fade — the "multiply, don't override" pattern that resolves the originalcacb19cskip-rationale. Closes Option B half 1 from the SESSION_HANDOFF Resume prompt. - Splash tiled scanline overlay (
a27cf5a). Runtime- generated 2×2 RGBA8 texture tiled viaNodeImageMode::Tiled; per-pixel alpha × tint alpha gives multiplicative fade integration without new abstractions. Closes Option B half 2. - Replay overlay scrub bar (
c84d9f4). 1px accent fill at the bottom of the banner, mirroringcursor / total. Per- frame updater + scrub-pct unit tests. - Replay overlay banner label port (
6204db8). The "▌ replay" headline picks up the cursor-block treatment that aligns it with the splash boot-screen idiom. - Replay overlay GAME caption (
54005d5).GAME #YYYY-DDDgame-identifier caption beneath the headline. Mirrors the mockup's right-anchored ID but stays grouped with the headline so the two pieces of "this is a replay of game X" read as one unit. - Replay overlay MOVE chip (
e080b49).MOVE N/Mprogress readout wrapped in a 1px accent-bordered chip — discrete callout rather than free-floating text. Closes Option C from the SESSION_HANDOFF Resume prompt (paired with54005d5). - Terminal desktop-adaptation spec (
39b8496).docs/ui-mockups/desktop-adaptation.md— the rules-based companion to the 24-mockup library. Closes the spec gap exposed when 23 of 24 mockups turned out to be mobile-only; any future plugin port should read this first and apply the universal rules before consulting the per-screen table. solitaire_engine::assets::card_face_svgmodule (48b28d2). Public SVG builders (face_svg,back_svg,suit_path_d) extracted from the example so the pin test could call them — examples can't be referenced fromtests/. The generator and the test now share the same source-of-truth, so the pin guards both rendering paths the engine consults.
Changed
ACCENT_PRIMARYswapped from cyan#6fc2efto brick red#a54242(a292a7e). Project-wide palette decision after initial rollout. Affects every cyan-accented surface — splash boot screen, home menu glyphs, action chevrons, replay overlay banner + scrub fill + chip border, achievement checkmarks, leaderboard #1 indicator, radial menu fill, focus ring, card-back canonical badge.RED_SUIT_COLOUR_CBMswapped in lockstep from cyan to lime#acc267so the colour-blind alternative stays hue-distinct from the new red-family primary. Comment doc strings throughout the engine retuned from "cyan" to "accent" / "primary-accent" so future palette changes don't require comment churn. Spec docdesign-system.mdupdated in lockstep with historical references preserved as audit trail.- Card-face / suit / card-back constants migrated to Terminal
palette in lockstep with new artwork (
e8bf9d7). Five constants flipped:CARD_FACE_COLOUR→#1a1a1a(was off-white#fafaf2),RED_SUIT_COLOUR→#fb9fb1(was deep red#c71f26),BLACK_SUIT_COLOUR→#d0d0d0(was near- black#141414),CARD_FACE_COLOUR_RED_CBMrenamed toRED_SUIT_COLOUR_CBMand repurposed from a face-background tint to a suit-glyph swap (the Terminal face is uniformlyCARD_FACE_COLOURregardless of CBM; CBM only swaps red suits to a hue-distinct alternative in the glyph itself).card_back_colour()retuned to the 5 base16-eighties accent colours matchingBACK_ACCENTS.face_colour()deleted — the function collapsed to a constant once the Terminal face became uniform.text_colour()gained acolor_blind: boolparameter to surface the CBM swap on the constant-fallback path (the production path bakes glyphs into the PNG, but tests underMinimalPluginsstill need the CBM-aware fallback). Fourface_colourCBM tests collapsed into twotext_colourCBM tests in the same commit. - Default-theme SVG art regenerated to Terminal aesthetic
(
a14200a).solitaire_engine/assets/themes/default/*.svg— the bundled-default theme thatinclude_bytes!()-embeds into the binary — was still the legacy vector-playing-cards art post-e8bf9d7. The PNG migration alone didn't change what production rendered becauseapply_theme_to_card_image_setoverridesCardImageSet.faces[..]at startup with the theme's rasterised SVG handles. Both rendering paths now agree: sameface_svg/back_svgbuilders feed both paths, and the pin test guards both. - Card glyphs render upright in both corners (
dd101b3). The traditional 180° inverted-corner-indicator rotation on the bottom-right glyph was dropped at user preference — single-orientation digital play doesn't benefit from the flipped-readback convention. Both glyphs now render in the same upright orientation.design-system.md§ Game Cards line 220 updated in lockstep — the deviation from traditional playing-card layout is documented in the spec, not just the code. - Action-button row typography aligned to
TYPE_BODY(ae84dc1). Was a hardcodedfont_size: 16.0literal that the v0.20.0 typography-migration audit walked past. Brings it in line with theTYPE_*token system every other text element inhud_pluginalready routes through, and trims ~12% off label widths so the action-button row no longer collides with the left-anchored HUD column at portrait / narrow window widths. Pairs with a horizontal-padding step- down fromVAL_SPACE_3toVAL_SPACE_2: ~96 px reclaimed across the 6-button row. - Table backgrounds flattened to solid Terminal colours
(
8719f77). Replaces the legacy felt-texture PNGs atassets/backgrounds/bg_*.pngwith 5 flat near-black variants per design-system.md (Terminal play surface is flat; no felt, no gradient). On-disk tile weight drops from ~16 KB average to ~100 bytes per tile; runtime appearance flips from green felt to flat#151515.
Fixed
- Card suit glyphs rendered as near-invisible "tofu" marks
(
af414b6). The bundledFiraMonoinsvg_loader::shared_fontdbdoesn't carry usable U+2660-2666 glyphs at the requested size — usvg silently substituted a default-size fallback regardless offont-size="20"/font-size="64". Switched suit-glyph rendering from<text>elements to inline SVG<path>elements via a newsuit_path_dhelper authoring each suit as a single closed perimeter in a 32×32 logical box. Path-based rendering bypasses the font system entirely — same bytes on every machine, no fontdb dependency, no substitution risk. Same path data renders correctly whether filled (♥ ♠) or outlined (♦ ♣ — the always-on color-blind glyph differentiation). - Default-theme SVGs were overriding new PNG artwork at
runtime (
a14200a). The PNG migration ine8bf9d7looked correct undercargo test(the constant-fallback path matched) but a realcargo runshowed legacy white cards becausetheme::plugin::apply_theme_to_card_image_setoverlays the bundled-default theme's rasterised SVGs ontoCardImageSet.faces[..]at startup, and those SVGs were still legacy. Fixed by regenerating both rendering paths from the sameface_svg/back_svgbuilders. The migration plan flagged "Theme system — out of scope here"; that was a planning miss documented in the SESSION_HANDOFF. - Top-bar HUD column collided with action-button row at
portrait window widths (
ae84dc1). Both nodes were absolute-positioned siblings attop: VAL_SPACE_2without a shared flex parent, so they could overlap horizontally when the window narrowed past their combined natural widths. Fixed via the typography + padding tightening described under "Changed" — minimal-blast-radius fix; the structural fix (sharedJustifyContent::SpaceBetweenparent) stays open as a follow-up if narrower windows surface. - Table-surface fill was still legacy green felt despite
v0.20.0's chrome-migration claim (
8719f77). Commit651f406retuned in-engine constants but the runtime path loads fromassets/backgrounds/bg_0.png, an on-disk PNG that the migration didn't touch. Same shape as the default-theme override above — token migration walked past a fallback rendering path. Fixed by regenerating the 5 background PNGs.
Stats
- 1184 passing tests / 0 failing across the workspace
(net +8 from v0.20.0's 1176 baseline). New tests this cycle:
the scrub-bar pair (
scrub_pct_covers_state_corners,overlay_scrub_fill_tracks_cursor); the splash boot-screen pair (splash_renders_terminal_boot_screen_content,fadables_start_transparent_and_reach_full_alpha); the splash-polish pair (build_scanline_image_has_expected_2x2_rgba_bytes,scanline_overlay_spawns_and_fades_with_splash); the card-face pin (one integration test incard_face_svg_pin.rsthat exercises 57 rasteriser outputs through 57 hash comparisons in a single#[test]-marked function); and the CBM consolidation that rewrote fourface_colourtests as twotext_colourCBM tests in the same commit (net 0 to count, clean rewrite). - Zero clippy warnings under
cargo clippy --workspace --all-targets -- -D warnings. cargo test --workspaceclean.
Documentation
docs/ui-mockups/card-face-migration.md(5623368) — the multi-session lockstep migration plan that the card-face arc followed step-by-step. Now reads as historical record of closed work; lessons documented under "Process notes" in SESSION_HANDOFF.md.docs/ui-mockups/desktop-adaptation.md(39b8496) — rules- based companion to the 24-mockup library. Required reading before any future plugin port.docs/ui-mockups/design-system.mdupdates: § Game Cards line 220 (glyph orientation), CTA / suit-red-cb / Card-back badge / Primary button / Bottom-bar active-icon palette retunes for the cyan→red swap. Historical references preserved as audit trail.- Multiple
SESSION_HANDOFF.mdrefreshes (a65e5b8,13ae160,44f5972,73ac67d,ef54cde,d109c32) recording Options B / C / D closures and process notes.
[0.20.0] — 2026-05-07
Two through-lines closed: a full Android port (build target,
first 54 MB APK, JNI-free per-app persistence shim) and the
Terminal visual-identity port that replaces the prior
Premium-Solitaire palette across every UI surface. The Android
arc opened in fb8b2ac (compile + APK), continued in 4b51e50
(solitaire_data::data_dir shim closing the CLAUDE.md §10
dirs::data_dir() = None pitfall), and is functional end-to-end
on a real device — though the runtime artwork is still the legacy
white-card palette, and JNI ClipboardManager / keyring bridges
remain stubbed (matching v0.19.0's documented fallback behaviour).
The Terminal port lands as a top-down stack: the ui_theme token
API in 0d477ac is load-bearing, and the rest of the cycle is
downstream applications (modal scaffold, gameplay-feedback,
toasts, table / card chrome, splash cursor, hint-highlight
pairing). The card faces and suit-pip palette are deliberately
NOT migrated — those track PNG artwork that hasn't been
regenerated yet, and swapping the fallback constants ahead of the
artwork would mix two visual systems on any code path where
image loading fails.
The 24 Stitch-rendered mockups in docs/ui-mockups/ are now
in-tree (fa7f98a); future plugin work should diff against the
matching mockup before touching pixels.
Two threads from v0.19.0's punch list also closed in this cycle:
the pull-failure test flake (67c150b), the Settings opt-out for
the smart-default window sizer (e1b8766), and the share-link
discoverability surfacing (9b065e5). The remaining v0.19.0
candidate — the app-icon round — stays open.
Added
ui_themeTerminal design-token system (0d477ac). Single source of truth for the engine's visual identity: base16-eighties palette (cyan primary CTA, lime/lavender/gold/ teal/pink semantic accents), 5-rung type scale, 7-rung 4-multiple spacing scale, 3-step radius, 14-rung z-index hierarchy, full motion budget, and four invariant-pinning unit tests. Every downstream port commit in this cycle reads from this module — swapping the palette is now a one-file edit, not a hunt across ~50 plugin files. Card-shadow alphas pinned to 0 (Terminal achieves depth via 1px borders + tonal layering, nobox-shadow); the rendering path is left intact so a future palette can re-enable shadows without touching consumers.ToastVariantenum + Terminal toast styling (a137607). Toasts now followdocs/ui-mockups/design-system.md: opaqueBG_ELEVATEDfill, 1px accent border keyed offInfo/Warning/Error/Celebrationvariants, 18px monospaced caption (TYPE_BODY_LG), bottom-anchored. All ten call sites pass their semantic variant: achievement / level-up / XP / daily / weekly / challenge → Celebration (lavender); goal-announcement / time-attack / settings volume / auto-complete → Info (teal). Two regression tests pin variant→border mapping to the design tokens and require all four borders to be visually distinct. Queued and immediate toasts use slightly different bottom anchors (6 % vs. 14 %) so a celebration toast spawned alongside a queued info banner layers above it.- Terminal cursor block on the splash overlay (
cdcadda). The launch splash now renders the design system's signature▌cyan (ACCENT_PRIMARY) glyph (96 px, hand-tuned literal) above the wordmark, matchingdocs/ui-mockups/splash-mobile.html. Cursor fades on the same per-frame alpha schedule as the title and subtitle so the brand beat still dissolves as a single layer. Did not pull in the mockup's full boot-loader treatment (scanline overlay, ✓ check log, progress bar, ROOT@SOLITAIRE prompt) — those are aesthetic features warranting their own commit. - Terminal design-system spec + 24-mockup library (
fa7f98a).docs/ui-mockups/design-system.md(palette, type scale, spacing scale, motion budget, component library, accessibility notes — color-blind toggle, high-contrast mode, glyph differentiation, canonical"Terminal"card-back theme) and 24 Stitch-rendered mockups (HTML + PNG): 12 redesigned existing screens, 1 desktop home variant, 2 onboarding steps, and 9 missing-plugin screens (splash, challenge, time-attack, weekly-goals, leaderboard, sync, level-up, replay, radial-menu). The spec the rest of this cycle ports against; future plugin work diffs here before touching pixels. - Android build target — first working APK (
fb8b2ac).cargo apk build -p solitaire_app --target x86_64-linux-androidnow produces a 54 MB debug-signed APK attarget/debug/apk/solitaire-quest.apk. Five gating points resolved end-to-end:solitaire_appsplit into bin + lib. cargo-apk needs acdylibto bundle aslibmain.so; pure-bin crates panic with "Bin is not compatible with Cdylib".src/lib.rscarries the ECS bootstrap aspub fn run;src/main.rsis a 3-line shim that delegates for the desktop path.[package.metadata.android]pins target SDK 34 / min SDK 26 and pointsassets = "../assets"at the workspace asset directory so desktop and APK share one set.- Workspace
bevyfeatures addandroid-native-activity(target-gated inside bevy_internal — desktop builds compile it out). Pairs with cargo-apk's NativeActivity wrapper. arboardtarget-gated tocfg(not(target_os = "android")). The crate has no Android backend; cargo apk fails with E0433 onplatform::Clipboardif left unconditional. Stats's "Copy share link" surfaces an informational toast on Android until JNI ClipboardManager lands in the Phase-Android round.keyring+keyring-coretarget-gated. Bionic doesn't exposelibc::__errno_locationso the transitiverpasswordwon't compile.auth_tokensships an Android stub returningKeychainUnavailablefor every call — matches the existing fallback for a Linux box without Secret Service.- Cosmetic: cargo-apk panics post-sign when it tries to also
wrap the bin target. The APK on disk is unaffected;
cargo apk build --libis the small workaround.
- Android developer setup + build runbook (
59424a3). Captures Debian 13 toolchain install (JDK 21, unzip, SDK licence prompts), thecargo apk buildinvocation, the cosmetic post-sign panic workaround, and a what-is-wired-vs- stubbed table for the android target. Runnable on a fresh clone — no machine-local context required. - F3-toggleable FPS / frame-time overlay (
690e1d2).DiagnosticsHudPluginwraps Bevy'sFrameTimeDiagnosticsPluginand renders a corner readout the developer toggles with F3. Hidden by default; F3 is not gated by pause / modal state. Readssmoothed()so the cell isn't a per-frame jittery scoreboard. Format:FPS NN \u{2022} M.MM ms. Anchored top-right atz = Z_SPLASH + 100above every modal / toast / splash. Update system bails when hidden so the diagnostic-store lookup is free when nobody's looking. - "Smart window size" Settings toggle (
e1b8766). Gameplay section gains an opt-out toggle for v0.19.0'sapply_smart_default_window_sizesystem. NewSettings::disable_smart_default_size: boolwith#[serde(default)]so legacysettings.jsonfiles load to the shipped behaviour (smart sizer enabled).solitaire_app::mainreads the flag once at startup and skips the system's registration when set. Saved window geometry still wins over both branches; tooltip on the row makes that explicit. - "Shareable" badge on the Latest-win caption (
9b065e5). The Stats overlay's Latest-win caption now appends\u{2022} Shareablewhen the displayed replay carries a populatedshare_url. Players can see at a glance whether the Copy share link button will produce a URL or surface the upload-prerequisite toast. - Help overlay covers M / P / Win-Summary-Enter (
35516d3). Three new rows in the Overlays section: M (Home / Mode launcher), P (Profile), and the Enter accelerator that dismisses the Win Summary modal. Three post-v0.18 entries that had drifted out of the cheat sheet are now listed.
Changed
- Gameplay-feedback colours route through Terminal state
tokens (
ceec4fc). Selection-highlight tints inselection_pluginand the valid-drop marker tint incursor_pluginwere hand-tuned RGB literals. Migrated to semantic state tokens: keyboard-drag picking source →ACCENT_PRIMARY(cyan focus); keyboard-drag lifted source →STATE_WARNING(gold attention); destination →STATE_SUCCESS(lime valid-move);cursor_plugin::MARKER_VALID→STATE_SUCCESSat 0.55 α with a tracking test pinning its RGB to the token. Three stale doc comments inui_modalcorrected ("loud yellow CTA" / "magenta secondary accent" → cyan / lavender to match the actual token values). table_pluginchrome migration to Terminal tokens (651f406).marker_colourpromoted to module-levelpub const PILE_MARKER_DEFAULT_COLOURsocursor_plugin::MARKER_DEFAULTimports the const directly — replaces the prior duplicated literal kept in sync only by doc comment with a compile-enforced invariant. The empty-tableau "K" placeholder text now usesTEXT_PRIMARYat 0.35 α;HINT_PILE_HIGHLIGHT_COLOURretuned from brightsrgb(1.0, 0.85, 0.1)to theSTATE_WARNINGtoken (#ddb26f) with a tracking test, and the existing "is gold" character test loosened to fit the muted Terminal gold while still rejecting non-warm colours.card_pluginchrome migration to Terminal tokens (d752870). Drag-elevation shadow now sources its colour fromCARD_SHADOW_COLOR+CARD_SHADOW_ALPHA_DRAGso the Terminal "no box-shadow" policy disables the stack shadow in lockstep with the per-card shadows.RIGHT_CLICK_HIGHLIGHT_COLOURretuned from raw green toSTATE_SUCCESSat 0.6 α with a tracking test. The duplicatedPILE_MARKER_DEFAULT_COLOURconst dropped — this plugin now imports the promoted const fromtable_plugin. Stock recycle "↺" text moved from raw white-at-0.7-α toTEXT_PRIMARY.with_alpha(0.7). Card-face / suit / card-back palette constants were intentionally NOT migrated (the runtime path renders PNG artwork that's still on the previous "white card" palette).- Hint-source card tint matches the destination pile
(
9891ae4).input_plugin's hint-source card tint moved from raw bright-yellowsrgba(1.0, 1.0, 0.4, 1.0)toSTATE_WARNING, so the source card and the destination pile (which already usesSTATE_WARNINGviaHINT_PILE_HIGHLIGHT_COLOUR) wear the same attention colour as a coherent pair.
Fixed
solitaire_data::data_dirshim closes the Android persistence gap (4b51e50).dirs::data_dir()returnsNoneon Android, which silently disabled every persistence path (settings, stats, achievements, replays, game-state, time-attack sessions, user themes). Newsolitaire_data::platform::data_dir()shim falls through todirs::data_dir()on desktop and returns the per-app sandbox at/data/data/com.solitairequest.app/fileson Android — no JNI needed, since the package id is pinned in[package.metadata.android]. Six call sites acrosssolitaire_dataplussolitaire_engine/assets/user_dir.rsmigrated. CLAUDE.md §10 already flagged this as a known pitfall; the shim pays it down at the one chokepoint instead of per feature.card_shadow_paramstest aligned with Terminal "no shadow" intent (1d1543e). The Terminal token system pinned bothCARD_SHADOW_ALPHA_IDLEandCARD_SHADOW_ALPHA_DRAGto 0.0, which made the priordrag_alpha > idle_alphaassertion fail (0 > 0is false). Loosened todrag_alpha >= idle_alphawith a comment naming the new invariant: under Terminal both are 0; under any future palette that re-enables shadows, drag still must not be weaker than idle. The useful regression-guard (catching an accidental swap of the two constants) is preserved.pull_failure_sets_error_statustest flake (67c150b). The fixed 5-update budget was the last test still subject to the AsyncComputeTaskPool starvation mode that v0.19.0's auto-save fix already cleared. Replaced with a wall-clock- bounded loop (5-second deadline,std::thread::yield_nowbetween iterations) that exits as soon as the status flips. Mirrors the auto-save flake fix shape.
Stats
- 1176 passing tests / 0 failing across the workspace
(six new tests this cycle: four
ui_themeinvariant guards for the type / spacing / z-index scales +scaled_duration, one toast-variant-border-mapping pair, and four palette- tracking guards onMARKER_VALID/HINT_PILE_HIGHLIGHT_COLOUR/RIGHT_CLICK_HIGHLIGHT_COLOUR/ toast-border distinctness). - Zero clippy warnings under
--workspace --all-targets -- -D warnings.
[0.19.0] — 2026-05-06
Closes the v0.18.0 punch list (items B and D — async hint and
persistent replay share URLs), expands desktop platform fit
(Wayland session support + monitor-aware default window size for
HiDPI / 4K displays), polishes the win-celebration and
double-click animation paths, and clears two test-flake
contributors. A short-lived "Rusty Pixel" pixel-art card theme
was prototyped and reverted in the same window — the engine
plumbing it touched (pixel_art field on ThemeMeta, PNG
manifest face support, second embedded:// theme channel) was
fully reverted and is not part of this release.
Changed
- H-key hint runs on
AsyncComputeTaskPool(3e11e9e). The synchronoustry_solve_from_statecall on every H press is gone;handle_keyboard_hintnow spawns a task whose result the newpending_hint::poll_pending_hint_tasksystem surfaces one frame later. NewPendingHintTaskresource carries the in-flight handle plusmove_count_at_spawnfor staleness detection;drop_pending_hint_on_state_changecancels the task whenever the game state shifts;PendingHintTask::spawnimplements cancel-on-replace so two quick H presses keep at most one task in flight. Mirrors the v0.18.0PendingNewGameSeedtemplate.emit_hint_visualsandfind_heuristic_hintare extracted aspubhelpers so the polling system can call them. - Persistent replay share URLs (
42d90b1). v0.18.0'sLastSharedReplayUrlwas an in-memory resource wiped on quit — the player had to share within the session of the win.solitaire_data::Replaynow carries ashare_url: Option<String>field with#[serde(default)](noREPLAY_SCHEMA_VERSIONbump needed; olderreplays.jsonfiles load unchanged withshare_url == Noneon every entry).poll_replay_upload_resultwrites the resolved URL intoreplays[0].share_urland persists the updated history viasave_replay_history_to. The Stats overlay's "Copy share link" button reads fromhistory.0.replays[selected.0].share_url, so the Prev/Next selector's currently-displayed replay drives the clipboard contents — each historical win keeps its own URL.LastSharedReplayUrlremoved (its role is now subsumed by theshare_urlfield on the replay record).
Added
- Wayland session support (
b57db01). The workspaceCargo.tomlBevy feature list now enableswaylandalongsidex11. winit prefers Wayland whenWAYLAND_DISPLAYis set on the session, falling back to X11 when it isn't. Pre-fix, a Wayland desktop environment fell through to XWayland, rendering the game inside an X11 frame stitched into the Wayland compositor. Post-fix, the game opens as a native Wayland surface. Costs a few hundred KB of binary for the libwayland-client bindings; cross-distro friendly because winit dlopen-probes the libraries rather than hard-linking them. - Monitor-relative default window size (
b57db01). On launches with no saved geometry, the newapply_smart_default_window_sizeUpdate system queriesMonitor(with thePrimaryMonitormarker) and resizes the primary window to ~70 % of the monitor's logical size on the first frame. Before, every fresh launch opened at 1280×800 regardless of monitor; on a 4K monitor that's a comparatively tiny window in one corner. Logical size already accounts for the OS's HiDPI scale factor, so a Retina display reporting scale_factor 2.0 yields the same physical inches as a 1080p display reporting 1.0. Skipped entirely when saved geometry was applied — the player's chosen size always wins.
Fixed
- Duplicate "You Win" toast on game-won (
55c235b). The post-win UI was firing two celebration surfaces: a 4-second toast banner ("You Win! Score: X Time: Y") on top of thewin_summary_plugin's "You Won!" modal. In screenshots the toast banner was partially clipped behind the modal card, peeking out on either side. The toast predated the modal and is strictly subsumed by it; removed. The cards-fly-off cascade animation (MotionCurve::Expressiveper-card rotation drift) is unchanged — that's the visual celebration, distinct from the textual celebration the modal owns.WIN_TOAST_SECSconst removed. - Double-click on a single card with no destination now plays
the reject animation (
d7ffb16).handle_double_clickonly firedMoveRejectedEventfor multi-card stacks with no destination; a double-click on a single card whose top didn't fit any foundation or tableau slot produced zero feedback — nocard_invalid.wav, no source-pile shake. Both priorities' failure paths now converge on a single rejection at the end of the double-click branch, so single-card and stack misses get the same feedback shape as drag-and-drop rejections. - Double-click move animation no longer plays twice
(
6037596). On a successful double-click, the slide-to- destination animation rendered twice — once from the move'sStateChangedEventlanding, then again from the release'send_dragfiring a redundantStateChangedEventmid-slide.sync_cards_on_changesaw the card mid-CardAnim (cur ≠ target) and replaced the in-flight tween with a fresh one starting at the mid-position, visibly restarting the slide. The defensiveStateChangedEventwrite inend_drag's uncommitted-drag branch is removed;start_dragonly mutatesDragState(never card transforms), so an uncommitted drag has no visual side effect to undo. The committed-drag branch keeps itsStateChangedEventsince real drag snap-backs do need a resync. auto_save_writes_after_30_secondstest flake (91b7605). The test's single-frameapp.update()was sensitive to first-frameTime::delta_secs()variance under heavy parallel cargo-test load, and to production-disk~/.local/share/solitaire_quest/game_state.jsonstate leaking into the test world viaGamePlugin::build's load path.test_appnow resetsPendingRestoredGame(None)after plugin build (preventing the dev machine's saved-game state from tripping the auto-save guard) and the test re-arms the timer in a small bounded loop until the file appears (robust against first-frame Time variance). No production-code change.
Stats
- 1170 passing tests (was 1166 at v0.18.0 close — net +4 from the persistent share URL backwards-compat test, the three async-hint tests, minus the dropped synchronous hint tests).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
[0.18.0] — 2026-05-06
The launch-experience round. The engine used to drop the player on a
silent default Classic deal whether they had unfinished work or not;
v0.18.0 replaces that with two stacked decision points — a Restore
prompt for in-progress saves, then an MSSC-style Home / mode picker
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
with live stats. The same round closes the last solver-on-main-thread
hot path (winnable-only seed selection moves to
AsyncComputeTaskPool), wires "Copy share link" into Stats, lights a
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
Added
- Restore prompt on launch (
3c7a0eb). Whengame_state.jsonholds an in-progress game (move_count > 0, not won), the engine now seedsGameStateResourcewith a fresh deal and holds the saved game in a newPendingRestoredGameresource. After the splash clears, a "Welcome back" modal offers Continue (Enter / C / click) or New game (N / click). Fresh-deal saves (move_count == 0) skip the prompt and load directly. - Save preservation while the prompt is unanswered (
f863d85). Bothsave_game_state_on_exitandauto_save_game_stateconsultPendingRestoredGamefirst: if it still holds a pending saved game, that's what gets persisted (or the auto-save is skipped), so exiting before answering the prompt no longer overwrites the meaningful save with the placeholder fresh deal. - Home / mode picker auto-shows on launch (
dd63261). The mode picker was only reachable via M during gameplay; players who hadn't discovered the hotkey never saw the Daily / Zen / Challenge / Time Attack entry points after the splash cleared.HomePlugingains anauto_show_on_launchflag (default true) and a one-shotLaunchHomeShowngate. Skips when the Restore prompt is on screen so Welcome-back still takes precedence. - MSSC-style Home picker — header / chips / score chips / draw
mode (
ae40a1d). Player-stats header strip (Level / XP / Lifetime Score, compact-formatted as1.2M/12.3K/1,234) acts as a clickable shortcut to Profile. Draw-mode chip row above the mode cards lets the player flip Draw 1 / Draw 3 from the picker itself; persistssettings.jsonand respawns the modal so the active state repaints cleanly. Per-mode best-score / streak chips on each card; hidden on a 0 best so a fresh profile doesn't read "Best 0" everywhere. - Today's Event callout on the Daily card (
b73d246). "Today, May 6" date line plus the server-fetched goal (when SyncPlugin is wired). Once today's daily is recorded as completed, the date flips toToday, May 6 • DoneinACCENT_PRIMARYso the picker reads as a reward state rather than a TODO. - Picture-tile mode cards (
9fe650f+ glyph-picking follow-ups40d6e0a,c30b04e,d065d49). Mode cards become a wrapping 2-up grid (FlexWrap::Wrap, tiles 48 % wide,min_height: 180px) with a centred Unicode-glyph centrepiece per tile. Final glyph set picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily, ○ Zen, ▲ Challenge, → TimeAttack.ACCENT_PRIMARYwhen the mode is unlocked,TEXT_DISABLEDwhen locked. Centrepiece is aTextnode for now — when real per-mode artwork lands, swap toImagewithout touching tile layout, focus order, or chip rendering. - Solver-vetted seed selection on
AsyncComputeTaskPool(d489e7a). Closes the worst-case 6 s UI stall on a New Game click with "Winnable deals only" enabled. NewPendingNewGameSeedresource holds the in-flightTask<u64>plus the original request'smode/confirmedflags.poll_pending_new_game_seedruns.before(GameMutation)and replays a syntheticNewGameRequestEventonce the task resolves — the player sees no extra-frame visual lag. Cancel-on-replace: a freshNewGameRequestEventwhile a task is in flight drops the old task, letting Bevy'sTaskDrop cancel cooperatively at the next await point. - "Won before" HUD indicator (
bdac754). When the current deal's(seed, draw_mode, mode)triple matches an entry in the rollingReplayHistory, the HUD's tier-2 context row shows ✓ Won before inSTATE_SUCCESS. Cleared on win (the on-screen victory cue is enough) and on first-time deals. NewHudWonPreviouslymarker driven by a separateupdate_won_previouslysystem; gracefully no-ops in headless tests that don't loadStatsPlugin. - "Copy share link" Stats button (
540869c). End-to-end replay sharing on a server-backed sync backend:sync_plugin::push_replay_on_winspawns the upload onAsyncComputeTaskPooland stores the handle inPendingReplayUpload(drops any in-flight predecessor — the most recent win is what the player wants the link for);poll_replay_upload_resultwrites<server>/replays/<id>toLastSharedReplayUrlon success; the Stats overlay's action bar gains a button that writes the URL to the OS clipboard viaarboardand surfaces a "Copied: <url>" toast. URL is in-memory only — sharing must happen within the session of the win. - Empty-state copy + onboarding hints (
56e2e6f). Leaderboard empty state: two-tier "Be the first on the leaderboard." headline- body invite. Achievements panel: first-launch hint above the
grid until the first unlock. Volume hotkeys (
[/]) now emit anInfoToastEventwith the new percentage so off-panel adjustments give visible feedback (previously silent).
- body invite. Achievements panel: first-launch hint above the
grid until the first unlock. Volume hotkeys (
- Enter dismisses the Win Summary and starts a fresh deal
(
17e0737). The post-win modal's "Play Again" was click-only; keyboard-only players had to reach for the mouse to leave the celebration screen. The button label gains a trailing return-key glyph so the keyboard path is discoverable on first sight. Nopens the real Confirm/Cancel modal (93660c2). The old "Press N again" double-tap pattern was a UI-first violation (only continuation was another keystroke).Nnow firesNewGameRequestEvent::default()directly;handle_new_game's active-game check spawns the existingConfirmNewGameScreen. The HUD button already routed through the same modal — keyboard and mouse paths are unified.Shift+Nkeeps the keyboard power-user bypass (confirmed: true).
Changed
- Settings row layout (
a4bc063). All five slider/toggle row helpers (volume × 2, tooltip delay, time-bonus multiplier, replay-move interval, generic toggle) restructured to a label-spacer-cluster layout (width: 100%, label getsflex-grow: 1, controls cluster sits flush right). Stable across varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s") and narrow windows. - Docs adopt the unified-3.0 rule set (
f2f30c8).CLAUDE.mdgrows from a 114-line pointer doc to a 571-line rulebook (hard global constraints §2, engine rules §3, asset rules §4, code standards §5, build + verification §6, git workflow §7, the ASK BEFORE list §8, Context Injection System §14). New companions:CLAUDE_SPEC.md(formal architecture spec — crate dependency graph, data ownership, state-machine invariants, sync merge / server contracts, validation checklist),CLAUDE_WORKFLOW.md(two-agent Builder/Guardian pipeline with hard-fail patterns),CLAUDE_PROMPT_PACK.md(task-type templates). Three duplicate rule passages removed acrossCLAUDE_SPEC.mdandARCHITECTURE.md. - Test discipline pruning (
a49a340). Removed 43 low-value tests acrosssolitaire_dataandsolitaire_core(default-value tests, serde-derive round-trips on plain structs, single-field clamp tests, near-duplicates, constant-equals-itself tests). None pinned a behaviour contract or a regression on a real bug. Future agent briefs request tests for behaviour contracts or real-bug regressions, not a count of N.
Fixed
- Esc on a modal no longer opens Pause underneath (
08b006f). A single Esc press on Confirm New Game / Restore / Home / Onboarding / Settings used to both close the modal and spawn the Pause overlay on top in the same frame.toggle_pausenow skips when any non-PauseModalScrimis in the world; the HUD-button path is gated too. The four modal queries are bundled into aPauseModalQueriesSystemParamto stay under Bevy's 16-parameter cap. - Esc dismisses Home / accepts the Restore-prompt default
(
d48b948). Both screens previously ignored Esc, leaving the player no keyboard-only escape after the previous fix. Home: Esc behaves like Cancel (despawns the modal, keeps the underlying default deal). Restore: Esc maps to Continue (preserves the saved game, matching how the primary action already advertises Enter). - Esc dismisses the topmost modal when Profile stacks on Home
(
9aa0dd2). Clicking the Home header chip opens Profile on top of Home; Esc used to close Home (becausehandle_home_cancel_buttonfired with no awareness of layered modals) and leave Profile orphaned over the game.profile_pluginnow splits P/button (toggle) from Esc (close-only);handle_home_cancel_buttonskips its Esc branch when any otherModalScrimexists. - Restore-prompt resolution suppresses Home auto-show
(
b7c3a49). Resolving the Welcome-back prompt clearedPendingRestoredGameand despawned the modal, but the launch-time Home auto-show then fired the next frame and stacked itself over the player's chosen path.LaunchHomeShownbecomespubsohandle_restore_promptflips it totrueafter either resolution; M still re-opens the picker on demand. - Game timers freeze while the Home picker is up (
c497c31). The HUD's elapsed-time counter ticked from the moment the default Classic deal landed at startup, even though the auto-show Home picker was still up — the player saw "0:11" before they had chosen a mode.tick_elapsed_timeandadvance_time_attacknow also gate on the absence ofHomeScreen, mirroring their existingPausedResourcecheck. - Popover rows stay visible regardless of action-bar fade
(
cc63532). Opening Modes / Menu showed a solid dark-purple block in the top-right with no readable content — the action-bar auto-fade was matching the popover rows by their sharedActionButtonmarker and dropping their alpha to the cursor-position-based fade value (typically 0). NewPopoverRowmarker on rows inspawn_modes_popover/spawn_menu_popover;apply_action_fadeexcludes them viaWithout<PopoverRow>.
Stats
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals from the test-discipline prune plus 1 net-new test from the async-seed work, no behaviour regressions).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
[0.17.0] — 2026-05-06
A short follow-up round on top of v0.16.0: the H-key hint is no longer a heuristic guess but the actual best first move suggested by the v0.15.0 solver, and the in-engine replay player now has a player-tunable playback rate.
Added
- Replay-rate slider in Settings → Gameplay. Tunes
replay_move_interval_secsfrom 0.10 s to 1.00 s in 0.05 s steps; default 0.45 s.tick_replay_playbackreads the value fromSettingsResourceper frame so the slider takes effect on the next playback tick — no restart required.
Changed
- Solver-driven hints. Pressing H used to surface a
heuristic-best move (foundation moves preferred, then
tableau-to-tableau by depth-of-flip-revealed). It now asks the
v0.15.0 solver for the actual provably-best first move via the
new
solitaire_core::solver::try_solve_with_first_move/try_solve_from_stateAPIs. When the solver returns inconclusive (rare deals where the bound runs out before a result), the old heuristic remains the fallback. Median 2 ms per H press.
Stats
- 1208 passing tests (was 1196 at v0.16.0 close).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
0.16.0 — 2026-05-06
A modal-feel polish round. Every overlay screen now scrolls when its content overflows the 800×600 minimum window, every clickable button shows a hand cursor on hover, keyboard focus lands on the primary button on the same frame the modal opens, and read-only modals dismiss when the player clicks the scrim outside the card.
Added
- Pointer cursor on hover for every interactive
Buttonentity (modal buttons, HUD action bar, mode-launcher cards, settings toggles, Stats selectors).update_cursor_icongains a fourth branch sitting between Grabbing (active drag) and Grab (draggable card hover): when no drag is active and anyInteraction::Hovered/Pressedbutton is detected, the window cursor swaps toSystemCursorIcon::Pointer. A purepick_cursor_iconhelper makes the priority logic unit-testable. - Click-outside-to-dismiss for the six read-only modals: Stats,
Achievements, Help, Profile, Leaderboard, Home. New
ScrimDismissiblemarker onModalScrimopts a modal in;dismiss_modal_on_scrim_clickruns inUpdate, despawns the topmost dismissible scrim on a left-mouse press whose cursor lands on the scrim and outside everyModalCard. Bevy's hierarchy despawn cascades to the card and children. Settings, Onboarding, Pause, Forfeit confirm, and Confirm New Game intentionally don't opt in — they carry unsaved or destructive state.
Fixed
- Modal content scrolls when it overflows (Achievements, Help,
Stats, Profile, Leaderboard). Each modal's body Node now
carries
Overflow::scroll_y()plus amax_heightconstraint (Val::Vh(70.0)for most,Val::Vh(50.0)for the leaderboard's variable-length ranking section) and a marker component (AchievementsScrollable,HelpScrollable,StatsScrollable,ProfileScrollable,LeaderboardScrollable). A siblingscroll_*_panelsystem per modal routesMouseWheelevents into the body'sScrollPosition. Mirrors the existingSettingsPanelScrollablepattern. Home modal intentionally not scrolled — its five mode cards + Cancel are sized to fit at 800×600 by design. - Modal focus arrives on the same frame the modal opens.
Previously
attach_focusable_to_modal_buttonsandauto_focus_on_modal_openran inUpdatealongside arbitrary click-handlers that spawn modals; with no ordering edge, Bevy's deferredCommandsqueued the new entities but the attach system couldn't see them on the same tick. Both systems moved toPostUpdateso the schedule boundary itself supplies the sync point —FocusedButtonis always populated beforeapp.update()returns. The very next Tab/Enter press lands on a populated resource instead of wasting itself moving focus from None to the primary.
Stats
- 1196 passing tests (was 1178 at v0.15.0 close).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
0.15.0 — 2026-05-02
In-engine replay playback, the Klondike solver + "Winnable deals only" toggle, a 19th achievement, rolling replay history, and a significant build-time / binary-size win from disabling Bevy's default audio stack.
Added
- In-engine replay playback for the Stats overlay's Watch Replay
button. New
ReplayPlaybackPluginruns a state machine (Inactive / Playing / Completed) that resets the live game to the recorded deal and ticks throughreplay.movesatREPLAY_MOVE_INTERVAL_SECS(0.45 s) firing the canonicalMoveRequestEvent/DrawRequestEventper recorded move. Recording is suppressed during playback so replays don't re-record themselves. - Replay overlay banner (
ReplayOverlayPlugin) anchored to the top of the window during playback. Shows "Replay" label, "Move N of M" progress, and a Stop button. Z-order leaves modals (Settings, Pause, Help) free to render on top so the player can adjust audio mid-replay. - Rolling replay history at
<data_dir>/replays.jsoncapped at 8 entries. Replaces the single-slotlatest_replay.json(legacy file is migrated forward on first launch viamigrate_legacy_latest_replay). Stats overlay gains a Prev / Next selector and a "Replay N / M" caption so the player can revisit older wins. - "Cinephile" achievement (#19). Unlocks the first time
ReplayPlaybackStatetransitions Playing → Completed (i.e. the replay played out to its end without the player pressing Stop). Stop transitions Playing → Inactive directly so it doesn't count. - Klondike solver in
solitaire_core::solver. Iterative-DFS with memoisation on a 64-bit canonical state hash, two budget knobs (move_budget + state_budget) for pathological cases, and a three-stateSolverResult(Winnable / Unwinnable / Inconclusive). Median solve time 2 ms; pathological inconclusives cap near 120 ms. Pure logic —solitaire_corekeeps no Bevy or I/O. - "Winnable deals only" toggle in Settings → Gameplay (default
off). When on,
handle_new_gamewalks seed N, N+1, N+2, … throughtry_solveuntil it finds Winnable or Inconclusive, capped atSOLVER_DEAL_RETRY_CAP(50) attempts. Daily challenges, replays, and explicit-seed requests bypass the solver — only random Classic deals are gated.
Changed
- Bevy default-feature trim (
bevy = { default-features = false, features = [...] }in workspace Cargo.toml) drops 51 transitive crates including thebevy_audio→ rodio → cpal 0.15 + symphonia chain that the project doesn't use (kira handles audio directly). The retained feature list is curated to exactly what the engine uses;solitaire_wasmis unaffected because it doesn't depend on bevy.
Stats
- 1178 passing tests (was 1134 at v0.14.0 close).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
0.14.0 — 2026-05-02
Two threads land in v0.14.0: the second half of the post-v0.12.0 UX candidate list (theme thumbnails, daily-challenge calendar, Time Attack auto-save, per-mode bests, time-bonus multiplier) plus a major new feature — the replay pipeline (record → upload → web viewer). Three Quat-reported bugs from a smoke-test round shipped alongside.
Added
- Theme-picker thumbnails in Settings → Cosmetic. Each theme chip
renders a small Ace-of-Spades + back preview pair via the existing
rasterize_svgpath. Cached per theme in a newThemeThumbnailCache. Themes that lack a preview SVG fall back to a transparent placeholder rather than crashing. - 14-day daily-challenge calendar in the Profile modal. Horizontal
row of dots showing the trailing two weeks; today's dot is ringed
in
ACCENT_PRIMARY, completed days fillSTATE_SUCCESS, missed days fillBG_ELEVATED. Caption above the row reads "Current streak: N · Longest: M". - Time Attack session auto-save to
<data_dir>/time_attack_session.json, atomic .tmp + rename. 30-second auto-save while a session is active, plus onAppExit. Sessions whose 10-minute window expired in real time while the app was closed are discarded on load. Classic, Zen, and Challenge already auto-saved correctly viagame_state.json— Time Attack was the only mode missing session-level persistence. - Per-mode best-score and fastest-win readouts in the Stats screen.
StatsSnapshotgains six#[serde(default)]fields (Classic / Zen / Challenge × best_score + fastest_win_seconds). Stats screen renders a "Per-mode bests" section between the primary cell grid and progression. Lifetime totals continue to roll all modes together. - Time-bonus multiplier slider in Settings → Gameplay (0.0–2.0, 0.1 steps, default 1.0, "Off" label at zero). Cosmetic only — multiplies the time-bonus shown in the win modal but does NOT affect achievement unlock thresholds (those still use the raw unmultiplied score).
- Win-replay recording + storage. Every move during a successful
game appends to a
RecordingReplayresource; onGameWonEventthe recording freezes into aReplay(seed + draw_mode + mode + score + time + ordered move list) and persists to<data_dir>/latest_replay.jsonatomically. Single-slot — overwrites on every win. - "Watch replay" button in the Stats overlay. Shows the latest
win's caption and surfaces a button that loads the replay (button
fires an
InfoToastEventdescribing the replay; full in-engine playback is deferred to a future build). - Replay upload + fetch endpoints on the server.
POST /api/replaysaccepts aReplayJSON;GET /api/replays/:idreturns it. JWT-gated with the existing auth middleware. Engine uploads winning replays automatically when the player has cloud sync configured. solitaire_wasmcrate — new workspace member compiling replay-relevantsolitaire_coretypes to WebAssembly so a browser can re-execute a replay client-side. No-std-friendly surface;wasm-bindgenglue.- Web replay viewer served from the Solitaire server.
GET /replays/:idreturns HTML + CSS + the wasm bundle that fetches the replay JSON, rasterises a deal from the seed, and animates the recorded moves. - Card flight animations on the web side so the browser viewer reads as a real game replay rather than a static dump.
Fixed
- Multi-card lift validation.
solitaire_core::rules::is_valid_tableau_sequencerejects a moved stack whose adjacent cards don't form a descending alternating-colour run. Previously a player could lift any multi-card selection and drop it as long as the bottom landed legally. Wired intomove_cards's tableau-destination branch. - Softlock detection.
has_legal_movesrewritten to walk every potential move source (every stock card, every waste card, the face-up top of every tableau column) and check it against every foundation and every tableau. Previously the heuristic early-returnedtruewhenever stock had cards — players got stuck in unwinnable end-states with no end-game screen.GameOverScreennow actually fires for true softlocks. Quat's exact reproduction case is pinned by a new test. - Deal-tween information leak. New-game now snaps every card
sprite to the stock pile position before writing
StateChangedEvent, so all 52 cards animate from a single point during the deal. Previously the sprites started from their previous-game positions, briefly revealing the prior deal.
Documentation
SESSION_HANDOFF.mdrefreshed for the Quat smoke-test round including investigation findings on solver decisions and dependency duplicates.
Stats
- 1134 passing tests (was 1053 at v0.13.0 close).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
0.13.0 — 2026-05-02
Third UX iteration round on top of v0.12.0. Six handoff candidates shipped — three small polish items, three larger interaction features (theme-aware backs, full keyboard play, right-click power shortcut). Plus two code-review fixes (font handling unified, sccache wiring removed).
Added
- Tooltip-delay slider in Settings → Gameplay.
tooltip_delay_secsranges [0.0, 1.5] in 0.1 s steps; "Instant" label when zero.Settings.tooltip_delay_secsround-trips through serialise/deserialise with#[serde(default)]. The hover-delay comparison inui_tooltipreads fromSettingsResourcewith the existingMOTION_TOOLTIP_DELAY_SECSas the test-fixture fallback. - Win-streak fire animation. New
WinStreakMilestoneEventfires fromstats_pluginwhenwin_streak_currentcrosses any of [3, 5, 10] (only the threshold crossing — not every subsequent win). The HUD streak readout scale-pulses 1.0 → 1.20 → 1.0 overMOTION_STREAK_FLOURISH_SECS(0.6 s). - Score-breakdown reveal on the win modal. Replaces the single
"Score: N" line with a per-component reveal (Base / Time bonus /
No-undo bonus / Mode multiplier / Total). Rows fade in over
MOTION_SCORE_BREAKDOWN_FADE_SECS(0.12 s) staggered byMOTION_SCORE_BREAKDOWN_STAGGER_SECS(0.15 s). HonoursAnimSpeed::Instantby spawning all rows fully visible. - Card backs follow the active theme.
theme.ron'sbackslot now actually drives the face-down sprite. Active-theme back rasterises alongside the faces and supersedes the legacyback_N.pngpicker. The picker remains as a fallback for themes that don't ship a back, and the Settings UI surfaces a caption ("Active theme provides its own back") + dimmed swatches when the override is in effect. - Keyboard-only drag-and-drop. Tab cycles draggable card stacks,
Enter "lifts" the focused stack, arrow keys (or Tab) cycle the
legal-destination targets only, Enter confirms, Esc cancels. A
new
KeyboardDragStateresource models the two-mode flow without changing the existingSelectionStatecontract. Mutual exclusion with mouse drag uses a sentinelDragState.active_touch_id = KEYBOARD_DRAG_TOUCH_ID(u64::MAX) so neither pipeline can trample the other. - Right-click radial menu. Hold right-click on a face-up card →
a small ring of icons appears at the cursor with one entry per
legal destination. Release over an icon → fires
MoveRequestEvent; release in dead space, Esc, or left-click cancels. Skips the drag motion entirely. NewRadialMenuPluginowns the flow; co-exists with the existingRightClickHighlightpile-marker tint.
Fixed
- Font handling consolidated to bundled-only. Code-review
feedback: the SVG rasteriser previously mixed
load_system_fonts+ bundled FiraMono + a lenient resolver, which made card text rendering depend on host fontconfig. Picked option (a) and applied it across both layers —font_pluginnow embedsassets/fonts/main.ttfviainclude_bytes!()and registers it withAssets<Font>;svg_loader::shared_fontdbloads only the bundled bytes; the newbundled_font_resolverignores the SVG'sfont-familyrequest and always returns the single bundled face. A parse failure aborts with a clear error ("bundled FiraMono failed to parse — binary is corrupt").
Removed
- Project-level sccache wiring. Code-review feedback: sccache
shouldn't be a per-project build dependency. Cargo's incremental
cache already covers the single-project case, and forcing
rustc-wrapper = "sccache"workspace-wide meant every contributor had to install it..cargo/config.tomldeleted entirely; plaincargo buildnow works without setup.
Documentation
help_plugincontrols reference gains a "Mouse" section covering double-click auto-move, right-click highlight, and the new hold-RMB radial.help_pluginalso gains a "Keyboard drag" section for the new Tab/Enter/Arrows/Esc flow.- Onboarding slide 3 picks up a
Tab → Enterrow referencing the full keyboard drag path.
Stats
- 1053 passing tests (was 1031 at v0.12.0 close).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
0.12.0 — 2026-05-02
UX feel polish round on top of v0.11.0. Six small-but-tangible improvements that make the play surface feel more responsive, forgiving, and discoverable, plus the doc refresh that should have ridden along with v0.11.0.
Added
- Foundation completion flourish. When a King lands on a
foundation (Ace-through-King for that suit), a brief celebration
fires: King card scale-pulses 1.0 → 1.15 → 1.0 over 0.4 s, the
foundation marker tints
STATE_SUCCESSfor the first half then fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms, octave abovewin_fanfare's root so the fourth completion + win cascade layer cleanly). NewFoundationCompletedEvent { slot, suit }carries the trigger so future systems can hook in. - Drag-cancel return tween. Illegal drops glide each dragged
card back to its origin slot over 150 ms with a quintic ease-out
curve (
MotionCurve::Responsive, zero overshoot — reads forgiving rather than jittery). The audio cue (card_invalid.wav) still fires for negative feedback. Right-click and double-click invalid paths still useShakeAnimsince there's no motion to interpolate. - Focus ring breathing. The keyboard focus ring's alpha modulates
with a 1.4 s sin curve over [0.65, 1.0] of its native value so the
indicator catches the eye on focus changes without competing with
gameplay. Honours
AnimSpeed::Instantby reverting to the static outline for reduced-motion users. - First-win achievement onboarding toast. After the player's
very first win, a one-shot info toast surfaces "First win! Press
A to see your achievements."
Settings.shown_achievement_onboardingpersists the seen state so the cue never re-fires (legacysettings.jsonfiles load tofalsevia#[serde(default)]). - Mode Launcher digit shortcuts. Pressing M opens the Home modal (the Mode Launcher); inside it, pressing 1–5 launches each mode directly without needing Tab + Enter. Locked modes (Zen, Challenge, Time Attack at level < 5) are silent no-ops. Modal-scoped — digit keys outside the launcher fire nothing.
Fixed
- Card aspect ratio matches hayeah SVGs.
CARD_ASPECT1.4 → 1.4523 to match the bundled artwork's natural 167.087 × 242.667 dimensions. Cards previously rendered ~3.6 % vertically squashed. The vertical-budget math incompute_layoutusesCARD_ASPECTalgebraically so the worst-case-tableau-fits-on-screen guarantee adapts automatically.
Documentation
- README refresh with v0.11.0+ features (card themes, HUD overhaul, drag feel, unlocked foundations) and a corrected controls table — the previous table inverted Z/U for undo and listed H for help when F1 is the binding.
- CHANGELOG.md added (this file), covering v0.9.0–v0.12.0 with Keep a Changelog 1.1.0 conventions.
Stats
- 1007 passing tests (was 982 at v0.11.0).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
0.11.0 — 2026-05-02
The biggest release since 0.10.0. Headline threads: a runtime card-theme system, an HUD restructure that reclaims the play surface, and a round of UX feel polish surfaced by smoke testing.
Added
- Runtime card-theme system (CARD_PLAN phases 1–7).
- Bundled default theme ships in the binary via
embedded://— 52 hayeah/playing-cards-assets SVGs (MIT) plus a midnight-purpleback.svgas original work. - User themes live under
themes://rooted atuser_theme_dir(). Drop a directory containingtheme.ron+ 53 SVGs and the registry picks it up on next launch. - Importer at
solitaire_engine::theme::import_theme(zip)validates archives (20 MB cap, zip-slip rejection, manifest validation, every SVG round-tripped through the rasteriser) and atomically unpacks. - Picker UI in Settings → Cosmetic; selection persists as
selected_theme_idand propagates to live sprites.
- Bundled default theme ships in the binary via
- Reserved HUD top band (64 px) so cards no longer crowd the score
readout or action buttons; layout's
top_yshifts down accordingly. - Action-bar auto-fade — buttons fade out when the cursor leaves the band, fade back in when it returns. Lerp at ~167 ms.
- Visible drop-target overlay during drag — a soft fill plus 3 px outline drawn ABOVE stacked cards for every legal target (full fanned column for tableaux, card-sized for foundations and empty tableaux). Replaces the previously invisible pile-marker tint.
- Card drop shadows — every card casts a neutral 25 % black shadow with a 4 px halo; cards in the active drag set switch to a lifted shadow (40 % alpha, larger offset, bigger halo).
- Stock remaining-count badge — small
·Nchip at the top-right of the stock pile so the player can see how close they are to a recycle. Hides when the stock empties.
Changed
- Foundations are unlocked.
PileType::Foundation(Suit)→Foundation(u8)(slot 0..3). The claimed suit is derived from the bottom card viaPile::claimed_suit()— no separate field, no claim-stuck-after-undo bugs. Any Ace lands in any empty slot, and the slot then claims that suit.next_auto_complete_moveprefers a claim-matched slot before falling back to the first empty slot for Aces. Empty foundation markers render as plain placeholders (no "C/D/H/S"). - HUD selection label and hint toast read
claimed_suit()and fall through to "Foundation N" / "move to foundation" only when the slot is empty.
Fixed
shared_fontdbnow bundles FiraMono. The hayeah SVGs referenceBitstream Vera SansandArialby name. On minimal Linux installs / fresh Wayland sessions / chroots where neither is installed AND the CSS-generic aliases don't resolve, card rank/suit text vanished. The bundled font is loaded into fontdb and pinned as every CSS generic's target so the resolver always lands on something real. Surfaced when a second-machine pull rendered cards without glyphs.- Theme asset path resolution —
AssetPath::resolve(concatenates) →resolve_embed(RFC 1808 sibling resolution). Was producing paths like…/theme.ron/hearts_4.svgand failing to load every face SVG. - Sync exit log spam —
push_on_exitsilently no-ops onLocalOnlyProvider'sUnsupportedPlatforminstead of warn-spamming every shutdown. - usvg font-substitution warn spam — custom
FontResolver.select_fontappendsFamily::SansSerifandFamily::Serifto every query so unmatched named families silently fall through.
Migration
- In-progress saves invalidated.
GameState.schema_versionbumped 1 → 2; pre-v2game_state.jsonfiles silently fall through to "fresh game on launch." Stats, progress, achievements, and settings live in separate files and are unaffected.
Stats
- 982 passing tests (was 819 at v0.10.0).
- Zero clippy warnings under
--workspace --all-targets -- -D warnings.
0.10.0 — 2026-04-29
PNG art pipeline plus a major dependency pass. The first release where the binary shipped with bundled artwork.
Added
- 52 individual card face PNGs generated via
solitaire_assetgen. - Custom font (FiraMono-Medium) loaded via
AssetServerat startup through the newFontPlugin. - Card backs and backgrounds upgraded to 120×168 with richer patterns.
- Ambient audio loop wired through the kira mixer.
- Arch Linux PKGBUILDs for the game client and sync server (under
the separate
solitaire-quest-pkgbuilddirectory). - Workspace README, CI workflow, migration guide.
Changed
- Bevy 0.15 → 0.18 workspace migration.
- kira 0.9 → 0.12 audio backend migration.
- Edition 2024, MSRV pinned to Rust 1.95.
- rand 0.9 upgrade.
- Card rendering moved from
Text2doverlay to PNG-backedSpritewith face/back atlases;Text2dretained as a headless fallback whenCardImageSetis absent (tests under MinimalPlugins). - Asset pipeline switched from
include_bytes!()for PNGs/TTFs to runtimeAssetServer::load()so artwork can be swapped without a recompile. Audio remains embedded. - Removed Google Play Games Services sync backend — redundant with the self-hosted server.
Fixed
- Server JWT secret loaded at startup (was lazy, surfaced as intermittent 500s).
- Daily-challenge race in the server's seed-generation path.
- Rate limiter switched to
SmartIpKeyExtractorso the limit applies per real client IP rather than per upstream proxy. - Touch input uses
MessageReader<TouchInput>(Bevy 0.18 rename). - Sync push/pull races in async task scheduling.
- Hot-path allocations reduced in card-rendering systems.
- Conflict report coverage added for sync merge edge cases.
Stats
- 819 passing tests at tag time.
0.9.0 — 2026-04-28
Initial public-tagged release. Established the workspace structure
(solitaire_core / _sync / _data / _engine / _server / _app /
_assetgen), the modal scaffold via ui_modal, the design-token system
in ui_theme, and the four-tier HUD layout. Foundations were
suit-locked at this point; cards rendered as Text2d rank/suit overlays
with no PNG artwork yet.
Added
- Klondike core (Draw One / Draw Three modes).
- Progression system (XP, levels, 18 achievements, daily challenge, weekly goals, special modes at level 5).
- Self-hosted sync server (Axum + SQLite + JWT auth).
- All 12 overlay screens migrated to the
ui_modalscaffold with real Primary/Secondary/Tertiary buttons. - Animation upgrades:
SmoothSnapslide curves, scoped settle bounce, deal jitter, win-cascade rotation. - Splash screen, focus rings (Phases 1–3), tooltips infrastructure + HUD/Settings/popover applications, achievement integration tests, destructive-confirm verb unification, leaderboard error/idle states, first-launch empty-state polish, hit-target accessibility fix, CREDITS.md, persistent window geometry, mode-launcher Home repurpose, client-side sync round-trip integration tests.