Files
Ferrous-Solitaire/CHANGELOG.md
T
funman300 8325bf6cf7 chore: rename app from Solitaire Quest to Ferrous Solitaire
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>
2026-05-13 17:04:45 -07:00

123 KiB
Raw Blame History

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_button now checks scrims.is_empty() — a Query<(), With<ModalScrim>> guard — before calling spawn_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+26602666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge.
  • UX-1: Modal Done button in gesture zone (safe_area.rs). New apply_safe_area_to_modal_scrims Bevy system pads every ModalScrim bottom by SafeAreaInsets.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). New SyncSetupPlugin (solitaire_engine/src/sync_setup_plugin.rs) provides the full server-connection UI. Three tab-stopped text fields (URL, Username, Password) handle keyboard input via MessageReader<KeyboardInput> with focus cycling on Tab. "Log In" and "Register" buttons each spawn an async AsyncComputeTaskPool task that calls the new SolitaireServerClient::login() / ::register() methods; poll_auth_task harvests the result, stores tokens via store_tokens(), hot-swaps SyncProviderResource to the new server backend, fires ManualSyncRequestEvent to pull immediately, and closes the modal. An inline SyncAuthError label 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. SyncConfigureRequestEvent opens the setup modal; SyncLogoutRequestEvent disconnects and resets SyncProviderResource to LocalOnlyProvider; DeleteAccountRequestEvent opens the deletion confirmation modal.
  • Settings sync section — dynamic backend UI (432061c). sync_row() in SettingsPlugin now takes backend: &SyncBackend and renders conditionally: Local → "Connect" button; SolitaireServer → username label + "Sync Now" + "Disconnect" + "Delete Account". Three new SettingsButton discriminants (ConnectSync tab 91, DisconnectSync tab 92, DeleteAccount tab 93) feed into a new handle_sync_buttons system extracted from handle_settings_buttons to stay within Bevy's 16-parameter system limit.
  • SolitaireServerClient::login() and ::register() (432061c). Both POST to /api/auth/login and /api/auth/register respectively. Private helper extract_auth_tokens parses { 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_result in SyncPlugin now fires InfoToastEvent("Session expired — please reconnect") + SyncConfigureRequestEvent when the pull task resolves to SyncError::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-slimdebian:bookworm-slim); copies .sqlx offline cache so SQLX_OFFLINE=true succeeds without a live database at build time; exposes port 8080. solitaire_server/docker-compose.yml: single-service compose file; db-data volume at /app/data; DATABASE_URL and JWT_SECRET from environment; HTTP health-check via wget. solitaire_server/.env.example: documents all required variables with generation hint (openssl rand -hex 32).
  • Account deletion flow (272d31f). "Delete Account" in Settings fires DeleteAccountRequestEventSyncSetupPlugin::open_delete_confirm_modal spawns a danger-red confirmation modal with "Cancel" and "Delete Forever" buttons. "Delete Forever" submits an async PendingDeleteTask that calls SyncProvider::delete_account(); poll_delete_task on Ok fires SyncLogoutRequestEvent + 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). DifficultyLevel enum (Easy / Medium / Hard / Expert / Grandmaster / Random) added to solitaire_core::game_state alongside a new GameMode::Difficulty(DifficultyLevel) variant. Five pre-verified seed catalogs (40 seeds each, 200 total) are generated by the new gen_difficulty_seeds binary in solitaire_assetgen; each catalog contains seeds proven winnable at progressively larger solver budgets (1 K → 200 K moves). DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed → NewGameRequestEvent; the Random tier uses a system-time seed and intentionally bypasses the winnable-only filter. The home overlay gains an expandable ▶ Difficulty section between the Draw Mode row and the mode-card grid; the last-played tier is persisted in Settings::last_difficulty and 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, and ReplaySelectorDetail nodes now spawn inside spawn_stats_screen as 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 carry ModalButton(Secondary) so the existing repaint_modal_buttons paint loop gives them hover/press feedback at zero extra cost. repaint_replay_selector_detail is wired into the existing .chain() alongside handle_replay_selector_buttons and repaint_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 when share_url is set, empty-history "No replays" caption, and ordinal wrapping. make_test_replay(time_seconds, share_url) helper encapsulates Replay::new(...) + chrono::NaiveDate.

Fixed

  • const { assert!() } for dim-layer z-order test (a449f60). Converted assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY, …) in replay_overlay tests to const { assert!(…) } to satisfy clippy::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_tap fires MoveRequestEvent (single card to foundation/tableau, or a whole face-up stack via best_tableau_destination_for_stack) when two TouchPhase::Ended events on the same card arrive within DOUBLE_TAP_WINDOW (0.5 s, slightly wider than the mouse DOUBLE_CLICK_WINDOW to account for touch latency). If no legal destination exists, fires MoveRejectedEvent (audio + visual rejection feedback). The system is inserted into the touch drag chain immediately before touch_end_drag so DragState.active_touch_id and committed are still readable; the tap timestamp is tracked in a Local<HashMap<u32, f32>> keyed by card ID.
  • Play-by-Seed dialog (0cb1587). PlayBySeedPlugin adds a numeric-input modal that accepts a decimal seed, runs a solver preview in the background (debounced 500 ms via AsyncComputeTaskPool), and shows a win/no-win verdict before dealing. A new HomeMode::PlayBySeed card in the home overlay fires StartPlayBySeedRequestEvent; the handler in PlayBySeedPlugin spawns the dialog. Digit, Backspace, Enter (confirm), and Escape (cancel) are handled via ButtonInput<KeyCode>. Five unit tests cover spawn, digit append, buffer read, confirm, and cancel paths.
  • 75 new challenge seeds (2062bd0). New gen_seeds binary in solitaire_assetgen brute-searches seeds in the 0xCAFEBABE… namespace and filters for hands solvable in ≤250 moves via the core solver. The 75 confirmed-win seeds are appended to CHALLENGE_SEEDS in solitaire_data::challenge.

Fixed (post-cut, same pending release)

  • Gate handle_fullscreen to non-Android (45436d0). F11 fullscreen toggle makes no sense on Android (the OS owns window sizing); the fn and its MonitorSelection/WindowMode imports are now #[cfg(not(target_os = "android"))]-gated. The add_systems call 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). NativeActivity dlopen-s libsolitaire_app.so and calls android_main as its entry point. Without the symbol the app crashed immediately with UnsatisfiedLinkError. The new function sets bevy::android::ANDROID_APP (required by WinitPlugin) then delegates to run() — equivalent to what #[bevy_main] would generate, but usable on an arbitrary entry point name.
  • Android APK launch: gate resize_constraints to non-Android (202a64d). On Android max_width/max_height default to 0.0; Bevy's clamp panicked with min=800 > max=0.
  • Android APK launch: gate apply_smart_default_window_size to 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 .gitignore and 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_HC constant (c50eaf8). Brighter lime (#c8e862, L≈0.73) in ui_theme for use wherever the standard STATE_SUCCESS (#acc267, L≈0.51) needs extra luminance under HC mode. Sits above the bumped notch ticks (BORDER_SUBTLE_HC gray, L≈0.60) so a WIN MOVE marker at this colour is unambiguous.
  • HighContrastBackground::with_hc(default, hc) constructor (c50eaf8). Extends HighContrastBackground with an hc_color: Color field (default = BORDER_SUBTLE_HC via with_default()). update_high_contrast_backgrounds now reads marker.hc_color instead of the hardcoded constant — backwards-compatible; all existing with_default() usages continue to bump to gray.
  • WIN MOVE scrub-bar marker HC bump (c50eaf8). Marker now carries HighContrastBackground::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 CSS translateX(-50%) pattern for Bevy 0.18 UI: a fixed SCRUB_LABEL_CENTER_WIDTH = 36 px container with margin.left = -18 px is placed at left: Percent(pct), and Justify::Center centres 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 a ReplayTableauDimLayer UI node (100 % × 100 %, 50 % opacity black) at Z_REPLAY_DIM = Z_REPLAY_OVERLAY 1 = 54 whenever a replay starts; despawned alongside the banner and move-log panel when the replay ends. Bevy's UI/world compositor means no changes to card_plugin are needed — UI nodes always render above world-space sprites regardless of Transform.z. The dim layer carries no Interaction component (purely visual; pointer events pass through). Adds Z_REPLAY_DIM and TABLEAU_DIM_ALPHA constants plus two new tests: lifecycle (spawn/despawn mirrors the floating-chip pattern) and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAY pinned). 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 primitive HighContrastBackground to ui_theme and a paint system update_high_contrast_backgrounds in settings_plugin that mirrors the existing border-marker pattern but targets BackgroundColor instead of BorderColor. Tags the 1 px scrub track Node and all five quarter-mark notch ticks so they bump from BORDER_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 [← →] scrub terminology while keeping single-press = single-step semantics. Per-key accumulators in a new ReplayScrubKeyHold resource; just_pressed events 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 in ACCENT_PRIMARY + 2 next rows below. Header reads ▌ MOVE LOG · N/M (or ▌ MOVE LOG · COMPLETE when finished). Active row carries a focus prefix and TEXT_PRIMARY_HC text colour for legible contrast against the brick-red highlight. Prev / next rows render in TEXT_SECONDARY so 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).
    • Panel grows from 56 → 84 → 112 px across the four move-log commits. MOVE_LOG_PREV_ROWS and MOVE_LOG_NEXT_ROWS constants (both = 2) parameterise the row count; format_kth_recent_row and format_kth_next_row return 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).

Changed

  • react_to_state_change despawns the Move Log panel on Playing → Inactive alongside 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_HEIGHT const.
  • format_active_move_row now prefixes the focus marker (e7345ae). Wraps format_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.md refreshed 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).
  • 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 helper scrub_notch_positions() returns the fixed array; spawn loop sits next to the WIN MOVE marker spawn so the lifecycles match. Notches paint in BORDER_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). Five 0% / 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 helper scrub_notch_labels() returns the fixed array, paired index-for-index with scrub_notch_positions(). Spawn loop applies an "endpoints flush, middle three percent-anchored" positioning pattern: leftmost label gets left: 0, rightmost gets right: 0, middle three anchor at left: Val::Percent(p) since Bevy 0.18 UI lacks a clean CSS-style translate-x: -50% centering primitive. Label colour is TEXT_SECONDARY rather than the mockup's BORDER_SUBTLE (the latter would match the notches but is too low-contrast against BG_ELEVATED_HI to 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 in BORDER_SUBTLE separates the footer from the labels row.
  • ESC keyboard accelerator for replay-stop (90e24d9). New handle_stop_keyboard system parallels handle_pause_keyboard in shape — fires only when state is Playing, calls stop_replay_playback. Cross-plugin coordination via pause_plugin::toggle_pause: added a fourth defer-if check (replay_state.is_some_and(|s| s.is_playing())) right after the existing other_modal_scrims check 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 existing apply_high_contrast_borders system bumps the 1 px top border from #505050#a0a0a0 when Settings::high_contrast_mode is 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). New step_backwards_replay_playback in replay_playback.rs decrements the cursor and dispatches UndoRequestEvent; the game's handle_undo reads 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 (d322abf then 1873b3f). Top row's flex_grow: 1.0 still 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_pause now defers when a replay is active (90e24d9). Adds a fourth defer-if check to the existing modal-stack pattern.
  • ReplayOverlayPlugin registers add_message::<UndoRequestEvent>() (e5c4f51). Defensive registration so the plugin runs cleanly under MinimalPlugins without GamePlugin attached.

Documentation

  • SESSION_HANDOFF.md refreshed 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_challenge warning test that fails when wall-clock UTC is within 30 minutes of midnight is documented in this cycle's handoff. Same shape as the earlier winnable_seed_search flake — 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)
  • Pre-existing flake: daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day fails 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 persisted Replay shape. #[serde(default)] keeps older latest_replay.json / replays.json files loadable without bumping REPLAY_SCHEMA_VERSION — this is purely additive. Populated at the live recording site (game_plugin::handle_game_won) via a new builder-style setter Replay::with_win_move_index. For fresh recordings the value is always Some(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). New ReplayOverlayWinMoveMarker component spawned as a sibling to ReplayOverlayScrubFill under the 1px scrub track, absolute-positioned at replay.win_move_index / total % of the bar. Painted in STATE_SUCCESS (green) so the marker reads as "this is where the win lives." Pure helper win_move_marker_pct returns None for 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 underlying Replay is immutable while Playing.
  • Pause / Resume / Step playback controls (fbe48ac). New paused: bool field on ReplayPlaybackState::Playing. tick_replay_playback skips the secs_to_next decrement entirely while paused so cursor and timer freeze together; resuming starts the next move from a full interval. New public API: toggle_pause_replay_playback and step_replay_playback (the latter hard-gated to Playing { 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; Space keyboard accelerator toggles pause / resume.
  • Replay::with_win_move_index builder (ab857bb). Chainable setter so the recording site can write Replay::new(...).with_win_move_index(idx). Keeps Replay::new's signature stable across the 13+ existing test-fixture call sites that don't care about the field.

Changed

  • Replay::new writes win_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_won populates the new field (ab857bb). The recording site computes recording.moves.len().checked_sub(1) as the win-move index. checked_sub rather than direct subtraction guards the unreachable empty-recording branch (which is also guarded earlier in the function).
  • tick_replay_playback honors the new paused flag (fbe48ac). Skipping the timer decrement is the only behavior change; the loop body and Completed-detection are unchanged. Stepping fires moves directly via step_replay_playback, bypassing the tick path entirely.
  • Pause / Resume button label is reactive (fbe48ac). update_pause_button_label walks Children from the marked button to its inner Text and repaints the label whenever ReplayPlaybackState changes. Pure helper pause_button_label covers all four state arms (running, paused, inactive, completed).
  • 25 existing Playing { ... } construction sites gained paused: false (fbe48ac). Mechanical edit across replay_overlay, achievement_plugin, and replay_playback tests to satisfy the new field requirement. No behavioral change.

Documentation

  • SESSION_HANDOFF.md refreshed 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's win_move_index coverage: 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.
  • Zero clippy warnings under cargo clippy --workspace --all-targets -- -D warnings.
  • cargo test --workspace clean.

[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) — first ToastVariant::Warning consumer (279e23d). Generic carrier message that any system can fire to spawn a 4 s amber-bordered fire-and-forget toast. Mirrors the v0.21.2 MoveRejectedEventError toast wiring: domain message crosses the plugin boundary, the animation plugin's handle_warning_toast system 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 of WarningToastEvent. New daily_challenge_plugin::check_daily_expiry_warning system fires at most once per DailyChallengeResource::date when 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 because Utc::now() can't be pinned without injecting a clock resource — overkill for one consumer.
  • radial_rim_outline pure helper (c153363). Decision logic for the radial-menu rim outline colour. Resting outlines always carry BORDER_SUBTLE; focused outlines carry BORDER_STRONG normally and BORDER_SUBTLE_HC under HC. Naive marker substitution would invert the focused-vs-resting hierarchy because BORDER_SUBTLE_HC (#a0a0a0) is lighter than BORDER_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 mutate BackgroundColorBorderColor is set once at spawn and never touched. So the existing HighContrastBorder::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_overlay despawns and respawns all rim sprites every frame the radial is Active. The HighContrastBorder marker can't apply (entities don't persist across frames) so HC is read directly in the system via Option<Res<SettingsResource>> and routed through radial_rim_outline. The Option<Res<...>> shape preserves test compatibility under MinimalPlugins.
  • Animation plugin registers WarningToastEvent (279e23d). Joins InfoToastEvent, MoveRejectedEvent etc. in AnimationPlugin::build. Daily-challenge plugin also registers it (idempotent) so the message exists when running the daily plugin under MinimalPlugins without the animation plugin attached.

Documentation

  • SESSION_HANDOFF.md refreshed 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). The High-contrast accessibility mode entry 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) pinning DailyExpiryWarningShown'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.
  • Zero clippy warnings under cargo clippy --workspace --all-targets -- -D warnings.
  • cargo test --workspace clean.

[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_visibility system precursor was v0.21.1's; this cycle adds: update_high_contrast_borders system in settings_plugin (c9af1ea). Walks all entities tagged with HighContrastBorder each Update tick, swaps BorderColor to BORDER_SUBTLE_HC when high-contrast mode is on. Compares current colour and only mutates when different so Bevy's change-detection doesn't trigger repaints every frame. New HighContrastBorder { 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 (c9af1ea modal scaffold; d87761d tooltip + onboarding key chips + help panel key chips + stats panel cells; ec804d5 home 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). New ReplayFloatingProgressChip marker on a Text2d entity 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_overlay spawns the chip alongside the banner when a replay starts; react_to_state_change despawns it when the replay ends. World-space placement (rather than UI-space + camera projection) uses the same LayoutResource pile 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 a StockClick (no destination pile to follow).
  • handle_move_rejected_toast system + first real ToastVariant::Error consumer (68d50b5). When MoveRejectedEvent fires (illegal placement attempt), spawns a 2-second pink-bordered "Invalid move" toast. Joins the existing card_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)] from ToastVariant::Error and updates its doc to point at the new consumer.

Changed

  • Splash scanline overlay skipped under reduce-motion (ed152e2). spawn_splash reads Settings::reduce_motion_mode and 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_cursor reads Settings::reduce_motion_mode and 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::build registers MoveRejectedEvent (68d50b5). Bevy's add_message is idempotent, so the duplicate registration with feedback_anim_plugin (which already registered the message) coexists cleanly. Required for the new handle_move_rejected_toast system to run under MinimalPlugins (tests).

Documentation

  • docs/ui-mockups/design-system.md and SESSION_HANDOFF.md refreshed 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: under MinimalPlugins, Assets<Image> isn't auto-inserted; the test had to add bevy::asset::AssetPlugin::default() and init_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 a MoveRejectedEvent spawns exactly one ToastOverlay on the next tick.
  • Zero clippy warnings under cargo clippy --workspace --all-targets -- -D warnings.
  • cargo test --workspace clean.

[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). Runtime Window::icon wired via WinitWindows; embedded 256 px PNG decoded on startup via tiny_skia and handed to winit. Plus a 9-size PNG hierarchy at assets/icon/icon_<size>.png covering Linux hicolor (16/24/32/48/64/128/256/512), Windows .ico targets (16/32/48/256), and macOS .icns targets (16/32/64/128/256/512/1024). All sizes generated from a shared icon_svg builder (Terminal ▌RS mark on dark #151515 with brick-red accent) by a new icon_generator example. Pin test icon_svg_pin guards rasterised RGBA bytes against usvg/resvg drift. Two new solitaire_app deps target-gated to non-Android: direct winit = "0.30" (for Icon construction — bevy_winit 0.18 doesn't re-export it) and direct tiny-skia (for PNG → RGBA decode). Android draws its launcher icon from the APK manifest, so neither dep is needed there.
  • Settings::high_contrast_mode flag (c5787c6). Boosts card text colours: hearts/diamonds → RED_SUIT_COLOUR_HC (#ff6868), clubs/spades → TEXT_PRIMARY_HC (#f5f5f5). Composes with color_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_mode flag (c5787c6). Forces effective_slide_secs to 0.0 regardless of the AnimSpeed selection, making cards snap instantly to their target. Two new tests pin the gate behaviour and the fall-through to anim_speed_to_secs when 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-walk order visits all three accessibility flags in one vertical run. Same shape as the existing ColorBlindText toggle scaffold with marker components, label updaters, click handlers, and disambiguator chains.
  • sync_pile_marker_visibility system (4d48cad). Implements the module-level doc invariant in table_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 (62b61ccddb6540). 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) and BLACK_SUIT_COLOUR = #e8e8e8 (near-white, brighter than the v0.21.0 #d0d0d0 foreground 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_HC rebumped 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 #1a1a1a body and #151515 surface 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 in handle_settings_buttons extended with Without<HighContrastText> and Without<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's Resumed event 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 in Option<T> and handle None when it happens"). Wraps winit_windows as Option<NonSend<WinitWindows>> and early-returns on None.
  • "Gray L corners" on cards (4d48cad). Two artifacts were producing similar-looking grey at card corners: the SVG stroke fading through gray pixels (closed by dd97021) and the pile-marker sprite bleeding through the rounded cutouts (closed by 4d48cad). 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.md refreshed twice this cycle (0c1cc40
    • 31139ae) — 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_CBM semantic 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_pin integration 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_secs tests in card_plugin / animation_plugin (from c5787c6).
    • 1 icon_svg_pin integration test guarding the icon rasterisation pipeline (from 48b28d2 — actually landed in v0.21.0's accounting but worth noting for the cycle).
    • 1 pile_markers_hide_when_pile_is_occupied test pinning the new visibility-by-occupancy invariant (from 4d48cad).
  • Zero clippy warnings under cargo clippy --workspace --all-targets -- -D warnings.
  • cargo test --workspace clean.

[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 (5623368 plan doc, 3a4bb63 PoC, babe5cc full generator, 48b28d2 pin test). solitaire_engine/examples/card_face_generator.rs writes 52 face PNGs + 5 back PNGs into assets/cards/ and 53 theme SVGs into solitaire_engine/assets/themes/default/, all from the shared face_svg / back_svg builders in the new solitaire_engine::assets::card_face_svg module. Run with cargo run --example card_face_generator --release. The PoC (card_face_poc.rs) stays alongside as historical record of the per-card grain proof. Pin test card_face_svg_pin guards 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.rs emits 5 flat Terminal-palette play-surface PNGs at 120 × 168, the same tile size the legacy felt textures used (the runtime stretches to window_size * 2.0 so source resolution is immaterial). All 5 slots stay in the near-black family — #151515 canonical, #0a0a0a deeper, #1a1a1a elevated, #121820 cool tint, #201812 warm tint.
  • Splash boot-screen port (cacb19c). Full mockup-spec splash: header, boot log, progress bar, palette swatches, version footer, plus the SplashFadable scaffold that lets any future overlay fade N >> 3 elements via one marker + one global lerp query (replaces the Without<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 original cacb19c skip-rationale. Closes Option B half 1 from the SESSION_HANDOFF Resume prompt.
  • Splash tiled scanline overlay (a27cf5a). Runtime- generated 2×2 RGBA8 texture tiled via NodeImageMode::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, mirroring cursor / 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-DDD game-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/M progress 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 with 54005d5).
  • 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_svg module (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 from tests/. The generator and the test now share the same source-of-truth, so the pin guards both rendering paths the engine consults.

Changed

  • ACCENT_PRIMARY swapped from cyan #6fc2ef to 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_CBM swapped in lockstep from cyan to lime #acc267 so 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 doc design-system.md updated 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_CBM renamed to RED_SUIT_COLOUR_CBM and repurposed from a face-background tint to a suit-glyph swap (the Terminal face is uniformly CARD_FACE_COLOUR regardless 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 matching BACK_ACCENTS. face_colour() deleted — the function collapsed to a constant once the Terminal face became uniform. text_colour() gained a color_blind: bool parameter to surface the CBM swap on the constant-fallback path (the production path bakes glyphs into the PNG, but tests under MinimalPlugins still need the CBM-aware fallback). Four face_colour CBM tests collapsed into two text_colour CBM tests in the same commit.
  • Default-theme SVG art regenerated to Terminal aesthetic (a14200a). solitaire_engine/assets/themes/default/*.svg — the bundled-default theme that include_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 because apply_theme_to_card_image_set overrides CardImageSet.faces[..] at startup with the theme's rasterised SVG handles. Both rendering paths now agree: same face_svg / back_svg builders 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 hardcoded font_size: 16.0 literal that the v0.20.0 typography-migration audit walked past. Brings it in line with the TYPE_* token system every other text element in hud_plugin already 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 from VAL_SPACE_3 to VAL_SPACE_2: ~96 px reclaimed across the 6-button row.
  • Table backgrounds flattened to solid Terminal colours (8719f77). Replaces the legacy felt-texture PNGs at assets/backgrounds/bg_*.png with 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 bundled FiraMono in svg_loader::shared_fontdb doesn't carry usable U+2660-2666 glyphs at the requested size — usvg silently substituted a default-size fallback regardless of font-size="20" / font-size="64". Switched suit-glyph rendering from <text> elements to inline SVG <path> elements via a new suit_path_d helper 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 in e8bf9d7 looked correct under cargo test (the constant-fallback path matched) but a real cargo run showed legacy white cards because theme::plugin::apply_theme_to_card_image_set overlays the bundled-default theme's rasterised SVGs onto CardImageSet.faces[..] at startup, and those SVGs were still legacy. Fixed by regenerating both rendering paths from the same face_svg / back_svg builders. 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 at top: VAL_SPACE_2 without 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 (shared JustifyContent::SpaceBetween parent) 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). Commit 651f406 retuned in-engine constants but the runtime path loads from assets/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 in card_face_svg_pin.rs that exercises 57 rasteriser outputs through 57 hash comparisons in a single #[test]-marked function); and the CBM consolidation that rewrote four face_colour tests as two text_colour CBM tests in the same commit (net 0 to count, clean rewrite).
  • Zero clippy warnings under cargo clippy --workspace --all-targets -- -D warnings.
  • cargo test --workspace clean.

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.md updates: § 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.md refreshes (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_theme Terminal 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, no box-shadow); the rendering path is left intact so a future palette can re-enable shadows without touching consumers.
  • ToastVariant enum + Terminal toast styling (a137607). Toasts now follow docs/ui-mockups/design-system.md: opaque BG_ELEVATED fill, 1px accent border keyed off Info / Warning / Error / Celebration variants, 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, matching docs/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-android now produces a 54 MB debug-signed APK at target/debug/apk/solitaire-quest.apk. Five gating points resolved end-to-end:
    • solitaire_app split into bin + lib. cargo-apk needs a cdylib to bundle as libmain.so; pure-bin crates panic with "Bin is not compatible with Cdylib". src/lib.rs carries the ECS bootstrap as pub fn run; src/main.rs is a 3-line shim that delegates for the desktop path.
    • [package.metadata.android] pins target SDK 34 / min SDK 26 and points assets = "../assets" at the workspace asset directory so desktop and APK share one set.
    • Workspace bevy features add android-native-activity (target-gated inside bevy_internal — desktop builds compile it out). Pairs with cargo-apk's NativeActivity wrapper.
    • arboard target-gated to cfg(not(target_os = "android")). The crate has no Android backend; cargo apk fails with E0433 on platform::Clipboard if left unconditional. Stats's "Copy share link" surfaces an informational toast on Android until JNI ClipboardManager lands in the Phase-Android round.
    • keyring + keyring-core target-gated. Bionic doesn't expose libc::__errno_location so the transitive rpassword won't compile. auth_tokens ships an Android stub returning KeychainUnavailable for 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 --lib is the small workaround.
  • Android developer setup + build runbook (59424a3). Captures Debian 13 toolchain install (JDK 21, unzip, SDK licence prompts), the cargo apk build invocation, 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). DiagnosticsHudPlugin wraps Bevy's FrameTimeDiagnosticsPlugin and renders a corner readout the developer toggles with F3. Hidden by default; F3 is not gated by pause / modal state. Reads smoothed() so the cell isn't a per-frame jittery scoreboard. Format: FPS NN \u{2022} M.MM ms. Anchored top-right at z = Z_SPLASH + 100 above 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's apply_smart_default_window_size system. New Settings::disable_smart_default_size: bool with #[serde(default)] so legacy settings.json files load to the shipped behaviour (smart sizer enabled). solitaire_app::main reads 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} Shareable when the displayed replay carries a populated share_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 in selection_plugin and the valid-drop marker tint in cursor_plugin were 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_VALIDSTATE_SUCCESS at 0.55 α with a tracking test pinning its RGB to the token. Three stale doc comments in ui_modal corrected ("loud yellow CTA" / "magenta secondary accent" → cyan / lavender to match the actual token values).
  • table_plugin chrome migration to Terminal tokens (651f406). marker_colour promoted to module-level pub const PILE_MARKER_DEFAULT_COLOUR so cursor_plugin::MARKER_DEFAULT imports 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 uses TEXT_PRIMARY at 0.35 α; HINT_PILE_HIGHLIGHT_COLOUR retuned from bright srgb(1.0, 0.85, 0.1) to the STATE_WARNING token (#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_plugin chrome migration to Terminal tokens (d752870). Drag-elevation shadow now sources its colour from CARD_SHADOW_COLOR + CARD_SHADOW_ALPHA_DRAG so the Terminal "no box-shadow" policy disables the stack shadow in lockstep with the per-card shadows. RIGHT_CLICK_HIGHLIGHT_COLOUR retuned from raw green to STATE_SUCCESS at 0.6 α with a tracking test. The duplicated PILE_MARKER_DEFAULT_COLOUR const dropped — this plugin now imports the promoted const from table_plugin. Stock recycle "↺" text moved from raw white-at-0.7-α to TEXT_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-yellow srgba(1.0, 1.0, 0.4, 1.0) to STATE_WARNING, so the source card and the destination pile (which already uses STATE_WARNING via HINT_PILE_HIGHLIGHT_COLOUR) wear the same attention colour as a coherent pair.

Fixed

  • solitaire_data::data_dir shim closes the Android persistence gap (4b51e50). dirs::data_dir() returns None on Android, which silently disabled every persistence path (settings, stats, achievements, replays, game-state, time-attack sessions, user themes). New solitaire_data::platform::data_dir() shim falls through to dirs::data_dir() on desktop and returns the per-app sandbox at /data/data/com.solitairequest.app/files on Android — no JNI needed, since the package id is pinned in [package.metadata.android]. Six call sites across solitaire_data plus solitaire_engine/assets/user_dir.rs migrated. 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_params test aligned with Terminal "no shadow" intent (1d1543e). The Terminal token system pinned both CARD_SHADOW_ALPHA_IDLE and CARD_SHADOW_ALPHA_DRAG to 0.0, which made the prior drag_alpha > idle_alpha assertion fail (0 > 0 is false). Loosened to drag_alpha >= idle_alpha with 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_status test 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_now between 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_theme invariant guards for the type / spacing / z-index scales + scaled_duration, one toast-variant-border-mapping pair, and four palette- tracking guards on MARKER_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 synchronous try_solve_from_state call on every H press is gone; handle_keyboard_hint now spawns a task whose result the new pending_hint::poll_pending_hint_task system surfaces one frame later. New PendingHintTask resource carries the in-flight handle plus move_count_at_spawn for staleness detection; drop_pending_hint_on_state_change cancels the task whenever the game state shifts; PendingHintTask::spawn implements cancel-on-replace so two quick H presses keep at most one task in flight. Mirrors the v0.18.0 PendingNewGameSeed template. emit_hint_visuals and find_heuristic_hint are extracted as pub helpers so the polling system can call them.
  • Persistent replay share URLs (42d90b1). v0.18.0's LastSharedReplayUrl was an in-memory resource wiped on quit — the player had to share within the session of the win. solitaire_data::Replay now carries a share_url: Option<String> field with #[serde(default)] (no REPLAY_SCHEMA_VERSION bump needed; older replays.json files load unchanged with share_url == None on every entry). poll_replay_upload_result writes the resolved URL into replays[0].share_url and persists the updated history via save_replay_history_to. The Stats overlay's "Copy share link" button reads from history.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. LastSharedReplayUrl removed (its role is now subsumed by the share_url field on the replay record).

Added

  • Wayland session support (b57db01). The workspace Cargo.toml Bevy feature list now enables wayland alongside x11. winit prefers Wayland when WAYLAND_DISPLAY is 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 new apply_smart_default_window_size Update system queries Monitor (with the PrimaryMonitor marker) 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 the win_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::Expressive per-card rotation drift) is unchanged — that's the visual celebration, distinct from the textual celebration the modal owns. WIN_TOAST_SECS const removed.
  • Double-click on a single card with no destination now plays the reject animation (d7ffb16). handle_double_click only fired MoveRejectedEvent for 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 — no card_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's StateChangedEvent landing, then again from the release's end_drag firing a redundant StateChangedEvent mid-slide. sync_cards_on_change saw 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 defensive StateChangedEvent write in end_drag's uncommitted-drag branch is removed; start_drag only mutates DragState (never card transforms), so an uncommitted drag has no visual side effect to undo. The committed-drag branch keeps its StateChangedEvent since real drag snap-backs do need a resync.
  • auto_save_writes_after_30_seconds test flake (91b7605). The test's single-frame app.update() was sensitive to first-frame Time::delta_secs() variance under heavy parallel cargo-test load, and to production-disk ~/.local/share/solitaire_quest/game_state.json state leaking into the test world via GamePlugin::build's load path. test_app now resets PendingRestoredGame(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). When game_state.json holds an in-progress game (move_count > 0, not won), the engine now seeds GameStateResource with a fresh deal and holds the saved game in a new PendingRestoredGame resource. 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). Both save_game_state_on_exit and auto_save_game_state consult PendingRestoredGame first: 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. HomePlugin gains an auto_show_on_launch flag (default true) and a one-shot LaunchHomeShown gate. 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 as 1.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; persists settings.json and 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 to Today, May 6 • Done in ACCENT_PRIMARY so the picker reads as a reward state rather than a TODO.
  • Picture-tile mode cards (9fe650f + glyph-picking follow-ups 40d6e0a, 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_PRIMARY when the mode is unlocked, TEXT_DISABLED when locked. Centrepiece is a Text node for now — when real per-mode artwork lands, swap to Image without 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. New PendingNewGameSeed resource holds the in-flight Task<u64> plus the original request's mode / confirmed flags. poll_pending_new_game_seed runs .before(GameMutation) and replays a synthetic NewGameRequestEvent once the task resolves — the player sees no extra-frame visual lag. Cancel-on-replace: a fresh NewGameRequestEvent while a task is in flight drops the old task, letting Bevy's Task Drop 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 rolling ReplayHistory, the HUD's tier-2 context row shows ✓ Won before in STATE_SUCCESS. Cleared on win (the on-screen victory cue is enough) and on first-time deals. New HudWonPreviously marker driven by a separate update_won_previously system; gracefully no-ops in headless tests that don't load StatsPlugin.
  • "Copy share link" Stats button (540869c). End-to-end replay sharing on a server-backed sync backend: sync_plugin::push_replay_on_win spawns the upload on AsyncComputeTaskPool and stores the handle in PendingReplayUpload (drops any in-flight predecessor — the most recent win is what the player wants the link for); poll_replay_upload_result writes <server>/replays/<id> to LastSharedReplayUrl on success; the Stats overlay's action bar gains a button that writes the URL to the OS clipboard via arboard and 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 an InfoToastEvent with the new percentage so off-panel adjustments give visible feedback (previously silent).
  • 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.
  • N opens the real Confirm/Cancel modal (93660c2). The old "Press N again" double-tap pattern was a UI-first violation (only continuation was another keystroke). N now fires NewGameRequestEvent::default() directly; handle_new_game's active-game check spawns the existing ConfirmNewGameScreen. The HUD button already routed through the same modal — keyboard and mouse paths are unified. Shift+N keeps 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 gets flex-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.md grows 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 across CLAUDE_SPEC.md and ARCHITECTURE.md.
  • Test discipline pruning (a49a340). Removed 43 low-value tests across solitaire_data and solitaire_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_pause now skips when any non-Pause ModalScrim is in the world; the HUD-button path is gated too. The four modal queries are bundled into a PauseModalQueries SystemParam to 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 (because handle_home_cancel_button fired with no awareness of layered modals) and leave Profile orphaned over the game. profile_plugin now splits P/button (toggle) from Esc (close-only); handle_home_cancel_button skips its Esc branch when any other ModalScrim exists.
  • Restore-prompt resolution suppresses Home auto-show (b7c3a49). Resolving the Welcome-back prompt cleared PendingRestoredGame and despawned the modal, but the launch-time Home auto-show then fired the next frame and stacked itself over the player's chosen path. LaunchHomeShown becomes pub so handle_restore_prompt flips it to true after 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_time and advance_time_attack now also gate on the absence of HomeScreen, mirroring their existing PausedResource check.
  • 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 shared ActionButton marker and dropping their alpha to the cursor-position-based fade value (typically 0). New PopoverRow marker on rows in spawn_modes_popover / spawn_menu_popover; apply_action_fade excludes them via Without<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_secs from 0.10 s to 1.00 s in 0.05 s steps; default 0.45 s. tick_replay_playback reads the value from SettingsResource per 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_state APIs. 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 Button entity (modal buttons, HUD action bar, mode-launcher cards, settings toggles, Stats selectors). update_cursor_icon gains a fourth branch sitting between Grabbing (active drag) and Grab (draggable card hover): when no drag is active and any Interaction::Hovered/Pressed button is detected, the window cursor swaps to SystemCursorIcon::Pointer. A pure pick_cursor_icon helper makes the priority logic unit-testable.
  • Click-outside-to-dismiss for the six read-only modals: Stats, Achievements, Help, Profile, Leaderboard, Home. New ScrimDismissible marker on ModalScrim opts a modal in; dismiss_modal_on_scrim_click runs in Update, despawns the topmost dismissible scrim on a left-mouse press whose cursor lands on the scrim and outside every ModalCard. 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 a max_height constraint (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 sibling scroll_*_panel system per modal routes MouseWheel events into the body's ScrollPosition. Mirrors the existing SettingsPanelScrollable pattern. 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_buttons and auto_focus_on_modal_open ran in Update alongside arbitrary click-handlers that spawn modals; with no ordering edge, Bevy's deferred Commands queued the new entities but the attach system couldn't see them on the same tick. Both systems moved to PostUpdate so the schedule boundary itself supplies the sync point — FocusedButton is always populated before app.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 ReplayPlaybackPlugin runs a state machine (Inactive / Playing / Completed) that resets the live game to the recorded deal and ticks through replay.moves at REPLAY_MOVE_INTERVAL_SECS (0.45 s) firing the canonical MoveRequestEvent / DrawRequestEvent per 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.json capped at 8 entries. Replaces the single-slot latest_replay.json (legacy file is migrated forward on first launch via migrate_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 ReplayPlaybackState transitions 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-state SolverResult (Winnable / Unwinnable / Inconclusive). Median solve time 2 ms; pathological inconclusives cap near 120 ms. Pure logic — solitaire_core keeps no Bevy or I/O.
  • "Winnable deals only" toggle in Settings → Gameplay (default off). When on, handle_new_game walks seed N, N+1, N+2, … through try_solve until it finds Winnable or Inconclusive, capped at SOLVER_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 the bevy_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_wasm is 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_svg path. Cached per theme in a new ThemeThumbnailCache. 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 fill STATE_SUCCESS, missed days fill BG_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 on AppExit. 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 via game_state.json — Time Attack was the only mode missing session-level persistence.
  • Per-mode best-score and fastest-win readouts in the Stats screen. StatsSnapshot gains 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.02.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 RecordingReplay resource; on GameWonEvent the recording freezes into a Replay (seed + draw_mode + mode + score + time + ordered move list) and persists to <data_dir>/latest_replay.json atomically. 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 InfoToastEvent describing the replay; full in-engine playback is deferred to a future build).
  • Replay upload + fetch endpoints on the server. POST /api/replays accepts a Replay JSON; GET /api/replays/:id returns it. JWT-gated with the existing auth middleware. Engine uploads winning replays automatically when the player has cloud sync configured.
  • solitaire_wasm crate — new workspace member compiling replay-relevant solitaire_core types to WebAssembly so a browser can re-execute a replay client-side. No-std-friendly surface; wasm-bindgen glue.
  • Web replay viewer served from the Solitaire server. GET /replays/:id returns 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_sequence rejects 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 into move_cards's tableau-destination branch.
  • Softlock detection. has_legal_moves rewritten 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-returned true whenever stock had cards — players got stuck in unwinnable end-states with no end-game screen. GameOverScreen now 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.md refreshed 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_secs ranges [0.0, 1.5] in 0.1 s steps; "Instant" label when zero. Settings.tooltip_delay_secs round-trips through serialise/deserialise with #[serde(default)]. The hover-delay comparison in ui_tooltip reads from SettingsResource with the existing MOTION_TOOLTIP_DELAY_SECS as the test-fixture fallback.
  • Win-streak fire animation. New WinStreakMilestoneEvent fires from stats_plugin when win_streak_current crosses 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 over MOTION_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 by MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s). Honours AnimSpeed::Instant by spawning all rows fully visible.
  • Card backs follow the active theme. theme.ron's back slot now actually drives the face-down sprite. Active-theme back rasterises alongside the faces and supersedes the legacy back_N.png picker. 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 KeyboardDragState resource models the two-mode flow without changing the existing SelectionState contract. Mutual exclusion with mouse drag uses a sentinel DragState.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. New RadialMenuPlugin owns the flow; co-exists with the existing RightClickHighlight pile-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_plugin now embeds assets/fonts/main.ttf via include_bytes!() and registers it with Assets<Font>; svg_loader::shared_fontdb loads only the bundled bytes; the new bundled_font_resolver ignores the SVG's font-family request 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.toml deleted entirely; plain cargo build now works without setup.

Documentation

  • help_plugin controls reference gains a "Mouse" section covering double-click auto-move, right-click highlight, and the new hold-RMB radial.
  • help_plugin also gains a "Keyboard drag" section for the new Tab/Enter/Arrows/Esc flow.
  • Onboarding slide 3 picks up a Tab → Enter row 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_SUCCESS for the first half then fades, and a synthesised C6→E6→G6 bell ping plays (~240 ms, octave above win_fanfare's root so the fourth completion + win cascade layer cleanly). New FoundationCompletedEvent { 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 use ShakeAnim since 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::Instant by 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_onboarding persists the seen state so the cue never re-fires (legacy settings.json files load to false via #[serde(default)]).
  • Mode Launcher digit shortcuts. Pressing M opens the Home modal (the Mode Launcher); inside it, pressing 15 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_ASPECT 1.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 in compute_layout uses CARD_ASPECT algebraically 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.0v0.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 17).
    • Bundled default theme ships in the binary via embedded:// — 52 hayeah/playing-cards-assets SVGs (MIT) plus a midnight-purple back.svg as original work.
    • User themes live under themes:// rooted at user_theme_dir(). Drop a directory containing theme.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_id and propagates to live sprites.
  • Reserved HUD top band (64 px) so cards no longer crowd the score readout or action buttons; layout's top_y shifts 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 ·N chip 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 via Pile::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_move prefers 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_fontdb now bundles FiraMono. The hayeah SVGs reference Bitstream Vera Sans and Arial by 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 resolutionAssetPath::resolve (concatenates) → resolve_embed (RFC 1808 sibling resolution). Was producing paths like …/theme.ron/hearts_4.svg and failing to load every face SVG.
  • Sync exit log spampush_on_exit silently no-ops on LocalOnlyProvider's UnsupportedPlatform instead of warn-spamming every shutdown.
  • usvg font-substitution warn spam — custom FontResolver.select_font appends Family::SansSerif and Family::Serif to every query so unmatched named families silently fall through.

Migration

  • In-progress saves invalidated. GameState.schema_version bumped 1 → 2; pre-v2 game_state.json files 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 AssetServer at startup through the new FontPlugin.
  • 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-pkgbuild directory).
  • 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 Text2d overlay to PNG-backed Sprite with face/back atlases; Text2d retained as a headless fallback when CardImageSet is absent (tests under MinimalPlugins).
  • Asset pipeline switched from include_bytes!() for PNGs/TTFs to runtime AssetServer::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 SmartIpKeyExtractor so 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_modal scaffold with real Primary/Secondary/Tertiary buttons.
  • Animation upgrades: SmoothSnap slide curves, scoped settle bounce, deal jitter, win-cascade rotation.
  • Splash screen, focus rings (Phases 13), 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.