Phase J audit findings: the visible focus ring the doc asked for
already exists (FocusOverlay singleton, 2px accent ring, breathing
pulse, reduce-motion aware — landed after the doc was written), and
post-Phase-C every modal already opens and dismisses keyboard-only.
The real gaps were two silently diverging static hotkey tables
(onboarding's slide vs Help's list — onboarding still said 'Mode
Launcher (then 1-5)') and no way to see the bindings mid-game.
- New crate::hotkeys module owns THE binding table (21 rows, verified
against a grep inventory of every just_pressed(KeyCode::..) handler);
rows carry an `essential` flag — the onboarding slide teaches that
subset, the cheat sheet shows everything.
- New cheat_sheet_plugin: hold `/` for a right-anchored reference
overlay of every binding; release hides it. Deliberately not a
spawn_modal modal (momentary reference, closer to a tooltip), never
spawns while a modal owns the screen (also keeps it out of the
seed-entry field), inert on touch builds.
- Onboarding's stale local table deleted in favour of the shared one
(copy updated: Home naming, hold-to-repeat undo, added H).
5 new tests (table integrity, essential-subset size, uniqueness,
show/hide driver, modal suppression). Workspace + clippy green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settings -> Accessibility gains a UI Scale row cycling four steps,
applied live through bevy::ui::UiScale: every menu, modal, and HUD
element scales while the table itself stays window-fit via
compute_layout. New Settings::ui_scale (serde default 1.0, sanitized
clamp to [0.9, 1.3]).
Safe-area anchors and modal scrim padding pre-divide physical insets
by the UI scale so post-multiplication lands exactly on the system
bars — without this, 90% would sink the bottom action bar into the
Android gesture zone. The anchor systems also re-run on UiScale
changes, not just inset changes.
Deferred from Phase K (noted in the doc): seeding the setting from the
Android system font scale on first run (needs JNI), and the 44px
touch-target audit (on-device).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two situation-fired teaches, each shown exactly once and recorded in
Settings like shown_achievement_onboarding:
- Stall tip: 45s with no board change in an active, started game
(clock frozen while paused / a modal is open) points at Hint with
platform-correct copy (H on desktop, bottom-bar Hint on touch).
- Radial teach: a player 15 moves into a game who has never opened the
radial menu learns the long-press / right-click gesture. Organic
radial use marks the tip done silently — nobody is taught what they
already know.
Tips ride the queued InfoToastEvent path, so they render in the unified
toast stack and never interrupt play. This completes Phase I.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ObtainX updates install silently, so shipped features went unnoticed.
On the first launch where the running release differs from the new
Settings::last_seen_whats_new, a dismissible card summarises the
latest CHANGELOG.md section (embedded; its top version doubles as the
app's release identity — no build-time version plumbing). Internal
sections are dropped and bullets reduce to their bold lead sentence.
Launch beat: splash -> onboarding (first run) -> what's-new -> Home;
spawn_home_on_launch waits on the new WhatsNewPending resource. Fresh
installs never see the card — onboarding completion stamps the current
version silently. The seen-stamp persists on spawn, not dismissal, so
the card can never nag twice.
8 new tests (changelog parsing incl. the real embedded file, upgrade/
seen/fresh-install gating, dismissal). Workspace + clippy green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A hint now spawns a translucent copy of the hinted card that glides to
the suggested destination twice (0.7s per pass, SmoothSnap easing, tail
fade so the loop reads as a repeat), alongside the existing static
source/destination highlights. Auto-disabled under reduce-motion — the
static highlights remain the whole story. The ghost despawns on timer,
on a fresh hint, or the moment the board changes (a preview of a stale
board is worse than none).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The live catalog has advertised preview_url per theme since the store
shipped, but the client never fetched it — rows were text-only. The
modal now downloads each advertised preview PNG off the main thread
(same AsyncComputeTaskPool + Tokio pattern as the catalog fetch and
avatar image), decodes it into an Image asset, and renders a thumbnail
sized by the theme's own card_aspect at the head of the row. Previews
pop in as they arrive and are cached for the session, so reopening the
store is instant. Failures log and leave the row text-only; the decode
path is skipped entirely under MinimalPlugins (no Assets<Image>).
solitaire_data grows ThemeStoreClient::fetch_preview with a 512 KB cap
(previews carry no checksum — decorative only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The how-to-play slide told touch players to left/right-click; Android
now gets tap/double-tap copy pointing at the bottom-bar Hint button.
- On a fresh profile the Home auto-show spawned underneath the
onboarding modal, stacking two scrims (v0.44.0 emulator smoke
finding). spawn_home_on_launch now waits until first_run_complete
and the onboarding modal is gone, so the launch beat is onboarding,
then Home, then the table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Queued and immediate toasts used two subtly different geometries and
staggered anchors purely to dodge each other when simultaneous. Both
paths now spawn geometry-free ToastNodes that adopt_toasts_into_stack
slots into a single persistent ToastStackRoot flex column — one anchor,
one style, simultaneous toasts stack upward. The root rides
SafeAreaAnchoredBottom and clears the Phase F touch action bar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Touch layout (USE_TOUCH_UI_LAYOUT) restructures the bottom action bar
to five buttons: an enlarged Undo / Draw / Hint trio (96x64px targets,
1.35x labels) between compact Menu and Pause. Draw is new — it fires
the same DrawRequestEvent as tapping the stock, so the most frequent
action no longer needs a reach to the top of a tall folded screen.
Help, Modes, and New Game leave the touch bar (they live in Menu ->
System, the Home grid, and Home's hero respectively). Desktop keeps
the seven-button bar unchanged (decision 5: touch-only).
Holding Undo now steps back repeatedly after a 0.45s delay (5.5/s),
each step through the normal request path so the scoring penalty
applies. New self-ambiguous DrawRequestWriters set keeps the ambiguity
gate at zero with the fourth DrawRequestEvent writer.
6 new hud_plugin tests; workspace suite + clippy green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Gitea actions cache on this instance stores caches (1.85 GB saves
confirmed in run 597) but never restores them — every run back through
run 584+ logs 'No cache found' even for exact keys saved an hour
earlier, including master-to-master. Every CI run has therefore been a
full cold build (~37 min), plus ~4 min tarring a cache nobody reads.
- Point CARGO_TARGET_DIR at a persistent path on the rust-host runner
(host executor — filesystem carries over between runs), with a 40 GiB
prune guard. Warm runs drop to minutes without touching the broken
cache API.
- Drop Swatinem/rust-cache (pure overhead until the server is fixed).
- Split cargo fmt --check into a seconds-long fmt job gating the heavy
test job, so a formatting slip can't burn a 35-minute build again
(run 600).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restructures the Home overlay per docs/ui-redesign-2026-07.md Phase B:
- Continue card (mode / elapsed / score) when a game is in progress;
clicking returns to the table
- Hero New Game replays the persisted Settings::last_mode (new field,
serde-default Classic) with one tap; locked modes fall back to Classic
- Deal options (draw 1/3, winnable-only, difficulty tiers) move into a
disclosure under the hero — off the Home top level (decision 2)
- Compact symmetric 2x3 mode grid; descriptions on wide viewports only
- Stats strip moves to the bottom (right pane on wide)
- Two-pane body on wide viewports (>= 1000 logical px) via new
ui_modal::spawn_modal_sized (default card stays 720 px)
- Cancel becomes "Back to table" and only renders while a live
(un-won) game exists
10 new home_plugin tests; all 936 engine tests + clippy green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cycle regression gate still read payload.moves, which schema v4
removed in favour of recording.instructions, so every game reported
replay_history_mismatch:0/N and games_with_issues tripped the
--require-zero-issues gate on master (runs 587/594/596).
Verified locally: 12-game gate run reports 0 issues.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #170 changed the web replay payload (moves list -> embedded session
recording) but missed these Playwright specs, which only run on master
pushes and so failed post-merge. Assertions now match the v4 shape:
schema_version 4, recording.initial_state present, moves at
recording.instructions.
Verified locally against a real server with freshly built wasm bundles:
full suite 18/18 green, including the five play_canvas specs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GameState's save serde had the same latent flaw PR #170 removed from
replays: v5 persisted seed + saved_moves and re-dealt the board from the
seed on load, so any RNG or upstream upgrade that shifts the seed->deal
mapping would invalidate every in-progress save (graceful error, but the
player loses their game). v6 persists the existing SessionRecording
payload (upstream card_game session serde: config + dealt board +
instructions) instead; seed stays as presentation metadata only.
- v4/v5 files still load through the legacy seed path and are rewritten
as v6 on the next save; v6 files load from the recording and replay
with per-instruction validation, so tampered or corrupt files surface
an error rather than a silently wrong board
- saved_moves() removed (dead once serialisation reads recording())
- tests: v6 round-trip via storage, seed-corruption immunity, v5 legacy
load, missing-recording rejection, invalid-history rejection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.
- core: SessionRecording newtype delegating to Session<Klondike> serde;
GameState::recording() / from_recording(); from_instructions_unchecked
fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
the existing version gate
- engine: win-recording and sync upload freeze game.recording();
playback rebuilds from the recording; Playing carries the extracted
move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
hand-assembles it (the old game.js path hardcoded schema_version: 2
and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
(Difficulty uploads previously 400'd against the String field)
Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three items the multi-agent sweep flagged, now removed with user
approval (§8 for the solitaire_sync changes):
- WinCascadePlugin: never registered; handle_win_cascade in
AnimationPlugin is the live win cascade and builds its own targets.
Its now-orphaned helpers (win_scatter_targets, cascade_delay,
WIN_CASCADE_INTERVAL_SECS) had no callers outside their own tests
and go with it.
- SyncCompleteEvent: written by the pull-completion system, zero
readers — UI reads SyncStatusResource instead.
- solitaire_sync::ApiError: unused by client and server; the merge_at
crate-root re-export goes too (merge::merge_at stays for the merge
module's own use).
ARCHITECTURE.md updated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wasm bundles in solitaire_server/web/pkg/ are no longer tracked. The
web-wasm-rebuild workflow (which rebuilt them in CI and committed them
back to master) is gone; instead:
- solitaire_server/Dockerfile gains a wasm-builder stage that runs
build_wasm.sh with the same pinned toolchain (wasm-bindgen 0.2.120,
wasm-pack 0.14.0, binaryen 130) and the runtime image copies pkg/
from it — the image build is now the artifacts' single source of truth.
- web-e2e builds the wasm before Playwright runs, and its trigger paths
now include the wasm-feeding crates it actually tests.
- docker-build triggers on solitaire_data/** and build_wasm.sh too, so
every wasm-affecting change redeploys.
- Self-hosters serving /web or /play from a source checkout run
./build_wasm.sh once (script header and ARCHITECTURE.md updated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Double-click/tap auto-move previously tried the run's top card alone
before the full run headed by the clicked card, so a top card with a
foundation move hijacked the intended whole-stack move. Both handlers
now share auto_move_for_run: a lone top card goes foundation-first, a
multi-card run moves whole to a tableau or not at all. The double-click
key is now the clicked card, so two clicks on different cards of the
same stack no longer register as a double-click.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide
reference greps; three parallel audit agents covered the engine crate, the
other eight crates, and Copilot commit-message-vs-diff drift.
Removed:
- replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also
defined in mod.rs — the glob re-export made the mod.rs copies win and
the file-level #![allow(dead_code)] hid the corpses. The live keyboard/
button handlers and ReplayScrubKeyHold stay; the allow is retired.
- retarget_animation (never called; doc examples were its only refs)
- ScanThemesRequestEvent (never registered/written/read; its doc claimed
a handle_scan_themes consumer that does not exist)
- _VEC3_REFERENCED workaround const + now-unneeded Vec3 import
- solitaire_data: load_stats/save_stats/time_attack_session_with_now
default-path wrappers (the _from/_to variants are the live API) and
surplus re-export names (settings MIN/MAX bounds, token loaders)
- solitaire_core: Session re-export (no external consumer)
- solitaire_wasm: ReplayPlayer::is_finished (no JS caller)
- solitaire_app: build_app wrapper (real entry is run())
Doc fixes:
- audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row,
drop bogus 'placeholder' label, bevy_kira_audio→kira
- ToastVariant::Warning: variant is live (5 writer plugins); dropped the
stale allow(dead_code) and its 'currently unused' comment
Deliberately kept: Spider module (staged forward work), WinCascadePlugin
(documented alternative cascade, pending owner decision), SyncCompleteEvent
and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commit 38e4c03 (shipped in v0.40.0) switched Android's focused_mode to
reactive_low_power(100 ms) on the premise that every animation tick
system writes RequestRedraw while it has active work. The writers were
never actually added — the diff only contained the WinitSettings change,
imports, and add_message registrations. Result: with no touch input,
nothing wakes the winit loop during a card slide except the 100 ms
fallback ceiling, so animations render at ~10 fps on Android. Desktop
and web keep Continuous mode, which is why only Android was affected
(reported by Rhys the day v0.40.0 shipped; confirmed absent on v0.39.1).
Adds the missing MessageWriter<RequestRedraw> to all seven systems the
original commit message named:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade/slides)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish
(FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step-interval keepalive)
Each writes one RequestRedraw per frame while active work exists
(including delay phases, which also need per-frame ticks). Regression
test asserts an active CardAnimation emits RequestRedraw and an idle
board does not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase G of docs/ui-redesign-2026-07.md. The win modal now leads with
actions and reads the stats recap quietly below them:
- Play Again: primary, full-row, Enter accelerator unchanged; still
fires NewGameRequestEvent::default() (same mode; deal options from
Settings) so the rematch is one tap.
- Watch Replay / Share Replay: secondary pair reusing the global
stats_plugin markers (WatchReplayButton / CopyShareLinkButton), so
both act on the just-won replay — SelectedReplayIndex snaps to 0 on
every win. This also fixes the old win-modal handler picking
replays.last(), which after the newest-first history refactor was
the OLDEST replay, not the newest.
- Watch closes the celebration overlay (new close_overlay_on_watch_-
replay system); Share keeps it open and relies on the existing
copy-feedback toasts.
- Stats recap (score breakdown reveal, time, XP, achievements) moves
below the actions; the Time line drops from headline/primary to
body/secondary styling.
Tests: 4 new (action presence, Play Again close+request, Watch closes,
Share keeps open) on a manual-clock fixture that steps the 0.5 s
celebration delay deterministically. Workspace suite green, workspace
clippy -D warnings clean, fmt applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):
- Settings: Esc clears SettingsScreen, gated on being the topmost
modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)
Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.
Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase C of docs/ui-redesign-2026-07.md. The Modes row is gone — Home
owns mode selection, so the popover's Play section carries a Home row
firing the new ToggleHomeRequestEvent (read by toggle_home_screen
alongside the existing M accelerator). Section headers are quiet
caption-size labels inside the existing panel widget, not a new
widget. The action-bar Modes button and its popover are untouched
(their removal is Phase B territory when Home gains hierarchy).
- MenuOption: Modes variant replaced by Home; rows grouped Play (Home)
· You (Profile, Stats, Achievements) · Community (Leaderboard) ·
System (Settings, Help)
- handle_menu_option_click no longer chains into spawn_modes_popover
- Tests: tooltip sweep updated (7 rows still), new
toggle_home_event_opens_home_screen covers the popover's open path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses upstream author review of the Spider core (PR #157):
- RNG: drop the hand-rolled SplitMix64 + Fisher-Yates; deals now use
rand::rngs::StdRng seeded via seed_from_u64 + SliceRandom::shuffle,
exactly like klondike::with_seed. solitaire_core gains the same
pinned rand dep klondike uses (0.10.1, std_rng only — already in the
lock, no new transitive deps). Deals for a given seed change; Spider
has no persisted games yet, so nothing breaks.
- Enums over integers: pile indices and card counts in the instruction
type are now SpiderTableau (Tableau1..Tableau10, with an ALL const)
and RunLength (Run1..Run13); out-of-range piles and zero-card moves
are unrepresentable. Rank comparisons use Rank::checked_add instead
of u8 arithmetic.
- Fixed-size containers: build_deck returns Stack<104> instead of
Vec<Card>; possible_instructions is a const-iterated SpiderIter +
validity filter (klondike's KlondikeIter pattern) instead of
collecting into a Vec.
- Upstream naming: SpiderGame -> Spider (matches Klondike),
tableau_face_up/_down -> tableau_face_up_cards/_down_cards,
stock_len -> stock() accessor, with_rng added alongside with_seed.
- Solver access: SpiderGameState now exposes session(), the same
escape hatch GameState has — the wrapper no longer pretends to hide
solve(), which SessionConfig budgets already gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runs 514/516/519 (incl. master itself) died on 'ld terminated with
signal 9' — the workspace now links enough large test binaries that
per-core parallel linking OOMs the runner even at line-tables-only
debuginfo. CARGO_BUILD_JOBS=2 keeps at most two links in flight.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the
modal shell (header, shared tab chips, single Done); each tab's content
is a body builder extracted from its original plugin with every marker
component unchanged, so per-row update/scroll/selector systems keep
working. The replay selector gets its own Replays tab (Watch/Copy
buttons move into the tab body).
- Toggle*RequestEvents + P/S/A accelerators open the hub on the right
tab, switch tabs in place, or toggle closed on a same-tab request;
Esc/Done/scrim-click close
- Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the
hub scrim for the active tab — external queries and tests keep their
meaning
- Standalone toggle/close systems and per-screen Done buttons removed
(ProfileCloseButton, StatsCloseButton, AchievementsCloseButton)
- Tests: 2 new hub lifecycle tests; profile/stats/achievements modal
tests adapted (fixtures add YouHubPlugin; selector tests target the
Replays tab). Engine suite 916 green, clippy -D warnings, fmt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settings' tab_chip becomes a thin wrapper; the You hub (Phase E) will
reuse the same widget so tabbed modals stay visually identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the 37-row single-scroll settings modal with five tabs
(Audio, Gameplay, Appearance, Accessibility, Account); only the active
tab's rows spawn, so every tab fits without scroll-hunting.
- SettingsTab + ActiveSettingsTab (session-only) + SettingsTabButton
chips under the modal header; rebuild-on-switch mirrors the
leaderboard despawn/respawn pattern via a shared build_panel helper
- Accessibility tab gathers color-blind, high-contrast, reduce-motion,
touch-input, and tooltip-delay from the old Gameplay/Cosmetic mix;
Account = Sync + Privacy
- SettingsButton enum, input handlers, and persistence untouched
- Tests: per-tab Focusable/Tooltip sweeps, tab-switch rebuild test,
picker/thumbnail tests pinned to the Appearance tab
Plan: docs/ui-redesign-2026-07.md (also added, phases A–M)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SolutionPlaybackPlugin: the pause modal's 'Show solution' button
resumes the game and requests a solve of the live position via
GameState::winning_line on AsyncComputeTaskPool (never blocks the main
thread; stale results discarded via a move_count snapshot). The line
then plays back one instruction per 0.45 s through the normal
MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring,
undo history and win detection behave as if the player made the moves.
Tableau run counts are decoded against the live state at step time.
Playback cancels on Esc, pause, undo / new-game requests, and any
rejected move (the signature of the player diverging the board).
Toasts cover search start, line found, unwinnable, and budget-
exhausted outcomes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two-deck (104-card) Spider on the upstream Stack/Pile containers,
exercising the multi-deck Card encoding for the first time:
- SpiderGame: 10-pile deal (4x6 + 6x5), build-down-any-suit,
same-suit-run pickup, deal-10 gated on no empty pile, automatic
K->A run removal, win at 8 runs
- SpiderSuits difficulty (1/2/4 suits over the same 104 cards);
1- and 2-suit games contain identical Card values by construction
(documented — engine entity mapping will need positional keys)
- Seeded deals via inline SplitMix64 + Fisher-Yates (core has no
rand dep; Spider's seed space is deliberately self-contained)
- SpiderGameState session wrapper mirroring GameState conventions:
Result<_, MoveError> mutations, upstream undo/score bookkeeping,
Microsoft-style scoring (500 base, -1/move, +100/run, -1/undo)
- 17 unit tests + card-conservation/validity proptest over random
legal walks; stacked-deal win-path test included
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New GameState::winning_line(moves_budget, states_budget) returns the
complete instruction sequence to a win (Ok(None) when unwinnable or
already won, Err on budget exhaustion), compacting the raw DFS trace
with the previously unused card_game Solution::clean_solution and
stripping foundation→foundation no-ops when the stripped line still
replays to a win — an internal replay check guarantees the returned
sequence always applies cleanly via apply_instruction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #152 poller fixes resolution + layout on a missed fold/unfold, but
a transient clip could survive if card sprites held stale visuals after
geometry converged. Emit StateChangedEvent alongside the synthetic
WindowResized so card_plugin re-renders every sprite from scratch —
silent self-heal, no player-facing prompt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Points THEME_STORE_DIR at the existing solitaire-db PVC so catalog
content survives pod restarts. Scan is startup-only; restart the
deployment after adding zips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).
- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
(additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
download on AsyncComputeTaskPool, atomic .tmp+rename write into
user_theme_dir, then the existing hardened import_theme pipeline and
an in-place registry refresh
New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- set_theme resolved every id to themes:// — the bundled dark/classic
themes live at embedded://, so switching to them via the public API
would NotFound and silently keep the old theme. All three load paths
now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
leftover from v0.33 when classic was the default; it now derives from
Settings::default() so it can't drift from the data crate again, with
a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
only the pre-rename "default" id is rewritten.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refresh tokens are single-use since the rotation change (PR #136). Two
in-flight requests hitting 401 together (replay upload racing a manual
sync) both called /api/auth/refresh; the second presented an already-
consumed token, got rejected, and surfaced as a spurious 'session
expired' re-login prompt.
refresh_token() now takes the stale access token that earned the 401
and runs behind a tokio::sync::Mutex; a loser of the lock race that
finds the stored token already rotated returns Ok and retries with it
instead of spending the new refresh token.
Untested caveat: exercising the race in a unit test needs an in-memory
auth_tokens double (the real keyring is unavailable on headless Linux
runners) — noted for a future test-support addition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
§8 documented a Waste variant that doesn't exist in klondike 0.4; the
real convention (Stock denotes the waste pile in pile-coordinate space)
is now stated where the enum is sketched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Random arm was only unreachable because seeds_for(Random) returns
None in a different function — a future catalog change would turn it
into a shipped runtime panic. Returning a system-time seed is the
correct Random behaviour either way. CLAUDE.md §2.3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
user_theme_dir() panicked on desktop when dirs::data_dir() returned
None (broken $HOME / $XDG_*). Return an empty path instead — exactly
what the wasm32 branch already did — so theme discovery reports 'no
user themes' and the bundled default keeps working. Warns once with
the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in
runtime logic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two runs (447, 486) died with 'ld terminated with signal 9' linking the
solitaire_engine test binary — the runner OOMs on full dev debuginfo for
the Bevy dependency graph. line-tables-only preserves file:line panic
backtraces at a fraction of the link footprint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).
Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).
Closes#130
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clears the final 47 pairs and turns the ratchet into a hard gate
(AMBIGUITY_BASELINE = 0, assert_eq):
- HudButtons: the 14 HUD button/popover handlers run as one chain,
before ui_focus::FocusKeys — Esc/keyboard consumption order is now
defined (restore prompt → buttons/popovers → focus navigation →
settings toggle) instead of scheduler-dependent.
- HUD text updaters (update_hud, update_selection_hud,
update_won_previously) chained, in UiTextFx, after the new
AutoComplete set (update_hud reads AutoCompleteState).
- restore_hud_on_modal → apply_hud_visibility chained before
UpdateOnResize: HudVisibility writes, application, and the layout
read happen in a fixed order.
- New writer sets UndoRequestWriters / InfoToastWriters (same
self-ambiguous pattern as NewGameRequestWriters).
- Logic-before-paint: check_no_moves and the AutoComplete chain order
before BoardVisuals; SettingsMutation after UpdateOnResize.
- MarkerVisuals set wraps the table painter chain; chrome fx declare
disjointness from it.
- update_hud_typography after BoardVisuals; avatar/settings-toggle
interaction handlers declared disjoint from HudButtons.
302 → 198 → 171 → 47 → 0 in four batches, one day. New systems now
fail CI unless they declare their ordering or their disjointness.
Closes#143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Sprite/Transform cluster (112 pairs) was every board painter
racing every other one. Two structural moves:
- BoardVisuals set (card_plugin): all card/marker painters run as ONE
deterministic chain in data-flow order — layout refinement → card
authority (sync_cards_on_change) → flip anims → shadows → highlights
→ stock indicators → resize snapping → corner labels — with
LayoutSystem::UpdateOnResize ordered before the whole set and the
table plugin's marker painters chained after it. Paint order is now
identical every frame instead of scheduler-dependent.
- UiTextFx set (ui_theme): chrome text effects (score pulse/floater,
streak flourish, modal enter, focus-ring pulse) animate Transform on
UI entities only; they are declared ambiguous with BoardVisuals and
with each other — the entity domains are disjoint by construction.
All chain members are cheap and change-gated; sequential execution is
not a measurable cost for a card game. Existing ordering constraints
(fan-frac before sync, shadows after sync, snap after collect) are
preserved inside the chain.
Baseline ratchets 171 → 47. Remaining: long tail of small subjects
(input, event writers, AutoCompleteState, HudVisibility).
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SettingsMutation set: the per-frame settings mutators
(handle_volume_keys → record_window_geometry_changes →
persist_window_geometry_after_debounce) run as a deterministic chain
ordered before GameMutation, so every reader already after GameMutation
observes the current frame's settings transitively. The four readers
outside that ordering (modal enter-speed chain, focus-ring pulse, HUD
avatar, and the game plugin's pre-mutation chain) are ordered after the
set explicitly.
SettingsResource ambiguities: 21 → 0. Baseline ratchets 198 → 171.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two structural fixes from the #143 backlog:
- Game-state ordering spine: the three pre-mutation GameStateResource
writers (tick_elapsed_time, sync_settings_to_game,
handle_restore_prompt) are now a deterministic chain before
GameMutation, and the remaining unordered readers
(update_selection_hud, handle_hint_button, tick_hint_highlight,
handle_right_click, snap_cards_on_window_resize,
sync_pile_marker_visibility, auto_save_game_state) are ordered after
it. Readers now see the current frame's moves deterministically
instead of racing the mutators.
- NewGameRequestWriters set: every in-cluster writer of
NewGameRequestEvent (buttons, modals, mode picker, seed poller,
restore prompt) is registered in a shared set marked ambiguous with
itself — writer-vs-writer append order is meaningless since consumers
drain the whole queue. Out-of-cluster writers (home, challenge,
time-attack, win-summary, play-by-seed, difficulty, stats plugins)
can join the set when the test cluster grows.
AMBIGUITY_BASELINE ratchets 302 → 198. Remaining backlog is dominated
by the Sprite (72) / Transform (48) visual-domain cluster, which needs
per-domain set architecture — next batch.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First measurement of schedule hygiene (issue #143): the headless
gameplay cluster (Game/Table/Card/Hud/AutoComplete/UiModal/UiFocus/
Settings) carries 302 system pairs with conflicting data access and no
ordering edge. Too many to triage in one pass and most are likely
benign event/resource writers — but unproven, and nothing stopped the
count from growing.
New schedule_checks test builds the cluster with ambiguity detection
promoted to error, parses the reported pair count, and asserts it never
exceeds AMBIGUITY_BASELINE (302). New ambiguous pairs now fail CI at
the PR; the legacy backlog can be burned down incrementally by adding
.before/.after or .ambiguous_with and lowering the baseline.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the three actionable server Low findings from the 2026-07-06
review:
- #139: unknown-username logins now verify against a static bcrypt
dummy hash so both failure paths pay the same cost — response timing
no longer reveals which usernames exist.
- #140: register maps a unique-constraint violation to UsernameTaken
(409) — the SELECT pre-check stays as the friendly fast path, the
constraint is the arbiter for the concurrent case.
- #141: avatar uploads must start with the magic bytes of the declared
image type; the stored extension (which decides how the file is
re-served) is now backed by content, not the Content-Type header.
Unit tests: real/spoofed/truncated signatures for all four formats,
and the dummy hash's validity at BCRYPT_COST.
Closes#139Closes#140Closes#141
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exit push spawned a detached task on AppExit, which process
teardown almost always killed before the network round-trip completed —
the final session's sync silently never happened (2026-07-06 review,
finding M3). Local persistence meant no data loss, but stats stayed
unsynced until the next launch.
push_on_exit now blocks the closing app's final frame for at most
EXIT_PUSH_TIMEOUT (2s) via tokio::time::timeout: long enough for one
healthy round-trip, short enough that quitting never feels hung when
the server is unreachable. Timeout and errors are logged and skipped —
the next launch's pull/push converges as before.
Closes review finding M3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings 1+2 (Quat-underuse lens, 2026-07-06):
- solitaire_core gains pub const FOUNDATIONS / TABLEAUS — the canonical
iteration source for the upstream pile enums (upstream klondike has no
Foundation::ALL, and inherent impls cannot be added to foreign types).
Deletes three identical private const-fn copies (radial_menu,
table_plugin, input_plugin) and the hand-enumerated variants in
card_plugin::sync::all_cards and solitaire_wasm.
- Hand-rolled [Suit; 4] / [Rank; 13] arrays replaced with upstream
Suit::SUITS / Rank::RANKS. The order-sensitive CardImageSet indexing
is re-keyed through canonical card_plugin::{suit_index, rank_index}
helpers that match upstream order, with regression tests asserting
the correspondence — one ordering everywhere instead of three
divergent local ones.
Net -177 lines. No behaviour change; all consumers go through the
canonical helpers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two concurrency fixes from the 2026-07-06 review (findings M1, M2):
sync push (M1): the load→merge→store cycle ran as three separate DB
operations. Two devices pushing concurrently both read the same stored
payload, merged independently, and the second store overwrote the first
merge — the server visibly regressed until the losing device pushed
again. The whole cycle (including the leaderboard update) now runs in
one transaction; SQLite serialises the writers. The leaderboard
helper's stale docstring (claiming a single conditional UPDATE that was
actually two statements) is corrected — the transaction now provides
the atomicity it described.
refresh rotation (M2): SELECT-then-DELETE let two concurrent refreshes
with the same token both pass the liveness check and both mint fresh
token pairs. The SELECT is gone; rotation now gates on the DELETE's
rows_affected — whoever removes the jti row wins, everyone else gets
401. Sequential reuse was already covered by
consumed_refresh_token_is_rejected, which still passes unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback on #135: floating 'stable' + -D warnings lets every new
clippy release redden master with new lints — pin 1.95.0 like the
web-wasm-rebuild workflow. And ubuntu-latest lacks the ALSA/udev/X11/
Wayland dev packages the Bevy crates link against; install the standard
Bevy CI set (the project's own builder images have no desktop-Bevy
precedent — android-builder targets the NDK and web-e2e only builds the
server).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Until now no CI workflow ran the test suite or clippy at all — the
android-release, docker-build, web-e2e, web-wasm-rebuild, and
builder-image workflows cover packaging and e2e, but a direct push to
master (including the web-wasm-rebuild bot commit) never executed
cargo test or clippy. The gate discipline in CLAUDE.md §6 existed only
on developer machines.
test.yml runs the exact §6 commands (clippy --workspace --all-targets
-D warnings; cargo test --workspace) on master pushes and PRs, with
SQLX_OFFLINE against the checked-in .sqlx cache, path-filtered so
docs-only merges don't burn CI. Toolchain/cache mirror web-e2e.yml.
Found during the 2026-07-06 large-scale review (finding H1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
canvas_bg.wasm shipped at 36.2 MB — plain release (opt-level 3, thin
LTO) piped through wasm-opt -O2. Download size, not throughput, is the
binding constraint for the browser canvas, so solitaire_web now builds
with a dedicated wasm-release profile: opt-level "s", fat LTO, one
codegen unit. Local result: 23.2 MB after the unchanged wasm-opt -O2
pass — 35.9% smaller.
The binaryen pass stays at -O2 (the -Oz grey-screen miscompile note in
build_wasm.sh still applies). profile.strip is deliberately NOT set: on
wasm it also removes the target_features custom section, which makes
wasm-opt reject the module ('all used features should be allowed' on
trunc_sat).
Verified with the new artifact: all 5 play_canvas e2e specs pass (debug
bridge, draw3 param, apply/undo, replay diagnostics, 40-seed autoplay
invariants). Headless pixel verification is inconclusive in this
environment — wgpu cannot create a usable adapter locally and panics
identically on the OLD artifact too — so visual parity should be
confirmed against production after the next deploy.
pkg/ artifacts are intentionally not committed: the web-wasm-rebuild
workflow is the single source of truth and will regenerate them with
this profile on merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ARCHITECTURE.md predated the card_game migration (PR #88, 2026-06-22)
and still documented the pre-migration core: local Card with face_up,
PileType, stored score/undo/recycle, and a snapshot undo stack. Rewrites
the Core Game Models section around the upstream card_game/klondike
types and the derived-stats/replay-undo model, updates the
solitaire_core crate section (deps + ownership), adds solitaire_web and
solitaire_assetgen to the workspace tree, and corrects the
solitaire_app and Settings entries. Version bumped to 1.4.
Also adds the two missing crates to the CLAUDE.md crate map (AGENTS.md,
its gitignored local twin, was regenerated in place) and records the
full device checklist passing on the Fold 7 in SESSION_HANDOFF.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CHANGELOG gains the missing [0.41.1] section (pile-marker child-resize
fix + Android relayout diagnostics). SESSION_HANDOFF is brought forward
from the stale v0.40.0 state to today's: v0.41.x releases, the July 6
review arc (PRs #121-#131), Fold 7 on-device verification results, the
remaining device-checklist items, and new architectural notes (plugin
submodule pattern, marker child-resize rule, Fold 7 inset quirks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found during Fold 7 on-device verification of v0.41.0: on_window_resized
resized the marker fill sprite but never its children, so after any
resize (fold/unfold, rotation) the outline frame and the A/K watermark
kept their spawn-time size — rendering as oversized grey slabs over the
empty foundation slots.
The resize handler now re-derives both children from the new layout
(outline = card_size + 2*PILE_MARKER_OUTLINE_WIDTH, watermark font =
card_size.x * 0.28 — same formulas as spawn). Adds a regression test
and an Android-gated relayout log line (width/height/insets) as the
evidence channel for the remaining foldable layout bug (#130).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second phase of #118 for settings_plugin: the 2,857-line mod.rs becomes
four focused submodules along existing system boundaries —
mod.rs 561 types, markers, SettingsButton, plugin build, persistence
input.rs 632 button/hotkey handlers, focus attachment, scrolling
updates.rs 495 per-frame value-text updater systems + label helpers
ui.rs 1,231 spawn_settings_panel + row builders + thumbnails
Moved items are pub(super); mod.rs glob-imports the submodules so
system registration and tests keep their bare names. No behaviour
change; all 29 settings tests pass unchanged.
Refs #118
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renames Unreleased to 0.41.0 (2026-07-06), noting it consolidates the
v0.40.x patch tags that were cut without sectioning. Adds entries for
today's safe-area resume re-poll fix (#116) and the plugin module
test-split phase (#118).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First increment of the module-split plan: card_plugin.rs becomes
card_plugin/{mod.rs, tests.rs} (2,536 + 1,592 lines), following the
replay_overlay/ pattern. Pure mechanical move via git mv — no runtime
code changes; all 70 tests preserved and passing.
Refs #118
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Polls the docker-build / web-wasm-rebuild workflow runs and prints a
compact status block until the newest deploy is live. Reads the API
token from ~/.config/tea/config.yml and never prints it.
Closes#119
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the Draw-1 recycle question from the June 500-game audit:
unlimited recycling with upstream score penalties is deliberate,
matching mainstream digital solitaire rather than strict 3-pass
tournament rules. The difficulty seed catalog and the winnable-deal
solver are verified under this rule, so a hard pass limit must not be
introduced casually.
- ARCHITECTURE.md: new 'Rules decisions' note in the solitaire_core
section
- GameState::draw() doc comment points at the decision record
- New lock-in test draw_one_recycling_is_unlimited_by_design asserts
10+ recycles are never rejected
Closes#117
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refresh_insets gated its loop on insets.is_populated(), so once insets
resolved at first launch, rearm_on_resumed's poll-counter reset was a
no-op and JNI was never queried again. Insets that changed while the
app was backgrounded (fold/unfold, rotation, gesture/3-button nav
switch) stayed stale until process restart.
Gate the loop on the poll counter alone and settle a cycle by
exhausting it once a populated reading arrives. The cached resource is
rewritten only when the value actually differs, so resumes where
nothing moved trigger no change detection and no relayout — preserving
the no-flash resume behaviour. Also correct the stale on_app_resumed
doc that still described the old inset-zeroing approach.
Closes#116
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MAX_DYNAMIC_FAN_FRAC 0.6 -> 0.9 so the dynamic tableau fill spreads
further on very tall / narrow viewports (e.g. a foldable cover screen),
which were left ~40% empty at the cap. Fills the unfolded near-square
screen to ~100% and lets the cover screen fill further (and the rest fills
as columns deepen during play). Normal phones are unaffected — their fill
fraction is already below the cap. apply_dynamic_tableau_fan still floors
at TABLEAU_FAN_FRAC and deeper columns drive the fraction down, so nothing
overflows and hit-testing stays in sync.
Vertical centring of the residual was investigated but dropped: a 21:9
phone is aspect-identical to the cover screen, so centring can't be
targeted to foldables without also disconnecting the board from the HUD on
tall phones.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a near-square viewport (e.g. an unfolded Galaxy Fold) a fresh deal left
the bottom ~40% of the screen empty: the dynamic fan (update_tableau_fan_frac)
measured only face-up column depth and returned early at a fresh deal (face-up
depth 1), so it never spread the tableau, and the deep face-down stacks were
ignored. The cold-start deal also never fired StateChangedEvent, and on Android
the safe-area-inset resize (frames 1-3) reset the fan to compute_layout's
sparse worst-case value, so even the post-move fill was wiped.
Move the fill into layout::apply_dynamic_tableau_fan, driven by each column's
TOTAL weighted depth (face-down cards count, scaled by the face-down/face-up
step ratio) so the deepest column fills the available height. Run it in three
places so every path stays filled: PostStartup (cold-start deal), on
StateChangedEvent (moves), and inside on_window_resized after compute_layout
(safe-area resize + fold/unfold). MAX_DYNAMIC_FAN_FRAC caps the spread so a
near-empty column keeps readable overlap; TABLEAU_FAN_FRAC floors it. Deeper
columns drive the fraction down so everything still fits — no overflow.
card_position/card_positions read the same fractions, so hit-testing stays
in sync.
Adds regression tests: cold-start deal fills the fan, and a resize re-fills it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every move fired StateChangedEvent -> sync_cards, which rebuilt the full
visual for all 52 cards: despawning and respawning every child entity
(drop-shadow, border frame, and on Android a Text2d corner label needing
a glyph re-layout) even for the ~50 cards that did not move. That ~250
entity despawn/spawns plus 52 text re-layouts in a single frame spiked
the StateChangedEvent frame and stuttered the slide animation on
high-resolution devices (reported on a Galaxy Fold 7).
Add a CardChildrenKey component capturing the only inputs the child
entities depend on (face_up, card_size, color_blind, high_contrast).
update_card_entity now rebuilds children only when that key changes (a
flip, resize/fold, or accessibility toggle); a position-only move just
updates the Transform. The Sprite is still refreshed every sync (a cheap
handle swap), so theme/card-back image changes need no child rebuild.
Adds a regression test asserting an appearance-neutral StateChangedEvent
no longer despawns/respawns card label children.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The apksigner step relied on auto scheme selection, which produced an APK
carrying invalid v1 (JAR) signature files: META-INF/*.SF and *.RSA were
present but failed v1 verification (apksigner reports `v1 scheme: false`
while v2/v3 verify). Android installs such an APK fine via v2/v3, but
Obtainium parses the legacy v1 certificate at install time, gets an empty
cert list, and crashes with:
RangeError (length): Invalid value: valid value range is empty: 0
This is why the app adds fine in Obtainium (Gitea API only) but fails on
install (APK parse). minSdk is 26, so v1/JAR signing is unnecessary —
sign explicit v2+v3 only (matching modern Android tooling for minSdk >= 24)
and pass --min-sdk-version 26. Adds a post-sign guard that fails the build
if any META-INF v1 signature files remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Elevates the physical-device smoke test as the single remaining task for the
v0.40.0 release and clarifies the Matomo validation is an independent task,
not a release blocker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Updates SESSION_HANDOFF.md with the v0.40.0 Android release (PRs #105/#106/#108),
the pre-release validation performed, and the still-open physical-device smoke test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The setup doc pinned NDK 26.3.11579264, but newer NDKs build fine
(verified locally on 30.0.14904198 / build-tools 37.0.0: cross-compile,
android-target clippy, and a full signed arm64-v8a APK). Note that the
exact versions are not load-bearing and build_android_apk.sh
auto-discovers the newest installed NDK/build-tools.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The waste fan x-offset was computed two ways: the renderer used
tableau_col_step * 0.224 while the hit-test hard-coded card_size.x * 0.28.
These coincide on desktop (col_step = 1.25*cw) but drift on Android, where
tighter column spacing (H_GAP_DIVISOR=32, col_step ~= 1.03*cw) makes the
renderer fan at ~0.231*cw. The top fanned waste card's sprite then sits
~14px left of its click target, so dragging the visible top card grabs
the card beneath it.
Extract waste_fan_step() and tableau_col_step() as the single source for
both the renderer (card_plugin::card_positions) and the hit-test
(input_plugin::card_position) so they can no longer diverge. Add a
regression test that simulates Android-tight spacing and asserts the hit
target tracks the renderer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds two find_draggable_at tests covering the reported stock/waste
drag bug: clicking the visible top of a multi-card waste must pick the
top index (not the buffer card beneath), and a lone waste card must
still be draggable. Both pass against current logic, pinning the
correct behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handle_stock_click (and handle_touch_stock_tap) hit-tested both the face-down
deck AND the waste slot, so a click/tap on the drawn waste card fired
DrawRequestEvent — drawing the next card instead of playing the waste card, and
swallowing the first click of a double-click so auto-move never triggered.
Only the deck draws now. The waste card is left free to play: double-click /
double-tap to auto-move, or drag it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cycle regression gate did a fresh page.goto() for each of 240 games in one
browser context. Around game ~100 the accumulated resources made
page.waitForFunction time out (30s), failing the gate on ~88% of runs — a
long-standing flaky-CI issue, not a product regression (the 18 e2e tests always
pass).
Load the page once and reset each game via a new __FERROUS_DEBUG__.newGame(seed,
drawThree) bridge method (added to game.js — it was already in play.html).
cycle_metrics.js now navigates once, then loops newGame() + runAutoplay with no
per-game reload, so the run stays fast and stable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The blanket `no-cache` from the earlier fix (#99) regressed the web-e2e cycle
regression gate: it reloads /play-classic 240 times, and with no-cache the
browser re-validated and recompiled the wasm on every load, blowing past the
30s bridge-ready timeout (green at #98, red from #99 onward).
Scope no-cache to just the `include_str!` HTML routes (which change on every
deploy and have no validators — the actual staleness source). The `/web` +
`/assets` ServeDir keep their default Last-Modified caching, so repeated page
loads reuse the downloaded/compiled wasm and the cycle gate is fast again. HTML
freshness — the fix Rhys needed — is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2048 surface limit was never wgpu's or the GPU's — it's
downlevel_webgl2_defaults().max_texture_dimension_2d (2048), which
WgpuSettingsPriority::WebGL2 forces. Switch to Functionality: on the WebGL2
(Gl) backend Bevy then adopts the adapter's real limits, which are still
WebGL2-constrained for features/buffers (shaders stay GLES-compatible) but
report the GPU's true max texture dimension (e.g. 16384). The device is
requested with exactly what the adapter offers, so creation can't fail, the
surface is no longer capped, and large viewports (4K) render with no letterbox
and no hardcoded cap.
Removes the resize_constraints cap and the play.html max-width/height caps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous attempts (#97/#98) capped a wrapper element's max-width and
relied on the canvas's width:100% resolving against it — but winit observes
and sizes the *canvas element itself* (via ResizeObserver on its content box),
so the wrapper cap never reached the surface and /play still panicked at
2560x1440 on a 4K@150% viewport.
Use the canonical mechanism instead: set the primary Window's
`resize_constraints { max_width: 2048, max_height: 2048 }`. On web, Bevy maps
this to winit `set_max_inner_size` → the canvas's own `max-width`/`max-height`
style, so the wgpu surface can never exceed wgpu's downlevel_webgl2
max_texture_dimension_2d (2048). play.html mirrors the same `max-*` directly on
#bevy-canvas (belt-and-suspenders) and centres the letterbox with margin:auto;
the obsolete wrapper + clamp script are removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Returning players kept getting stale builds: after the /play canvas fix
deployed, the origin served the new play.html (verified — 8/8 cache-busted
requests) but browsers still rendered the old one even after a hard reload.
Cause: the server sets no Cache-Control on the web router. The HTML pages are
include_str!'d into the binary and the wasm-bindgen output (canvas.js,
canvas_bg.wasm, solitaire_wasm.*) uses fixed filenames that change in place on
every deploy, so browsers heuristically cache them indefinitely.
Add `Cache-Control: no-cache` in the security_headers middleware (which wraps
the web router). Browsers now revalidate before using a cached copy; ServeDir
supplies Last-Modified/ETag so unchanged assets still return a cheap 304, while
changed ones (a new deploy) are re-fetched. Stops the stale-build problem for
everyone going forward.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correction to the previous canvas clamp (#97), which would NOT have fixed the
crash. It capped the wrapper to the device's gl.MAX_TEXTURE_SIZE, but that's
the hardware limit — on a 4K/integrated GPU it reports 8192+, so the wrapper
was never actually capped and the 2560-wide surface still exceeded the limit.
The real ceiling is wgpu's, not the hardware's: on wasm Bevy creates the device
with Limits::downlevel_webgl2_defaults() (forced by WgpuSettingsPriority::WebGL2
in solitaire_web/src/lib.rs; see bevy_render-0.18.1 settings.rs), whose
max_texture_dimension_2d is a fixed 2048 regardless of GPU. So the cap must be
the constant 2048.
(For the record: the reporter's display is 3840x2160 at 150% scale → a 2560x1440
logical viewport, which is the 2560 in the panic — nothing hardcodes 1440p.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rhys hit a fatal wgpu validation panic loading /play on a 1440p display:
Surface::configure ... Requested was (2560, 1440), maximum extent for
either dimension is 2048
The earlier scale_factor_override(1.0) fix only neutralised HiDPI (CSS×DPR);
it didn't help when the *logical* viewport itself exceeds 2048. `fit_canvas_to_
parent` sizes the wgpu surface to the canvas's parent, so a 2560-wide viewport
configures a 2560-wide surface — past WebGL2's 2048 per-dimension limit on
laptop/integrated GPUs, and the panic kills the WASM thread on the first frame.
Wrap the canvas in #bevy-wrap and, before init, clamp that element's max-
width/height to the device's actual gl.MAX_TEXTURE_SIZE (falling back to the
2048 WebGL2 floor). fit_canvas_to_parent then never produces a surface larger
than the GPU allows. Only devices at the 2048 floor letterbox (centered); GPUs
that report 4096/8192 still fill the viewport.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /play-classic timer ran at double speed. startTimer() always created a new
setInterval and overwrote timerInterval without clearing the old one, so any
extra call leaked a second interval that also incremented elapsedSecs. The
visibilitychange handler calls startTimer() on "visible", and a load-time
visibilitychange (while startGame's timer is already running) stacks a second
interval — after which stopTimer() only clears one, so the leak persists and
the clock counts ~2x forever.
Guard startTimer() to no-op when an interval is already running. This fixes the
two e2e timer tests that surfaced it once the suite actually ran (they were
asserting 0:03 / 0:04 but seeing 0:06 / 0:13), and the user-visible 2x timer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rebuild-and-diff freshness gate (#93) could never pass: the Bevy wasm
artifacts aren't byte-reproducible across machines. Confirmed conclusively —
identical rustc 1.95.0 / LLVM 22.1.2, identical flags, Cargo.lock and remapped
source paths still yield host-dependent output (CI's build was 248 KB smaller
than a local one), while same-machine rebuilds are bit-identical. Path
remapping (kept in build_wasm.sh) is necessary but not sufficient.
Make CI the single source of truth instead: `web-wasm-rebuild` rebuilds pkg/ on
every master change to a wasm-feeding crate and commits it back. The deployed
artifacts can't silently rot and contributors no longer hand-run build_wasm.sh.
The pkg/ commit isn't in this workflow's trigger paths (no loop) but is in
docker-build's, so the refreshed wasm deploys.
Removes the false-failing web-wasm-freshness workflow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two latent test bugs surfaced once the e2e suite actually ran (it had been
red on the webServer startup timeout, so these never executed): 14 passed,
4 failed.
- game.js `__FERROUS_DEBUG__` was missing `serialize()` — play.html's bridge
has it but the /play-classic bridge (which the resume/move-history tests
use) drifted. Added it (the wasm SolitaireGame already exposes serialize()).
- game_behaviors.spec.js called `page.clock.tick()`, which is the sinon name;
Playwright's Clock API method is `page.clock.runFor()`. Replaced all 6 calls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web-wasm-freshness gate (#93) false-failed because the Bevy wasm build
baked in machine-specific absolute source paths — the cargo registry
(/home/<user>/.cargo/registry/...), the rustup std sources (~/.rustup/...),
and this checkout — so CI's rebuild (under /workspace, /root) never matched the
committed bytes even with identical pinned tool versions.
build_wasm.sh now exports CARGO_ENCODED_RUSTFLAGS with three --remap-path-prefix
rules (cargo home -> /cargo, rustup home -> /rustup, repo -> /build) so the
embedded paths are identical on any machine. It re-states the
getrandom_backend cfg because a *_RUSTFLAGS env var replaces (not merges with)
.cargo/config.toml's target rustflags.
Regenerated all four artifacts with the remap applied — verified zero
machine-specific paths remain (only canonical /cargo + /rustup prefixes), which
is the precondition for the gate's byte-for-byte rebuild-and-diff to match
across machines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web-e2e job has been failing on every run with:
Error: Timed out waiting 120000ms from config.webServer.
Playwright's `webServer` runs `cargo run -p solitaire_server --quiet` and
waits 120s for /health. The workflow cached only npm — no Rust cache — so each
run cold-compiled the entire server graph (axum/sqlx/reqwest/aws-lc-sys) inside
that 120s window and never came up. The browser tests never executed; the
failure was infra, not a web regression.
Fix the harness so the tests actually run:
- add `Swatinem/rust-cache@v2` to warm the cargo cache across runs
- add a `Prebuild server` step (`SQLX_OFFLINE=true cargo build -p
solitaire_server`) before Playwright, so `webServer`'s `cargo run` reuses the
compiled binary and starts in seconds (.sqlx offline cache is committed)
- raise `webServer.timeout` 120s -> 300s as a safety margin for a cold cargo
cache (e.g. first run after a deps bump)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web build shipped a ~3-week-stale pkg/ because the old "Check wasm pkg
drift" step in docker-build.yml only hard-failed on direct solitaire_web/
edits and treated solitaire_engine/_core changes as a non-blocking notice —
so the entire card_game migration (engine/core/data churn, v4->v5 save
schema) slipped through.
Replace it with a dedicated `web-wasm-freshness` workflow that rebuilds the
artifacts and diffs them against what's committed. A fresh build on a pinned
toolchain (rust 1.95.0 / wasm-bindgen 0.2.120 / wasm-pack 0.14.0 / binaryen
130) is byte-for-byte reproducible — verified locally — so this is precise:
it fails on *any* real drift (including gameplay-logic changes that don't
touch the JS API surface) and has no false positives on wasm-irrelevant edits.
Runs on pull_request as well as master push, so staleness is caught before
merge rather than only blocking the post-merge deploy.
Remove the superseded heuristic from docker-build.yml; master stays fresh via
the PR gate, so the deploy image is always built from current artifacts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The committed `solitaire_server/web/pkg/` artifacts (canvas.* and
solitaire_wasm.*) were last built 2026-06-02 (8b262af) and predate the entire
card_game/klondike migration (#82–#90) plus the v4→v5 save-schema change. The
deployed WASM therefore no longer matched the current source, the JS API glue,
or the HTML (play.html changed in 2cf7282) — which is why the web build was
broken even though the wasm crates compile cleanly on `wasm32-unknown-unknown`.
Rebuilt all four artifacts via build_wasm.sh (wasm-pack 0.14.0,
wasm-bindgen CLI 0.2.120 matching the crate). The regenerated solitaire_wasm.js
and canvas.js glue confirm the API drift (the replay move currency became
KlondikeInstruction in #89).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These six warnings only surfaced on an `aarch64-linux-android` clippy build;
the host workspace gate never compiles the `cfg(target_os = "android")` HUD
tap-toggle code, so they had accumulated unseen.
- unqualify `Vec2` / `TouchInput` (reachable via the bevy prelude) in the
android tap tracker, plugin wiring, and `toggle_hud_on_tap` signature
- make the `PausedResource` import unconditional and unqualify its use in
`handle_hint_button` (host-compiled, so the import had been android-gated
purely to satisfy `toggle_hud_on_tap`)
- allow `clippy::too_many_arguments` on the `toggle_hud_on_tap` Bevy system
- collapse a nested `if let` / `if` into a let-chain
Verified clean on both host `clippy -p solitaire_engine --all-targets
-- -D warnings` and `aarch64-linux-android` clippy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses #91: the #90 posture (`deny(unsafe_code)` + three scattered
`#![allow(unsafe_code)]` across solitaire_data and solitaire_engine) punched
unsafe holes into otherwise-pure logic crates. Replace it by *reducing* the
unsafe rather than relocating it, then forbidding it everywhere it can be
forbidden.
Changes:
- solitaire_data: new safe `android_jni` bridge owning the cached `JavaVM`
and activity `GlobalRef`; exposes `with_env` / `with_activity_env` so
keystore/clipboard/safe-area never touch a raw handle.
- keystore: drop the `JavaVM::from_raw` init path (now in the app) and
replace the three `unsafe { JByteArray::from_raw(x.into_raw()) }` casts
with the safe `JByteArray::from(JObject)` conversion jni 0.21 provides.
- engine clipboard + safe_area: route through the bridge; remove their
`#![allow(unsafe_code)]` and all `from_raw` calls.
- solitaire_app: becomes the single owner of FFI unsafe. `android_main`
reconstructs the raw `JavaVM` / activity once (it must, as the cdylib that
exports `#[unsafe(no_mangle)]`) and registers the safe wrappers. It opts to
its own `deny`-level lints with two scoped `#[allow(unsafe_code)]`.
- workspace: `unsafe_code` is now `forbid`. Every crate except the app entry
point is fully unsafe-free.
Net: 7 unsafe sites across three crates collapse to 3 at the OS boundary in
one crate. Verified with host `clippy --workspace -- -D warnings` and an
`aarch64-linux-android` clippy build of solitaire_app (transitively engine +
data); also fixed two latent android-only `collapsible_if` warnings surfaced
in the keystore by the cross-target check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
font_plugin's module doc claimed a parse failure aborts the program, but
the code warns and continues with glyph-less UI; fix the doc to match.
CLAUDE.md §4.2 listed only audio and the card theme as embedded while
saying "do not embed user fonts"; document that the bundled FiraMono face
is legitimately embedded via include_bytes! as the canonical UI font.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The win-time bonus is Ferrous house-rule scoring policy, not a bridge to
the upstream klondike crate, so it does not belong in klondike_adapter.
Relocate it to a dedicated solitaire_core::scoring module and update the
sole caller (win_summary_plugin) and the adapter/settings doc references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
check_win() manually projected through session.state().state().is_win(),
reaching the inner Klondike's is_win. Session::is_win() (card_game 0.4.1)
wraps that exact same projection, so collapse the three-hop reach into a
single-hop delegation. check_auto_complete() keeps its projection because
is_win_trivial() is a Klondike-only method with no Session wrapper.
Behavior is bit-for-bit identical; the test-support override in
is_won()/is_auto_completable() (which call check_win) is untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add [workspace.lints.rust] and wire each member crate up with
[lints] workspace = true:
unsafe_code = "deny" (forbid would break the Android JNI build)
single_use_lifetimes = "warn"
trivial_casts = "warn"
unused_lifetimes = "warn"
unused_qualifications = "warn"
variant_size_differences = "warn"
unexpected_cfgs = "warn"
unsafe_code is "deny" rather than the issue's "forbid" so the three
Android JNI FFI modules (android_keystore, android_clipboard, safe_area)
can opt back in with a scoped #![allow(unsafe_code)] — forbid cannot be
locally overridden. Pure crates carry no unsafe and stay clean.
Clean up the warnings the new lints surface:
- 150ish unused_qualifications removed via `cargo fix` (purely syntactic
redundant-path-prefix removals).
- table_plugin: the TABLE_COLOUR import was #[cfg(test)]-gated while the
camera clear-colour used the fully-qualified path; unqualifying it left
a non-test build with no import. Made the import unconditional instead.
- assets/sources: the `as &[u8]` casts in embed_*_svg! coerce each
fixed-size &[u8; N] to a uniform slice so the tuples fit the
&[(&str, &[u8])] arrays — load-bearing, so scoped #[allow(trivial_casts)].
Workspace clippy -D warnings and the full test suite pass. Android build
not compiled here (needs the NDK; built separately per CLAUDE.md §15) —
the deny + scoped-allow keeps the JNI unsafe blocks legal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pile-position types (Tableau, Foundation, KlondikePile, KlondikePileStack)
are runtime-only and have no serde upstream. Per Rhys's guidance, the
persistence layer now stores the moves (KlondikeInstruction) rather than
board coordinates, decoding back to runtime pile positions on demand.
Core / data:
- game_state: instruction_history() -> Vec<KlondikeInstruction>; add
instruction_to_piles() and apply_instruction(); drop AnyInstruction.
- klondike_adapter: delete the entire Saved* serde mirror section
(SavedTableau/Foundation/SkipCards/KlondikePile/TableauStack/
KlondikePileStack/DstFoundation/DstTableau/SavedInstruction).
- replay: drop the bespoke ReplayMove serde mirror; Replay.moves is now
Vec<KlondikeInstruction>; REPLAY_SCHEMA_VERSION 2 -> 3.
- storage: game_state save format v3 rejected (v4/v5 only).
Engine / wasm consumers:
- record via KlondikeInstruction (stock click = RotateStock).
- playback decodes each instruction to (from, to, count) against the
live state via instruction_to_piles, then fires the canonical event;
undecodable instructions are skipped with a warning, never panic.
- remove all use solitaire_data::ReplayMove and Saved* imports.
Workspace check, clippy -D warnings, and the full test suite all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
card_to_id was a frankenstein 0..=51 id shim. Replace it with card_game::Card:
- feedback_anim deal jitter now seeds off a hash of the Card itself
- radial_menu RightClickRadialState.cards: Vec<u32> -> Vec<Card>
- wasm CardSnapshot.id: u32 -> Card (serialises transparently as a plain JS
number, the same opaque key the renderer already used; new test asserts the
JSON id field is a number)
- wasm DebugInvariantReport deck-completeness check reworked from a [bool;52]
index into a HashSet<Card> + Card::new reference deck; the out-of-range check
is dropped since a Card is always valid
Delete card.rs entirely: the Card/Deck/Rank/Suit re-exports move to the crate
root and the 69 `solitaire_core::card::` import paths flatten to `solitaire_core::`.
The JS card.id is purely an opaque identity key (Map key / dataset.cardId, no
arithmetic, card faces render from rank+suit), so the value change is safe.
cargo test --workspace and clippy --workspace --all-targets -- -D warnings green.
Closes#83
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DrawMode was a 1:1 mirror of klondike::DrawStockConfig (DrawOne/DrawThree).
Delete it and use the upstream type everywhere; re-export DrawStockConfig from
solitaire_core. config_for assigns draw_stock directly and draw_mode() returns
session.config().inner.draw_stock.
Serde is unchanged — DrawStockConfig serialises to the same "DrawOne"/"DrawThree"
named variants, so persisted game_state.json / replay JSON stay byte-compatible
(no migration). Field/method/variable names containing draw_mode are unchanged.
35 files, mechanical type swap across all crates. Implemented via a multi-agent
workflow (core → per-crate consumers → verify). cargo test --workspace and
clippy --workspace --all-targets -- -D warnings green.
Closes#82
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A claude-flow tool run left solitaire_engine/src/.claude-flow/. Ignore
.claude-flow/ anywhere in the tree, matching the existing agent-tooling
artifact rules.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ignore the local token-saving Go helpers under scripts/ (peek, cargoclip,
testfail, diffclip, cratemap, sessionpack, etc.) via scripts/*.go. These are
inspection-only dev tools, not committed. Tracked scripts/*.sh and *.md are
unaffected. Replaces a broad, machine-local .git/info/exclude rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the standalone solver wrapper module. Its thin shaping — build a
solve-budgeted Session, run card_game::Session::solve(), extract the first
useful move — moves onto the domain type in solitaire_core as
GameState::solve_first_move() / GameState::solve_fresh_deal(), with the budget
consts and the SolveOutcome alias re-exported from solitaire_core.
Solving is deterministic, IO-free game logic, so core (which already owns
GameState and exposes session().solve()) is its correct home; solitaire_data is
the persistence/sync layer and never should have owned it.
Consumers now call the core API directly:
- engine: pending_hint (solve_first_move), game_plugin + play_by_seed_plugin
(solve_fresh_deal), input_plugin (budget consts)
- assetgen: gen_seeds + gen_difficulty_seeds (solve_fresh_deal)
The solver tests move to solitaire_core. cargo test --workspace and
clippy --workspace --all-targets -- -D warnings both green.
Resolves the "delete the solver" directive — card_game provides the solver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace O(n) `Query::iter().find()` card scans with O(1) `CardEntityIndex`
lookups in the mouse and touch drag pipelines (`follow_drag`, `end_drag`,
`touch_follow_drag`, `touch_end_drag`) and `update_drag_shadow` — 7 sites
across 5 systems. Each ran per dragged card per frame during a drag.
`InputPlugin` now defensively `init_resource::<CardEntityIndex>()` (idempotent;
`CardPlugin` still owns and rebuilds it) so the plugin is self-sufficient in
tests. The lone remaining card-keyed `.find` is a `#[cfg(test)]` world-query
helper, which is the correct pattern there.
Completes the CardEntityIndex migration started in ef1efdc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the bespoke WXP scoring engine with the upstream
card_game/klondike session stats, eliminating duplicated state that
could drift from the single source of truth.
score()/undo_count()/recycle_count() now read session.stats(); the -15
undo penalty is configured as SessionConfig::undo_penalty and applied by
the upstream score formula. Save schema bumped v4 -> v5 (the three
counters are no longer persisted -- they are rebuilt by replaying the
forward instruction history on load).
- Remove GameState fields score, undo_count, recycle_count (#87)
- Remove score_history / is_recycle_history undo journal (#86)
- Remove KlondikeAdapter::apply_undo_score and the score_for_* helpers,
plus pre_instruction_score_delta / will_flip_tableau_source (#84)
These three issues are a single atomic change: each removed field/helper
is consumed by the same draw/apply_instruction/undo/serde/PartialEq
paths, so they cannot compile or pass tests in isolation.
Behaviour changes (intentional): the escalating recycle penalty and
per-step score floor are gone (upstream linear scoring, floored once at
0); recycle_count is now cumulative; undo_count resets across save/load.
Refs #84, #86, #87
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep Codex / claude-flow scaffolding (.agents/, .codex/, AGENTS.md) out
of the repo — these are locally generated and not project sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-migration audit found the card_game/klondike migration essentially
complete; these are the four small redundancies that remained:
- core: delete dead GameState::compute_time_bonus (zero callers; engine
uses the klondike_adapter free fn directly)
- data: drop dead public re-exports load_latest_replay_from /
save_latest_replay_to (no callers outside replay.rs); keep
latest_replay_path (engine legacy migration still uses it)
- data+engine: lift win-XP scoring into a shared XpBreakdown so the
win-summary modal breakdown and xp_for_win share one source of truth
instead of duplicating the speed/no-undo constants
- engine: replace feedback_anim_plugin's private foundation_from_slot
copy with the canonical klondike_adapter::foundation_from_slot
cargo test --workspace + clippy -D warnings green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the (from, to, count) tuple as an internal move-passing wrapper.
Game logic now stays in KlondikeInstruction space end to end:
- Add GameState::apply_instruction, the native apply path. move_cards
becomes a thin pile-coordinate adapter that converts to an instruction
and delegates, so move bookkeeping (validation, score/recycle history,
undo snapshot) lives in one place instead of being duplicated.
- next_auto_complete_move matches DstFoundation directly instead of
projecting every candidate to pile coordinates.
- proptests and the storage round-trip test apply instructions directly
rather than round-tripping instruction -> tuple -> move_cards.
The single instruction -> pile decode is renamed instruction_to_highlight
-> instruction_to_piles and kept in core: decoding a tableau run length
needs upstream pile-stack types core does not re-export, so relocating it
would duplicate the logic across engine and wasm. The two rendering edges
(engine hint highlight, wasm debug move list) call this one decoder; the
engine's hint_piles is a thin delegation to it.
Also includes the CardEntityIndex render-side index and a SelectionPlugin
init_resource fix so update_selection_highlight no longer panics in test
harnesses that omit CardPlugin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the git-rev pin (fb01881f, commit 2d0359c) with the published
Quaternions registry releases klondike 0.4.0 / card_game 0.4.1. The
mainline-rev switch broke clean resolution because it dropped the
`registry = "Quaternions"` selector; pinning the registry versions
restores a reproducible lockfile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three byte-identical copies of the stable 0..=51 card-id helper
(suit_index*13 + rank-1) lived in feedback_anim_plugin, radial_menu, and
solitaire_wasm. The WASM copy's own comment notes it MUST match the engine
for cross-platform replay parity — exactly the kind of invariant a single
source of truth should enforce.
Add `solitaire_core::card::card_to_id(&Card) -> u32` and have all three
call sites import it. No behaviour change (same formula).
cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Rhys: card_game's solver is the real engine, so drop the redundant
adapter types in solitaire_data::solver rather than maintain a parallel
verdict/config/move vocabulary.
- Delete SolverResult, SolverConfig, SolverMove, and snapshot_to_solver_move.
The verdict now reads straight off card_game's return:
Ok(Some(instr)) = winnable (first move on the path)
Ok(None) = provably unwinnable
Err(_) = inconclusive (budget exceeded)
- SolveOutcome is now Result<Option<KlondikeInstruction>, SolveError>.
- try_solve / try_solve_from_state take plain (moves_budget, states_budget)
u64s; add DEFAULT_SOLVE_{MOVES,STATES}_BUDGET consts.
- snapshot_to_solver_move duplicated core's GameState::instruction_to_move,
so make that pub and have the hint convert the first-move instruction to
highlighted (from, to) piles through it. Re-export KlondikeInstruction
from solitaire_core.
- HintSolverConfig now holds { moves_budget, states_budget } instead of
wrapping the deleted SolverConfig.
- Update consumers: pending_hint, play_by_seed (verdict badge), game_plugin
(choose_winnable_seed), input_plugin, hud_plugin, and the gen_seeds /
gen_difficulty_seeds asset tools.
solver.rs drops 274 -> 140 lines. cargo test --workspace and
cargo clippy --workspace --all-targets -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move both crates off the damaged "hacked" rev 99b49e62 onto mainline
master (card_game 0.4.0->0.4.1, klondike 0.3.0->0.4.0) to pick up the new
serialize implementation.
Mainline drops the serde derives from Deck/Suit/Rank (only Card is serde
now, as a compact transparent NonZeroU8) and gives KlondikeInstruction a
hand-written serde impl. Adapt the repo:
- Rank::value() was removed; the enum discriminant is the 1..=13 value, so
use `rank as u32/u8` in the three card_to_id helpers (wasm, radial_menu,
feedback_anim).
- Drop the vestigial Serialize/Deserialize derive on theme::CardKey; theme
manifests address faces by manifest_name strings, never by serialising
CardKey, and Suit/Rank no longer implement serde.
GameState's own instruction-mirror serde (schema v3/v4) is insulated from
the klondike serde change, so the on-disk save format is unchanged.
cargo test --workspace and cargo clippy --workspace -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the draw_mode, move_count, is_won, and is_auto_completable fields
from GameState; they are now &self methods deriving from the underlying
card_game session (draw_mode from session config, move_count from history
length, is_won/is_auto_completable from check_win/check_auto_complete).
Tests previously fabricated these via direct field writes, which is no
longer possible. Add gated test-support overrides on TestPileState
(won/auto_completable/move_count) plus setters set_test_won,
set_test_auto_completable, set_test_move_count, and set_test_draw_mode
(re-deals the seed). All compiled out in production builds.
Fix the field->method ripple across solitaire_data, solitaire_wasm, and
solitaire_engine. Add a test-support dev-dependency to solitaire_data for
the won-game storage test.
cargo test --workspace and cargo clippy --workspace -- -D warnings pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the half-applied Card refactor. solitaire_core::card::Card is now an
alias for the opaque card_game::Card: suit()/rank() are methods, there is no
id or face_up field, and it is Clone+Eq+Hash but not Copy. Pile accessors
return Vec<(Card, bool)> where the bool is face-up.
Card identity is now the Card value itself (via Eq/Hash), not a numeric u32:
- CardEntity stores `card: Card` (was `card_id: u32`); lookups compare cards.
- Drag/selection collections and the touch/keyboard selection setters use
Vec<Card>; CardFlippedEvent/CardFaceRevealedEvent/HintVisualEvent carry Card.
- replay_overlay and feedback/settle/deal animations updated accordingly.
solitaire_wasm: CardSnapshot derives its JSON id from suit+rank (matching the
desktop engine), and consumes the (Card, bool) pile tuples.
test-support: TestPileState tableau overrides now carry a per-card face-up flag
so tests can place face-down tableau cards. set_test_tableau_cards keeps its
Vec<Card> signature (defaulting to face-up); new set_test_tableau_cards_with_face
takes Vec<(Card, bool)>.
cargo test --workspace passes (engine lib 897 ok, 0 failed); cargo clippy
--workspace --all-targets -- -D warnings is clean. Save/serde format unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Delete solitaire_core::solver — moved wholesale to solitaire_data::solver (re-exported at crate root)
- Delete solitaire_core::pile — no external users
- Move DrawMode from game_state to klondike_adapter; re-export as solitaire_core::DrawMode
- Remove schema_version field from GameState (redundant — deserializer stamps it from the constant)
- Update all callers across solitaire_data, solitaire_engine, solitaire_assetgen, solitaire_wasm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
apply_safe_area_to_modal_scrims now sets both padding.top (status-bar
height) and padding.bottom (gesture-bar height) on every ModalScrim.
With align_items/justify_content: Center on the scrim, the modal card
lands at the visual midpoint of the visible area between the two system
bars, fixing the slight upward shift that occurred when only the bottom
inset was applied.
Also: mark all rewrite-plan phases (0–3) complete; drop obsolete stash
whose 20 files are already incorporated into master; update CLAUDE.md
§14.3 to document both edges.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 3 of the in-place card_game rewrite.
Two bugs on undo:
1. recycle_count was incremented when recycling but never decremented on
undo, causing the free-recycle allowance to be exhausted faster than
it should be after undo+redo cycles.
2. undoing a penalised recycle applied the −15 undo penalty on top of
the post-penalty (post-recycle) score rather than on the pre-recycle
score, compounding the −100 / −20 penalty rather than reversing it.
Fix:
- Add score_history: Vec<i32> and is_recycle_history: Vec<bool> to
GameState, both parallel to session.history() at all times.
- Extract pre_instruction_score_delta() helper — single source of truth
for all scoring logic, called from draw(), move_cards(), and the
Deserialize replay.
- draw() and move_cards() push to both stacks before processing.
- undo() pops from both stacks: uses the popped pre-move score as the
base for apply_undo_score() and decrements recycle_count if the
undone instruction was a recycle.
- Deserialize rebuilds is_recycle_history and recycle_count from the
instruction replay (recycle detection needs only pre-instruction
session state, so it is always correct across save/load cycles).
score_history is not rebuilt on load (undo-penalty history is absent
from saved_moves); undo falls back to old behaviour for pre-load
moves, but is fully correct for moves made in the current session.
- Remove recycle_count from PersistedGameStateIn (now rebuilt; serde
silently ignores the field in existing JSON saves).
Tests added:
- recycle_count_decrements_when_recycle_is_undone
- score_recycle_penalty_is_reversed_on_undo
All 71 solitaire_core tests and full-workspace suite pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- solitaire_data: add game_state_v3_mid_game_round_trip — first test to
exercise the schema-v3 instruction-replay path with a real mid-game
state (draws + card move + undo); GameState::PartialEq validates all
pile layouts, score, move_count, undo_count, and recycle_count
- solitaire_data: add save_format_v2_is_rejected — schema-version gate
test, parallel to the existing v1 rejection fixture
- solitaire_core: add SavedInstruction proptest (256 random cases across
all three instruction variants) and four boundary unit tests for
out-of-range Tableau/Foundation/SkipCards values
- solitaire_core: document pile() KlondikePile::Stock → waste mapping
- solitaire_core: document replay_config() take_from_foundation=true
invariant and the re-export policy for upstream types
- Cargo.toml: pin card_game + klondike git deps to rev 99b49e62
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three independent hardening changes:
1. bcrypt on a blocking thread: hash() and verify() are CPU-bound
(~300 ms at cost 12). Running them directly on an async task starved
the Tokio runtime under concurrent load. Wrapped in spawn_blocking.
2. Async avatar file I/O: std::fs::write/rename/remove_file in an async
handler blocks the executor. Replaced with tokio::fs equivalents.
3. JWT_SECRET minimum length: a secret shorter than 32 bytes is fatally
weak. validate_jwt_secret() now rejects it at startup with a clear
message rather than silently accepting it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the 2026-05-09 seed lists with seeds regenerated on 2026-06-04.
All seeds remain verified winnable within their respective solver budgets.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
build_android_apk.sh no longer requires all four env vars to be set
manually. It probes common SDK paths and uses the newest installed
build-tools/NDK/platform when vars are absent. Also adds llvm-strip
pass to strip debug symbols from .so files before packaging (controlled
by STRIP_NATIVE_LIBS, default 1), moves the debug keystore to a stable
target/android/debug.keystore path, and prints resolved paths at start.
Also adds scripts/ANDROID_TESTING.md and scripts/android_smoke.sh for
on-device smoke testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the dependency on bevy::android::ANDROID_APP inside
android_keystore.rs. Instead, solitaire_data owns a process-wide
OnceLock<JavaVM> initialised by a new pub fn init_android_jvm().
solitaire_app calls it from android_main before run() so JNI is
ready before any auth-token operation can execute.
- android_keystore: drop ANDROID_APP import; add ANDROID_JVM OnceLock
and init_android_jvm(vm_ptr: *mut c_void)
- solitaire_data/lib.rs: re-export init_android_jvm for android target
- auth_tokens.rs: update doc comment (Android backend is now complete)
- solitaire_app/lib.rs: call init_android_jvm from android_main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All downstream crates now import Foundation, KlondikePile, Tableau,
Klondike, Session, Suit, Rank exclusively from solitaire_core.
solitaire_core is the single version-pin point for the upstream crates.
- solitaire_engine: 19 files updated, klondike direct dep removed
- solitaire_wasm: use statement updated, klondike direct dep removed
- solitaire_data: unused klondike dep removed
- Cargo.lock: klondike no longer a direct dep of engine/wasm/data
- Full workspace clippy clean, all tests pass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the parallel solitaire_core::Suit and solitaire_core::Rank
definitions with pub-use re-exports from card_game. card_game upstream
gained serde, is_black(), and value() to make this clean.
- card.rs: remove Suit/Rank enums and impls; add pub use card_game::{Suit,Rank}
- klondike_adapter.rs: remove From<card_game::Suit/Rank> bridges (now same type)
- Simplify card_from_kl: .into() calls become direct assignment
- Cargo.toml: switch to git deps (serde feature), Cargo.lock updated
All 62 solitaire_core tests pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire card_game 0.4.0 and klondike 0.3.0 as workspace deps in
solitaire_core and clean the integration seam across five areas:
- Move From<card_game::Suit/Rank> bridge impls out of card.rs and into
klondike_adapter.rs so the product-type module is upstream-dep-free
- Add `use crate::card` alias to adapter; rename card_from_kl parameter
to avoid shadowing; correct score_for_undo doc (it is Ferrous policy,
not an upstream default — the solver explicitly passes undo_penalty=0)
- Mark Pile as a read-only projection / data-transfer type in its doc
comment so game logic isn't accidentally routed through it
- Add GameState::session() read accessor exposing the underlying
Session<Klondike> for replay history and solver use by external crates;
update solver.rs to use the accessor instead of the pub(crate) field
- Re-export Foundation, Klondike, KlondikePile, Session, Tableau from
solitaire_core::lib so downstream crates (engine, wasm) can import
from one place without a direct klondike/card_game dep
- Add proptest property tests: card conservation (52 unique IDs always
present), deal determinism, undo pile-layout invariant, legal moves
always succeed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
solitaire_wasm/src/lib.rs — 5 new unit tests (9 total, was 4):
- serialize_from_saved_round_trip: board key matches after JSON round-trip
- undo_reverts_to_prior_state: state + history length restored after undo
- draw_one_advances_waste_by_one: DrawOne takes exactly 1 card from stock
- draw_three_advances_waste_by_three: DrawThree takes up to 3 cards
- debug_apply_move_json_stock_click: JSON DebugMove path via native method
solitaire_server/e2e/tests/game_behaviors.spec.js — 5 new Playwright tests:
- resume overlay shows when localStorage save exists; seed() returns null
until user interacts (before bootstrap completes a game)
- clicking New Game on overlay clears history and starts fresh (0 moves)
- clicking Resume restores saved move history length exactly
- HUD new-game button resets history to 0 and score to 0
- tab-visibility timer: timer freezes during hidden, resumes when visible
(tests the visibilitychange fix from the 500-game UX audit); uses
page.clock.install() to control setInterval without real-time delay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
play_canvas.spec.js covers the window.__FERROUS_DEBUG__ bridge on the
/play route (five tests): bridge availability + seed param, draw3 URL
param, applyLegalMove/undo round-trip, failureReport schema, and
autonomous autoplay invariant batch across 7 seeds.
All tests drive exclusively through the debug bridge — no DOM selectors,
because the Bevy canvas is a single <canvas> element with no HTML
controls.
Also update SESSION_HANDOFF.md to reflect post-v0.35.1 work (10 commits
since 2026-05-18 handoff), new e2e architecture notes, and HiDPI fix doc.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
play.html now loads solitaire_wasm.js alongside the Bevy canvas and
exposes the same window.__FERROUS_DEBUG__ object as /play-classic.
The bridge runs an independent SolitaireGame (WASM logic layer) seeded
from ?seed= / ?draw3= URL params; Bevy renders the visual game in
parallel without coupling.
Methods exposed: seed, state, legalMoves, moveHistory, snapshot,
applyLegalMove, applyMove, draw, undo, serialize, fromSaved, newGame,
failureReport, replayPayload, runAutoplay — matching the /play-classic
contract so the shared Playwright harness targets either route without
modification.
cycle_metrics.js: add --route play-classic|play flag (default
play-classic). Routes to /${route}?seed=N. The resume-overlay clear
step is skipped for /play since the Bevy build uses localStorage-backed
WasmStorage, not a #resume-overlay element.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: fit_canvas_to_parent requests a wgpu surface sized in
physical pixels (CSS pixels × devicePixelRatio). On HiDPI displays
(DPR ≈ 2) the physical size (e.g. 2612×1469) exceeds WebGL2's per-
dimension texture limit of 2048, triggering a wgpu validation panic
that kills the WASM thread immediately on the first window resize.
Fix: add `resolution: WindowResolution::default().with_scale_factor_override(1.0)`
to the primary window so Bevy uses CSS/logical pixels as the surface
dimensions. For a 1306×734 CSS viewport this keeps the framebuffer well
within 2048 regardless of devicePixelRatio.
Also remove the temporary [drag] console logging added in the previous
commit — the panic was causing drag to never run, not a hit-test bug.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add warn!/info! calls to start_drag so every click that doesn't produce
a drag emits a console line with the cursor world position, stock/waste
sizes, and per-tableau pile lengths. This lets us see in browser DevTools
whether find_draggable_at is returning None (wrong hit position) or
something earlier in the pipeline is blocking.
Remove once root cause is identified.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OnboardingPlugin previously used PostStartup which fires before the
first Update tick — guaranteeing the onboarding modal and the launch
splash (MOTION_SPLASH_TOTAL_SECS = 1.6 s) overlap for the entire
splash duration. The splash sits at Z_SPLASH (the highest UI z-index),
so the two screens fought visually and the user saw a confusing frozen
composite before the splash faded out.
Fix: move spawn_if_first_run to Update and gate it on
`splashes.is_empty()` (no SplashRoot entity alive). A Local<bool>
ensures the spawn fires at most once per session. Cost: ~one frame of
latency after the splash clears, which is imperceptible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
solitaire_server/e2e/:
- smoke.spec.js: verifies /play-classic loads, exposes window.__FERROUS_DEBUG__
bridge, keyboard parity (Space=draw, U=undo), debug failure report, and
replay payload builder exports schema-v2 moves.
- gameplay_review.spec.js: HUD/controls render check, stock-click + undo
player flow, draw-mode toggle, autonomous play invariant batch, and
cycle-detection regression guard.
- cycle_metrics.js: headless cycle-rate analysis tool; run via
`npm run review:cycles` with configurable policy, game count, and
thresholds. Regression gate baked into package.json scripts.
- playwright.config.js: targets the local server at http://localhost:8080.
- package.json / package-lock.json: @playwright/test 1.60.0.
.gitea/workflows/web-e2e.yml:
- Runs on pushes to solitaire_server/, solitaire_wasm/, solitaire_core/,
or Cargo changes. Starts the server binary, waits for /health, runs
the full Playwright suite, uploads test-results/ on failure.
docs/testing-architecture.md: documents the three-tier test strategy
(unit → Playwright smoke → cycle regression) and the __FERROUS_DEBUG__
bridge contract.
scripts/update_quaternions_deps.sh: helper to bump the Quaternions
registry deps (klondike, card_game) by version and run the full
safety gate including deterministic replay checks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pile_positions[KlondikePile::Stock] stores the waste column position
(col_x(1)). card_plugin renders the face-down deck one column to the
left (col_x(0) = Tableau1 x) via `base.x -= tableau_col_step`.
handle_stock_click and handle_touch_stock_tap were using pile_positions
[Stock] directly, so the click hotspot was on the waste card (right
column) instead of the deck (left column). Result: clicking the
visible face-down deck did nothing, while clicking the waste pile
triggered draw.
Fix: compute deck_pos = Vec2::new(tableau1.x, waste_pos.y) and hit-test
both the deck column AND the waste slot. Accepting waste clicks matches
standard Klondike UX where either card acts as the draw trigger.
Touch tap handler receives the same fix.
Also rebuild canvas_bg.wasm with the corrected engine source and
-O2 optimisation (replacing the previous -Oz that caused grey screen).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Apply cargo fmt to solitaire_engine, solitaire_server formatting.
- solitaire_server/src/lib.rs: add https://analytics.aleshym.co to
script-src, img-src, and connect-src so the analytics beacon loads
without a CSP violation.
- docs and README updates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Grey screen fix (canvas_bg.wasm):
- Rebuilt Bevy WASM from refactored solitaire_core that removes the
per-game KlondikeAdapter field from GameState. The old binary was
built with wasm-opt -Oz; the large adapter allocation pattern appears
to trigger an over-aggressive wasm-opt optimisation that corrupts
Bevy's render pipeline, causing a permanent grey screen on /play.
- build_wasm.sh: change wasm-opt -Oz → -O2. Speed-optimised level avoids
the size-focused transforms that miscompile Bevy's deep render stacks.
solitaire_core refactoring:
- game_state.rs: remove adapter: KlondikeAdapter field; use static
KlondikeAdapter::config_for() instead of a per-instance allocation.
Gate test_pile_state behind #[cfg(feature = "test-support")] so
production builds carry no test-only heap state.
Add instruction_history() public accessor (delegates to saved_moves()).
- card.rs: add Card::new(), face_up(), face_down() const constructors
for more ergonomic test and wasm code.
- pile.rs, solver.rs: cargo fmt.
solitaire_wasm interactive API:
- lib.rs: add SolitaireGame wasm-bindgen struct with draw(), move_cards(),
undo(), auto_complete_step(), serialize(), from_saved() — the full
player-action surface used by game.js.
Add DebugSnapshot, DebugMove, DebugInvariantReport structs and
debug_snapshot(), debug_legal_moves(), debug_apply_move_json()
methods for e2e test automation (window.__FERROUS_DEBUG__ bridge).
Add replay_moves() to export the current game as a Replay v2 payload.
- solitaire_wasm.js + solitaire_wasm_bg.wasm: rebuilt with new API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dockerfile:
- Drop --mount=type=secret,id=cargo_token: the Quaternions private
registry has been migrated to the public Cargo.io path so the build
secret is no longer needed. Removes the requirement for CI_TOKEN to
carry registry credentials.
CI workflow (docker-build.yml):
- Add solitaire_wasm/** and solitaire_web/** to the push-trigger paths
so changes to either WASM crate actually fire the build job.
- Add wasm drift check for solitaire_wasm artifacts (solitaire_wasm.js,
solitaire_wasm_bg.wasm) — exits 1 if solitaire_wasm/ or solitaire_core/
changed without updating the committed pkg files.
- Add hard canvas drift check: solitaire_web/ changes MUST update
canvas_bg.wasm or the deploy gets a stale Bevy binary.
- Add advisory notice for solitaire_engine/ / solitaire_core/ changes
that omit a canvas_bg.wasm rebuild (non-blocking; formatting commits
should not fail CI).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Web client (game.js):
- Restart game timer after undo exits auto-complete sequence
- Pause timer while browser tab is hidden (visibilitychange)
- Validate URL seed — NaN / negative falls back to randomSeed()
- Guard onBoardClick/onBoardDblClick during win (snap.is_won)
- Delay win overlay 320 ms so last card CSS transition finishes
- Force reflow in flashIllegal() to restart shake on rapid re-trigger
Android (safe_area.rs):
- Preserve last-known insets on app resume instead of zeroing them;
eliminates double layout flash on every foreground cycle
All clients — Bevy engine:
- Radial menu: clamp icon anchors to viewport bounds so icons are
never placed off-screen on narrow phones
- Auto-complete: deactivate state.active when is_auto_completable
goes false (undo mid-sequence) to stop perpetual background retry
- Touch selection: gate highlight rebuild on is_changed() — was
despawning/respawning entities every frame unnecessarily
- Input: fire "Tap a pile to move" InfoToast on first tap in
TapToSelect mode; document cursor_world 1:1 viewport invariant
- Drag threshold: raise desktop from 4 → 6 px to prevent accidental
drags from cursor jitter on HiDPI displays
Desktop / Android (solitaire_app):
- Call cleanup_orphaned_tmp_files() at startup to remove .tmp files
left by crashes between atomic write and rename
Design clarification (klondike_adapter.rs):
- Doc comment: Draw-1 recycling is penalty-only by design (never
blocked) to avoid creating unwinnable positions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 'No Moves Available' dialog's New Game button and keyboard shortcut
were firing NewGameRequestEvent::default() (confirmed: false). When the
player has made moves, handle_new_game sees needs_confirm = true, then
hits the scrims.is_empty() guard — which is false because the GameOver-
Screen itself is a ModalScrim — and silently returns without starting a
new game or showing the confirm dialog.
Fix: set confirmed: true in both handle_game_over_input (N/Escape key)
and handle_game_over_button_input (click). The game is already stuck so
the abandon-confirmation guard does not apply, as the doc comment on the
button handler has always said.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cargo fetch --locked was failing with "failed to parse manifest" because
.cargo/config.toml (which registers the Quaternions sparse index) was
never copied into the build image, and the registry's auth token was
never supplied.
Changes:
- COPY .cargo/config.toml into the builder stage so Cargo knows the
Quaternions registry URL.
- Replace bare `cargo fetch` and `cargo build` with
`--mount=type=secret,id=cargo_token` variants that set
CARGO_REGISTRIES_QUATERNIONS_TOKEN from the mounted secret — token
never appears in image layers or docker history.
- Workflow: pass CI_TOKEN as the `cargo_token` build secret.
- Add solitaire_engine/** and solitaire_server/Dockerfile to trigger
paths so engine changes and Dockerfile edits kick off rebuilds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Gate `Startup` and `user_theme_dir` imports in theme/registry.rs
behind `#[cfg(not(target_arch = "wasm32"))]` — they are only used
in the non-wasm code path, eliminating two unused-import warnings
in the WASM release build.
- Rebuild canvas_bg.wasm and solitaire_wasm_bg.wasm with wasm-opt -Oz
(binaryen v129); canvas_bg.wasm drops from 57 MB → 30 MB.
- Add solitaire_web/Cargo.toml stub to server Dockerfile so
`cargo fetch --locked` resolves all workspace members.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this setting, wgpu's naga SPIR-V→GLSL translator uses features
unsupported by ANGLE (Chromium's WebGL2 implementation): storage buffers,
tight inter-stage component limits, etc. ANGLE rejects these shaders with
a fatal "Shader translation error" and a context-lost event.
WgpuSettingsPriority::WebGL2 constrains naga to emit GLES 300es-compatible
GLSL (same limits as WebGL2 spec: no storage buffers, max 31 inter-stage
components, max 255-byte vertex stride). Firefox was already permissive
enough to work without this; Chromium required it.
Result: game renders correctly in both Chromium (ANGLE/SwiftShader) and
Firefox (native WebGL2), with zero JS errors in both environments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes found while testing the Bevy WASM build in a real browser:
1. chrono wasmbind: add `wasmbind` feature to workspace chrono dep so
Local::now()/Utc::now() use js-sys::Date on wasm32 (previously
fell through to std::time::SystemTime which panics on wasm32).
2. std::time::SystemTime: replace all remaining direct SystemTime::now()
calls (4 sites across game_plugin, difficulty_plugin, time_attack_plugin,
solitaire_data/storage) with chrono::Utc::now() which is wasm32-safe.
3. user_dir: return empty PathBuf (instead of panicking) when data_dir()
is None on wasm32; there is no filesystem in the browser so user themes
are unsupported and a benign empty path is correct.
4. ThemeRegistryPlugin: gate build_registry_on_startup to non-wasm32
(the filesystem scan for user themes has nothing to scan in the browser;
only the bundled embedded themes are available).
5. AssetMetaCheck::Never: configure AssetPlugin in solitaire_web to skip
`.meta` sidecar fetches — we don't ship .meta files, so the default
AssetMetaCheck::Always produced a 404 flood on every card/background asset.
Result: `http://localhost:<port>/play` boots in Firefox with zero errors
and renders the full Bevy game — home screen, onboarding modal, HUD all
visible. Assets load correctly from /assets/. Chromium has a separate
wgpu-27/ANGLE/GLES shader translation bug (not in our code); Firefox works.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace PileType with typed KlondikePile (Foundation/Tableau variants)
throughout solitaire_core, solitaire_wasm, and solitaire_engine;
ReplayMove now uses SavedKlondikePile for serialisation stability
- Split replay_overlay.rs into replay_overlay/ module (mod, format,
input, update, tests) for maintainability
- Add klondike dep to solitaire_engine and solitaire_data Cargo.toml
- Add TestPileState infrastructure to game_state.rs for engine unit tests
- Rebuild solitaire_wasm pkg (js + wasm artefacts updated)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These were scaffolded for a future KlondikeState::from_piles() path
that never materialised. card_from_kl (used by sync_piles_from_session)
is retained; suit_from_kl / rank_from_kl are narrowed to pub(crate).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Full gap analysis between Quaternions/card_game and solitaire_core,
integration steps 1-7 (all now complete), and references.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Closes#80: add AUTO_COMPLETE_INITIAL_DELAY (0.75 s) before the first
auto-complete move fires. Previously cooldown was 0.0, causing the
sequence to hijack the board the same frame the condition was met.
Closes#81: fire MoveRejectedEvent in radial_open_on_right_click when
the right-clicked card has no legal destinations, so the shake
animation and invalid-move sound play consistently on desktop/web.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Step 2 prep — card_game dep + type-conversion utilities:
- Add card_game = "0.3.0" (registry Quaternions) to workspace + core
- suit_to_kl / suit_from_kl, rank_to_kl / rank_from_kl
- card_to_kl (drops id, Deck1), card_from_kl (reconstructs stable id
from Clubs-first suit×13+rank ordering matching deck.rs)
- Ready to wire into KlondikeState pile projection once upstream
adds KlondikeState::from_piles()
Step 5 — GameMode-aware scoring in the adapter:
- score_for_move_with_mode, score_for_flip_with_mode (return 0 in Zen)
- apply_undo_score (static, handles Zen + −15 penalty + clamp)
- score_for_recycle_with_mode (return 0 in Zen)
- game_state.rs: all inline GameMode::Zen checks replaced with
adapter calls; adapter is now the single source of truth for
"what score does this action give in this mode"
192 tests pass; clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Step 1 — Cargo & registry:
- Add .cargo/config.toml with Quaternions sparse registry
(https://git.aleshym.co/api/packages/Quaternions/cargo/)
- Add klondike = "0.2.0" to workspace deps (+ card_game v0.3.0,
arrayvec v0.7.6 as transitives via the Quaternions registry)
- Add klondike as a solitaire_core dep
Step 3 — KlondikeConfig / MoveFromFoundationConfig:
- KlondikeAdapter::new(draw_mode, take_from_foundation) builds a
KlondikeConfig with the correct DrawStockConfig and
MoveFromFoundationConfig (Allowed/Disallowed); exposes it via
klondike_config() for future solver and pile-mapping steps
Step 4 — Scoring via ScoringConfig:
- GameState.adapter (serde(skip)) owns the authoritative KlondikeConfig
with ScoringConfig::DEFAULT (WXP values)
- score_for_move/flip/undo/recycle replace direct scoring.rs calls;
scoring.rs retained for reference and future deletion
- score_for_recycle implements the WXP free-recycle allowance rule
that ScoringConfig::recycle cannot express (flat delta)
- PartialEq/Eq for KlondikeAdapter compare draw_stock and
move_from_foundation only (scoring is always DEFAULT)
All 192 solitaire_core tests pass; clippy -D warnings clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both upstream issues are now merged:
- PR #13 (closes#10): ScoringConfig with 5 configurable deltas lands
in KlondikeConfig; KlondikeStats gains flip_up_bonus_count and
move_from_foundation_count; score() takes &ScoringConfig
- PR #12 (closes#11): MoveFromFoundationConfig (Allowed/Disallowed)
lands in KlondikeConfig; is_instruction_valid enforces it
Doc changes:
- "Already has" table updated with ScoringConfig, MoveFromFoundationConfig,
richer KlondikeStats counters, and version numbers (v0.3.0 / v0.2.0)
- Gap 1 scoring table gains a "Handled by" column showing which deltas
upstream now owns vs. which remain in our adapter (undo penalty,
recycle-with-free-allowance, score floor, time bonus)
- Gap 1 adds note that ScoringConfig::recycle is a flat delta and cannot
express the "N free recycles then penalty" WXP rule
- Gap 4 marked as upstream merged; notes that upstream default is
MoveFromFoundationConfig::Allowed — we must explicitly set Disallowed
- Integration path: steps renumbered (8→7), step 3 now configures
MoveFromFoundationConfig, step 4 splits upstream-handled vs.
adapter-owned scoring; dependency versions pinned
- References updated with PR links and release commit hashes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All per-frame animation tick systems now write MessageWriter<RequestRedraw>
each frame they have active work, allowing WinitSettings focused_mode to
switch from Continuous to reactive_low_power(100 ms) on Android.
Systems updated:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish (FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step interval keepalive)
The 100 ms low-power ceiling means the game timer still ticks ~10×/s
with no input; animations self-sustain via the redraw chain at full
frame rate while active; and the GPU is completely idle between frames
when the board is static.
Each plugin registers add_message::<RequestRedraw>() so the message
type is available under MinimalPlugins in unit tests.
Closes#78, #79
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
toggle_leaderboard_screen was missing the other_modal_scrims guard that
all other panel-toggle systems have. Pressing L (or the HUD button) while
any other modal was open would spawn a second ModalScrim on top of the
existing one, breaking z-ordering and leaving the first modal un-dismissable.
Adds:
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>
and the early-return guard before spawn_leaderboard_screen is called.
Closes#77
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pressing S (or the Stats HUD button) while another modal was open
(Settings, Profile, Leaderboard, etc.) would spawn a second ModalScrim
on top of the existing one, violating the one-scrim-at-a-time invariant.
Add other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>
matching the guard pattern used by every other modal-spawning system.
Also import ModalScrim which was previously not imported in this file.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- sync.rs: replace Uuid::nil() placeholder with the authenticated
user's real UUID before the mismatch check so desktop client pushes
no longer fail with 400 user_id mismatch (#73)
- replays.rs: use server-computed received_at instead of client-supplied
header.recorded_at when updating leaderboard recorded_at to prevent
timestamp spoofing (#74)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- challenge_plugin: replace silent warn+return with InfoToast when all
challenges are completed so the player gets clear feedback (#72)
- input_plugin: add AutoCompleteState guard to start_drag,
touch_start_drag, and handle_double_tap so player input cannot race
with the auto-complete move sequence (#71)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
queries already in .sqlx cache; EXISTS variant would require sqlx prepare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All engine plugin registrations now live in CoreGamePlugin::build().
build_app() is reduced to DefaultPlugins setup + CoreGamePlugin registration.
sync_provider is threaded through CoreGamePlugin::new() via Mutex<Option<...>>.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
'git checkout deploy' is ambiguous because the repo contains both a
deploy/ directory and a deploy remote tracking branch. Switch to
'git switch' which is branch-only and unambiguous.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The binary in pkg/ was built on May 18, predating commit 3322fd4
(fix(wasm): enable take-from-foundation in web game client, May 19).
Dragging Foundation cards to Tableau was silently rejected because
take_from_foundation was false in the stale binary.
Rebuilt with ./build_wasm.sh against current solitaire_core.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add two tests verifying that possible_instructions includes
Foundation→Tableau moves when take_from_foundation is enabled,
and excludes them when it is disabled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes#34, #35, #36
- all_hints: add Foundation as source for Tableau hints (guarded by
take_from_foundation); previously H key never suggested Foundation→Tableau
- end_drag / touch_end_drag: enforce take_from_foundation at input layer
so a rejected-by-core MoveRequestEvent is never fired
- animation_plugin: pub CARD_ANIM_Z_LIFT so card_plugin can consume it
- update_card_entity: set CardAnim start.z = z + CARD_ANIM_Z_LIFT to
eliminate 1-frame z artifact where animated card appeared behind resting cards
- solitaire_app: use AutoVsync on Android (caps GPU at display Hz vs
spinning at 200+ fps); add WinitSettings unfocused reactive_low_power
so app draws ~1fps when backgrounded
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
targetRevision changed from master to deploy so Argo CD tracks the
image-tag commits the CI bot writes there, not the source branch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The CI bot was committing image-tag bumps back to master after every
Docker build, which forced a `git pull --rebase` before every developer
push. Moving the kustomization commit to a dedicated `deploy` branch
keeps master clean — the build bot no longer diverges it.
Argo CD / Flux should now watch the `deploy` branch (targetRevision:
deploy) instead of master.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Engine: replace broken has_legal_moves loop (which checked buried
mid-column cards without sequence validation) with a delegation to
possible_instructions(), mirroring the hint system's logic exactly.
WASM: add has_moves: bool to GameSnapshot, computed in snap() using the
same stock/waste/possible_instructions check so the web client gets the
flag in every state update at no extra round-trip cost.
Web: show a non-blocking no-moves banner (slide-up toast) with Undo and
New Game actions when has_moves is false and the game is not won. Banner
hides automatically once a move restores legal play (e.g. after undo).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The flag was modelled as an opt-in non-standard rule but moving a card
off a foundation is in fact standard Klondike — disabling it is the
non-standard variant.
Changing the core default to true means every client (desktop, Android,
web) gets correct behaviour without each having to independently patch
the value after construction. Clients that expose a settings toggle
(desktop/Android) can still disable it through SettingsResource.
- game_state.rs: flip default from false → true in new_with_mode
- game_state.rs: rename/update take_from_foundation_disabled_by_default
test to reflect the new intended default
- solitaire_wasm/lib.rs: remove now-redundant override in new()
(from_saved keeps its override to fix old saves that serialised false)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GameState::new_with_mode defaults take_from_foundation=false (non-
standard; the flag exists so the desktop can offer it as a setting).
The WASM web client has no settings layer, so this flag was never
flipped on — every drag or double-click from a foundation pile was
silently rejected by the rules engine.
Set take_from_foundation=true in both SolitaireGame::new (fresh games)
and SolitaireGame::from_saved (restored games, which may have the old
default serialised).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- solitaire_wasm: add SolitaireGame::serialize() and from_saved() so JS
can round-trip the full GameState through localStorage as JSON
- game.js: save {gameState, elapsedSecs, drawThree} to localStorage
(key: fs_game_save) on every render(); clear the save on win
- game.js: on bootstrap, check for a saved game and show a resume
dialog if one exists; Resume restores state + timer, New Game discards
the save and starts fresh with a random seed
- game.html: add #resume-overlay markup (same pattern as win-overlay)
- game.css: add styles for the resume dialog and its secondary button
localStorage failures (private-browsing quota) are silently ignored so
they never block gameplay.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sync-setup modal was silently blocked by its own guard:
other_modal_scrims checks for any ModalScrim without SyncSetupScreen,
but the Settings panel IS a ModalScrim, so clicking Connect from within
Settings always hit the guard and returned early.
Two fixes:
- handle_sync_buttons: set SettingsScreen.0 = false when ConnectSync
is pressed so settings closes as the event is fired
- open_sync_setup_modal: exclude SettingsPanel from other_modal_scrims
to handle the deferred-despawn timing window (settings scrim entity
still exists in the world until command buffers flush at frame end)
- Make SettingsPanel pub so sync_setup_plugin can reference it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GameState serializes take_from_foundation=false (the core default),
so saved games on disk and direct-loaded states never had the setting
applied from SettingsResource — only freshly dealt games did.
Two fixes:
- sync_settings_to_game: new system that reads SettingsChangedEvent
and patches game.0.take_from_foundation on every settings change
(covers initial settings load at startup and in-session toggles)
- handle_restore_prompt: apply settings immediately after game.0 =
restored so the Continue path also respects the current setting
- Register SettingsChangedEvent in GamePlugin::build (idempotent with
SettingsPlugin) so the message is available in headless test apps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- radial_menu: replace active_id.unwrap() with let Some guard — no
runtime panic possible even if DragState races (§2.3)
- card_plugin: add bottom-right AndroidCornerBg overlay to mask the
rotated baked-in text on classic PNG cards (mirrors top-left treatment)
- hud_plugin: bump Android action button min_width 44→52 px to give
~22px glyphs adequate padding after dynamic font-size increase
- layout: fix doc-lazy-continuation clippy lint in BOTTOM_BAR_HEIGHT comment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
compute_layout only subtracted safe_area_bottom (OS gesture/nav bar) from
the vertical budget, but the app's own action bar (≡ ← || ? ! M +) sits
*above* that zone — invisible to safe_area_bottom. On Android the bar is
60 px tall (44 px min-height buttons + 8 px top + 8 px bottom bar padding),
so deep tableau columns scrolled 60 px behind the button row.
Fix: add BOTTOM_BAR_HEIGHT (60 px Android, 0 desktop) to safe_area_bottom
before both affected calculations:
• card_width_height_based — height-based card sizing
• avail — budget fed to update_tableau_fan_frac for adaptive fan spacing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The bottom bar's 7 icon buttons (≡ ← || ? ! M +) used TYPE_BODY = 14 px,
a fixed size that is too small on phone screens.
New behaviour:
- `action_bar_font_size(window_width)` returns `(window_width / 40).clamp(16, 30)`,
giving ~22 px on a 900 logical-px phone and ~16 px on narrow viewports.
- `ActionButtonLabel` marker added to each button's text node (Android only).
- `spawn_action_buttons` reads `Query<&Window>` at startup to apply the
correct initial size before the first frame renders.
- `resize_action_bar_labels` system re-runs whenever `LayoutResource`
changes (window resize / orientation change) to keep glyphs in sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
card_plugin: AndroidCornerLabel used CARD_FACE_COLOUR (dark ~#1a1a1a) as
the background and BLACK_SUIT_COLOUR (near-white) for clubs/spades text —
both designed for the Terminal theme. On classic PNG cards (white face),
this produced an ugly dark box with invisible black-suit text. Switch the
corner-label background to Color::WHITE and black-suit text to
CARD_FACE_COLOUR (dark ink on white), matching traditional card printing.
layout: HUD_BAND_HEIGHT on Android raised 80 → 112 px. The HUD column has
4 flex tiers plus 3 inter-tier gaps (4 px each) and a SPACE_2 = 8 px top
offset. With empty tiers still occupying gap height in Bevy's flex layout,
the actual rendered HUD could reach ~80 px, overlapping the top card row
by up to one text line. 112 px provides ~28 px clearance in the common
case (Tiers 1 + 3 visible) and remains workable even when Tier 1 wraps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Android corner label children sit at local z=0.02; with STACK_FAN_FRAC=0.003
the card below's label (world z=1.02) rendered above the card on top's sprite
(world z=1.003), causing overlapping rank/suit text on foundation piles.
Raising STACK_FAN_FRAC to 0.025 ensures every card sprite covers all children
of the card below it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Core (solitaire_core):
- fix(scoring): apply -15 penalty for Foundation→Tableau moves when
take_from_foundation is enabled; update test
- fix(solver): is_won() validates full Ace→King suit sequence, not
just card count — prevents hint system from emitting invalid paths
Engine — animation / layout:
- fix(animation): guard CardAnim advance against duration=0 to prevent
NaN-poisoned Transform (analogous to CardAnimation's instant-snap path)
- fix(card_plugin): align TABLEAU_FAN_FRAC (0.25→0.18) and
TABLEAU_FACEDOWN_FAN_FRAC (0.20→0.14) with layout.rs so the initial
layout and first dynamic update produce identical fan spacing
- fix(layout): update tableau_fan_frac doc comment from 0.25→0.18
Engine — ECS / modal guards:
- fix(auto_complete): drive_auto_complete now checks PausedResource so
cooldown does not tick while paused (prevents instant-move on unpause)
- fix(play_by_seed): handle_open_dialog checks global ModalScrim guard
to prevent stacking over an existing modal
- fix(win_summary): spawn_win_summary_after_delay checks global
ModalScrim guard; collect_session_achievements uses .next() not
.last() to avoid draining the new_games stream
Engine — message registration:
- fix(leaderboard): register InfoToastEvent in LeaderboardPlugin::build
so opt-in/opt-out toasts work under MinimalPlugins
- fix(replay_playback): register StateChangedEvent in
ReplayPlaybackPlugin::build to prevent panic when used standalone
Security:
- fix(sync_setup): zero password SyncFieldBuffer immediately after
spawning auth task — credential must not linger in ECS components
Server:
- fix(auth): replace MIME contains-chain with exact match for avatar
upload; removes illusory starts_with guard and dead ALLOWED_IMAGE_TYPES
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tag-push events are not reliably processed by the self-hosted Gitea
runner. workflow_dispatch with a tag input allows manual triggering
via the Gitea UI or API as a fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue #7 — new game during win cascade:
sync_cards now stores each in-flight CardAnimation's end position instead of
a plain bool. Before calling update_card_entity, the end position is compared
against the game-state target. If they differ by more than 2 px (stale cascade
scatter vs. new-game dealt position) the CardAnimation is removed immediately
so the card slides to its correct dealt position. Drag-rejection tweens are
unaffected because their end equals the card's current game-state position.
Issue #6 — Android stale corner label text:
AndroidCornerLabel now carries the label string as AndroidCornerLabel(String).
resize_android_corner_labels refreshes Text2d content from the stored value
alongside the existing font-size and transform updates, closing the narrow
race where a layout change could display a previous card's rank/suit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The four tests polled the async task pool with a fixed budget of five
app.update() calls. Under cargo test --workspace the pool's background
threads are starved by other tests, so even an instantly-resolving future
can take more than five frames to be polled. Replace the fixed loop with a
deadline-bounded loop (5 s timeout) that exits early once the expected
side-effect is observable — the same pattern used in sync_plugin.rs tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a card slides to a foundation slot already occupied, both card entities
share the same x,y for the duration of the tween. With STACK_FAN_FRAC only
0.003 apart, the incoming card partially occludes the stationary one, making
the two exposed corners look like a single mismatched card.
Elevate every CardAnim-driven card to target.z + 50 during transit so it
fully occludes any card resting at the destination. On completion the card
snaps to the correct resting z. The value sits below DRAG_Z (500) so dragged
cards still render above animated ones.
Closes #implicitly-related-to-corner-mismatch-investigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The game timer kept counting during the auto-complete animation even
though the player had already made their last decision. stopTimer() is
now called the moment is_auto_completable fires so elapsed_seconds
reflects only real play time, not the animation delay.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The pre-built pkg predated fix c35c045 (enable take-from-foundation by
default) so the WASM game always had take_from_foundation=false, silently
rejecting every drag from a foundation pile to a tableau column.
Rebuilt with wasm-pack --release against current solitaire_core.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Initialised ruflo v3 via `npx @claude-flow/cli@latest init --wizard --force`.
Registers the ruflo MCP server in .mcp.json (hierarchical-mesh topology,
max 15 agents). Includes .claude-flow/ runtime config and capability manifest.
.claude/ remains gitignored (local agents/commands/settings stay per-developer).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every draw-from-stock tap was also firing the HUD auto-hide toggle
because the stock pile is not an ActionButton and toggle_hud_on_tap
had no way to know the tap was consumed by game logic.
Add GameInputConsumedResource(bool): handle_touch_stock_tap sets it
on TouchPhase::Started when a draw fires; toggle_hud_on_tap checks
and clears it on TouchPhase::Ended, treating it as equivalent to
started_on_button so the HUD stays put.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Rank=1..13 explicit discriminants so `rank as u8 == rank.value()`; collapse 13-arm value() match to `self as u8`
- Add Rank::RANKS and Suit::SUITS iteration constants
- Add Rank::checked_add / checked_sub (const fn, type-safe boundary enforcement); update rules.rs to use them
- Add GameState::possible_instructions() enumerating all valid move_cards triples (foundation for hints/solver)
- Fix waste buffer card peeking through during draw-slide animation by setting Visibility::Hidden on the buffer entity in sync_cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- poll_opt_in_task / poll_opt_out_task: error branches now fire WarningToastEvent instead of InfoToastEvent
- Settings gains leaderboard_opted_in: bool (serde-defaulted to false); set true/false when opt-in/out tasks succeed
- handle_display_name_confirm: when already opted in and a remote provider is active, spawns an opt_in_leaderboard task to push the new name (server endpoint is an upsert)
- LeaderboardPublicNameText marker component added; update_leaderboard_public_name_label system rewrites the label each frame the panel is open, so it reflects SettingsResource immediately after the display-name modal saves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ScorePulse, ScoreFloater, StreakFlourish (hud_plugin) and ShakeAnim,
FoundationFlourish, FoundationMarkerFlourish (feedback_anim_plugin) are
now all suppressed when Settings::reduce_motion_mode is on. Events are
still drained so no messages accumulate. Closes the remaining gap from
the v0.21.1 "future scope" footnote for the reduce-motion flag.
Three new tests pin the gates:
- score_change_skips_pulse_and_floater_under_reduce_motion
- shake_anim_skipped_under_reduce_motion
- foundation_flourish_skipped_under_reduce_motion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sync errors were silently swallowed — the player had no feedback when a
pull failed due to network issues or an expired session. Now `poll_pull_result`
emits a `WarningToastEvent` with a human-readable message for every error
variant, and reopens the Connect modal on auth failure so the player can
re-enter credentials without navigating through Settings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ModalCard carries Transform (for its 0.96→1.0 scale entrance animation),
which auto-inserts GlobalTransform. Bevy 0.18's on_insert hook on
GlobalTransform fires B0004 when the child has GlobalTransform but the
parent does not. ModalScrim had only Node (which gives InheritedVisibility
via UiTransform but not GlobalTransform), so every modal spawn triggered
the warning.
Adding Transform::default() to ModalScrim gives it GlobalTransform and
satisfies the hook. UI layout is unaffected because Bevy's layout pipeline
reads UiTransform, not Transform.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ZIndex(Z_HUD + 4) and ZIndex(Z_HUD + 5) across four sites in
hud_plugin.rs were magic-number expressions. Define named constants in
ui_theme:
Z_HUD_POPOVER_BACKDROP = Z_HUD + 4 (fullscreen dismiss backdrop)
Z_HUD_POPOVER = Z_HUD + 5 (popover panel)
The score-delta floater (Z_HUD + 10) now uses the existing Z_HUD_TOP
constant, whose doc is updated to mention transient annotations.
Both new constants are added to the monotonic z-hierarchy test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The FOCUS_RING constant was updated to match ACCENT_PRIMARY (brick-red,
srgb 0.647/0.259/0.259) during the Terminal palette swap but the doc
comment still described the old cyan value (rgba 111/194/239). Update
the colour name and rgba sample to match the actual constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
opt_in_leaderboard in sync_client.rs was passing display_name through
as-is, relying solely on the engine's .chars().take(32) call upstream.
Add the truncation in the sync client so any caller is protected, and
also apply it at save-time in handle_display_name_confirm so settings
never stores an over-length name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The keystore atomic write used path.with_extension("tmp") producing
auth_tokens.tmp, while cleanup_orphaned_tmp_files only matched *.json.tmp.
A crash after the write but before the rename left an orphaned file
invisible to cleanup.
Fix: use path.with_extension("bin.tmp") to produce auth_tokens.bin.tmp,
and broaden the cleanup glob from ends_with(".json.tmp") to
ends_with(".tmp") so both JSON and binary temp files are caught.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add PartialEq, Eq, Serialize, Deserialize to AchievementContext per
CLAUDE.md §5.3 derive order. The struct holds only primitive types
(u32, u64, i32, bool, Option<u32>) so all four derives apply without
complications.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
recycle_count already used saturating_add(1); move_count was
inconsistently using += 1 at all three call sites. No real-world
overflow risk (u32 at ~4 billion moves), but the inconsistency was
a code smell flagged by the review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DrawMode is a fieldless two-variant enum — it is trivially bitwise-
copyable. Adding Copy + updating choose_winnable_seed to take the value
directly eliminates 13 superfluous .clone() calls across solitaire_core,
solitaire_engine, solitaire_assetgen, and solitaire_wasm.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The HUD buttons section in the Android controls reference showed "→"
(right-arrow) for the Hint action, but the actual on-screen button is
labelled "!" (ASCII exclamation). Extract ANDROID_HINT_LABEL from
hud_plugin so both the spawn path and the help text share a single
source of truth. Add a cfg(android) regression test that asserts the
hint row's key string matches the const.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace per-call new_current_thread() runtimes with a single
TokioRuntimeResource(Arc<Runtime>) built once at startup using
new_multi_thread(worker_threads(2)). The Arc is cloned cheaply into
each AsyncComputeTaskPool closure, eliminating repeated OS thread
allocation on every sync pull/push, auth, avatar fetch, and analytics
flush. Using a multi-threaded runtime ensures concurrent block_on calls
from different worker threads are safe.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously both ReplayPlayer::state() and ::step() returned JsValue::NULL for
both the expected "replay exhausted" case and the unexpected "serialisation
failed" case. JavaScript callers could not distinguish the two.
Now both methods return Result<JsValue, JsValue>:
- step() returns Ok(null) when the replay is finished (expected sentinel)
- step() and state() Err(string) when serde_wasm_bindgen fails (throws JS exception)
Same fix applied to SolitaireGame::state().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The same "Leaderboard update failed" message was shown for both join and
leave failures, leaving the player unable to tell which operation failed.
Now shows "Failed to join leaderboard" or "Failed to leave leaderboard"
with specific wording that matches the player's intent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Hoist APP_DIR_NAME = "ferrous_solitaire" to solitaire_data crate root
as pub(crate); remove 5 duplicate local definitions across achievements,
progress, settings, storage, replay modules (L-9)
- Add #[must_use] to can_place_on_foundation, can_place_on_tableau, and
is_valid_tableau_sequence in solitaire_core::rules so callers that
accidentally discard the result get a compile-time warning (L-6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- find_draggable_at: break instead of return None on non-top non-tableau
hit so remaining pile searches are not abandoned early (M-9)
- update_stock_count_badge: run only when GameStateResource changes (M-5)
- update_drop_highlights: run only when DragState changes (M-6)
- update_high_contrast_borders/backgrounds: run only when SettingsResource
changes (M-7)
- update_selection_hud: run only when SelectionState or GameStateResource
changes; uses resource_exists_and_changed to avoid panic in tests where
SelectionState is not registered (M-8)
- Volume toast threshold: f32::EPSILON → 0.001 to avoid spurious toasts
from float rounding noise in settings events (M-10)
- check_no_moves: collapse read().next().is_some() + clear() into a single
read().count() > 0 drain (M-11)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Derive PartialOrd+Ord on PileType and sort pile entries in pile_map_serde
before serializing so save-file output is deterministic (M-4)
- Add #[serde(skip)] to undo_stack so transient undo history is never written
to save files, eliminating unnecessary bloat (M-3)
- Add merge_at() accepting an explicit resolved_at timestamp so callers can
inject the server-side time; merge() wraps it with Utc::now() for
backwards compatibility (M-1)
- Fix url_encode to percent-encode UTF-8 bytes rather than Unicode codepoints
so multi-byte characters produce RFC 3986-compliant output (M-2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move /avatars ServeDir behind require_auth middleware so avatar files
can only be fetched by authenticated users (H-11)
- Make avatar upload atomic via .tmp write + rename, cleaning up stale
extensions only after the rename succeeds (H-12)
- Return 401 instead of silently returning an empty username string when
the user row is unexpectedly missing a username (L-17)
- Add user_id mismatch guard to merge(): returns local payload unchanged
with a ConflictReport rather than silently cross-contaminating data (H-2)
- Truncate opt-in display_name to 32 chars client-side before sending,
matching the server's DISPLAY_NAME_MAX validation (L-5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
H-3: cursor_plugin drop_overlay_rect and card_centre_for_index now use
layout.tableau_fan_frac instead of the static TABLEAU_FAN_FRAC constant,
so drop zones match the actual card fan on portrait Android.
Removed now-unused TABLEAU_FAN_FRAC import.
H-4: touch_end_drag uncommitted-tap branch no longer writes StateChangedEvent.
The mouse path (end_drag) already omits this event for uncommitted drags;
the touch path now matches, preventing double-animation on valid taps.
H-6: update_selection_highlight is now gated with run_if(resource_changed)
on SelectionState | KeyboardDragState | GameStateResource, eliminating
the unconditional every-frame despawn+respawn of highlight sprites.
H-7: toggle_home_screen (M-key) now checks other_modal_scrims.is_empty()
before spawning the home screen, preventing a second concurrent ModalScrim
when another overlay is already open.
H-8: spawn_mode_card now inserts ModalButton(ButtonVariant::Secondary) so
paint_modal_buttons applies hover/press colour feedback on Android.
H-10: auto_resume_on_overlay excludes ForfeitConfirmScreen from its
"other scrims" query via NonPauseFamilyScrim type alias. Opening the
forfeit confirm no longer immediately despawns its parent pause modal.
Also guards paused.0 assignment with an if-check to suppress spurious
change-detection writes (L-15).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CR-2: dismiss_modal_on_scrim_click now queries only the target scrim's
Children rather than all ModalCard entities globally. Prevents
dismissing the wrong scrim when two overlapping modals are open.
CR-5: handle_home_draw_mode_buttons and handle_home_difficulty_toggle
now check other_modal_scrims.is_empty() before the despawn+respawn
cycle, preventing a concurrent second ModalScrim in the same frame.
H-1: solitaire_core::game_state — replaced all panicking piles[&key]
index accesses with safe .get().ok_or(MoveError::InvalidSource)?,
.get().is_some_and(...), or .get().and_then(...) in draw(),
check_auto_complete(), next_auto_complete_move(), foundation_slot_for().
H-5: input_plugin end_drag and touch_end_drag — replaced piles[&target]
with .get(&target).is_some_and(...) so missing pile types reject the
move rather than panicking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CR-1: apply_stock_empty_indicator now receives a Handle<Font> from FontResource
so the ↺ label uses FiraMono (Arrows block) instead of the default font.
All three callers (startup, state-change, window-resize) updated.
CR-4: spawn_hud_band, spawn_hud, spawn_hud_avatar, spawn_action_buttons no
longer add SafeAreaInsets physical-pixel values to initial Val::Px offsets.
SafeAreaAnchoredTop/Bottom systems already divide by scale_factor and apply
the correct logical-pixel offset when insets arrive; the initial spawn value
is always 0.0 at Startup on Android anyway. Removed now-unused SafeAreaInsets
import and parameter from all four Startup systems.
H-9: Difficulty section chevrons ▶/▼ (U+25BA/U+25BC, Geometric Shapes — not in
FiraMono) replaced with ASCII ">"/"v" which render correctly on Android.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 (card_plugin): waste Draw-Three fan step was a fixed 0.28×card_width,
chosen for the desktop gap ratio (H_GAP_DIVISOR=4). On Android
(H_GAP_DIVISOR=32) the column spacing is only 1.031×card_width, so the same
fraction pushed the top fanned card's centre past the waste column's right
edge. Fix: derive fan_step from column spacing × 0.224 — preserves 0.28×cw
on desktop while reducing to ≈0.231×cw on Android, keeping fanned cards
within their column footprint. Adds regression test on 900×2000 portrait window.
Bug 2 (safe_area): refresh_insets stored its retry counter as Local<u32>,
making it impossible to re-arm after a background/foreground cycle. On resume
the counter was already saturated so JNI was never re-queried; layouts
computed with stale (zero) insets pushed the top card row up under the HUD.
Fix: convert tries to SafeAreaPollTries Resource; add android::rearm_on_resumed
which resets both counter and SafeAreaInsets on AppLifecycle::WillResume so
the poller re-fires; add on_app_resumed (all platforms) which emits a synthetic
WindowResized on WillResume to immediately trigger layout recomputation. Adds
pure-function regression test in layout.rs pinning the suspend→resume invariant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All three spades face cards had a heart (♥) baked into their
bottom-right corner instead of a spade (♠). Fixed by rotating the
correct top-left corner 180° and stamping it over the wrong corner.
Pixel-count parity confirmed between TL and BR corners on all three cards.
Deletes QS_BUG.md now that the asset content bug is resolved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug #1 (QS wrong watermark): extracted card_face_asset_path() pure helper so
the (Rank, Suit) → filename mapping is tested in isolation. 6 new unit tests
confirm all 52 keys are unique and each suit resolves to its correct letter.
QS.png has the wrong artwork baked in (confirmed via MD5); QS_BUG.md documents
the required asset replacement.
Bug #2/#3 (red square / invisible black suit on Android): add_android_corner_label
used TextFont { ..default() } which gives Bevy's built-in font — that font
lacks U+2660–U+2666, so suit glyphs rendered as a colored missing-glyph
rectangle. Threaded Option<&Handle<Font>> from sync_cards_startup/on_change →
sync_cards → spawn/update_card_entity → add_android_corner_label, which now
passes FiraMono explicitly. Non-Android builds silence the unused param with
let _ = font_handle.
Bug #4 (waste pile): static analysis found no z or fan-offset bug; two new
tests (waste_pile_cards_have_strictly_increasing_z, _draw_one_cards_have_distinct_z)
pin the invariant for future changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SettingsResource is not yet available at Startup, so load_initial_theme
fell back to "dark" on every run. On AMOLED the dark back (▒151515) is
invisible, showing only a 24×32 px red badge — the "tiny red squares"
bug. Cascade-collapse and top-row legibility were visual consequences of
the same invisible face-down cards, not layout bugs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1: StockCountBadge was centred 12 px inward from the stock pile's right
edge but its half-width of 17 px pushed the right edge 5 px past the pile
boundary. On Android (H_GAP_DIVISOR=32, inter-pile gap ~4 px) the badge
corner covered the waste pile's left edge at Z=30, making the waste card
appear clipped. STOCK_BADGE_INSET.x: -12 → -20 keeps the right edge 3 px
inside the stock pile on every device.
Bug 2: The top HUD band Node had an opaque dark-grey BackgroundColor sized to
HUD_BAND_HEIGHT (64/80 px). With only Tier-1 content (~30 px) visible in
typical gameplay the grey block appeared far taller than its content. Removed
BackgroundColor from the band entity; layout reservation in compute_layout is
unchanged and the bottom action bar retains its own background.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dark theme back.svg uses #151515 (near-black) as the card back background,
which AMOLED screens render as fully-off pixels, leaving only the tiny
#a54242 red badge visible — user sees solid red squares instead of card backs.
Fix: change fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly visible on all display types).
Also remove the stale "classic" -> "dark" sanitize migration, correct wrong
asset paths in load_card_images (classic/ subdirectory was missing), and
update tests that hardcoded the old TABLEAU_FAN_FRAC=0.25 constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
aapt2 --version-code/--version-name only inject when the attribute is
absent — they silently no-op when the manifest already has a value.
Removed both attributes from AndroidManifest.xml so the CI flags take
effect. Local debug builds fall back to code=1 / name=0.0.0-dev.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the 5 per-run tool-install steps (~2m 30s) with a pre-built
container image (git.aleshym.co/funman300/android-builder) that ships
Ubuntu 22.04 + Java 17 + Android SDK/NDK + Rust stable + aarch64 target
+ cargo-ndk + sccache. android-release.yml now runs inside the container
and adds two cache steps instead: Cargo registry and sccache directory.
sccache (RUSTC_WRAPPER) caches at the translation-unit level so partial
hits survive Cargo.lock changes — far more resilient than caching the
full target/ directory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AndroidManifest.xml had hardcoded versionCode=1 / versionName=1.0, so
every shipped APK looked identical to Android and Obtainium could never
confirm the installed version matched the latest release tag — causing
a persistent false-update notification loop.
VERSION_NAME is now passed into the build script from the CI tag
(e.g. "v0.28.0" → versionCode=2800, versionName="0.28.0") and
forwarded to aapt2 link via --version-code / --version-name, overriding
the manifest without touching the file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced the curl|bash install_kustomize.sh approach (which makes an
unauthenticated GitHub API call to resolve the latest version) with a
direct pinned tarball download. Eliminates the tar glob failure that
broke run #226.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fetches /api/me with the stored fs_token and renders a 32px circular
avatar in hud-right. Shows the profile photo when set, or the first
letter of the username as initials otherwise. Hidden when not signed in.
Clicking the avatar navigates to /account.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Places a floating "↩ Undo" button at the bottom-right of the green felt
surface so it is visible without looking in the header. Both the board
button and the header button share the same handler; both track
undo_stack_len and disable when nothing can be undone.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splits the old single "⏮ Restart" button into two: "⏮ Restart" (resets
to step 0 with card fade-in from dealt positions) and "◀ Back" (steps
back one move at a time via fast-forward replay). Both are disabled at
step 0 and enabled after any forward step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The "⏮ Restart" button now steps back one move at a time instead of
resetting to the beginning. Re-creates the ReplayPlayer and fast-forwards
to (step_idx - 1) without rendering intermediate frames; the CSS transform
transition then animates each card back to its previous position.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The rank+suit text overlay was transparent, letting the card art's
own small corner text show through underneath — giving the appearance
of two sets of labels on each face-up card.
Add AndroidCornerBg, a CARD_FACE_COLOUR sprite child sized at
(2.0 × font_size) × (1.25 × font_size) rendered at z+0.015,
just below the text overlay (z+0.02). This covers the art corner
text so only the large overlay label is visible.
resize_android_corner_labels now also resizes AndroidCornerBg so
both layers stay aligned on orientation change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Android, face-up cards now render a large rank+suit overlay in
the upper-left corner (FONT_SIZE_FRAC_MOBILE = 0.35 × card_width,
using Anchor::TOP_LEFT) so the rank and suit are legible at phone
scale. The baked-in SVG art corner text is only ~10–15 px physical;
the overlay is ~52 px physical — roughly 3-4× larger.
Accompanying changes:
- H_GAP_DIVISOR on Android raised 8 → 32, widening cards from
112.5 → 124.1 logical px (135 → 149 physical px on Pixel 7 AVD).
- AndroidCornerLabel marker component tracks overlay entities so
resize_android_corner_labels can update font-size + transform
on orientation change without a full card respawn.
- Uses text_colour() for overlay tint so black suits render as
near-white (BLACK_SUIT_COLOUR) on the dark Terminal card face,
matching the existing fallback overlay behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug A: Replace U+21C4 (tofu on FiraMono) with plain ASCII "M" on the
Modes action button.
Bug B: HudAvatar disc was invisible against BG_HUD_BAND (same dark
grey). Switch background to ACCENT_PRIMARY and text to TEXT_PRIMARY so
the disc is clearly visible.
Bug C/D: toggle_hud_on_tap improvements:
- Drain buffered TouchInput events in the early-return path (scrim
present or paused) so the modal-dismiss frame does not replay the
button tap's Started+Ended pair as a spurious toggle.
- Stop clearing start_pos on TouchPhase::Moved — Android fires Moved
even for clean taps (jitter), and the distance check at Ended already
rejects real drags via drag.is_idle(). Clearing it silently swallowed
toggle attempts on physical devices.
- Increase HUD_TAP_SLOP_PX from 15 → 25 for better tap recognition.
Also reduces Android HUD_BAND_HEIGHT from 128 → 80 px now that action
buttons live in the bottom bar rather than the top band.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Right-edge panel shows foundation tops (F: A♠ 7♥ 5♦ K♣) and
stock/waste head (STK:14 WST:7♥) while a replay plays, giving
players a compact game-state readout without scanning the dim tableau.
Architectural changes:
- DespawnWithReplay marker on every sibling root entity so
react_to_state_change uses a single despawn query instead of
one per entity type — future overlay surfaces just add the marker.
- react_to_state_change reduced from 9 args to 5 via the above.
- Two update systems (update_mini_tableau_foundations,
update_mini_tableau_stock_waste) watch GameStateResource.is_changed()
and repaint; split to avoid Bevy B0001 query conflict on &mut Text.
New format helpers: format_rank_short, format_suit_glyph,
format_card_short, format_foundations_row, format_stock_waste_row —
all use FiraMono-covered suit glyphs (U+2660–U+2666, verified Android).
+9 tests (lifecycle + format helper unit coverage).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Obtainium matches overrideSource via runtimeType.toString(), which is the
Dart class name "Codeberg", not the display name "Forgejo (Codeberg)".
The wrong name caused "URL does not match the source" on import.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Obtainium's fromJson calls jsonDecode(json['apkUrls']), so the field must
be a JSON-encoded string ("[]") not a raw array ([]). Passing a raw array
caused the Dart runtime error: List<dynamic> is not a subtype of String.
Also adds allowIdChange and otherAssetUrls fields required by v1.4.3.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The redirect service at apps.obtainium.imranr.dev/redirect parses the
obtainium://app/ payload via JSON.parse(decodeURIComponent(...)), so
base64 caused an "invalid URL" error. Switch to URL-percent-encoded JSON.
Also updates package id to com.ferrousapp.solitaire (renamed package).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Obtainium has no dedicated Gitea source provider. The correct override
is "Forgejo (Codeberg)" which uses the same /api/v1/repos/ API that
Gitea exposes. Previous overrideSource "Gitea" was silently ignored,
falling back to failed auto-detection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The add/ scheme relies on auto-detection which fails for this self-hosted
Gitea instance. The app/ scheme encodes the full config (including
overrideSource: Gitea) in base64 so no detection is needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The self-hosted Gitea instance's /api/v1/meta endpoint returns 404,
causing Obtainium's auto-detection to fail on custom domains. Rewrite
the install steps to lead with the manual Add App flow (URL + set source
type to Gitea) and demote the one-tap badge to a secondary option.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fires on any v* tag push. Steps:
- Installs Android SDK + NDK 30.0.14904198 (cached by SDK version key)
- Builds release APK for arm64-v8a via scripts/build_android_apk.sh
- Signs with the release keystore stored in RELEASE_KEYSTORE_B64 secret
- Creates (or reuses) the Gitea release for the tag
- Uploads solitaire-quest.apk as a release asset
Obtainium users can track releases by adding:
https://git.aleshym.co/funman300/Rusty_Solitare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switch docker-build.yml from paths-ignore to an explicit paths allowlist
so the workflow only fires on changes to solitaire_server/, solitaire_sync/,
solitaire_core/, Cargo.toml, Cargo.lock, or the workflow file itself.
Also harden the "commit and push updated kustomization" step:
- exit 0 early when the kustomization has no staged diff (nothing to push)
- retry the pull+push loop up to 3 times with a 5 s delay to handle
concurrent pushes that race the CI commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 10 warnings were caused by hotkey/keyboard UI code behind
#[cfg(not(target_os = "android"))] call sites whose definitions lacked
the matching gate. Fixes:
- help_plugin: gate keyboard-chip imports and font_kbd; #[allow(dead_code)]
on ControlRow (keys field is data, not dead)
- hud_plugin/ui_modal: replace cfg shadow pattern with cfg!() expression
so the hotkey parameter is read on every platform
- home_plugin: gate fn hotkey behind not(android)
- onboarding_plugin: gate HotkeyRow, HOTKEYS, spawn_slide_hotkeys and
their exclusive imports behind not(android)
- replay_overlay: gate keybind_footer_hint_text behind not(android)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Delete CLAUDE_PROMPT_PACK/SPEC/WORKFLOW (superseded by CLAUDE.md),
SESSION_HANDOFF files, old android investigation notes, phase-plan docs
under docs/superpowers/, and all docs/ui-mockups/ (HTML+PNG mockups
from the pre-Terminal design pass). Also removes local artifacts:
analytics_impl_prompt.md, review_project.py, delete_runs.sh, ruvector.db
files, and the stray solitaire_wasm/solitaire_server/ build artefact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Double-clicking or right-clicking a face-up card now auto-places it to
the best valid pile (foundation preferred for single cards, tableau
otherwise). Right-click also suppresses the browser context menu.
Theme button re-render now calls game.state() instead of reusing snap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Standard Klondike allows returning the top card of a foundation pile to a
compatible tableau column. The flag was previously off by default, making the
move impossible for all players.
Changes:
- GameState::new_with_mode: take_from_foundation now initialises to true
- Settings: default changed to true; custom serde default function ensures
older settings.json files without the key also resolve to true
- Tests: rename "blocked_by_default" → "allowed_by_default" (asserts the
move now succeeds); add "blocked_when_disabled" to cover the flag=false path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add avatar_plugin: AvatarPlugin, AvatarResource, AvatarFetchEvent
- After AvatarFetchEvent fires, spawns an async reqwest download task
- On completion, decodes image bytes via image::load_from_memory →
Image::from_dynamic and inserts into Assets<Image>
- Expand auth task to also call fetch_me_with_token immediately after
login/register so avatar_url is available without a second round-trip
- poll_auth_task fires AvatarFetchEvent when avatar_url is Some, building
the full URL from base_url + relative avatar path
- Profile modal shows 48px circular avatar ImageNode when AvatarResource
is populated, or an initials disc (first letter of username) as fallback
- Add image = "0.25" and reqwest to solitaire_engine deps
- Add fetch_me_with_token helper to SolitaireServerClient for use when
the access token hasn't been persisted to keychain yet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add migration 005: nullable avatar_url column on users table
- Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug)
- Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif),
writes to avatars/ dir, updates avatar_url in DB
- Serve /avatars via ServeDir so uploaded images are publicly accessible
- Update account.html: fetch username from /api/me instead of parsing JWT;
add circular avatar display with initials fallback and click-to-upload
- Add SolitaireServerClient::fetch_me() for desktop/Android profile display
- Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None)
- Update sqlx offline query cache for new avatar_url queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Reorganise card PNGs into assets/cards/faces/{classic,dark}/ and
assets/cards/backs/{classic,dark}/
- Rasterise dark SVG theme alongside existing classic set
- Add "Dark / Classic" toggle button in the game HUD; persists to
localStorage as fs_theme (defaults to classic)
- Preload both themes on page load so switching is instant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rasterized all 52 classic SVGs via rsvg-convert at 256×384. The web
game was showing dark-background cards; it now shows the traditional
white card face style.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Building locally or via a different pipeline; the self-hosted runner's
resource constraints made the 3-ABI release build unreliable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
workflow_dispatch caused the entire android-release workflow to be silently
dropped; tag-triggered releases (v0.25.6) worked fine before this trigger
was added. Removing it restores tag-based release triggering.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Allows manually triggering the release build from the Gitea UI
(Actions tab → Android Release → Run workflow) without needing
to push a version tag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If KEYSTORE_BASE64 is unset, base64 -d writes an empty file silently,
cargo ndk then spends ~7 min compiling all ABIs, and only then does
apksigner fail. Add a size check after decode so the job fails in
seconds with a clear error message instead of wasting a full build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The runner LXC was bumped from ~56 GB to 106 GB, giving ~70 GB of free
space — well above the ~40 GB a full 3-ABI release build needs. Revert
the disk-budget workarounds added in ab35fcf:
- Remove "Free disk space" step (no longer needed)
- Restore x86_64 target (arm64-v8a + armeabi-v7a + x86_64)
- Remove ABIS override so build script uses its full default set
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Run 181 (v0.25.0 tag) failed at "Build signed release APK" after ~7 min —
same disk-exhaustion pattern that hit the debug build. The debug workflow
was already fixed to arm64-v8a only; the release workflow still built all 3
ABIs and exceeded the runner's disk budget.
Changes:
- Add "Free disk space" step before system deps: removes /usr/local/lib/android,
/usr/share/dotnet, /opt/ghc, /usr/local/share/boost (~10 GB reclaimed).
- Limit ABIS to arm64-v8a + armeabi-v7a (drops x86_64, which is emulator-only).
- Remove x86_64 from rustup target add to match.
arm64-v8a covers all modern Android devices; armeabi-v7a covers legacy ARM.
x86_64 can be re-added later if a simulator-targeted test build is needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- default_theme_id() returns "dark" (was briefly "classic" after the
rename commit 20b7a61)
- sanitized() migrates "default" and "classic" → "dark" so existing
settings.json files are upgraded automatically on next launch
- Registry lists Dark first so the Settings picker opens with it at top
- Classic remains available as an option in the picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The disk-budget fix worked — debug APK now builds, signs, and verifies
in ~6 minutes on a single ABI. But the upload step failed with:
GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
and download-artifact@v4+ are not currently supported on GHES.
upload-artifact@v4 rewrote the upload path to use a new artifact
service hosted on github.com; Gitea's GHES-compatibility layer doesn't
implement that endpoint. v3 still uses the older chunked HTTP upload
API that Gitea supports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous run got all the way through compile + link + zipalign and
then died inside apksigner with `IOException: No space left on device`.
Cross-compiling all three Android ABIs (arm64-v8a, armeabi-v7a, x86_64)
in debug mode blows target/ past 25 GB, and by the time apksigner is
streaming the signed APK to disk the runner has nothing left.
Two changes:
1. build_android_apk.sh now reads `ABIS` from the environment (defaults
to all three for backwards compat) and uses it to assemble the
cargo-ndk `-t` flags.
2. android-build.yml passes ABIS=arm64-v8a, since the debug artifact
is consumed by adb-installing to a single arm64 device and the
other two ABIs were dead weight.
Also frees \$STAGING/app-unsigned.apk right after zipalign so it's not
sitting next to the aligned APK and the output APK during signing.
Release workflow is untouched — release APKs still ship all three ABIs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cargo-apk 0.10 and its fork cargo-apk2 both failed to discover the
installed Android platform in this Gitea runner, despite ANDROID_HOME,
platforms;android-34, build-tools, and NDK all being present, readable,
and pointed at correctly. We never isolated whether the bug is in the
shared ndk-build crate's discovery logic or in the runner's env-var
propagation through cargo subcommand exec, so this commit stops fighting
either tool and assembles the APK from explicit toolchain steps instead:
cargo ndk -> per-ABI .so files
aapt2 compile/link -> manifest + resources -> base APK
zip -> bundle native libs into lib/<abi>/
zipalign -> 4-byte alignment
apksigner -> v2/v3 signing (debug keystore for CI, real for release)
The pipeline lives in scripts/build_android_apk.sh so it's reproducible
locally (same env vars, same commands). AndroidManifest.xml is now
checked in under solitaire_app/android/ and mirrors what cargo-apk would
have generated from [package.metadata.android] — keep them in sync if
either is changed. Local `cargo apk build` still works on developer
machines where cargo-apk is happy; CI just stops depending on it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cargo-apk 0.10.0 has been unable to discover an installed Android
platform in this runner environment despite ANDROID_HOME, NDK,
build-tools, and platforms;android-34 all being present and readable.
cargo-apk2 is the maintained community fork on crates.io that reads
the same `[package.metadata.android]` block, so the solitaire_app
Cargo.toml needs no changes. Cache keys updated to apk2- so we don't
restore the broken cargo-apk binary from prior runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirror the fix from android-build.yml: rename ANDROID_HOME -> ANDROID_SDK
in the env block to avoid the Docker-image-baked ANDROID_HOME overriding
the workflow value in run scripts. Use ${{ env.ANDROID_SDK }} template
expressions throughout, and explicitly export ANDROID_HOME/ANDROID_NDK_HOME
before cargo-apk build so it finds the SDK at the right path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace shell variable $ANDROID_HOME references in run blocks with
${{ env.ANDROID_SDK }} template expressions. Gitea runner v1 may not
override Docker-image-baked ENV vars via docker exec; template expansion
happens at workflow compilation time, so the literal path is hardcoded
into the script before the shell runs it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove SDK detection logic and install directly to /opt/android-sdk,
matching the release workflow. The container Docker image has ANDROID_HOME
baked in at /usr/local/lib/android/sdk; installing there with sudo while
cargo-apk resolves ANDROID_HOME from the image ENV created a divergence.
Using a controlled path we own eliminates that class of conflict entirely.
Add SDK cache shared with the release workflow (same key prefix v2-).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ANDROID_SDK_ROOT was never set; zipalign and apksigner were resolving
to empty paths and would fail. All three occurrences replaced with
ANDROID_HOME which is defined in the workflow env block.
Also adds sudo to the cache-miss SDK install (mkdir/mv/sdkmanager) to
match the debug workflow pattern — /opt/android-sdk requires root on
a fresh runner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Classic SVGs and manifest are now compiled in via include_bytes!(),
making the theme available on all platforms (desktop, Android) without
requiring filesystem assets. Removes the now-redundant Dockerfile COPY
of solitaire_engine/assets/themes/classic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
solitaire_engine/assets/themes/classic/ was absent from the container
because only the workspace-root assets/ directory was copied. The
AssetServer serves themes/classic/ from that same root, so the classic
theme manifested as a missing-asset load failure at runtime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename assets/themes/default/ → assets/themes/dark/; update theme.ron
id/name to "dark"/"Dark"
- Rename all DEFAULT_THEME_* constants → DARK_THEME_* and
default_theme_svg_bytes / populate_embedded_default_theme → dark_*
- Add bundled_theme_url() helper for URL resolution without needing the
registry (used by Startup systems where ordering isn't guaranteed)
- Registry now lists Classic first (new player default), Dark second
- settings.rs default_theme_id() returns "classic" so fresh installs
start on the white card theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
White/cream card faces with traditional red (hearts/diamonds) and black
(clubs/spades) colours, plus a navy diamond-pattern card back. Shipped
as a bundled AssetServer theme alongside the existing Default theme.
Registry updated to include the Classic entry; registry tests updated
to reflect the new BUNDLED_COUNT of 2.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Only game.html had the snippet; the other five pages were missing it,
causing the Matomo installation verification check to fail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/index.php returns 302 after tables are created (installer redirect),
which fails k8s HTTP probes. /matomo.php is the tracker endpoint and
always returns 200 regardless of installation state. Also add
timeoutSeconds: 5 since PHP startup can exceed the 1s default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bitnami/matomo was removed from Docker Hub (0 tags). Switch to the
official matomo:5.10.0 image; update port 8080→80, volume path to
/var/www/html, and env var names to match the official image schema.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Secrets committed in prior commits (matomo-secret.yaml,
secret-analytics-auth.yaml) have been scrubbed from history via
filter-branch — rotate those credentials immediately.
Going forward:
- deploy/*-secret.yaml is gitignored; apply manually with kubectl
- deploy/matomo-secret.yaml.example shows the required shape
- ArgoCD ignoreDifferences on matomo-secret prevents it pruning a
manually-applied secret
- Remove matomo-secret.yaml from kustomization.yaml so ArgoCD never
manages it again
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
check_auto_complete no longer requires the waste pile to be empty —
only the stock must be exhausted and all tableau cards face-up.
next_auto_complete_move checks the waste top card before scanning
tableau, and auto_complete_step falls back to draw() when no direct
foundation move is available so the waste drains automatically.
Fixes the end-game state where the player could see a clear win but
the auto-complete interval never fired because the waste was non-empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the hand-rolled analytics endpoint and SQLite event table in favour
of Matomo — a self-hosted, full-featured analytics platform.
k8s:
- Deploy MariaDB 11 + Bitnami Matomo 5 in the solitaire namespace
- Route analytics.aleshym.co ingress to the Matomo service
- Remove Datasette sidecar and its BasicAuth middleware/secret
- Remove the analytics port from the solitaire-server Service
Rust:
- Replace AnalyticsClient (custom HTTP endpoint) with MatomoClient (Matomo
HTTP Tracking API bulk endpoint); maps game events to Matomo categories
- Add matomo_url + matomo_site_id fields to Settings (serde default → None/1)
- Privacy toggle in Settings now activates when matomo_url is set (not tied
to SyncBackend::SolitaireServer)
- Remove POST /api/analytics route from solitaire_server
Web:
- Add Matomo JS tracking snippet to game.html (/play page)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a Datasette container alongside the existing server in the same pod so
it can read the SQLite PVC without a second ReadWriteOnce mount. Protected
by a Traefik BasicAuth middleware at analytics.aleshym.co.
Also fixes the ArgoCD repoURL to point to the migrated Gitea hostname
(git.aleshym.co) instead of the old bare IP.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Content-Security-Policy, X-Content-Type-Options, and X-Frame-Options are
now injected by a single Axum middleware on the web router subtree, so
all HTML pages get consistent headers without touching each file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- leaderboard.html, replays.html: escape user-supplied display_name and
username before inserting into innerHTML to prevent stored XSS
- game.js: call POST /api/replays on win so browser-game completions are
recorded; scores were never submitted before this fix
- replays.rs: after replay insert, upsert leaderboard best_score /
best_time_secs for opted-in users when the new score beats their current
best (classic mode only); scores were never updated before this fix
- leaderboard.rs: add LIMIT 100 to GET /api/leaderboard to prevent
unbounded query growth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a card flipped face-up, the browser fetched the PNG on demand,
showing the cream fallback colour until the image arrived. Preloading
all 52 faces and the back at module load ensures they are cached before
any flip can occur.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add account.html: tabbed form for login and registration, signed-in
state with sign-out, links to leaderboard and replays
- Wire /account route in build_router_inner
- Add Account card to landing page
- Link leaderboard login prompt to /account for new users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
- Add leaderboard.html: JWT login form + localStorage token + table
- Add replays.html: public listing of recent replays, row click to viewer
- Wire /leaderboard and /replays routes in build_router_inner
- Fix home.html Recent Replays link from /api/replays/recent to /replays
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two runs for the same SHA racing to push the kustomization update
caused the second to fail with "failed to push some refs".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Caches compiled dependency layers in the Gitea registry under
:buildcache. Subsequent builds that only touch solitaire_server/src/
skip recompiling the full workspace dependency tree.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Aligns /play with the landing page and app color scheme — same
bg, panel, accent, and felt tokens from ui_theme.rs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replay viewer was using the old midnight-purple palette. Both pages now
use the exact color tokens from ui_theme.rs — matching the desktop and
Android app exactly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SqlitePool::connect defaults create_if_missing=false in SQLx 0.8, causing
SQLITE_CANTOPEN (error 14) when the PVC is empty on first deploy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The server binary dynamically links against libsqlite3.so.0, which is not
present in debian:bookworm-slim by default, causing SQLite error code 14
at startup when connecting to the database.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile: copy web/ and assets/ to runtime stage so ServeDir routes work
- .gitea/workflows/docker-build.yml: build/push image on master push, pin SHA
tag back into deploy/kustomization.yaml so ArgoCD sees a real manifest change
- deploy/: Kustomize manifests — Namespace, PVC, Deployment (Recreate for
SQLite), Service, Traefik Ingress at klondike.aleshym.co
- argocd/application.yaml: auto-sync Application watching deploy/ on Gitea
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without top:0;left:0, Firefox and other non-Chrome engines place
absolute elements at the content edge (padding offset = 20px) before
the JS transform is applied, shifting slots 20px below/right of cards.
Cards already had explicit top:0;left:0; slots now match.
.recycle-label also had top:50%;left:50% which combined with the JS
inline transform would place the ↺ symbol halfway across the board.
Changed to top:0;left:0 so JS transform is the sole position source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- header: position sticky so HUD/controls never scroll off screen
- .card .corner.bottom: remove rotate(180deg) — ♠ rotated looks like ♥,
causing players to misread suit on the bottom corner
- main: add min-width:0 so flex container doesn't push board off-edge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add `SolitaireGame` WASM binding to `solitaire_wasm` exposing draw(),
move_cards(), undo(), auto_complete_step(), and state() — all backed by
the real solitaire_core rules engine.
Add /play route to solitaire_server serving a full vanilla-JS
interactive Klondike game (game.html / game.css / game.js). Features:
drag-and-drop card moves (mouse + touch via PointerEvents), click stock
to draw, double-click card to auto-move to foundation, undo, draw-1/3
toggle, new game, auto-complete animation, win overlay, seed display.
Rebuild solitaire_wasm.js + solitaire_wasm_bg.wasm.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add `GameState::take_from_foundation` flag (default false). When off,
Foundation→Tableau moves are blocked at the core rule layer. When on,
the top card of a foundation pile may be moved back to a compatible
tableau column (one card at a time).
Wire the matching `Settings::take_from_foundation` field through
`handle_new_game` so the player's preference applies to every new deal.
Four targeted tests cover: blocked-by-default, allowed-when-enabled,
illegal-tableau-placement, and count>1 rejection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A2: docs/ANDROID.md — remove stale "permanent fix to come" note;
clarify --lib is the canonical command; root-cause the upstream
cargo-apk bug. SESSION_HANDOFF.md closes the open item.
A3: Remove dead CARD_PLAN.md references from four source module
doc comments (theme/importer.rs, theme/plugin.rs, assets/mod.rs,
assets/svg_loader.rs). Also fix stale "future picker UI" language
in plugin.rs (picker shipped in Phase 7).
A4: ui_modal.rs spawn_modal_button — add min_height: Val::Px(48.0)
so every modal action button meets Material's 48 dp touch target
minimum. Modal button height was 42 px (2×SPACE_3 + TYPE_BODY_LG);
now clamped to 48 px minimum. Cards at 40 dp on 360 dp phones are
layout-constrained (7 columns) and cannot be widened.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Android, a short tap on the empty game area (not on a card) toggles
the HUD band, info column, and action bar between Visible and Hidden.
Layout recomputes with band_h=0 when hidden so cards fill the full
screen. Any modal open restores the HUD to Visible automatically.
- hud_plugin: HudVisibility resource, HudBand/HudColumn/HudActionBar
markers, apply_hud_visibility (fires synthetic WindowResized),
restore_hud_on_modal, and Android-only toggle_hud_on_tap +
HudTapTracker (15 px slop, skips card taps via DragState.is_idle())
- layout: compute_layout gains hud_visible: bool; passes band_h=0.0
when hidden; all callers updated
- input_plugin: TouchDragSet (AfterStartDrag / BeforeEndDrag) public
system-set anchors for cross-plugin ordering
- table_plugin: setup_table + on_window_resized read HudVisibility and
pass hud_visible to compute_layout
- Desktop behaviour is unchanged (HudVisibility always Visible, tap
system is #[cfg(target_os = "android")] gated)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UX-1 (safe_area.rs): apply_safe_area_to_modal_scrims pads ModalScrim
bottom by insets.bottom / scale_factor so Done buttons clear the
gesture bar; fires on inset change + Added<ModalScrim>
- UX-5b (home_plugin.rs): replace Geometric Shapes (U+25xx, missing
from FiraMono) with card suits U+2660/2665/2666
- UX-7 (help_plugin.rs): shorten Android ≡ button description to
"Open menu (Stats, Settings, Profile...)" — fits one line at 360 dp
- BUG-3 (hud_plugin.rs): guard spawn_menu_popover with
scrims.is_empty() so tapping ≡ while a modal is open is a no-op
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BUG-1: pause_plugin — auto_resume_on_overlay system despawns PauseScreen
whenever any other ModalScrim becomes live; fixes Pause modal stacking on
top of Stats / Settings / Help / Achievements / Profile overlays opened
from the HUD menu while paused.
BUG-2: game_plugin — tick_elapsed_time skips the first delta_secs after
AppLifecycle::WillSuspend/Suspended so the Android post-resume frame spike
(equal to the full suspension duration) no longer inflates the in-game timer.
UX-2: help_plugin — Android build gets a touch-specific CONTROL_SECTIONS
(Tap / New Game / HUD buttons); desktop sections (Mouse, Keyboard drag,
Mode Launcher, Overlays) remain on non-Android builds only.
UX-3: achievement_plugin — replace \u{25CB} (○) and \u{2713} (✓) prefixes
with ASCII "- " / "+ "; both Geometric Shapes codepoints are absent from
FiraMono and rendered as the fallback letter "o".
Phase 8 work from previous session (already compiled, not yet committed):
hud_plugin — HUD visual hierarchy (Undo/Pause bright, nav buttons dim);
menu popover — Help + Game Modes entries added (7 items total).
card_plugin — stock badge drops "·" prefix, shows plain count.
pause_plugin — Draw Mode segmented control (Draw 1 / Draw 3 explicit buttons).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the Menu or Modes popover was open, pressing Escape (Android back)
fired the Pause system instead of closing the popover, because both
systems listened to the same key with no coordination.
Fix:
- Add HudPopoverOpen marker to both popover entities on spawn.
- Add close_menu/modes_popover_on_escape systems in HudPlugin that
despawn the popover + backdrop when Escape is pressed.
- Guard toggle_pause with an open_hud_popovers query: bail if any
HudPopoverOpen entity exists, preventing Pause from stacking behind
the closing popover.
- Init ButtonInput<KeyCode> in HudPlugin::build() so the new systems
work under MinimalPlugins in tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
‖ (U+2016) and ▾ (U+25BE) are absent from FiraMono and rendered as
boxes on device. Replace with || (ASCII) and ↓ (U+2193, Arrows block)
which are confirmed FiraMono-safe alongside the existing ≡ ← →.
Also removes the erroneous Android-only TextFont split introduced in
22303c6: that split accidentally used Bevy's built-in ASCII-only bitmap
font instead of FiraMono on Android, causing ALL non-ASCII HUD glyphs
to render as boxes. Now both platforms use the same FiraMono handle.
Separately, suppress the "Tab = next field" hint in the sync login
modal on Android (no Tab key on mobile).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hardcoded 18 in the profile summary line diverged from the actual count
of 19. Use ALL_ACHIEVEMENTS.len() so the count stays in sync when new
achievements are added.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Register touch_scroll_panel::<HelpScrollable> so the Controls overlay
can be scrolled by swipe on Android. Without it, the Mode Launcher and
Overlays sections (rows 2–19) were unreachable via touch.
Also add 96px bottom padding to HelpScrollable — same fix applied to
settings_plugin — so the last row clears the scroll-container edge.
Register TouchInput message so existing headless tests continue to pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 96px bottom padding to SettingsPanelScrollable so the Sync section
is fully reachable by scrolling on Android (was clipped at container edge).
Fix check_system_fires_warning_event_only_once_per_day flakiness: Bevy
0.18 Messages<T> keeps events visible for two frames, so tests running
near UTC midnight saw a stale WarningToastEvent from headless_app()'s
initial update. Clear the buffer with .clear() before each assertion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts touch_scroll_panel<M: Component> into ui_modal.rs and wires it
to SettingsPanelScrollable, AchievementsScrollable, and StatsScrollable
so all three panels respond to finger swipe on Android.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On high-DPI Android (Pixel 7: 420 DPI → ~411 dp logical width), the
modal card fits at ~363 dp wide. The stats modal's three-button row
("Watch replay" + "Copy share link" + "Done") totals ~455 dp, causing
text to wrap inside each button (2–3 lines per button label).
Added flex_wrap: FlexWrap::Wrap + row_gap: VAL_SPACE_2 to
spawn_modal_actions so buttons that don't fit flow onto a second line
as whole units instead of wrapping text inside them. Affects all modals
uniformly; desktop (wide modal) is unaffected since buttons fit in one
line with room to spare.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
scroll_settings_panel only read MouseWheel, which is generated by desktop
scroll wheels and two-finger OS-level scroll gestures. On Android, a
single-finger swipe generates TouchInput, not MouseWheel, leaving the
settings panel unscrollable on real touchscreen devices.
Added touch_scroll_settings_panel: tracks touch start Y, applies the
vertical delta from each Moved event to ScrollPosition, resets on lift.
Registered TouchInput messages in SettingsPlugin::build so tests that use
MinimalPlugins (which omit InputPlugin) don't fail with "Message not
initialized".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tick_elapsed_time already stopped the clock for PausedResource and
HomeScreen, but not for the first-run onboarding modal. A new player
reading the three welcome slides would see their first-game time inflated
by however long they spent on the tutorial. Added OnboardingScreen to the
early-return guard using the same pattern as HomeScreen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drawing from a non-empty stock and recycling a non-empty waste are always
legal moves in standard Klondike (unlimited recycles). The old implementation
only scanned face-up tableau cards and the waste top for valid placements,
returning false for any fresh deal where the initial 7 face-up cards had no
immediate destination — causing a spurious "No more moves" game-over dialog
at Moves: 0. The correct stuck condition is stock=0 AND waste=0 AND no
visible card can be placed.
Updated the "false when stock unplayable" test to assert true instead, since
a non-empty stock means drawing is always legal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds `leaderboard_display_name: Option<String>` to `Settings` (serde
default = None, backwards-compatible). When set, this name is submitted
to the server on opt-in instead of the player's username, giving players
a separate public identity on the leaderboard.
Engine changes:
- `handle_opt_in_button` prefers `leaderboard_display_name` over username
- Leaderboard panel shows "Public name: X" row with "Set Name" button
- "Set Name" opens a modal with a single text-input field (32-char max)
- Save/Cancel buttons write to SettingsResource and persist to disk
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds `replay_player_completes_full_winning_sequence` to `solitaire_wasm`.
A greedy solver runs over seeds 1–200 to find the first deterministically
winnable DrawOne Classic game, serialises the move list as a Replay JSON,
and feeds it to `ReplayPlayer::from_json`. Every move is stepped with
`step_native`; the test asserts `is_won = true` on the final snapshot.
Regression target: any change to `GameState` move semantics or `ReplayMove`
serialisation that breaks a historically valid replay will fail this test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Self-hosters can now run:
./solitaire_server --reset-password <username>
to update a player's password and invalidate all their refresh tokens
(forcing re-login on every device). Password is read from stdin so it
can be piped from scripts or a password manager without appearing in
shell history.
Implementation:
- reset_password() in auth.rs: validates length, bcrypt-hashes new
password, updates users.password_hash, deletes all refresh_tokens
rows for the user.
- main.rs: --reset-password dispatch before HTTP server startup;
JWT_SECRET not required for this path.
- 4 integration tests covering: login works after reset, old password
rejected, refresh tokens invalidated, unknown user → NotFound,
short password → BadRequest.
- README_SERVER.md: admin password-reset section with examples.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds push_retries_after_401_on_expired_access_token to sync_round_trip.rs,
closing the push-side coverage gap alongside the existing pull test
(jwt_refresh_on_401_succeeds). Both tests use an expired-but-validly-signed
access token to trigger the 401 → refresh → retry path in
SolitaireServerClient.
Also exposes build_test_pool() from solitaire_server so downstream crates
can boot a test server without duplicating the migration boilerplate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the exact wasm-pack build command needed to regenerate
solitaire_server/web/pkg/. Removes wasm-pack's package.json and
.gitignore artifacts from the output dir since we manage it directly.
Includes a dependency guard and install instructions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a UserIdKeyExtractor that decodes the Authorization JWT to rate-limit
each user individually (falls back to client IP for unauthenticated
requests). Protected routes now throttle at 10-request burst / 1 token
per 10 s steady-state (6/min), matching the surface attack area of the
1 MB sync/push endpoint.
Also adds an integration test: sync_push_rate_limit_returns_429_on_11th_request.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The method had a no-op default, was never overridden in
SolitaireServerClient, and was never called by any engine system.
Achievements are already synced via the full SyncPayload push, so
the method provided no additional value and was a dead maintenance trap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds "Scan for new themes" button to the Settings Appearance section.
The button fires ScanThemesRequestEvent, handled by a separate
handle_scan_themes system that walks user_theme_dir() for unrecognised
.zip archives, calls import_theme() on each, refreshes ThemeRegistry,
and fires InfoToastEvent messages reporting per-file results.
The import path (label) is shown above the button so players know where
to drop theme archives.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `refresh_tokens` table (migration 003) with one row per live
refresh token, keyed by UUID jti. On every POST /api/auth/refresh the
old jti row is deleted and a new token pair is issued and stored. Using
a consumed token returns 401. Expired rows are pruned inline on each
successful rotation.
Server: Claims gains an optional `jti` field; make_refresh_token now
returns (jwt, jti); register/login insert the jti row; RefreshResponse
now carries both tokens. Client: stores the rotated refresh token from
the response. ARCHITECTURE.md: API table + Security Model updated.
Three new integration tests cover rotation, consumed-token rejection,
and chained rotations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds solitaire_wasm crate (§2/§3), replay API endpoints (§9), web
replay player routes, SyncProvider 7 optional methods, ThemePlugin +
SyncSetupPlugin in plugin table (§5), Settings new fields (§8), and
DB migration 002 replays table (§7). Also fixes missing [0.23.0]
section header in CHANGELOG.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a two-step account-deletion UX: "Delete Account" button in the
Settings sync section (visible only when server backend is configured)
fires DeleteAccountRequestEvent → SyncSetupPlugin opens a confirmation
modal. "Delete Forever" spawns an async delete_account task; on success
SyncLogoutRequestEvent clears local credentials and resets the backend.
Errors surface via InfoToast.
Also splits handle_settings_buttons into handle_settings_buttons +
handle_sync_buttons to stay within Bevy's 16-parameter system limit.
Sync buttons (Sync Now, Connect, Disconnect, Delete Account) are now
handled in the dedicated handle_sync_buttons system.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On auth failure during pull (access + refresh both expired), sync_plugin now
fires SyncConfigureRequestEvent so the Connect modal reopens automatically
instead of leaving the player with a silent error status. The modal's existing
double-open guard keeps repeated failures idempotent.
Also removes the unused SyncAuthResultEvent (results handled inline in
SyncSetupPlugin via PendingAuthTask polling) and adds server deployment
artifacts: Dockerfile (multi-stage, SQLX_OFFLINE), docker-compose.yml (SQLite
volume, health-check), and .env.example for local development setup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SyncSetupPlugin: a three-field (URL / Username / Password) modal
that handles both login and register flows via an async task on
AsyncComputeTaskPool wrapped in a Tokio single-thread runtime (same
pattern as the existing sync push/pull). On success, tokens are stored
to the OS keychain / Android Keystore and SyncProviderResource is
hot-swapped so subsequent pull/push use the new credentials immediately.
Settings sync section now shows Connect (when Local) or Sync Now +
Disconnect + username label (when SolitaireServer). SyncAuthResultEvent
stub registered for future re-auth prompt wiring.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
⏸ (U+23F8), ★ (U+2605), ⚙ (U+2699) are absent from FiraMono and rendered
as boxes on device. Replace with ← ‖ → ▾ which all fall within FiraMono's
covered blocks (Basic Latin + Arrows + General Punctuation + Geometric Shapes).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- camera clear colour → TABLE_COLOUR green so the background reads as
felt even before bg_0.png finishes loading (async on Android)
- foundation empty markers now show "A" child text (same pattern as the
"K" on tableau markers) — no suit letter since any Ace claims any slot
- HUD_BAND_HEIGHT = 128 on Android to accommodate the two-row button
wrap on narrow phones; card grid reserves this space so buttons no
longer overlap the top card row
- TABLEAU_FACEDOWN_FAN_FRAC 0.12 → 0.20 (layout.rs + card_plugin.rs):
face-down stacks show ~67% more back strip per card on fresh deal,
bringing the deepest column from ~27% to ~40% of available screen height
- update_tableau_fan_frac: return early when max face-up depth ≤ 1
instead of overwriting the layout-computed adaptive value with the
desktop minimum (0.25); fixes a regression where the portrait-phone
adaptive fan_frac was silently snapped to 0.25 on every new deal
- update_tableau_fan_frac: also propagate facedown_fan_frac updates in
the mid-game path (previously computed but immediately discarded)
- Android HUD buttons: compact Unicode icon labels (≡ ↩ ? ⏸ ⚙▾ +) with
tighter padding (4 dp) and min-size (44 dp), max-width 90% — all 7
buttons fit in a single 44 dp row on a 411 dp phone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Draw-Three waste fan: slot.saturating_sub(1) was a constant shift that
hid slot-0 even when the pile had fewer cards than visible. Fixed to
slot.saturating_sub(rendered_len.saturating_sub(visible)) so small piles
fan correctly and only a genuine buffer card gets hidden. New regression
test covers the small-pile case.
Android toast: game-over "press D / N" message now shows touch-friendly
copy ("Tap the stock...") on Android via cfg gate.
Onboarding: SLIDE_COUNT drops from 3 to 2 on Android so first-time
users skip the keyboard-shortcuts slide (irrelevant on touchscreen).
spawn_slide dispatch is gated identically.
Hint button: added HintButton to the HUD action bar (order 4, between
Help and Modes). Clicking it triggers the async solver hint — same path
as the H key — via optional resources so headless tests stay clean.
All button-order and tooltip tests updated for the new 7-button bar.
Watch Replay: win-summary modal now shows a "Watch Replay" secondary
button alongside "Play Again". It loads the most recent entry from
ReplayHistoryResource and hands it to start_replay_playback, dismissing
the modal. Falls back to an info toast when the replay or playback
plugin is unavailable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
has_legal_moves: was only checking the top face-up card of each tableau
column as a move source. In Klondike any face-up card can anchor a
movable run, so mid-column cards were missed, causing premature game-over
declarations. Now iterates all face-up cards in each column.
Also tightened the source set: stock (face-down) cards were included
as placement sources producing false positives; waste now only considers
its top card (the one actually reachable by the player).
Waste flash: card_positions rendered exactly `visible` waste cards, so
the card sliding off-pile was despawned the same frame the draw tween
started, causing a one-frame flash. Now renders `visible + 1` cards;
the extra card sits at x=0 (hidden under the stack) and disappears
naturally once the tween positions the new top card over it.
Adds regression test: non-top face-up tableau card as only legal move.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-tap auto-move (input_plugin):
- Remove 0.5 s double-tap window; any uncommitted TouchPhase::Ended on
a face-up card now fires MoveRequestEvent immediately.
Bottom safe-area inset (layout, table_plugin):
- compute_layout gains safe_area_bottom param; height budget and bottom
margin both respect the navigation bar reservation.
Card back contrast (card_plugin):
- CardBackFrame child sprite (gray, card_size + 3 px, local z=-0.01)
spawned behind every face-down card so the dark back_0.png reads as
a distinct rectangle against the dark felt.
HUD action bar compactness (hud_plugin):
- max_width 50% → 65% on the action button row; 6 buttons now wrap to
2 rows instead of 3 on a 360 dp phone.
Dynamic tableau fan fraction (layout, card_plugin):
- Layout gains available_tableau_height field.
- update_tableau_fan_frac system (after GameMutation, before
sync_cards_on_change) grows face-up fan from 0.25 to the window max
as revealed column depth increases. Face-down fan is left at the
window-adaptive value so stacks stay visible.
ModesPopover + MenuPopover light-dismiss (hud_plugin):
- Fullscreen transparent Button backdrop spawned at Z_HUD+4 behind each
popover; tapping outside the panel despawns both panel and backdrop.
Stock badge legibility (card_plugin):
- Badge font TYPE_CAPTION (11 pt) → TYPE_BODY (14 pt); background
sprite 28×16 → 34×20 world units.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Android aarch64 compiler cannot infer the type of `let hotkey = None` inside
the `#[cfg(target_os = "android")]` block — it needs to know the Option's inner
type to resolve the rebinding downstream. Added `: Option<&'static str>` to match
the parameter type and match the non-Android path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`WindowInsets.getInsets(systemBars())` returns physical pixels (e.g. 84 px
on a 2.625× Pixel 7) but both Bevy's `Val::Px` (UI layer) and the world-
space layout coordinate system use logical pixels. Dividing by
`window.scale_factor()` before applying gives the correct 32 dp offset.
- `safe_area.rs::apply_safe_area_anchors`: query `Window`, divide `insets.top`
by `scale_factor()` before writing `Val::Px(base_top + top_logical)`.
- `layout.rs::compute_layout`: new `safe_area_top: f32` parameter (logical px)
subtracts from the vertical budget (`card_width_height_based`) and from
`top_y` so both card sizing and pile positioning honour the status-bar band.
- `table_plugin.rs`: `setup_table` and `on_window_resized` now read
`SafeAreaInsets` and divide by scale before passing `safe_area_top` to
`compute_layout`. New `on_safe_area_changed` system fires a synthetic
`WindowResized` when insets arrive (~frame 2-3 on Android) so the full
resize pipeline (layout → pile markers → card snap) re-runs automatically.
- All test call-sites updated with `, 0.0` safe_area_top (desktop/no inset).
- Two regression tests added: shift amount equals `safe_area_top` exactly;
horizontal layout is unaffected by vertical inset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
reqwest/hyper-util's GaiResolver calls tokio::runtime::Handle::current()
which panics with "no reactor running" when driven by Bevy's
AsyncComputeTaskPool (async-executor, not Tokio). Fixed all three spawn
sites in sync_plugin.rs (start_pull, handle_manual_sync_request,
push_replay_on_win) and the push_on_exit fallback by wrapping each HTTP
future in tokio::runtime::Builder::new_current_thread().enable_all().
Also fixes a clippy type_complexity warning in hud_plugin.rs by
extracting HudScoreFont / HudMovesFont / HudTimeFont type aliases for
the update_hud_typography query parameters.
Closes P4 AVD JNI bridge test: keystore JNI verified working on
Android 14 x86_64 AVD (load_access_token returned NotFound correctly);
clipboard JNI compiled and linked, runtime test deferred to a real-device
session with a won game and active sync server.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P3 — App-icon density buckets:
- Created solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/
ic_launcher.png from assets/icon/ (48→mdpi, 64→hdpi, 128→xhdpi,
256→xxhdpi+xxxhdpi). aapt downscales oversized buckets; no quality loss.
- Added resources = "res" to [package.metadata.android] so cargo-apk/aapt
packages the mipmap tree into the APK.
- Added icon = "@mipmap/ic_launcher" to [package.metadata.android.application]
so the launcher references the density-bucketed icon instead of the
default grey system icon.
P3 — Density-aware card scaling: investigated, no code change required.
WindowResized fires with logical pixels; 256×384 card textures are
downscaled on all current phone targets (40dp logical → 120px physical
at 3× DPI). Upscaling only occurs on tablets wider than ~765dp at 3× DPI.
P4 — B0004 hierarchy warnings: investigated, no fix required.
.despawn() is recursive in Bevy 0.18; warnings are startup timing
artifacts (UI components propagating before parent initialises), not
gameplay bugs. No crashes or defects in 2+ min AVD runtime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the final two P2 Android playability items:
1. HUD typography — new `update_hud_typography` system fires on
`WindowResized` and adjusts Tier-1 font sizes: below 480 logical px
Score drops HEADLINE(26)→BODY_LG(18) and Moves/Timer drop
BODY_LG(18)→CAPTION(11), so all three fit in the 180dp HUD column
on a 360dp phone without wrapping.
2. Orientation lock — `[package.metadata.android.application.activity]`
with `orientation = "portrait"` in solitaire_app/Cargo.toml; cargo-apk
maps this to `android:screenOrientation="portrait"` in the generated
AndroidManifest.xml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Touch screens have no right mouse button, so right-click radial was
inaccessible on Android. New system radial_open_on_long_press counts
up while a touch is held on a face-up card without crossing the drag
threshold; after 0.5 s it transitions RightClickRadialState to Active,
which the existing visual overlay and destination-ring infrastructure
then renders unchanged.
Three supporting changes to wire up the touch-driven confirm path:
- radial_track_cursor: falls back to the first active Touches position
when cursor_world returns None, so the hover ring tracks a sliding
held finger on Android.
- radial_handle_release_or_cancel: confirms on Touches::iter_just_released
(finger lift) in addition to right-mouse release. Cancels on
Touches::iter_just_canceled. No new event reader — uses the Touches
resource which is already in scope after the track_cursor addition.
- handle_double_tap: skips when the radial is active. Guards the
narrow edge case where the finger lifts on the exact same frame
as the 0.5 s long-press threshold fires; prevents a spurious
double-tap move from racing with the radial confirm.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two improvements to drag responsiveness on Android:
1. Guard start_drag against touch-simulated mouse presses.
start_drag (mouse path) now bails when Touches::iter_just_pressed()
finds an active touch, so touch_start_drag always owns drag state on
touch-screen devices. Without the guard, Bevy/Winit versions that
synthesise MouseButton::Left from the primary touch would have the
mouse drag path claim drag state first (start_drag runs before
touch_start_drag in the system chain), leaving the card tracked via
cursor_world instead of the Touches resource.
2. Lower mobile drag commit threshold 10 px → 8 px.
Matches Android ViewConfiguration.getScaledTouchSlop() exactly.
Smaller threshold reduces the snap-to-finger displacement at commit
and makes drag feel more immediate.
Hardware confirmation (verify no stutter, tune if needed) remains a
manual step recorded in PLAYABILITY_TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When handle_double_tap recognises a double-tap and fires MoveRequestEvent,
the moved card(s) are immediately tinted STATE_SUCCESS (lime #acc267) with
a 0.35 s HintHighlight so the player sees visual confirmation before the
card animation begins.
- Priority 1 (single top card): flashes that card only.
- Priority 2 (whole face-up stack): flashes every card in drag.cards.
Reuses the existing tick_hint_highlight cleanup path (restores sprite
to WHITE when timer expires) so no new system or component is needed.
The flash duration (0.35 s) slightly outlasts a typical card animation
(~0.3 s), giving the tint a brief moment at the destination before clearing.
Marks P1 "Double-tap auto-move visible feedback" as closed in
PLAYABILITY_TODO (hardware trigger-verification still manual).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On a 360 dp portrait phone the card width is set by the 9-column
horizontal packing (360/9 = 40 dp); the fixed 0.25 fan fraction then
places the worst-case 13-card column in the top ~44 % of the screen,
leaving the bottom 56 % empty black.
`compute_layout` now solves for the fan fraction that exactly uses the
available vertical space below the tableau row:
ideal = avail / (12 * card_height)
On height-limited (desktop) windows ideal ≈ 0.25 and the clamp to the
minimum keeps existing behaviour. On width-limited (portrait phone)
windows the fan expands — ≈ 0.84 at 360 × 800 dp — stretching the
tableau to fill the screen.
Both `tableau_fan_frac` and `tableau_facedown_fan_frac` (scaled
proportionally) are stored on the `Layout` struct. `card_plugin` and
`input_plugin` read from the struct so rendering and hit-testing stay
in sync at every viewport size.
Three new regression tests:
- portrait phone expands fan_frac beyond desktop minimum
- expanded fan fits inside phone viewport (no overflow)
- desktop fan_frac stays at minimum 0.25
Closes P1 "Portrait-first card spacing" in PLAYABILITY_TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Action buttons sized to text + 8 px padding made "Undo" end up
~46 x 33 px — fine for a mouse but at the threshold of a finger.
Adds `min_width: 48 px` and `min_height: 48 px` to the button
Node so every button meets Material's 48 dp thumb-target guideline.
Applied universally; the floor is a no-op for buttons whose
content already exceeds 48 px on either axis (Menu, Modes,
New Game, Pause, Help all clear 48 px wide; height was the
binding constraint at ~33 px).
Closes P1 #2 of docs/android/PLAYABILITY_TODO.md. Engine tests
pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The U / Esc / F1 / N caption chips next to the HUD action buttons
are meaningless on a touch device and visibly clutter the
narrow-viewport action row (visible as "Esc A [] N" in the v0.22.3
screenshot). `spawn_action_button` now rebinds `hotkey` to `None`
under `#[cfg(target_os = "android")]` so the chip-spawn branch is
skipped on touch builds.
Menu / Modes chevrons are unaffected — they indicate dropdown
behaviour and still apply on touch. Other hint surfaces
(onboarding, pause modal Esc hint, mode-card chips, replay
footer, modal toggle chips, help screen) live behind navigation
and are tracked as a P3 sweep in PLAYABILITY_TODO.md.
Closes P1 #1 of docs/android/PLAYABILITY_TODO.md. 855 engine
tests pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`compute_layout` runs `window.max(MIN_WINDOW)`, which acts as a
component-wise floor: any window smaller than MIN_WINDOW on either
axis gets clamped up. The previous floor of 800x600 was set with
desktop in mind, but on Android the OS-provided window size is the
device resolution (~360 dp wide on a typical phone) and the clamp
silently re-laid the board for an 800 dp width.
Side effect: total grid width (9 * card_width) became ~800 px on a
360 dp viewport, so the leftmost foundation x-position fell past
-180 and the rightmost tableau pile past +180 — both clipped at
the visible edges, matching the v0.22.3 hardware screenshot.
Lowered MIN_WINDOW to 320x400, below the smallest reasonable phone
(~360x640), so every real device flows through compute_layout
unclamped. The floor is preserved as a sentinel against degenerate
windows (Bevy can briefly report 0-size during startup or after
minimisation on some compositors). Desktop's "minimum supported
playable size" is enforced separately via WindowResizeConstraints
in solitaire_app.
Updates `layout_below_minimum_clamps_to_minimum` to use values
below the new floor, and adds a new regression test
`phone_portrait_layout_fits_horizontally` that asserts all 13
piles fit inside a 360 x 800 dp viewport.
Closes P0 #4 of docs/android/PLAYABILITY_TODO.md. 855 engine tests
pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The v0.22.3 hardware screenshot showed the 6-button action row
(~510 px when laid out) overflowing into a 360 dp viewport from
the right anchor, with Menu and Undo clipped off-screen left and
Pause/Help/Modes/New_Game overlapping the left HUD column's
Score / Moves / Timer text.
Cap both clusters at `max_width: 50 %` so on mobile each takes
half the viewport (~180 px) and on desktop the cap is wider than
either cluster's natural width so the existing single-line
layout is preserved.
- Action button row: adds `flex_wrap: Wrap`, `row_gap`, and
`justify_content: FlexEnd` so the row breaks to multiple
right-aligned lines instead of clipping. 6 buttons become 2-3
lines of 2-3 buttons.
- HUD column tier rows: add `flex_wrap: Wrap` and `row_gap` to
the shared `row_node` helper so a long Mode/Challenge/Draw-cycle
combo soft-wraps onto two lines instead of pushing into the
action button column.
Closes P0 #2 of docs/android/PLAYABILITY_TODO.md. All 854 engine
tests pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`AssetPlugin::file_path = "../assets"` was set unconditionally to
make `cargo run -p solitaire_app` find the workspace-root assets
directory from inside `solitaire_app/`. On Android cargo-apk packages
the same directory into the APK at `assets/`, and Bevy's
AndroidAssetReader is already rooted there — prepending `../` walked
the reader out of the APK assets root and every load failed silently.
The cascade: CardImageSet handles were inserted but pointed at
non-existent paths, so `card_sprite` saw `Some(set)` but the textures
never resolved. The face-down branch then rendered with `Color::WHITE`
over a missing texture — which on hardware showed as the
`card_back_colour(0)` solid-red brick fallback that's *supposed* to
only fire under MinimalPlugins in tests.
Gates the `file_path` override behind
`#[cfg(not(target_os = "android"))]` so Android picks up the default
empty path. Closes P0 #3 of docs/android/PLAYABILITY_TODO.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds *.jks / *.jks.bak / *.keystore to .gitignore so the
release signing material can never be committed accidentally.
Cargo.lock drift catches up with 7c07f71 (bevy dep added to
solitaire_data for Android target) — the prior commit edited
solitaire_data/Cargo.toml but didn't regenerate the lockfile.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.
UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.
Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).
Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the gap between "boots without crashing" (v0.22.3 status)
and "actually playable on a phone." Tracks P0-P4 work items grouped
by impact: safe-area, HUD layout, card-back rendering, viewport
overflow, touch UX, density.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cargo-apk panics with "Bin is not compatible with Cdylib" after
successfully signing the APK, when cargo-subcommand's artifact
iterator walks the bin target after the cdylib has been produced.
The APK file survives the panic on disk, but the non-zero exit
fails the workflow step before the upload runs.
Passing --lib scopes the build to the cdylib target only.
SESSION_HANDOFF.md documented this as the canonical workaround.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
actions/checkout, actions/cache, actions/upload-artifact, and
actions/download-artifact bumped from v4 to v5 across both
ci.yml and release.yml. Pre-empts the 2026-06-02 Node 20
deprecation deadline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Python heredoc had TOML section lines at column 0 inside a YAML
literal block, which YAML interprets as terminating the block (parse
error, instant workflow failure). printf keeps all lines at proper
indentation within the run block while avoiding sed escaping issues
with special characters in passwords.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cargo-apk refuses --release builds without [package.metadata.android.
signing.release] in the package Cargo.toml. Instead of committing
credentials, the workflow now: decodes the keystore secret to a temp
file, uses a Python heredoc to append the signing section referencing
the absolute keystore path and secret env-vars, then removes the
keystore after the build. This replaces the post-build apksigner step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These three bevy::window types are only referenced by
apply_smart_default_window_size, which is already cfg(not(android)).
The unconditional import triggered -D unused-imports on the Android
cross-compile. Split into a separate cfg-gated use statement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both symbols are desktop-only: the variable feeds apply_smart_default_
window_size which is only registered inside a cfg(not(android)) block.
Without the matching cfg gate on the declaration / definition, the
Android cross-compile emits unused-variable and dead-code errors
(-D warnings turns them into hard failures).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two cfg(android) issues hidden from Linux CI:
- android_clipboard.rs: JValue was imported but never used (JValueOwned
covers all call sites). Removed to satisfy -D unused-imports.
- stats_plugin.rs: both arms of the clipboard match now return () via
explicit block+semicolon, resolving the type mismatch that pinged-pong
between runs due to bidirectional match-arm type inference.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Err arm in stats_plugin.rs had a trailing semicolon on
toast.write(...) making it return () while the Ok arm returned
MessageId<InfoToastEvent>. Only caught on Android because the block is
cfg(target_os = "android") gated; the Linux CI never compiled it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
android_keystore.rs uses bevy::android::ANDROID_APP to obtain the
process-wide JavaVM handle, but bevy was absent from the Android-target
dep block in solitaire_data/Cargo.toml. Cargo resolved the symbol in
the workspace dev build (where bevy is reachable transitively) but the
Android cross-compile with cargo-apk failed with E0433. Adding bevy
under [target.'cfg(target_os = "android")'.dependencies] fixes it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tag-triggered (v*) workflow builds a Linux tarball (binary + assets) and
a multi-arch Android APK signed with a release keystore stored in GitHub
secrets. A final job creates the GitHub Release with both files attached
so Obtainium can track and auto-download the APK.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to
solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel).
Five seed catalogs (40 seeds each) are pre-verified by the new
gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves).
DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed →
NewGameRequestEvent; Random uses a system-time seed and bypasses the
winnable-only filter. The home overlay gets an expandable Difficulty section
between Draw Mode and the mode grid; last-played tier persists in Settings.
Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the four KeychainUnavailable stubs in auth_tokens.rs with a
real Android Keystore implementation:
- Device-bound AES-256/GCM/NoPadding key under alias
'solitaire_quest_token_key'; generated on first use, survives
restarts, destroyed on uninstall.
- Tokens serialised as JSON, encrypted to
{data_dir}/auth_tokens.bin as [12-byte IV][ciphertext+GCM-tag];
writes are atomic (tmp → rename).
- Key invalidation (biometric/lock change) surfaces as
TokenError::KeychainUnavailable, matching desktop fallback semantics.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the informational "Share link: {url}" toast on Android with a
real clipboard write via ClipboardManager JNI. Falls back to the old
toast on JNI error so the user can still copy the URL manually.
Adds `jni = "0.21"` (default-features = false) as a workspace dep;
`jni 0.21.1` was already in Cargo.lock as a transitive dep so no new
packages are fetched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GPGS integration will not be implemented. Removed from Phase Android
open items and from the Phase 8 (sync) description.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
F11 fullscreen toggle only makes sense on desktop; Android windows are
always full-screen. Gates the fn and the MonitorSelection/WindowMode
imports with #[cfg(not(target_os = "android"))] to keep clippy clean
on the Android target. The add_systems call is extracted as a separate
statement so #[cfg] can annotate it (cannot appear mid-chain).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a gen_seeds binary to solitaire_assetgen that brute-searches seeds
for hands solvable in ≤250 moves, then writes the list. The 75 new
seeds (0xCAFEBABE prefix) are appended to CHALLENGE_SEEDS in
solitaire_data::challenge.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type
a decimal seed and receive an instant solver-verified verdict before the
hand is dealt. A new HomeMode::PlayBySeed card surfaces it in the home
overlay, matched by the StartPlayBySeedRequestEvent carrier.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors handle_double_click for the touch pipeline. A double-tap on a
face-up card fires MoveRequestEvent to the best legal destination using
the same priority order (foundation first, tableau second; stack move
as priority 2 when the tapped card is a stack base).
Implementation:
- handle_double_tap reads TouchPhase::Ended events. When
drag.active_touch_id is set and drag.committed is false, the touch
ended without crossing the drag threshold = pure tap. The top card ID
from drag.cards is used as the tracking key.
- DOUBLE_TAP_WINDOW = 0.5s (wider than DOUBLE_CLICK_WINDOW = 0.35s;
touch screens have higher input latency; pinned by a const-assert test).
- System is inserted between touch_follow_drag and touch_end_drag in
the .chain() so drag state is readable before touch_end_drag clears it.
- touch_end_drag's uncommitted-tap cleanup path still fires after
handle_double_tap — the drag.clear() + StateChangedEvent are
harmless in sequence with a MoveRequestEvent already queued.
1 new test (1283 total): double_tap_window_is_wider_than_double_click_window
(compile-time const assert).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the APK launch verification punch-list item. Three fixes in
202a64d boot the app on Pixel_7 AVD (Android 14, x86_64). Next open
arcs: Phase 8 (sync) or Android JNI follow-ups.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Android Studio created .idea/ when the project was opened during the
Android APK verification run. These are IDE-local and should not be
tracked; adding .gitignore entry and removing the accidentally-committed
files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three changes to get the APK past the NativeActivity launch crash:
1. Export `android_main` — NativeActivity dlopen-s libsolitaire_app.so
and calls `android_main` as its entry point. Without the symbol the
app crashed immediately with UnsatisfiedLinkError. The function sets
bevy::android::ANDROID_APP (required by WinitPlugin) then delegates
to the existing `run()`.
2. Gate `resize_constraints` to non-Android — on Android max_width and
max_height default to 0.0; Bevy's clamp panicked with min=800 > max=0.
3. Gate `apply_smart_default_window_size` to non-Android — the system
calls `.clamp(800.0, logical_w)` which panics when the window surface
reports zero dimensions during early Android lifecycle events. Window
sizing is OS-controlled on Android so the system is irrelevant there.
Verified: app boots on x86_64 Android 14 emulator (Pixel_7 AVD,
SwiftShader Vulkan), runs for 2+ minutes without crashing. Desktop
build: clippy clean, 1282 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire the long-dormant ReplayPrevButton / ReplaySelectorCaption /
ReplayNextButton / ReplaySelectorDetail spawn site that was missing
since v0.19.0. The click handler and repaint systems already existed;
this commit adds the actual UI nodes so players can step through all
stored replays (up to REPLAY_HISTORY_CAP) instead of always watching
the most recent win.
Also fix an assertion-on-constant clippy lint in the replay_overlay
dim-layer z-order test (const { assert!() } form required).
1282 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HighContrastBackground gains an optional hc_color field so sites can
specify a domain-specific HC variant rather than always bumping to
BORDER_SUBTLE_HC (gray). with_default() fills hc_color = BORDER_SUBTLE_HC
preserving all existing behaviour; new with_hc(default, hc) lets callers
specify both ends. update_high_contrast_backgrounds reads marker.hc_color
instead of the hardcoded constant.
STATE_SUCCESS_HC (#c8e862, L≈0.73) added to ui_theme — a brighter lime
that maintains the success hue while standing out from bumped notch
ticks (BORDER_SUBTLE_HC gray, L≈0.60) under HC mode.
WIN MOVE marker now carries HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC): lime stays lime under HC instead of turning gray.
Unit test pins both the default and hc color fields on the spawned marker.
1276 tests pass / 0 failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The three middle scrub-bar labels (25%, 50%, 75%) previously had their
left edge anchored at the notch percentage, making them read as
"starting after" the notch. Apply the CSS translateX(-50%) pattern for
Bevy 0.18 UI: give each middle label a fixed-width container
(SCRUB_LABEL_CENTER_WIDTH = 36px), offset the container's left edge by
-width/2 via margin.left, and add Justify::Center so the text renders
centred within the container. The container's centre then coincides with
the notch line at the chosen percentage.
Endpoints (0%, 100%) keep their flush-left / flush-right anchoring
unchanged. 1275 tests pass / 0 failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spawn a `ReplayTableauDimLayer` UI node (100% × 100%, 50% opacity black)
at z=54 (Z_REPLAY_OVERLAY − 1) whenever a replay starts. The dim layer
darkens the entire card world so the replay chrome (banner at z=55,
move-log panel at z=55) reads clearly against the scene without
obscuring card positions — matching the mockup's "Game Peek Band at
50% opacity" spec. 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 values.
The dim layer carries no Interaction component (purely visual; pointer
events pass through). Despawned alongside the banner and move-log panel
in `react_to_state_change` when the replay ends.
Adds Z_REPLAY_DIM (= 54) and TABLEAU_DIM_ALPHA (= 0.5) constants plus
two new tests: lifecycle (spawn/despawn mirrors floating chip pattern)
and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAY pinned).
1275 tests pass / 0 failing. Closes the last major B-2 sub-piece.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fold the six post-v0.21.5 commit narratives into CHANGELOG §
[0.21.6] (now the source of truth for that release's scope).
Reset the Since-cut log to "no threads in flight." Update
status (HEAD f63db76, tags through v0.21.6, tests 1273
passing). Resume prompt now anchors at v0.21.6.
The post-cut menu's main item is now the mini-tableau preview
— the only major B-2 sub-piece left after Move Log panel
shipped. Architectural change (touches card_plugin rendering),
best tackled in a fresh session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Patch release rolling up six post-v0.21.5 commits under the
through-line "Move Log panel + scrub-UX polish":
- d3cb1a5: HC-mode coverage for scrub track + notches
- 2e25476: continuous scrub on key-held ← / → at 100ms cadence
- d6f32d3: Move Log panel + active row (header + format helpers)
- 140251b: 2 prev rows above active
- e7345ae: active-row highlight with ACCENT_PRIMARY background
- 4437a1a: 2 next rows below active
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 multi-anchor replay UI pattern that the
remaining B-2 sub-piece (mini-tableau preview) will inherit.
Panel grows 56 → 84 → 112 px across the four move-log commits.
HighContrastBackground primitive lifted to ui_theme parallel
to HighContrastBorder; settings_plugin gains
update_high_contrast_backgrounds for the BackgroundColor
repaint cycle. Continuous scrub uses a per-key accumulator
resource (ReplayScrubKeyHold) gated on SCRUB_REPEAT_INTERVAL_SECS
(0.1s).
Tests: 1250 → 1273 (+23 net new). Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Symmetric to the prev-rows commit. Adds 2 about-to-apply move
rows below the active row so the panel now shows a full 5-row
window: prev offset 2 → prev offset 1 → active → next offset 1
→ next offset 2. Panel grows from 84 → 112 px to fit the
additional rows.
Format helper `format_kth_next_row(state, k)` returns the kth
about-to-apply move's text:
- k=1 → moves[cursor], displayed as "{cursor + 1} │ {body}"
- k=2 → moves[cursor + 1], displayed as "{cursor + 2} │ ..."
- Returns empty when cursor + k - 1 >= moves.len() (under-fill
late in the replay) or k=0 (degenerate).
Symmetric implementation:
- New `ReplayOverlayMoveLogNextRow { offset: u8 }` component
- Spawn loop iterates 1..=MOVE_LOG_NEXT_ROWS in order so offset
1 sits directly below active, offset 2 below that
- Per-frame `update_move_log_next_rows` system mirrors the
prev-rows updater
- TEXT_SECONDARY (matching prev rows) keeps the active row's
highlight as the focal point
For post-game replays the next rows aren't spoilers (the game
is already won). If a future use case reuses the panel during
live play, the preview-shape would need rethinking.
4 new tests:
- format_kth_next_row: k=1, 2 in-range cases + k beyond
moves.len() out-of-range + k=0 degenerate.
- move_log_next_rows_spawn_with_panel: cardinality matches
MOVE_LOG_NEXT_ROWS.
- move_log_next_rows_paint_helper_strings_at_spawn: text
matches helper output per offset.
- move_log_next_rows_underfill_at_replay_end: offset 1
populates at cursor=9/10, offset 2 stays empty.
Tests: 1269 → 1273. Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps the active-row Text in a Node with
BackgroundColor(ACCENT_PRIMARY) so the row reads as "current
focus" against the panel's elevated background. Inner Text
colour bumps from TEXT_PRIMARY (#d0d0d0) to TEXT_PRIMARY_HC
(#f5f5f5) for legible contrast against the brick-red highlight.
format_active_move_row now prefixes the row with `▶` (the focus
marker) so the visual hierarchy is reinforced even before the
background paints (HC mode, future palette tweaks). The empty
case still returns empty — cursor=0 doesn't paint a stray "▶ "
prefix on an otherwise-empty row.
Mirrors the mockup at docs/ui-mockups/replay-overlay-mobile.html
§ "Move Log Card" where the active row has bg-suit-red-cb
(brick-red equivalent) + dark text + the ▶ marker.
3 new tests:
- active_row_wrapper_carries_accent_primary_background: walks
from the active-row Text to its parent Node and asserts the
wrapper carries BackgroundColor(ACCENT_PRIMARY).
- active_row_text_uses_high_contrast_color_for_highlight: pins
the TextColor as TEXT_PRIMARY_HC.
- active_row_format_includes_focus_prefix: pure-helper guard for
the ▶ prefix + the cursor=0-stays-empty contract.
Plus 2 existing tests updated for the new prefixed format
(format_active_move_row_handles_cursor_zero_and_positive,
move_log_active_row_repaints_on_cursor_advance).
Tests: 1266 → 1269 (+3 net new, +2 updated). Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends the Move Log panel's single active-row to a 3-row recent-
history window: 2 prev rows showing the moves applied just before
the active one, then the active row. Display order top-to-bottom:
header → prev offset 2 (oldest) → prev offset 1 → active.
Panel grows from 56 → 84 px to fit the additional rows. Active
row keeps TEXT_PRIMARY; prev rows render in TEXT_SECONDARY so
the active row stands out from context rows even without an
explicit highlight. (Active-row highlight is a follow-up commit.)
The format helper generalises:
- New `format_kth_recent_row(state, k)` returns the text for the
kth-most-recently-applied move (k=1 is active, k=2 is row above,
etc.). Returns empty when k > cursor (early-replay under-fill)
or k = 0 (degenerate).
- `format_active_move_row` becomes a thin wrapper for k=1, kept
at module scope so call sites stay readable.
New `ReplayOverlayMoveLogPrevRow { offset: u8 }` component carries
the row's offset (1 = just-before-active, 2 = before that). Spawn
loop iterates `MOVE_LOG_PREV_ROWS..=1` in reverse so the highest-
offset (oldest) row sits topmost in the panel's flex column.
Per-frame `update_move_log_prev_rows` system reads each row's
offset, computes k = offset + 1, and repaints via
format_kth_recent_row. Empty-when-out-of-range means panels gracefully
under-fill at cursor=1 (only active populated) and cursor=2
(active + offset 1, offset 2 empty).
4 new tests:
- format_kth_recent_row: k=1, 2, 3 in-range cases + k>cursor
out-of-range + k=0 degenerate.
- move_log_prev_rows_spawn_with_panel: cardinality matches the
MOVE_LOG_PREV_ROWS const.
- move_log_prev_rows_paint_helper_strings_at_spawn: text matches
helper output per offset.
- move_log_prev_rows_repaint_on_cursor_advance: drives cursor=2
→ cursor=5 and asserts offset 1 / offset 2 texts follow.
Tests: 1262 → 1266. Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First slice of the move-log mockup at
docs/ui-mockups/replay-overlay-mobile.html § "Move Log Card".
Adds a separate root UI entity anchored to the viewport's bottom
edge (sibling-of-banner pattern, mirrors ReplayFloatingProgressChip
lifecycle) carrying a `▌ MOVE LOG · N/M` header plus a single row
showing the most-recently-applied move.
Subsequent commits in this multi-session arc add prev/next rows,
active-row highlight, and auto-scroll on cursor advance. Splitting
the work at "panel + active row only" lands the structural piece
(panel exists, lifecycle works, format helpers proven) before
tackling the harder questions about rendering un-applied future
moves and scrolling.
Position decision: bottom-of-viewport (matches mockup), separate
root entity from the 92 px top banner. Keeps the banner from
growing further into a top-heavy 170+ px strip; the
top-status + bottom-info paradigm reads as vim/IDE-style buffer
chrome that players intuitively scan.
Four pure helpers handle the formatting:
- format_pile(p) → lowercase, 1-indexed display string
("foundation 3" rather than enum's 0-indexed Foundation(2))
- format_move_body(m) → "{from} → {to}" or "stock cycle"
- format_move_log_header(state) → "▌ MOVE LOG · N/M",
"▌ MOVE LOG · COMPLETE" for `Completed`, empty for `Inactive`
- format_active_move_row(state) → "{cursor} │ {body}" with
1-based cursor for player display, empty at cursor=0
Two per-frame update systems (update_move_log_header,
update_move_log_active_row) repaint the texts on resource change
with the standard early-exit-on-no-change idiom.
Despawn handling: react_to_state_change gains a third query for
ReplayOverlayMoveLogPanel entities and despawns them on
Playing → Inactive alongside the banner root and floating chip.
Panel border carries HighContrastBorder so the 1 px top edge
bumps under HC mode — same pattern as the keybind footer.
8 new tests:
- format_pile pile-name + 1-index pinning
- format_move_body both-variant pinning
- format_move_log_header three-state coverage
- format_active_move_row cursor=0 vs cursor>0
- move_log_panel spawn cardinality (exactly one)
- move_log_panel header paints helper string at spawn
- move_log_active_row repaints on cursor advance
- move_log_panel despawn parity with overlay tree
Tests: 1254 → 1262. Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two carve-outs land on top of v0.21.5:
- d3cb1a5: HC-mode coverage for scrub track + notches via new
HighContrastBackground primitive in ui_theme + paint system
in settings_plugin.
- 2e25476: continuous scrub on key-held ← / → at 100ms cadence;
matches mockup's "[← →] scrub" terminology while keeping
single-press = single-step semantics.
Update Since-cut log, status (1250 → 1254 tests passing,
flake cleared), and next-step menu. B-2 keyboard accelerator
coverage + accessibility + scrub UX are all complete; remaining
options are notch-label centering polish (smallest), the
move-log/mini-tableau multi-session arcs that close B-2, or
WIN MOVE marker HC bump (optional).
Recommended next-step: notch label centering (small).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Holding ← or → now triggers continuous step at 100 ms cadence
(10 steps/sec) — matches the mockup's `[← →] scrub`
terminology while keeping single-press = single-step semantics.
Implementation: per-key accumulators in a new
`ReplayScrubKeyHold` resource. Each frame the key is held, the
corresponding accumulator absorbs `time.delta_secs()`; when it
exceeds `SCRUB_REPEAT_INTERVAL_SECS` (0.1s) the handler fires
another step and resets the accumulator. `just_pressed` events
bypass the accumulator entirely and fire immediately —
release resets to 0 so the next fresh press also fires
immediately rather than at half-interval.
Symmetric handling for ← (backwards step via undo) and →
(forward step). Both keys remain paused-only via the same
destructure-gate pattern in the underlying step helpers.
Footer text unchanged (`[← →] step`) — the only-wired-keybinds
discipline says "list what works"; held-key continuous scrub
is a discoverable enhancement to the same keybind, not a new
keybind.
`handle_arrow_keyboard` gains `Res<Time>` and
`ResMut<ReplayScrubKeyHold>` parameters. `Time` is provided by
MinimalPlugins's TimePlugin so headless tests already have it.
2 new tests (in addition to the 4 existing arrow scenarios):
- arrow_right_keyboard_repeats_while_held: drives time at
exactly SCRUB_REPEAT_INTERVAL_SECS per tick and asserts that
a second step fires after the just_pressed one.
- arrow_keyboard_release_resets_accumulator: verifies the
release branch zeros the per-key accumulator.
Tests: 1252 → 1254. Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 1 px scrub track and 5 quarter-mark notch ticks paint their
shape via BackgroundColor (not BorderColor — they're tiny
full-bleed Nodes, not borders on wider containers), so the
existing HighContrastBorder marker doesn't apply to them.
Add a parallel primitive in ui_theme: HighContrastBackground
marker carrying default_color, mirroring HighContrastBorder's
shape exactly. Add update_high_contrast_backgrounds system in
settings_plugin alongside update_high_contrast_borders — same
on/off rule (off → marker.default_color, on → BORDER_SUBTLE_HC),
same change-suppression idiom (only mutate when different so
Bevy's change-detection doesn't trigger per-frame repaints).
Tag the scrub track Node and all five notch Nodes with
HighContrastBackground::with_default(BORDER_SUBTLE) so the
existing settings repaint cycle picks them up under HC mode.
The scrub fill (ACCENT_PRIMARY brick-red) and WIN MOVE marker
(STATE_SUCCESS lime-green) don't get the marker — accent and
state colours are already saturated and don't need an HC
luminance variant.
2 new tests: spawn-time marker presence on the track and
cardinality-matches-notch-count on the ticks.
Tests: 1250 → 1252. Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fold the six post-v0.21.4 commit narratives into CHANGELOG §
[0.21.5] (now the source of truth for that release's scope).
Reset the Since-cut log to "no threads in flight." Update
status (HEAD `a2432df`, tags through v0.21.5, tests still
1250/1249 passing pending the time-dependent flake clearing).
Resume prompt now anchors at v0.21.5 with the smaller post-cut
menu of next-finite-steps.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Patch release rolling up six post-v0.21.4 commits under the
through-line "replay-overlay scrubbing affordances + accessibility":
- fe68861: quarter-mark scrub-bar notches
- d322abf: percentage labels under notches (banner 60 → 76 px)
- 1873b3f: keybind-hint footer (banner 76 → 92 px)
- 90e24d9: ESC accelerator + cross-plugin pause-modal gate
- 23902cd: HC-mode coverage for footer top border
- e5c4f51: ← / → keyboard accelerators for paused stepping
v0.21.4 shipped pause / resume / step + the WIN MOVE marker as
the first scrubbing-shaped additions; 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 are layout-changing — they grow the
banner from 60 → 76 → 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.
Tests: 1228 → 1250 (+22 net new), 1249 passing, 1 pre-existing
time-dependent flake (daily_challenge warning, fails when UTC
clock is within 30 min of midnight; verified not introduced by
this release).
Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two more post-v0.21.4 carve-outs land:
- 23902cd: HC-mode coverage for keybind-footer top border
(HighContrastBorder marker so apply_high_contrast_borders
bumps the 1 px top border under HC).
- e5c4f51: ← / → keyboard accelerators for paused stepping
(hooks game's undo system for backwards step; footer
extended to [SPACE] pause/resume · [ESC] stop · [← →] step).
Update Since-cut log, visual-identity bullet, B option in the
Resume menu, status (1244 → 1250 total tests / 1249 passing /
1 pre-existing flake), and HEAD hint.
Six post-v0.21.4 commits now form a coherent through-line:
replay-overlay scrubbing affordances + accessibility. Resume
menu's B option now recommends cutting v0.21.5 as the natural
next boundary.
Pre-existing flake noted: daily_challenge warning test fails
when wall-clock UTC is within 30 minutes of midnight (the
warning window the test asserts against). Verified not
introduced by recent commits via stash-and-retest.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
→ during a paused replay advances by one move (mirrors the Stop
button's existing forward-step semantics). ← decrements the
cursor and dispatches `UndoRequestEvent`, which the game's
`handle_undo` reads next frame to reverse its most-recent move
— hooking the existing undo system rather than replaying
forward from cursor 0 (every replay-applied move pushes to the
undo stack the same way a player move would, so undo is the
right reversal primitive).
Both accelerators are paused-only — backwards via a new
`step_backwards_replay_playback` in `replay_playback.rs` that
hard-gates with the same destructure pattern as
`step_replay_playback`. Pressing → during running playback or ←
at cursor 0 are silent no-ops; the player learns "pause first,
then arrow."
The mockup labels these `[← →] scrub` (continuous fast scan).
Single-move step is the closest behaviour shippable today —
continuous scrub would need either a key-held event source or
an internal speed-up loop. Footer hint reads
`[← →] step` to match what's wired rather than the aspirational
"scrub."
Footer hint extended in lockstep:
`[SPACE] pause/resume · [ESC] stop · [← →] step` — the
only-wired-keybinds discipline holds.
ReplayOverlayPlugin gains `add_message::<UndoRequestEvent>()`
defensively so the plugin can run under MinimalPlugins without
GamePlugin attached (idempotent registration; harmless when
GamePlugin is also present).
6 new tests (2 hint pins + 4 keyboard scenarios) + 1 helper-pin
update for the new hint string.
Pre-existing flake noted: `daily_challenge_plugin::tests::
check_system_fires_warning_event_only_once_per_day` is failing
because wall-clock UTC is currently within 30 minutes of
midnight, inside the daily-expiry warning window the test
asserts against. Verified pre-existing by stashing all changes
and re-running — failure persists. Same shape as the
`winnable_seed_search` flake the handoff documented earlier
this session: time-dependent, deterministically passes under
different clock conditions. Not introduced by this commit.
Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tag the footer's border-carrying Node with
`HighContrastBorder::with_default(BORDER_SUBTLE)` so the existing
`apply_high_contrast_borders` system bumps the 1 px top border
from `BORDER_SUBTLE` (#505050) to `BORDER_SUBTLE_HC` (#a0a0a0)
when `Settings::high_contrast_mode` is on.
Without this the footer reads as floating loose under HC because
the border that visually anchors it to the labels row above is
near-invisible at #505050 against the elevated banner background.
The footer's text colours (`TEXT_SECONDARY` on both the
mode-line and the hint) don't need an HC bump — `TEXT_SECONDARY`
is already at `#a0a0a0`, the same luminance as `BORDER_SUBTLE_HC`.
There's no `TEXT_SECONDARY_HC` constant in the palette because
secondary text is already at HC-border level by design.
The notch labels also use `TEXT_SECONDARY` and inherit the same
"already HC-bright" property — no marker needed there either.
The 1 px scrub track, notch ticks, and WIN MOVE marker render
via `BackgroundColor` (not `BorderColor`) so the
`HighContrastBorder` marker doesn't apply. HC coverage for those
decorative pieces would need a custom settings-aware paint
system (precedent: `radial_rim_outline` in `radial_menu`) and is
deferred to a follow-up commit.
1 new test pinning the marker on spawn. 1243 → 1244. Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Post-v0.21.4 fourth carve-out: 90e24d9 wires ESC for replay-stop
with a cross-plugin gate in pause_plugin to defer when replay is
playing. Footer extended in lockstep to
[SPACE] pause/resume · [ESC] stop. Update Since-cut log,
visual-identity bullet, B option in the Resume menu, status
(1240 → 1243 tests), and HEAD hint.
B option's next-step menu now has three branches: HC polish
(smallest), ← / → wiring (medium, needs backwards-step path),
and the multi-session move-log/preview arcs that close B-2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ESC during an active replay now stops it (mirrors the existing
Stop button click). UI-first contract from CLAUDE.md §3.3 holds
for the keyboard accelerator: every keybind the footer surfaces
points at a wired action.
Cross-plugin coordination: pause_plugin's `toggle_pause` already
listens for ESC and would otherwise open the pause modal on the
same press. Resolved by adding a fourth defer-if check to the
existing modal-stack pattern in `toggle_pause` —
`replay_state.is_some_and(|s| s.is_playing())` slots in right
after `other_modal_scrims` and before `selection`. Symmetric
shape to the existing forfeit / modal-scrim / selection /
game-over / drag gates.
Footer hint extended from `[SPACE] pause/resume` to
`[SPACE] pause/resume · [ESC] stop` in lockstep — the
"only-wired-keybinds" discipline holds.
3 new tests:
- esc_keyboard_stops_active_replay (positive: Esc → Inactive,
overlay despawns next frame)
- esc_keyboard_is_noop_when_not_playing (negative: doesn't fire
on Inactive state, lets global Esc listeners own those frames)
- keybind_footer_hint_lists_space_and_esc (footer text contains
both keybinds)
Plus updated helper-pin test for the new hint string. Existing
pause_plugin tests unaffected (they don't insert a
ReplayPlaybackState resource so the new gate is a no-op for
them).
Tests: 1240 → 1243 (+3). Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Post-v0.21.4 third carve-out: 1873b3f ships a keybind-hint footer
(vim-style mode line + `[SPACE] pause/resume`) at the bottom of
the banner (76 → 92 px). Update Since-cut log, visual-identity
bullet, B option in the Resume menu, status (1236 → 1240 tests),
and HEAD hint.
Footer lists only wired keybinds. Next finite step on B-2: wire
ESC for stop and extend the footer to `[SPACE] pause/resume ·
[ESC] stop` — small, single-axis, surfaces another keyboard
accelerator alongside the existing Stop button.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Vim-style mode line on the left (`▌ NORMAL │ replay`) plus a
keybind-hint on the right (`[SPACE] pause/resume`) gives the
existing Space accelerator a visible UI counterpart, satisfying
the UI-first contract from CLAUDE.md §3.3 for the keyboard
accelerator that v0.21.4 shipped.
The footer lists only keybinds that are *actually wired today*.
Future commits that wire ESC for stop or ← / → for prev/next
move will extend the right-hand text in lockstep — the footer
never lists aspirational keybinds (would lie to users).
Banner height grew from 76 → 92 px to make room for the 16 px
footer row. Second layout-changing commit in B-2's screen-
takeover arc; same "grow container, add flex-column child"
pattern as the notch-labels commit. 1px top border in
BORDER_SUBTLE separates the footer from the notch-label row.
Two pure helpers (`keybind_footer_mode_text`,
`keybind_footer_hint_text`) keep the static text testable
without per-text marker components on the inner Text entities.
The shared `font_handle_for_labels` clone covers both label and
footer text spawns since the labels closure only `.clone()`s
the handle (never moves it).
4 new tests: pure-helper guards, footer-spawn cardinality
(exactly one), text-set assertion (both helper strings appear as
descendants), lifecycle parity with the overlay tree.
Tests: 1236 → 1240 (+4). Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Post-v0.21.4 second carve-out: d322abf ships percentage labels
under each scrub-bar notch (banner 60 → 76 px — first real layout
change in B-2's arc). Update Since-cut log, visual-identity
bullet, B option in the Resume menu, status (1232 → 1236 tests),
and HEAD hint.
Banner geometry is now mutable; future B-2 sub-pieces follow the
same "grow container, add flex-column child" pattern. Next
finite step: keybind-hint footer (small) before the bigger
move-log / mini-tableau pieces.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 to pair with the notch ticks.
Pure helper `scrub_notch_labels()` returns the fixed array,
paired index-for-index with `scrub_notch_positions()`. Spawn loop
zips both helpers and applies an "endpoints flush, middle three
percent-anchored" positioning pattern: leftmost label gets
`left: 0` (no clip on `0%`), rightmost gets `right: 0` (no overflow
on `100%`), middle three anchor at `left: Val::Percent(p)` since
Bevy 0.18 UI lacks a clean CSS-style `translate-x: -50%` centering
primitive. The slight right-of-notch offset on the middle three
is visually subtle at TYPE_CAPTION; explicit polish target if
anyone notices.
Banner height grew from 60 → 76 px to make room for the label row
(76 = top row 59 flex-grow + scrub track 1 + label row 16). First
real layout change in B-2's screen-takeover arc — every prior
B-2 commit was additive at fixed banner geometry.
Label color is TEXT_SECONDARY rather than mockup's `text-outline`
(BORDER_SUBTLE) — the latter would match the notches but is too
low-contrast against BG_ELEVATED_HI to read at 12 px. TEXT_SECONDARY
keeps the subdued caption hierarchy while staying legible.
4 new tests: pure-helper guard pinning the array + helper-positions
pairing invariant, spawn cardinality, set equality between spawned
texts and helper output, lifecycle parity with the overlay tree.
Tests: 1232 → 1236 (+4). Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Post-v0.21.4 carve-out: fe68861 ships quarter-mark notches on the
scrub bar. Update Since-cut log, visual-identity bullet, B option
in the Resume menu, status (1228 → 1232 tests), and HEAD hint.
Next finite step on B-2: percentage labels under each notch —
forces banner height to grow from 60 px to ~76 px, making it the
first real layout change in the screen-takeover arc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five 1px vertical ticks at 0/25/50/75/100% give the player visual
anchor points for "where am I, relative to the quarter-marks of the
replay" without needing to mentally bisect the bar.
Pure helper `scrub_notch_positions()` returns the fixed array; the
spawn loop sits next to the WIN MOVE marker spawn so the two
overlays share their lifecycle with the rest of the overlay tree.
Notches paint in BORDER_SUBTLE (same as the unfilled track) and
extend vertically past the 1px track (5px tall, anchored 2px above
the track top) — same visibility 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.
Mirrors the notch ladder in the screen-takeover mockup at
docs/ui-mockups/replay-overlay-mobile.html. First finite step toward
B-2's screen-takeover layout reflow; labels under each notch land in
a follow-up commit when the banner height grows to accommodate them.
4 new tests: pure-helper guard pinning the [0,25,50,75,100] array,
spawn-cardinality matching helper.len(), lifecycle parity with the
overlay tree, independence from win_move_index.
Tests: 1228 → 1232 (+4). Clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Anchors handoff to v0.21.4 at `23ff62c`, resets the "Since the cut"
section to placeholder, updates the READ FIRST CHANGELOG pointer,
bumps the Resume-prompt summary to reflect replay-scrubbing
accessibility as the v0.21.4 through-line, and identifies the
screen-takeover layout reflow as the remaining multi-session arc
on B (with move-log scroller + mini-tableau preview as small
sub-pieces inside it).
Resume menu stays at A/B/C — A and C unchanged; B's prerequisite
sub-pieces shipped in v0.21.4 so the entry now points cleanly at
the layout reflow as the single remaining multi-session piece.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Patch release for the three post-v0.21.3 commits on the B-2 replay
screen-takeover redesign arc. One through-line: the replay overlay
gains scrubbing affordances. The player can see at a glance where
the winning move sits (WIN MOVE marker on the scrub bar) and stop
on any move to inspect the board (pause / resume / step controls
plus a Space keyboard accelerator).
Also adds the data foundation that makes the marker possible:
`Replay::win_move_index: Option<usize>`, an additive serde-default
field that doesn't bump `REPLAY_SCHEMA_VERSION` because legacy
on-disk replays load with `None` and simply don't get a marker.
Remaining B-2 work — screen-takeover layout, move-log scroller,
mini-tableau preview — shares a layout-reflow prerequisite the
banner-only overlay can't carry, so it's deferred to a future
cycle that can take it as a single multi-session arc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures `fbe48ac` (pause / resume / step + Space accelerator) under
"Since the v0.21.3 cut", marks playback controls closed in the
Visual-identity follow-ups list, identifies the screen-takeover
layout itself (with move-log scroller + mini-tableau preview as its
sub-pieces) as the next finite step on B, and bumps the test count
to 1228.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third commit on the B-2 replay screen-takeover redesign. Adds the
ability to pause an in-flight replay, step through it one move at
a time while paused, and resume — both via on-screen buttons
(UI-first contract per CLAUDE.md §3.3) and the optional `Space`
keyboard accelerator.
State shape: a new `paused: bool` field on
`ReplayPlaybackState::Playing`. The `tick_replay_playback` system
skips the `secs_to_next` decrement entirely while `paused` is set
so cursor and timer freeze together — resuming starts the next
move from a full interval. Stepping fires the next move directly
via a new `step_replay_playback` API that bypasses the tick path
and is hard-gated to `Playing { paused: true }` so it can't race
the running tick loop.
Public API additions:
- `toggle_pause_replay_playback(state)` — flips the flag, returns
the new value (or None when not Playing).
- `step_replay_playback(state, moves_writer, draws_writer)` —
advances exactly one move when paused; returns true on dispatch,
false on any guard miss.
UI:
- Pause / Resume button next to Stop. Label repaints reactively
via `update_pause_button_label`, which walks `Children` from
the marked button to its inner `Text` so the spawn path doesn't
need a second marker.
- Step button next to Pause. Click fires the next move; while
unpaused the click is a no-op (guarded inside
`step_replay_playback`).
- `Space` keyboard handler reads `Option<Res<ButtonInput>>` and
no-ops when missing — keeps test-app compatibility under
`MinimalPlugins`.
Test coverage: pause-button label truth table, label repaint on
state change, click-toggles-paused, step advances cursor exactly
one with paused flag preserved, step-while-running is no-op,
Space toggles paused flag. 8 new tests (1220 → 1228).
Side-effect: 25 existing `Playing { ... }` construction sites
across `replay_overlay`, `achievement_plugin`, and
`replay_playback` tests gained `paused: false` to satisfy the new
field requirement. Mechanical edit; no behavioral change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures `52befa6` (WIN MOVE marker on the scrub bar) under "Since
the v0.21.3 cut", marks the marker piece of B-2 closed in the
Visual-identity follow-ups list, identifies playback controls
(play/pause/step) as the next bounded commit on B, and bumps the
test count to 1220.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Second commit on the B-2 replay screen-takeover redesign — the UI
that consumes the data field landed in `ab857bb`. Adds a small
green tick on the scrub bar at `replay.win_move_index / total`,
positioned so the playback cursor reaches the marker exactly when
the move it's about to apply IS the winning move.
Implementation: a new `ReplayOverlayWinMoveMarker` component
spawned alongside `ReplayOverlayScrubFill` as a sibling under the
1px scrub track. Position computed by a pure helper
`win_move_marker_pct` that returns `None` for any of: state not
`Playing`, replay's `win_move_index` is `None` (older replay
loaded from disk pre-dating the field), or empty move list. The
percentage is clamped to `[0, 100]` defensively. Marker is
absolute-positioned with `top: -1px` so the 3px-tall tick is
centered on the 1px track line — 1px above and 1px below.
Lifecycle is "spawn-time only" — the marker position never changes
during a single playback because the underlying replay is
immutable while `Playing`. Despawned with the rest of the overlay
tree when the state returns to `Inactive`.
8 new tests cover: pure helper for Inactive / Completed / no-field /
correct-position / clamp; spawn presence with field; spawn absence
without field; despawn-with-overlay lifecycle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures `ab857bb` (Replay::win_move_index data field) under "Since
the v0.21.3 cut". Updates the Visual-identity follow-up entry for
B-2 to flag the data-layer prerequisite as landed and identifies
the WIN MOVE scrub-bar marker UI as the natural next finite commit.
Bumps test count to 1212.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First finite step toward the B-2 replay screen-takeover redesign:
the data foundation. Adds an additive optional `win_move_index:
Option<usize>` field on `Replay`, defaulting to `None` via
`#[serde(default)]` so older `latest_replay.json` /
`replays.json` files load unchanged — no `REPLAY_SCHEMA_VERSION`
bump needed since the field is purely additive and nullable.
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 the index
explicitly lets the playback UI read the WIN MOVE position
directly without re-deriving it on every render — and leaves
room for future recording semantics that capture post-win state.
UI consumption (the WIN MOVE marker on the scrub bar, plus the
broader screen-takeover redesign — move-log scroller, mini-
tableau preview, playback controls) lands in subsequent commits.
Test coverage: default value, builder set / set-None, on-disk
round-trip, and the legacy-JSON-loads-with-None backward-compat
contract (the test that pins the no-schema-bump claim).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Anchors handoff to v0.21.3 at `3d92a91`, resets the "Since the cut"
section to placeholder, updates the READ FIRST CHANGELOG pointer,
and bumps the Resume-prompt summary to reflect the accessibility
arc closure as the v0.21.3 through-line. Resume menu stays at
A/B/C since v0.21.3 closes only post-v0.21.2 carve-outs (the
remaining options were already heavy / multi-session).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Patch release for the two post-v0.21.2 commits. One through-line:
the v0.21.2 "dynamic-paint sites stay un-tagged" carve-out turned
out to be over-cautious — re-reading the code showed only the
radial rim was actually a border-paint cycle. v0.21.3 closes the
carve-out: HUD action buttons + modal buttons take the existing
`HighContrastBorder` marker pattern; the radial rim folds HC into
its per-frame respawn via `radial_rim_outline`.
Bonus: `ToastVariant::Warning` gets its first real consumer in
this cycle (daily-challenge expiry < 30 min from UTC reset). Every
`ToastVariant` now has at least one driver — the enum is fully
load-bearing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Marks the HC dynamic-paint rollout (`c153363`) closed under the
High-contrast accessibility entry, captures it in "Since the v0.21.2
cut", bumps the test count to 1207, and trims the Resume prompt
menu from 4 → 3 options (A Android, B replay screen-takeover,
C Phase 8 sync). All three remaining options are multi-session by
nature; the resume prompt now flags that explicitly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the v0.21.2 carve-out: dynamic-paint sites that were left
un-tagged because their paint cycles were assumed to race
`update_high_contrast_borders`. Re-reading the code revealed only
one of three sites is actually a border-paint cycle — the other
two paint backgrounds, with static borders that take the marker
pattern cleanly:
* HUD action buttons (`spawn_action_button`): `paint_action_buttons`
only mutates `BackgroundColor`. Tag the spawn with
`HighContrastBorder::with_default(BORDER_SUBTLE)`.
* Modal buttons (`spawn_modal_button`): `paint_modal_buttons` also
only mutates `BackgroundColor`. Same marker pattern.
* Radial menu rim (`radial_redraw_overlay`): full despawn-respawn
every frame; sprites, not UI nodes; the marker can't apply. Folds
the HC choice into the spawn site instead — under HC the
*focused* rim boosts to `BORDER_SUBTLE_HC` rather than
`BORDER_STRONG`. Naive marker substitution would invert the
visual hierarchy because `BORDER_SUBTLE_HC` (#a0a0a0) is lighter
than `BORDER_STRONG` (#505050); folding the choice in keeps the
focused rim *more* visible under HC, not less.
Decision logic for the rim is extracted to `radial_rim_outline` —
a pure function with a 4-row truth-table test (focused × HC).
After this commit, every UI surface tagged in v0.21.x's
accessibility arc either carries `HighContrastBorder` or has its
HC behaviour folded into its own spawn cycle. No "un-tagged
because race-risk" surfaces remain.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Marks the daily-challenge-expiry Warning toast (`279e23d`) closed in
the Visual-identity follow-ups list, captures it in "Since the
v0.21.2 cut", bumps the test count to 1203, and trims the Resume
prompt menu from 5 → 4 options (A Android, B-2 replay takeover,
C Phase 8 sync, D HC dynamic-paint).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the first in-engine consumer of `ToastVariant::Warning` — a 4s
amber-bordered toast that fires once per daily-challenge date when the
player is within 30 minutes of UTC midnight reset and hasn't yet
completed today's challenge.
Mirrors the v0.21.2 `ToastVariant::Error` wiring: a domain-event
message (`WarningToastEvent(String)`) crosses the plugin boundary;
`animation_plugin::handle_warning_toast` reads it and spawns the
fire-and-forget toast. Suppression is decided by a pure helper
(`compute_expiry_warning_minutes`) that's exhaustively covered by 7
unit tests + 1 in-Bevy idempotence test.
After this lands, every `ToastVariant` (Info, Warning, Error,
Celebration) has at least one real driver — closing the "is this enum
scaffolding or load-bearing?" ambiguity that's been latent since the
variant was introduced.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the post-v0.21.0 → v0.21.1 → v0.21.2 cut-then-refresh
pattern. Cut commit (f23df3b) edited only CHANGELOG; this
follow-up resets the handoff so a fresh session picks up cleanly
post-v0.21.2.
Updated:
- Header points to v0.21.2 at f23df3b; opening paragraph
summarizes the patch's three threads (accessibility
extensions, replay polish, first real Toast Error consumer).
- Status at pause: tests bumped to 1195 (net +3 from v0.21.1's
1192); tags list extended through v0.21.2.
- "Since the v0.21.1 cut" → "Since the v0.21.2 cut" with the
closure narratives dropped (now in CHANGELOG.md § [0.21.2]).
Section reset to "no threads in flight" placeholder.
- Visual-identity follow-ups: marked floating MOVE chip closed
by v0.21.2 (`2fb2d63`), Toast Error closed by v0.21.2
(`68d50b5`); HC + reduce-motion entries updated to reflect
v0.21.2's HC chrome rollout (8 surfaces) and splash
reduce-motion gating. Toast Warning still open with a
candidate driver suggestion (daily-challenge expiry).
- Resume prompt menu retuned: A (Android) and D (Phase 8)
unchanged; B narrowed to just the screen-takeover redesign
(the floating chip piece shipped); C narrowed to just
Warning variant (Error done); new E added for
HC+reduce-motion on dynamic-paint sites (HUD action buttons,
etc — explicitly carved out of the v0.21.2 HC rollout
because of paint-cycle races).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Promotes [Unreleased] to [0.21.2] dated 2026-05-08 and opens a
fresh empty [Unreleased]. Patch release covering 6 substantive
post-v0.21.1 commits (plus the v0.21.1 handoff refresh).
Three through-lines:
- **Accessibility extensions.** Closes the two threads v0.21.1
left explicitly open. Reduce-motion was previously gated only
on card slide_secs; v0.21.2 extends it to splash scanline +
cursor pulse (`ed152e2`). HC borders had `BORDER_SUBTLE_HC`
defined but no consumers; v0.21.2 builds the
`HighContrastBorder` marker + `update_high_contrast_borders`
system (`c9af1ea`) and rolls it out across 8 surfaces
(`d87761d` + `ec804d5`).
- **Replay polish.** New floating MOVE chip rendered above the
destination pile of the most-recently-applied move during
playback (`2fb2d63`). World-space `Text2d` entity that
reuses the same `LayoutResource` pile coordinates as every
other piece of pile geometry — stays correctly positioned
through window resizes without any UI / camera math.
- **First real `ToastVariant::Error` consumer.** Wires
`MoveRejectedEvent` to a 2-second pink-bordered "Invalid move"
toast (`68d50b5`). Joins the existing `card_invalid.wav`
audio + destination-pile shake visual as the
accessibility-focused readable text channel.
cargo clippy --workspace --all-targets -- -D warnings clean.
1195 passing / 0 failing (net +3 from v0.21.1's 1192).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resume-prompt Option C — first in-engine consumer of
`ToastVariant::Error`. The variant has had a slot in the enum
since v0.20.0's toast system landed; this commit wires a real
driver event so the slot is no longer dead code.
### Driver: MoveRejectedEvent
When a player tries an illegal placement (drops dragged cards on
a real pile but the move violates the rules), `MoveRejectedEvent`
fires. The existing rejection-feedback chain plays
`card_invalid.wav` (audio cue) and triggers the destination-pile
shake (visual cue via `feedback_anim_plugin`). This commit adds a
third leg — a 2-second pink-bordered Error toast reading
"Invalid move" — primarily for accessibility:
- **Audio cue alone** doesn't help deaf players.
- **Visual shake alone** is brief and easy to miss for low-vision
players or anyone with reduce-motion enabled (which gates the
shake's animation timing).
- **Toast text** is persistent ~2 s, readable, and unambiguous.
The three legs together cover the major perception channels.
### Implementation
New `handle_move_rejected_toast` system in `animation_plugin`
mirrors the shape of `handle_xp_awarded_toast` — read events,
fire `spawn_toast(commands, "Invalid move", 2.0,
ToastVariant::Error)`. Registered in the plugin's Update set
between `handle_xp_awarded_toast` and `tick_toasts` so the toast
spawn pipeline picks it up the same frame the event fires.
`AnimationPlugin::build` gains
`.add_message::<MoveRejectedEvent>()` so the message is
initialized when the plugin runs under MinimalPlugins (tests).
The message is also registered by `feedback_anim_plugin` —
Bevy's `add_message` is idempotent, so both registrations
coexist cleanly.
Also drops the `#[allow(dead_code)]` from `ToastVariant::Error`
(stale now that the variant has a real consumer) and updates the
variant's doc comment to point at `handle_move_rejected_toast`.
### Test
New `move_rejected_event_spawns_error_toast` pins the wiring:
firing a `MoveRejectedEvent` spawns exactly one `ToastOverlay`
on the next tick. Matches the shape of the existing
`info_toast_event_spawns_toast_overlay` test. 1195 passing
(+1 from prior 1194).
Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Continues the rollout from `c9af1ea` (modal scaffold) and
`d87761d` (tooltip + 3 panels). Tags the remaining 7 static-
border surfaces in the chrome so the HC chrome thread is
effectively complete:
- **`home_plugin.rs` × 3**: the home-screen Level/XP/Score
summary row (line 842), the home-screen mode-selector
buttons (line 945), the home-screen mode-hotkey chips
(line 1158).
- **`settings_plugin.rs` × 4**: the card-back picker swatches
(line 1952), the theme picker swatches (line 2093), the
Sync Now button (line 2214), and the swatch glyph buttons
(line 2274).
Pre-tagging audit: confirmed none of these sites have a
dynamic-paint system that would race the
`update_high_contrast_borders` system. `paint_action_buttons`
in `hud_plugin.rs` only paints entities tagged with the
`ActionButton` marker (HUD buttons only). The focus-overlay
system in `ui_focus.rs` spawns *separate* overlay entities for
focus indication, never mutating the original `BorderColor`.
Settings panel buttons / swatches use their own
`SettingsButton` enum for click routing; their `BorderColor`
is set at spawn time and not touched again.
After this commit, every `BorderColor::all(BORDER_SUBTLE)` site
in the chrome (excluding the dynamic-paint sites that are
intentionally skipped — HUD action buttons, modal buttons,
radial menu rim) carries a `HighContrastBorder` marker. The
HC thread for chrome borders is closed; the dynamic-paint
sites remain open for a future iteration that needs a
different shape (folding HC into the dynamic-paint logic, or
having HC consult hover/focus state).
1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level lifecycle of `HighContrastBorder`
was already covered by the modal-scaffold scaffolding in
`c9af1ea`). Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Continues the HC chrome rollout started by `c9af1ea` (which wired
just the modal scaffold). Tags four more static-border surfaces
so they boost to `BORDER_SUBTLE_HC` (#a0a0a0) when high-contrast
mode is on:
- **Tooltip** (`ui_tooltip.rs:191`). The hover-revealed caption
popup. Border legibility matters because tooltips are usually
brief — if the player has to squint to find the panel edge,
the tooltip dismisses before they've parsed it.
- **Onboarding banner key chips** (`onboarding_plugin.rs:388`).
The first-run UI's "press H or ?" key chips. First-run
onboarding has the highest stakes for accessibility — a
low-vision player who can't see the chips can't discover
the help system.
- **Help panel key chips** (`help_plugin.rs:265`). Same
treatment as the onboarding chips: keyboard-shortcut chips
inside the F1 cheat sheet.
- **Stats panel cells** (`stats_plugin.rs:1019`). The S-key
overlay's individual stat cells. A dense grid of bordered
numbers is exactly the kind of surface where HC's
`#505050 → #a0a0a0` boost makes the layout legible.
Each tagging is one line on the spawn tuple plus an import. The
existing `update_high_contrast_borders` system in
`settings_plugin` (added in `c9af1ea`) handles all tagged
entities uniformly — no system changes needed.
### Skipped on this pass
Sites with dynamic hover/focus paint systems (HUD action
buttons, modal buttons, radial menu rim) intentionally not
tagged because their existing paint cycles would race the HC
system. Wiring HC into those needs a different shape — either
fold HC into the dynamic-paint logic, or have HC consult the
hover/focus state. Future scope.
Other HC-tagging candidates (`home_plugin.rs:842/945/1158` home
menu element borders, `settings_plugin.rs:1952/2093/2214/2274`
settings panel rows) are likely fine to tag but I'm capping
this commit at four to keep it reviewable. Pattern is
established; future commits can extend.
1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level test in `c9af1ea`'s scaffolding
covers all tagged entities uniformly). Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resume-prompt Option B (smaller scope variant) — closes the
"floating MOVE chip" piece flagged as future scope in v0.21.1's
replay-overlay punch list. Leaves the multi-session screen-
takeover redesign for a future B-2.
The existing banner-anchored MOVE chip stays put — it provides
the at-a-glance overview. The new floating chip mirrors the same
text but renders above the destination pile of the most-recently-
applied move, keeping progress at the player's focal point so they
don't have to look up at the banner during fast-paced playback.
### Architecture
- New `ReplayFloatingProgressChip` marker component on a
`Text2d` entity rendered in 2D world space. World-space
placement (rather than UI-space + camera projection) keeps
the math trivial — the chip uses the same `LayoutResource`
pile coordinates that drive every other piece of pile
geometry, so it stays correctly positioned through window
resizes without any extra wiring.
- Lifecycle matches the banner overlay: `spawn_overlay` spawns
the chip alongside the banner when a replay starts;
`react_to_state_change` despawns it when the replay ends.
The chip lives outside the UI tree (because it's world-space)
so the despawn needs its own query — added a second
`Query<Entity, With<ReplayFloatingProgressChip>>` parameter.
- Z = 100 keeps the chip above every card stack
(Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular tableau
cards stack to the low double digits at most).
### Position + visibility logic
`update_floating_progress_chip` runs each Update tick:
- Resolves the destination pile of the last-applied move
(`replay.moves[cursor - 1]`'s `to`).
- Hides the chip when `cursor == 0` (no moves applied yet —
nowhere meaningful to land) or when the last move was a
`StockClick` (no destination pile, and stock-click feedback
already lives at the stock pile — letting the chip jitter
back to the stock every cycle would be visual noise).
- Otherwise positions the chip at `pile_position + (0,
card_size.y * 0.6)` — half a card lifts above the pile
centre, the extra 10 % is breathing room above the card's
top edge so the chip doesn't visually clip.
- Updates the chip text via `format_progress(&state)` —
shares the same MOVE N/M format with the banner chip.
### Test
New `floating_chip_spawns_and_despawns_with_overlay` pins the
lifecycle: chip absent on Inactive, exactly one chip on Playing,
absent again on return to Inactive. Position correctness needs
`LayoutResource` (which the headless fixture doesn't set up);
covered via running-game verification rather than a unit test —
the system's gate logic is small enough that pixel positioning
isn't load-bearing on a test.
1194 passing (+1 from prior 1193). Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resume-prompt Option E, part 2 of 2 — HC chrome borders. Pairs
with the reduce-motion gating in `ed152e2`.
v0.21.1 introduced `BORDER_SUBTLE_HC` (#a0a0a0) but never wired
it: the constant existed, no consumer used it. Spec at
`design-system.md` §Accessibility (#2) mandates outline boost
from `#505050` (BORDER_STRONG) to `#a0a0a0` under high-contrast
mode so panels and popovers stay legible on low-quality
displays.
### Architecture
- New `HighContrastBorder` component in `ui_theme` carrying a
`default_color: Color` field that records the off-state colour
the entity was spawned with. Tag any UI node where border
legibility is accessibility-critical.
- New `update_high_contrast_borders` system in `settings_plugin`
walks all tagged entities each Update tick, sets `BorderColor`
to `BORDER_SUBTLE_HC` when `Settings::high_contrast_mode` is
on, otherwise to `marker.default_color`. Compares against
current `BorderColor` and only mutates when different so
Bevy's change-detection doesn't trigger repaints every frame.
### Tagged in this commit
- The modal scaffold's card border (`ui_modal::spawn_modal`).
This is the primary accessibility target — modals demand
attention and a low-vision player needs to perceive the panel
boundary. Default colour: `BORDER_STRONG` (#505050); HC
variant: `BORDER_SUBTLE_HC` (#a0a0a0).
### Future scope
Other `BORDER_SUBTLE` / `BORDER_STRONG` consumer sites (help
panel, stats panel, tooltip, action buttons, settings rows,
etc.) can be tagged in follow-ups by adding
`HighContrastBorder::with_default(...)` to their spawn tuple.
The system handles any entity carrying the marker — no further
changes needed once a site is tagged. Started small here to
keep the commit reviewable and prove the architecture before
rolling out broadly.
Workspace clippy + cargo test --workspace clean. 1193 passing
(unchanged from prior — no new tests added; the system is
small enough that the running-game verification is the meaningful
check).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resume-prompt Option E, part 1 of 2 (the reduce-motion piece;
HC chrome borders follow in a separate commit).
v0.21.1 wired `Settings::reduce_motion_mode` through
`effective_slide_secs` so cards snap instead of sliding under
reduce-motion. The design-system spec at §Accessibility (#3)
calls out two more sources of non-essential motion that
reduce-motion should suppress: the splash CRT scanline effect
and the splash cursor pulse. This commit gates both.
### Splash cursor pulse (`pulse_splash_cursor`)
Previously sine-pulsed every frame regardless of settings. Now
reads `Settings::reduce_motion_mode` and skips the pulse
multiplier when on — the cursor still fades in / out with the
global splash alpha (essential timing), but doesn't blink
(decorative motion). The fade is preserved on purpose: skipping
it would hard-cut the splash on/off, which is jarring; the spec
specifically calls out *non-essential* motion as the reduce-
motion target, and a decorative blink is more clearly
non-essential than a fade timeline.
### Splash scanline overlay (`spawn_splash`)
Previously generated and spawned unconditionally when
`Assets<Image>` was available. Now skipped entirely when
reduce-motion is on — without the scanline overlay the boot
screen still reads as terminal-themed (foreground content,
borders, palette swatches all unchanged); the scanlines are
purely decorative.
### Test
New `splash_skips_scanline_overlay_under_reduce_motion` pins
the gate behaviour: under `reduce_motion_mode = true`, the
splash root still spawns (essential motion intact) but the
`SplashScanlineOverlay` entity is absent. 1193 passing
(+1 from prior 1192).
Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the post-v0.20.0 → v0.21.0 → v0.21.1 cut-then-refresh
pattern. Cut commit (daa655a) edited only CHANGELOG; this
follow-up resets the handoff so a fresh session picks up cleanly
post-v0.21.1.
Updated:
- Last-updated header points to v0.21.1 at daa655a; opening
paragraph summarizes the patch's three threads (icon,
accessibility, card-visual iteration with two bug fixes).
- Status at pause: tests bumped to 1192 (net +8 from
v0.21.0's 1184); tags list extended through v0.21.1.
- "Since the v0.21.0 cut" → "Since the v0.21.1 cut" with the
closure narratives dropped (now in CHANGELOG.md § [0.21.1]).
Section reset to "no threads in flight" placeholder so
future post-cut work has a clean starting point.
- Resume prompt menu trimmed: A and F closure entries dropped
(preserved in CHANGELOG); remaining options renumbered A-E
with the v0.21.1 closure callouts inline. New option E
added: "extend HC through chrome borders + reduce-motion to
splash/warning-chip" — both small finite items that v0.21.1
flagged as future scope.
- Workflow notes gain the doc-vs-implementation-drift pattern
observation from the pile-marker fix: when a module's
top-level doc comment claims "X happens" but no code enforces
it, the gap is invisible until a player notices the missing
behaviour. Worth checking such claims and adding tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:59:24 -07:00
612 changed files with 60540 additions and 35379 deletions
"query":"UPDATE leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
"query":"SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC",
"query":"SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
> core card/pile types come from the upstream `card_game` workspace and
> score/undo/recycle are derived from the upstream session, not stored.
---
@@ -34,7 +36,7 @@
## 1. Project Overview
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
### Sync Backend by Platform
@@ -43,6 +45,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
| macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set |
| Linux | Self-hosted server | Full feature set |
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
### Design Principles
@@ -57,7 +60,7 @@ Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enf
## 2. Workspace Structure
```
solitaire_quest/
ferrous_solitaire/
│
├── Cargo.toml # Workspace manifest
├── .env.example # Server environment variable template
@@ -81,12 +84,15 @@ solitaire_quest/
│ ├── win_fanfare.wav
│ └── ambient_loop.wav
│
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
├── solitaire_sync/ # Shared API types — used by client and server
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build` (`build_wasm.sh`); the output lands in `solitaire_server/web/pkg/` — gitignored, built in CI (Docker `wasm-builder` stage, web-e2e workflow) — and is served statically by the server.
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
Owns:
-`ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
-`StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
-`ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
### `solitaire_app`
**Dependencies:** `bevy`, `solitaire_engine`, `solitaire_data` (+ `jni` on Android).
Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
plugins and sets initial window properties. The one crate in the workspace
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
hands them to the safe `solitaire_data::android_jni` bridge; everything else
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
### Web Replay Player
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output — gitignored, produced by `build_wasm.sh` / CI) |
### Account Management
| Method | Path | Auth | Body | Response |
@@ -825,7 +953,7 @@ All sound effect WAV files are embedded at compile time via `include_bytes!()` i
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
@@ -945,6 +1073,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
| Token expiry | Access: 24h, Refresh: 30d |
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
| Payload abuse | 1MB max request body, enforced by Axum middleware |
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
rules:
* id: single_source_of_truth
description: "GameStateResource is the only mutable game state in runtime"
* id: sync_is_additive
description: "Remote data must never destructively overwrite local data"
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and
@@ -31,6 +31,23 @@ optional self-hosted sync so your stats follow you across machines.
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
glyph
## Android Install
### Obtainium (recommended — automatic updates)
1. Install [Obtainium](https://github.com/ImranR98/Obtainium/releases) on your device
2. Tap the badge below on your Android device — the source type is pre-configured, no manual selection needed:
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="40">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.ferrousapp.solitaire%22%2C%22url%22%3A%22https%3A//git.aleshym.co/funman300/Ferrous-Solitaire%22%2C%22author%22%3A%22funman300%22%2C%22name%22%3A%22Ferrous%20Solitaire%22%2C%22installedVersion%22%3Anull%2C%22latestVersion%22%3Anull%2C%22apkUrls%22%3A%22%5B%5D%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%7D%22%2C%22lastUpdateCheck%22%3Anull%2C%22pinned%22%3Afalse%2C%22categories%22%3A%5B%5D%2C%22releaseDate%22%3Anull%2C%22changeLog%22%3Anull%2C%22overrideSource%22%3A%22Codeberg%22%2C%22allowIdChange%22%3Afalse%2C%22otherAssetUrls%22%3A%22%5B%5D%22%7D)
3. Tap **Install** to download the current release — Obtainium will notify you when updates are available.
### Direct APK
Download the latest `ferrous-solitaire.apk` from the
| #106 | **fix(engine):** Draw-Three waste fan hit-test now shares the renderer's fan step (`card_plugin::waste_fan_step` / `tableau_col_step`). The two had diverged under Android's tighter column spacing (`H_GAP_DIVISOR=32`), shifting the top fanned waste card's click target onto the card beneath it — so dragging the visible top card played the wrong one. Desktop/web were unaffected (the formulas already coincided there). |
| #105 | **test(engine):** waste-card draggability regression tests (`find_draggable_at` picks the waste top with multiple cards and as a lone card). |
| #108 | **docs(android):** NDK reference updated `26.3.11579264` → `30.0.14904198`; noted versions are not load-bearing and `build_android_apk.sh` auto-discovers the newest NDK/build-tools. |
Pre-release validation performed locally this session: workspace clippy/test/build
gates; `aarch64-linux-android` cross-compile + clippy clean (covers the
`#[cfg(target_os = "android")]` paths that host CI never lints); release manifest
sanity (`solitaire_app/android/AndroidManifest.xml` has no version fields so CI
injection works; `lib_name` matches `[lib].name`); and a full signed local APK
proving the `build_android_apk.sh` packaging pipeline end-to-end.
---
## What shipped since v0.39.0
- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
- `5e87358` integrates upstream deps cleanly.
- `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
- `d864d98` routes klondike/card imports through `solitaire_core`.
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
- Native analytics and Android physical-device validation now have runbooks in
`docs/analytics-validation.md` and `docs/ANDROID.md`.
---
## Historical notes before v0.39.0
See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.
---
## What shipped since the last handoff (v0.23.0 → v0.35.1)
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.