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>
Promotes the [Unreleased] section to [0.21.1] dated 2026-05-08
and opens a fresh empty [Unreleased]. Patch release covering the
10 post-v0.21.0 commits.
Two Resume-prompt options closed:
- A — App icon. Runtime Window::icon wired via WinitWindows on
desktop (target-gated to non-Android since Android draws its
launcher icon from the APK manifest); 9-size PNG hierarchy at
assets/icon/ generated by a new icon_generator example from a
shared icon_svg builder. The follow-up `716a025` wraps
NonSend<WinitWindows> in Option<...> to satisfy Bevy 0.18's
stricter system-param validation.
- F — High-contrast and reduce-motion accessibility modes.
Settings flags wired through the engine + Settings panel UI
toggles. CBM and HC compose; reduce-motion forces card slide
duration to 0 regardless of AnimSpeed.
Card-visual iteration cycle moved through three states: v0.21.0
Terminal pink/gray → 4-colour-deck experiment (`62b61cc`) →
traditional 2-colour reversion at player request (`ddb6540`,
saturated red + near-white). Two visible bugs surfaced and
were fixed:
- `dd97021` dropped the suit-coloured card border to remove
anti-aliasing artifacts at the rounded corners.
- `4d48cad` hides pile markers when occupied — the actual
visible-artifact fix for "gray L corners". Implements the
documented but previously-not-enforced "remain visible only
where a pile is empty" invariant in table_plugin's module
doc.
cargo clippy --workspace --all-targets -- -D warnings clean.
1192 passing / 0 failing (net +8 from v0.21.0's 1184).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Player feedback after the border-drop fix did NOT close the
"gray corners" complaint: "I do not see anything change." The
border was a real artifact, but the *visible* gray came from a
different source.
Root cause: pile markers are 8%-alpha-white sprites sized to
the card area, sitting at `Z_PILE_MARKER = -1.0` beneath every
card. Composited against the dark play surface, the marker's
effective colour is ≈`#272727` — visibly gray. When a card
(rounded corners, opaque body) sits on top, the marker's
rectangular fill bleeds through the 4 small triangular regions
where the card's rounded corner curves cut away from the card's
bounding rectangle. That bleed-through is the "gray L" the
player saw at each card corner.
Fix: hide pile-marker sprites for any pile that has a card on
top. New `sync_pile_marker_visibility` system runs each Update
tick, guarded by `game.is_changed()` so the work skips on idle
frames. Iterates `(&PileMarker, &mut Visibility)` and sets
`Hidden` for occupied piles, `Inherited` for empty.
This implements the *documented* invariant declared in the
module-level doc comment ("Pile markers ... remain visible only
where a pile is empty") that was previously not enforced —
markers always rendered. Strictly speaking this is a
documentation-vs-implementation drift fix, not a behaviour
change.
### Why the border-drop fix didn't address this
The border drop changed the SVG stroke and removed *one* source
of corner artifacts (anti-aliased red/near-white stroke fading
through gray). It correctly drifted 52 face hashes. But the
visible gray at corners came from a *different* layer — the
pile-marker sprite *behind* the card, not the card stroke
itself. Right test target, wrong visible-artifact target.
Two layers, two fixes; this commit closes the second.
### Test
New `pile_markers_hide_when_pile_is_occupied` pins the
post-deal state: 8 markers hidden (stock + 7 tableau), 5
markers visible (waste + 4 foundations). 1192 passing
(+1 from prior 1191).
Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Player feedback after the 2-colour revert: "I do not like the
grey corners on the cards." The visible artifact was anti-
aliasing physics — the 1 px suit-coloured stroke (red for
hearts/diamonds, near-white for clubs/spades) faded through
gray pixels into the dark play surface at each rounded corner,
producing a visible "gray sliver" at the four arcs of every
card.
Fix: drop the stroke entirely. The card body fill defines the
shape against the play surface; the 5-unit brightness gap
between `#1a1a1a` body and `#151515` surface is enough to read
as a card edge without an explicit stroke. Anti-aliasing on a
fill-only rounded rect blends `#1a1a1a → #151515` over a few
pixels — barely perceptible compared to the
`stroke → transparent` gradient that produced the artifact.
### Changes
- `card_face_svg.rs`: removed `stroke="{colour}" stroke-width="2"`
from the card body rect. Reverted the 1 px stroke inset back
to `(x=0, y=0, width=256, height=384)` since there's no
longer a stroke to keep inside the pixmap. Module-level
comment updated to document the reasoning.
- `design-system.md` § Game Cards line 225 updated: "Border:
1px solid in suit color" → "Border: none." with the
artifact rationale recorded as audit trail.
- `card_face_svg_pin.rs` rebaselined: all 52 face hashes drift
(every card's perimeter pixels changed); 5 back hashes
unchanged.
Workspace clippy + cargo test --workspace clean. 1191 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per player feedback after the brief 4-colour-deck experiment:
"can we make the card suit colors the same as a regular
solitaire game would." Reverts the 4-colour split (`62b61cc`)
and bumps both 2-colour hues to read more like a real
Microsoft-Solitaire-on-dark-mode deck.
### Constants
- `RED_SUIT_COLOUR`: `#fb9fb1` (Terminal pink, then briefly
hearts-only) → `#e35353` (saturated red). More chromatic, less
pastel; reads as "the red suit" rather than "a Terminal-
themed pink." Visually distinct from `ACCENT_PRIMARY`
`#a54242` (the brick-red CTA accent) so chrome and suit don't
collapse to the same hue.
- `BLACK_SUIT_COLOUR`: `#d0d0d0` (matched `TEXT_PRIMARY`) →
`#e8e8e8` (near-white). Bumped slightly brighter so it reads
as a chromatic-neutral counterpart to the new saturated red,
not as "the same gray as body text." `TEXT_PRIMARY_HC`
(`#f5f5f5`) is still brighter for the high-contrast boost
path.
- `RED_SUIT_COLOUR_HC`: `#ff8aa0` (pinkish boost matching the
v0.21.0 pink default) → `#ff6868` (brighter saturated red).
Now reads as "more chromatic" than the new default red, not
"less saturated."
- `DIAMOND_SUIT_COLOUR` and `CLUB_SUIT_COLOUR` deleted — the
4-colour split is gone, hearts/diamonds re-pair under
`RED_SUIT_COLOUR` and clubs/spades under
`BLACK_SUIT_COLOUR`.
### `card_face_svg.rs`
- Module-level constants collapse from four (`SUIT_HEART` /
`SUIT_DIAMOND` / `SUIT_CLUB` / `SUIT_SPADE`) back to two
(`SUIT_RED` / `SUIT_DARK`) at the new saturated-red /
near-white values.
- `suit_paint()` reverts to the 2-colour pairing: hearts
filled-red, diamonds outlined-red, spades filled-near-white,
clubs outlined-near-white. Filled-vs-outlined glyph
differentiation stays the always-on CBM fallback.
### `card_plugin.rs`
- `text_colour()` reverts to a `card.suit.is_red()`
bifurcation. Comment block updated to reflect the new
truth table: red suits → saturated red (or CBM lime / HC
brighter red); dark suits → near-white (or HC brighter
near-white).
### Tests
Test block restructured back to the pre-4-colour shape: two
red/black pairing tests instead of one 4-colour distinctness
test. CBM/HC compose tests retuned to the 2-colour world (red
suits compose, dark suits compose; no separate diamonds-immune
or clubs-immune cases). 1191 passing / 0 failing — net 0 from
the prior commit (3 tests removed: the 4-colour distinctness
test + the diamonds/clubs-immune test; 2 tests added back: the
red-pairing + dark-pairing tests; existing tests amended to
new colour assumptions).
### `card_face_svg_pin`
All 52 face hashes drift (every suit's colour shifted); 5 back
hashes unchanged. Surgical rebaseline.
### `design-system.md`
§Suit Colors retitled "Two-color traditional pairing", table
updated with the new hex values, CBM section text simplified
back to red→lime swap on both red suits.
Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hearts pink (`#fb9fb1`), Diamonds gold (`#ddb26f`), Clubs lime
(`#acc267`), Spades gray (`#d0d0d0`) — each suit picks up its
own base16-eighties accent so a player scanning the table can
distinguish the suit by hue alone (faster recognition than the
2-colour traditional red/black scheme; common in poker decks).
All four colours already exist in the palette as semantic
state-token accents, so this is a pure remapping at the suit-
glyph site, not a palette extension.
The outlined-glyph differentiation (♦ ♣ outlined, ♥ ♠ filled)
is preserved on top of the colour split — it stays the always-
on colour-blind fallback per `design-system.md` §Accessibility,
and matters more than ever now that CBM hearts (lime) and
default clubs (lime) share a hue.
### Changes
- `card_face_svg.rs`: split `SUIT_RED` / `SUIT_DARK` into four
per-suit constants (`SUIT_HEART` / `SUIT_DIAMOND` / `SUIT_CLUB`
/ `SUIT_SPADE`). `suit_paint()` returns each suit's own
colour. Card border picks up the suit colour automatically
via the existing `(colour, paint)` destructure.
- `card_plugin.rs`: new `DIAMOND_SUIT_COLOUR` + `CLUB_SUIT_COLOUR`
constants; `text_colour()` rewritten as a per-suit match (was
red/black bifurcation). Both rendering paths (PNG production +
constant fallback under MinimalPlugins) stay in lockstep.
- CBM behaviour clarified: only hearts swap to lime now;
diamonds + clubs + spades are already hue-distinct from
the heart pink and stay unchanged. Under CBM the heart
(lime) and club (lime) share a hue but stay distinguishable
via the always-on filled-vs-outlined glyph differentiation.
- HC behaviour: only hearts (→ HC red) and spades (→ HC white)
have defined boosts. Diamonds (gold) and clubs (lime) are
already mid-luminance accents and stay at their default.
New test `text_colour_diamonds_and_clubs_are_immune_to_accessibility_flags`
pins all four flag combinations as no-ops for the gold +
lime suits.
- `design-system.md` §Suit Colors retitled "Four-color deck"
with the 4-colour table; CBM section text updated to
describe the hearts-only swap and the hearts/clubs hue
collision under CBM.
- `card_face_svg_pin.rs` rebaselined: 26 hashes drift
(13 clubs + 13 diamonds — the two suits whose colours
changed). Hearts, spades, and the 5 backs all keep their
prior hashes. Surgical scope, exactly what the pin test
was designed to surface.
### Tests
1191 passing / 0 failing — net 0 from the prior baseline:
two old 2-colour tests removed
(`text_colour_is_red_for_hearts_and_diamonds`,
`text_colour_is_black_for_clubs_and_spades`), one consolidated
4-colour test added
(`text_colour_4_colour_deck_assigns_each_suit_its_own_hue`)
plus a pairwise-distinct invariant guard, and one new test
covering the gold/lime suits' immunity to CBM/HC flags. Six
existing CBM/HC tests rewritten to use only the suits each flag
actually affects under the new scheme (hearts for CBM, hearts +
spades for HC).
Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two post-v0.21.0 options closed today; "Since the v0.21.0 cut"
section now narrates both:
- A — App icon (`3eb3a26` + `716a025`). Runtime Window::icon
wired via WinitWindows on desktop, 9-size PNG hierarchy at
assets/icon/. The follow-up `716a025` wraps NonSend in
Option<...> to satisfy Bevy 0.18's stricter system-param
validation.
- F — Accessibility modes (`c5787c6` + `07e0357`). High-
contrast and reduce-motion settings flags + Settings UI
toggles + engine wiring. CBM and HC compose; reduce-motion
forces card slide_secs to 0.
Open punch list refreshed:
- Visual-identity follow-ups: HC and reduce-motion entries
marked closed with future-scope notes (HC chrome borders,
reduce-motion splash gating).
- Carried forward from v0.19.0: App icon entry marked closed
with future-scope note for .ico/.icns bundle formats (need
new deps + matter only at packaging time).
Resume prompt menu trimmed: A and F decision options now
marked closed inline (preserved for audit-trail readability).
B, C, D, E remain live.
No runtime / test changes — pure docs hygiene to keep the
handoff orientation accurate as work flows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resume-prompt Option F, part 2 of 2 — pairs with the engine
wiring in c5787c6. Adds two toggle rows to the Settings panel
under Cosmetic so players can flip the new accessibility flags
without hand-editing settings.json.
Mirrors the Color-blind Mode row pattern almost exactly:
- Two new marker components (`HighContrastText`,
`ReduceMotionText`) tagging the Text nodes that show
ON/OFF.
- Two new `SettingsButton` enum variants
(`ToggleHighContrast`, `ToggleReduceMotion`) with
`focus_order` 61/62 — sit right after `ToggleColorBlind` (60)
so tab-walk visits all three accessibility flags in one
vertical run before continuing to picker rows.
- Two new click-handler branches in `handle_settings_buttons`
flipping the bool, persisting, broadcasting
`SettingsChangedEvent`, and updating the row label.
- Two new live-label updaters
(`update_high_contrast_text`, `update_reduce_motion_text`)
so the row reflects external changes (e.g. someone editing
settings.json mid-session, or a future a11y-import feature).
- Generic `on_off_label(enabled: bool) -> String` helper shared
by both new toggles. Could fold `color_blind_label` and
`winnable_deals_only_label` into it too — punted for scope;
both already work and a name-only refactor would just churn
the diff.
Query-disambiguator chains updated: every existing settings-text
query in `handle_settings_buttons` gains
`Without<HighContrastText>, Without<ReduceMotionText>` at the
end so the new components don't ambiguate the existing
mutations. The two new queries carry mirrored `Without<...>`
chains for the same reason. Verbose but matches the existing
pattern; future Bevy archetype-set query API would simplify
this, not in 0.18.
Workspace clippy + cargo test --workspace clean. 1191 passing
(unchanged from c5787c6 — UI plumbing has no test coverage in
this commit; the toggle behaviour is exercised through the
engine tests in c5787c6).
Closes Resume-prompt Option F.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resume-prompt Option F, part 1 of 2. Adds two accessibility flags
to Settings and threads each through the engine surfaces that
react to them. Settings UI toggle rows follow in a separate
commit; players who want to test today can edit `settings.json`
manually.
Spec at `docs/ui-mockups/design-system.md` §Accessibility (#2 and
#3).
### High-contrast mode
`Settings::high_contrast_mode: bool` (defaults to false; serde-
default for back-compat). When on:
- Red-suit text colour boosts from `RED_SUIT_COLOUR` (`#fb9fb1`)
to a new `RED_SUIT_COLOUR_HC` (`#ff8aa0`).
- Black-suit text colour boosts from `BLACK_SUIT_COLOUR`
(`#d0d0d0`) to a new `TEXT_PRIMARY_HC` (`#f5f5f5`).
- New `BORDER_SUBTLE_HC` (`#a0a0a0`) constant available for
future chrome-side wiring (this commit only routes HC through
card text rendering — chrome border boost is a separable
follow-up).
The HC and CBM flags compose. CBM red→lime wins over HC on red
suits when both are on (lime is itself a high-luminance accent,
so the HC boost has nothing further to do). HC still applies to
black suits when both flags are on (CBM doesn't touch black).
Four new `text_colour` tests pin the truth table.
### Reduce-motion mode
`Settings::reduce_motion_mode: bool` (defaults to false; serde-
default for back-compat). When on:
- Card-slide animation duration is forced to `0.0` regardless of
the player's `AnimSpeed` selection — cards snap instantly to
their target position. Implemented by extracting a new
`effective_slide_secs(&Settings)` helper that wraps
`anim_speed_to_secs` with the reduce-motion gate.
- Future scaffolding hooks (splash scanline, warning-chip pulse,
card-lift z-bump animation) follow the same `if
settings.reduce_motion_mode { skip }` pattern when wired —
stays out of scope for this commit since each motion path
needs its own per-system gate.
Two new tests cover the gate behaviour and the fall-through-to-
AnimSpeed pass-through path.
### Threading
`text_colour` signature extended with a `high_contrast: bool`
parameter; `sync_cards` / `sync_cards_startup` /
`sync_cards_on_change` / `sync_cards` core / `spawn_card_entity`
/ `update_card_entity` all gain a parallel parameter mirroring
the existing `color_blind: bool` plumbing. Verbose but matches
the established pattern; a future refactor could pack both into
an `AccessibilityView` struct, but bigger blast radius.
### Stats
1191 passing / 0 failing across the workspace (net +6 from
v0.21.0's 1185 baseline once the icon-pin test landed):
- 4 new `text_colour` HC tests in `card_plugin`
(red-suit boost, black-suit boost, CBM-wins-on-red,
black-suits-with-CBM+HC-still-boost).
- 2 new `effective_slide_secs` tests in `animation_plugin`
(zero-out under reduce-motion, fall-through to AnimSpeed when
off).
`cargo clippy --workspace --all-targets -- -D warnings` clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`NonSend<WinitWindows>` failed system-param validation on the
first few frames before `WinitWindows` was populated, panicking
the Update system before any logic could run. Bevy 0.18's
stricter validation panics rather than skips when a non-send
resource is absent, with an error message spelling out the fix:
*"wrap the parameter in `Option<T>` and handle `None` when it
happens."*
Wraps `winit_windows` as `Option<NonSend<WinitWindows>>` and
early-returns on `None`, mirroring the same lifecycle handling
already applied to `winit_windows.get_window(primary_entity)` —
both fail in the same window of frames before winit's `Resumed`
event fires.
Repro from the user's `cargo run` log:
```
thread 'Compute Task Pool (2)' panicked at .../bevy_ecs-0.18.1/src/error/handler.rs:125:1:
Encountered an error in system ...: Parameter ... failed validation:
Non-send resource does not exist
```
Workspace clippy + cargo test --workspace clean, 1185 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes Resume-prompt Option A (the post-v0.21.0 first option).
Half-day desktop work, no cert dependency.
Three deliverables:
1. **SVG-authored icon** (`solitaire_engine/src/assets/icon_svg.rs`)
— square Terminal mark: `#151515` background, brick-red
`#a54242` 1 px border, brick-red ▌ cursor block centered, "RS"
monogram in `#d0d0d0` foreground gray beneath. Same shape that
already lives on the splash boot screen and card-back monogram,
reused as the project's signature visual mark. Authored in a
64-unit logical box so it scales cleanly at every rasterisation
target.
2. **9-size PNG hierarchy** (16, 24, 32, 48, 64, 128, 256, 512, 1024
px) regenerated by `solitaire_engine/examples/icon_generator.rs`
into `assets/icon/icon_<size>.png`. Sizes cover Linux hicolor
(16, 24, 32, 48, 64, 128, 256, 512), Windows .ico targets (16,
32, 48, 256), and macOS .icns targets (16, 32, 64, 128, 256,
512, 1024). The runtime path uses just the 256 px slot; the
smaller sizes are pre-rendered for downstream packaging.
3. **Runtime `Window::icon` wiring** (`solitaire_app/src/lib.rs`).
Bevy 0.18 has no `Window::icon` field — the icon is set through
the underlying `winit::window::Window` via the `WinitWindows`
resource. `set_window_icon` runs each Update tick, retries
silently until `WinitWindows` is populated (typically frame 1
or 2), decodes the embedded 256 px PNG via `tiny_skia`, builds
a `winit::window::Icon`, and self-disables via `Local<bool>`.
Same one-shot pattern as `apply_smart_default_window_size`.
Desktop-only — Android draws its launcher icon from the APK
manifest, so the system is target-gated to
`cfg(not(target_os = "android"))`.
Dep changes (CLAUDE.md §8 user-confirmed):
- `winit = "0.30"` promoted from a transitive Bevy dep to a direct
dep on `solitaire_app` so `winit::window::Icon` is in scope —
bevy_winit 0.18 doesn't re-export it. Version pinned to whatever
Bevy uses; if Bevy bumps winit, this line bumps in lockstep.
- `tiny-skia` added as a direct dep on `solitaire_app` for PNG →
RGBA decode. Already in workspace deps for `solitaire_engine`;
no version drift risk.
- Both new deps target-gated to non-Android only.
Test infrastructure: `solitaire_engine/tests/icon_svg_pin.rs`
hashes the rasterised RGBA bytes at all 9 sizes via FNV-1a (same
shape as `card_face_svg_pin`). Bootstrap pattern (empty EXPECTED
→ panic with hashes formatted as Rust source → paste back in)
handles future intentional builder edits cleanly.
Workspace clippy + cargo test --workspace clean. 1185 passing
(+1 from v0.21.0's 1184 baseline — the icon pin's
`rasterised_icon_bytes_match_pinned_hashes`).
Out of scope for this commit: `.icns` / `.ico` bundling for
macOS / Windows app packaging. Both are packaging-time concerns
(set via bundle manifests, not runtime calls) and would need new
deps (`ico` and `icns` crates) — separate followup if/when the
project ships as a packaged macOS / Windows app rather than just
`cargo run`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the v0.20.0 → post-cut refresh pattern (commit a65e5b8):
the cut commit (04f9bf9) only edits CHANGELOG.md; this follow-up
resets the handoff so it serves a fresh session cleanly rather
than carrying forward the v0.20.0-era narrative as cruft.
Removed (now redundant with CHANGELOG.md § [0.21.0]):
- The full "Since the v0.20.0 cut (un-pushed)" section — ~300
lines of per-commit narratives for the post-tag work.
- The "What shipped in v0.20.0 (frozen at 41a009a)" section —
v0.20.0 detail lives in CHANGELOG.md § [0.20.0].
Replaced with:
- Short header pointing to CHANGELOG.md § [0.21.0] for cycle
detail.
- "Since the v0.21.0 cut" placeholder ("No threads in flight").
Refreshed:
- Status at pause: HEAD on origin matches local; latest tag
v0.21.0 at 04f9bf9; tests 1184; references to v0.20.0
baseline preserved as audit trail.
- Visual-identity follow-ups: dropped the closed entries
(card-face arc, splash polish, replay banner pieces). Added
what's still open: replay screen-takeover redesign, floating
MOVE chip above focused card, toast Warning/Error wiring,
high-contrast accessibility, reduced-motion accessibility.
- Canonical remote: dropped the "unpushed commits" warning
since origin is caught up.
- Design direction palette: brick-red primary instead of cyan,
red→lime CBM swap instead of red→cyan, glyph orientation
upright. v0.21.0 source commits cited.
- Resume prompt: rebased to v0.21.0 anchor. Decision options
rewritten — closed B/C/D dropped; live A/E/F renumbered into
fresh A/B/C plus three new candidates (Toast variants, Phase
8 sync, accessibility modes). Workflow notes gain the
token-port-pattern lesson from v0.21.0's three "fallback path
the migration walked past" follow-ups.
Net diff: −513 / +117 lines; file shrinks from 668 to 272.
v0.20.0 historical context preserved in CHANGELOG.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Promotes the [Unreleased] section to [0.21.0] dated 2026-05-08
and opens a fresh empty [Unreleased]. The cycle's three through-
lines:
- **Card-face / suit / card-back artwork migration.** Closes
the v0.20.0 thread that explicitly deferred card-face palette
migration. 10 commits across 2 days landed both rendering
paths (assets/cards/*.png fallback + the bundled-default
theme SVGs that include_bytes!()-embed into the binary) on
identical Terminal art generated by shared face_svg /
back_svg builders. The card_face_svg_pin integration test
guards rasteriser drift via FNV-1a on raw RGBA bytes.
- **Splash + replay-overlay polish.** Closes Resume-prompt
Options B (splash cursor pulse + scanline overlay) and C
(replay banner ▌ label + GAME caption + MOVE chip + scrub
bar). Splash gets the SplashFadable scaffold that lets
future overlays fade N >> 3 elements via one marker + one
global lerp query.
- **ACCENT_PRIMARY palette swap.** Late-cycle stakeholder
decision: cyan #6fc2ef → brick red #a54242. Touches every
primary-accent surface across the engine. RED_SUIT_COLOUR_CBM
swapped from cyan to lime #acc267 in lockstep so the colour-
blind alternative stays hue-distinct from the new red-family
primary.
Three sign-off follow-ups surfaced once a human booted the
running game; all matched the same shape ("fallback path the
chrome migration walked past"): the embedded default theme
overrode the new PNGs, the table backgrounds were a separate
PNG path the v0.20.0 chrome migration didn't touch, and the
action-button row's font_size: 16.0 literal slipped through the
typography migration audit. All recorded under "Fixed".
Phase 8 (sync) and Phase Android runtime gaps (JNI bridges,
APK launch verification on device) remain open and roll
forward.
cargo clippy --workspace --all-targets -- -D warnings clean.
1184 passing / 0 failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Project-wide palette shift at user request. Replaces the cyan
primary accent everywhere it surfaces — splash boot screen,
home menu glyphs, action chevrons, replay overlay banner +
scrub fill + chip border, achievement checkmarks, leaderboard
#1 indicator, radial menu fill, focus ring, card-back canonical
badge, etc. — with `#a54242` from the same base16-eighties
family as the existing pink suit colour.
Knock-on changes that all land in this commit per the
lockstep rule:
- ui_theme.rs: ACCENT_PRIMARY (#a54242), ACCENT_PRIMARY_HOVER
(#c25e5e brightened companion), FOCUS_RING (same hue, 0.85
alpha). Module-level palette comment + STOCK_BADGE_FG +
CARD_SHADOW_ALPHA_DRAG doc strings updated to match.
- card_plugin.rs: card_back_colour(0) now returns the brick-red
ACCENT_PRIMARY (was cyan). RED_SUIT_COLOUR_CBM swapped from
cyan to lime #acc267 — the CBM alternative needs to stay
hue-distinct from the new red-family primary, lime is the
next-best non-red base16-eighties accent. text_colour doc
+ CBM tests renamed cyan→lime in lockstep
(text_colour_color_blind_mode_swaps_red_suits_to_lime).
- card_face_svg.rs: BACK_ACCENTS[0] now "#a54242" (canonical
Terminal back).
- splash_plugin.rs / ui_modal.rs / replay_overlay.rs /
selection_plugin.rs: descriptive "cyan" comments swapped to
"accent" / "primary-accent" wording so the doc strings stay
decoupled from any specific hue. Future palette tweaks won't
require comment churn.
- design-system.md: YAML token frontmatter updated (primary,
surface-tint, suit-red-cb, primary-container,
on-primary-container, inverse-primary). Palette table gains
a project-specific `base08` slot for the new red. CTA /
Selection / Card-back badge / Primary button / Bottom-bar
active-icon / glow / CBM swap text all retuned. Historical
references preserved (e.g. "Was cyan #6fc2ef before the
2026-05-08 swap") so the audit trail stays in the spec.
- card_face_svg_pin.rs: rebaselined. Exactly one hash drift
(back_0 — the canonical Terminal back's badge changed
colour). Other 56 hashes identical (face SVGs don't
reference the accent; back_1..4 use unchanged accents). The
one-hash-drift signal confirms the change scope was
surgical.
Workspace clippy + cargo test --workspace clean, 1184 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Updates SESSION_HANDOFF.md to reflect the post-2026-05-08 state:
- "Last updated" + status header rewritten — origin caught up
to local through dd101b3, 1184 tests passing (net +4 from
the 1180 baseline: splash polish +2, card-face pin +1, CBM
test consolidation -2 then +1).
- New narrative entry under "Since the v0.20.0 cut" walks
through the 9-commit Option D arc: plan + tooling
(5623368/3a4bb63/babe5cc/48b28d2), lockstep step 4+5
(e8bf9d7), the three sign-off follow-ups (a14200a default-
theme SVG override, 8719f77 backgrounds flattened, ae84dc1
top-bar overlap), the path-glyph fix (af414b6), and the
glyph-orientation tweak (dd101b3).
- "Visual-identity follow-ups" punch-list: card-face item
marked closed with the same commit chain referenced from
the narrative.
- Resume prompt header rewritten — Options B/C/D all closed,
the post-tag work is fully on origin. Option D's bullet
expanded to record the closure rather than describe pending
work.
- The "fallback path the migration walked past" pattern is
documented explicitly so a future session can pattern-match
on it (token migrations need a checklist of every concrete
artifact downstream of the tokens, not just the tokens
themselves).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The user noticed the bottom-right large suit glyphs were
rendering upside-down — point-up hearts, stem-up spades — because
the SVG transform pipeline applied a `rotate(180)` to match the
traditional playing-card inverted-corner convention.
That convention exists so a card reads correctly when flipped or
read from the opposite side of the table. Single-orientation
digital play doesn't benefit from it; most modern digital decks
have abandoned it. User preference is upright.
Drops the rotate from face_svg's bottom-right `<g transform>`
and adjusts the translate so the visible glyph still lands at
(178, 286)–(242, 350) — same screen footprint, same scale, just
no flip.
design-system.md § Game Cards updated in lockstep — line 220
no longer says "rotated 180°", instead documents the deliberate
deviation from the traditional convention.
Knock-on lockstep changes in this commit:
- EXPECTED in tests/card_face_svg_pin.rs rebaselined: 52 face
hashes shift, 5 back hashes unchanged.
- assets/cards/faces/*.png regenerated (52 face PNGs).
- solitaire_engine/assets/themes/default/*_*.svg regenerated
(52 theme face SVGs that production rasterises at startup).
Workspace clippy + cargo test --workspace clean. Pin test
passes against the new hashes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The user's first post-migration screenshot showed near-invisible
suit glyphs on every card — the rank rendered at correct size but
the ♠ ♥ ♦ ♣ marks were tiny dots regardless of the requested
20px / 64px font-size.
Root cause: the bundled FiraMono in svg_loader::shared_fontdb
doesn't carry usable Unicode suit glyphs (U+2660-2666). usvg
silently fell back to a substitute rendering at default size,
producing the "tofu" effect.
Fixes by replacing the `<text>` glyph rendering with inline SVG
paths. `suit_path_d(suit)` returns a single closed-perimeter path
authored in a 32 × 32 logical box, then face_svg wraps it in two
`<g transform>` blocks (top-left small + bottom-right rotated
large). Path-based rendering bypasses the font system entirely
— same bytes on every machine, no fontdb dependency, no
substitution risk.
Same path data renders correctly whether filled (♥ ♠) or
outlined (♦ ♣ — the always-on color-blind glyph differentiation
from the design system).
Knock-on changes that must land in this commit per the migration
plan's lockstep rule:
- `EXPECTED` in tests/card_face_svg_pin.rs rebaselined: 52 face
hashes change (text → path), 5 back hashes unchanged
(back_svg untouched). The bootstrap pattern in the test
handled the rebaseline cleanly — empty EXPECTED, re-run,
paste, re-run.
- assets/cards/faces/*.png regenerated (the 52 face PNGs).
- solitaire_engine/assets/themes/default/*_*.svg regenerated
(the 52 theme face SVGs that production rasterises at
startup). Both rendering paths must agree.
Workspace clippy + cargo test --workspace clean. Pin test
passes against the new hashes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The post-Option-D screenshot showed the left-anchored HUD column
("Score: 0 Moves: 0 0:00") and the right-anchored action button
row colliding mid-screen at portrait/narrow window widths. Both
were absolute-positioned siblings without a shared flex parent,
so Bevy 0.15's UI couldn't auto-arrange them when their natural
widths exceeded the available horizontal space.
The action button text was a hardcoded `font_size: 16.0` literal
— a miss from the typography migration audit, since every other
text element in `hud_plugin.rs` already routes through the
`TYPE_*` tokens. Switching to `TYPE_BODY` (14.0) brings the
button row in line with the design system *and* trims roughly
12% off label widths.
Pairs with a horizontal-padding cut from VAL_SPACE_3 to
VAL_SPACE_2: 8px less on each side, six buttons, ~96px total
reclaimed across the row. Vertical padding stays at VAL_SPACE_2
so button height tracks the rest of the chrome band.
Combined effect: the action button row narrows by ~150-200px,
which is enough margin to clear typical portrait window widths
without requiring a structural refactor (a shared SpaceBetween
flex parent for HUD+actions would be more robust but touches
many query sites and was out of scope for the visual-polish
pass).
cargo clippy + cargo test --workspace clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The post-Option-D screenshot showed Terminal cards correctly but
a green felt play surface — the chrome migration only retuned
in-engine constants, leaving the on-disk PNGs at
assets/backgrounds/bg_*.png as the legacy felt textures.
Adds solitaire_engine/examples/background_generator.rs following
the same regeneratable pattern as card_face_generator. Five solid
near-black variants from the base16-eighties palette:
- bg_0: #151515 (Terminal canonical, BG_PRIMARY)
- bg_1: #0a0a0a (BG_DEEPEST)
- bg_2: #1a1a1a (BG_ELEVATED — same as card face)
- bg_3: #121820 (slight cool tint)
- bg_4: #201812 (slight warm tint)
Per design-system.md the Terminal play surface is *flat* — no
felt, no gradient — so all 5 slots are pure solid colours. Each
PNG is 120 × 168 (matches the legacy tile size; spawn_background
stretches to window_size * 2.0 at runtime so source resolution
is immaterial). On-disk weight drops from ~16KB average to ~100
bytes per tile.
Run with: cargo run --example background_generator --release
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 4's PNG regeneration left the cards looking unchanged at
runtime because the PNGs at assets/cards/ are only the *fallback*
art — production renders the bundled-default theme's SVGs, which
get include_bytes!()-embedded into the binary by
solitaire_engine::assets::sources and applied to CardImageSet at
startup by theme::plugin::apply_theme_to_card_image_set. Those
SVGs were still the legacy vector-playing-cards art.
Extends card_face_generator to write SVGs into both runtime
paths in lockstep:
1. assets/cards/{faces,backs}/*.png — fallback art (unchanged
from step 4).
2. solitaire_engine/assets/themes/default/*.svg — what production
actually renders. 52 face SVGs + 1 back SVG, generated from
the same face_svg / back_svg builders as the PNGs so the two
paths can never visually diverge.
Adds two helper functions to card_face_svg:
- theme_suit_token (clubs/diamonds/hearts/spades — lowercase
full word, matching CardKey::manifest_name)
- theme_rank_token (ace/2..10/jack/queen/king — same)
The theme back uses BACK_ACCENTS[0] (canonical Terminal cyan).
The other four accents only live as PNG fallbacks because the
theme system carries one back per theme.
Net SVG diff: -14884 / +940 lines — the legacy vector-playing-
cards SVGs were ~300 lines each of Inkscape-authored paths;
the Terminal SVGs are ~10 lines of programmatic output.
Workspace clippy + cargo test --workspace clean. Pin test
unaffected (the SVG builders themselves did not change).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 4+5 lockstep commit closing Option D from SESSION_HANDOFF.
The 52 face PNGs + 5 back PNGs in assets/cards/ are regenerated
to the Terminal-aesthetic artwork emitted by the
card_face_generator example (#1a1a1a face, #fb9fb1 / #d0d0d0
suit glyphs, scanline-pattern backs with palette-rotated badge
accents). Resolution drops from 512×768 to 256×384 — sufficient
for ~250 px-wide desktop sprites and ~⅓ the on-disk weight.
Constant fallback path migrated in lockstep so the
constant-fallback tests (under MinimalPlugins) and the PNG path
(production) agree at every commit boundary:
- CARD_FACE_COLOUR → #1a1a1a (was off-white #fafaf2)
- RED_SUIT_COLOUR → #fb9fb1 (was #c71f26)
- BLACK_SUIT_COLOUR → #d0d0d0 (was #141414)
- CARD_FACE_COLOUR_RED_CBM → renamed to RED_SUIT_COLOUR_CBM,
value #6fc2ef (was #d9ebff). Semantic shift: pre-Terminal
this was a face-background tint, now it's a suit-glyph
colour swap. The Terminal face is uniformly CARD_FACE_COLOUR
regardless of CBM; CBM only swaps red suits to cyan in the
glyph itself.
- card_back_colour() → returns the 5 base16-eighties accents
matching card_face_svg::BACK_ACCENTS in lockstep, so the
test-fallback back is the same hue family as the on-disk
PNG art for that index.
Function signatures shift to follow the semantic move:
- text_colour gains a color_blind: bool parameter (returns
RED_SUIT_COLOUR_CBM for red+CBM).
- face_colour deleted entirely. The face is uniform
CARD_FACE_COLOUR; card_sprite inlines the constant. CBM
parameter dropped from card_sprite as a knock-on.
Test updates land in this commit per the migration plan:
- text_colour_is_red_for_hearts_and_diamonds + sibling: pass
`, false` to text_colour calls now that the signature has
the CBM bool.
- 4 face_colour CBM tests replaced with 2 text_colour CBM
tests asserting (a) red-suit cards swap to cyan in CBM and
(b) black-suit cards do not change.
Engine test count: 747 → 745 (net -2 from the test
consolidation — 4 face_colour tests collapsed into 2
text_colour CBM tests).
Sign-off criteria: a human still needs to `cargo run -p
solitaire_app` and confirm Terminal cards render. clippy +
cargo test --workspace clean as of this commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 3 of the migration plan in docs/ui-mockups/card-face-migration.md.
Extracts face_svg / back_svg + palette constants from the
card_face_generator example into a new
solitaire_engine::assets::card_face_svg module so an integration
test can call them. The example becomes a thin wrapper.
The new tests/card_face_svg_pin.rs hashes the raw RGBA8 pixel
bytes from rasterising every face × suit + every back accent and
compares each FNV-1a fingerprint against an embedded constant.
Catches silent rendering drift if usvg / resvg / tiny_skia / the
bundled FiraMono ever change in a way that perturbs pixels.
Hashing is FNV-1a inline (~5 lines) rather than adding sha2 or
blake3 — cryptographic strength isn't load-bearing here, just
stable byte fingerprints.
When the SVG builders intentionally change, empty EXPECTED to
`&[]` and re-run the test once; it panics with the new hashes
formatted as Rust source ready to paste back in.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Generates 52 face PNGs (4 suits × 13 ranks) + 5 back PNGs
into assets/cards/. Implements step 2 of the migration plan
in docs/ui-mockups/card-face-migration.md — the bytes this
emits are what step 4 commits alongside the card_plugin
constant migration.
Filled vs outlined glyphs (♥♠ filled; ♦♣ outlined) implement
the always-on color-blind glyph differentiation from the
design system. The 5 back themes share the canonical
Terminal scanline pattern but rotate the badge accent
through the base16-eighties palette so all 5 slots stay
distinguishable without leaving the palette.
Run with: cargo run --example card_face_generator --release
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rasterises one Ace of Spades to /tmp/ace_spades_terminal.png via
the existing usvg + resvg + tiny_skia stack already used by
svg_loader. Proves the per-card grain works before looping over
all 52 faces + 5 backs in step 2 of the migration plan.
Run with: cargo run --example card_face_poc --release
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lays out the lockstep migration from legacy white-card PNGs +
constants to the Terminal aesthetic. Steps 4 + 5 (artwork +
constant + test updates) must land in one commit so the PNG
path and the constant-fallback path don't visually diverge.
Tracks Option D from the SESSION_HANDOFF Resume prompt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bookkeeping pass after 29136d8 (cursor pulse) and a27cf5a (scanline
overlay) shipped — both halves of the splash polish arc deferred in
cacb19c are now done.
Changes:
- "Since the v0.20.0 cut": added per-commit narratives for 29136d8
and a27cf5a with the implementation notes worth preserving past
the commit log:
- The "multiply, don't override" pattern that resolved the
cursor-pulse / fade-timeline conflict (and generalises to any
two ECS systems writing the same per-frame component).
- The texture-α × tint-α GPU-composite trick that integrates the
scanline with the fade without a new "multiplicative fadable"
abstraction.
- Two Bevy 0.18 API surprises (RenderAssetUsages module move;
pixel_size() returning Result) — pinned for next time we touch
runtime-generated images.
- The defensive period <= 0.0 guard on cursor_pulse_factor — a
cheap NaN-prevention pattern worth mirroring on every trig
helper.
- "Open punch list" → "Visual-identity follow-ups": collapsed the
two splash-polish bullets into closed pointers.
- Resume prompt → Option B: marked closed with "no further splash
work pending unless a new mockup detail surfaces" so a future
session knows it's a finished arc, not an in-flight one.
Three options now closed (A, B, C); D / E / F remain — all three
have a real blocker (D = multi-session, E = artwork PNGs missing,
F = Android hardware/AVD) so the next session starts with a
genuine commitment-vs-blocker decision rather than picking the
smallest piece.
Closes the second half of the splash polish arc deferred in cacb19c.
A fullscreen ImageNode tiles a runtime-generated 2×2 RGBA8 texture
over the splash content — top row transparent, bottom row #1a1a1a
at ~30 % alpha — producing the 1 px-pitch horizontal scanline
pattern called for in docs/ui-mockups/splash-mobile.html.
Implementation:
- New build_scanline_image() pure helper returns the 2×2 source
texture. Pixels hard-coded as RGBA bytes (0,0,0,0 / 26,26,26,76)
so the visible appearance is locked into source rather than
reconstructed from constants.
- spawn_splash gains an `Option<ResMut<Assets<Image>>>` parameter;
when present (always in production), the image is added and an
ImageNode child of the splash root tiles it via
NodeImageMode::Tiled { tile_x: true, tile_y: true, stretch_value: 1.0 }.
When absent (legacy bare-MinimalPlugins tests), the overlay is
silently skipped — the rest of the splash still spawns.
- New SplashFadableImage marker + extension to advance_splash that
writes (1, 1, 1, global_alpha) into the ImageNode tint each tick.
Multiplying (rather than overwriting like SplashFadableBg does)
preserves the per-pixel 30 % alpha in the texture so the GPU
composite is `0.3 × global_alpha` — fades cleanly with the
splash without drifting to 100 % alpha during the hold.
- New SplashScanlineOverlay marker for tests. Distinct from
SplashFadableImage so the test query intent stays explicit
(there's only one fadable image today, but adding more later
shouldn't break the scanline-locator).
Bevy 0.18 API quirks worth pinning for next time: RenderAssetUsages
is re-exported under `bevy::asset::` (not `bevy::render::render_asset`),
and TextureFormat::pixel_size() returns Result<usize, _> rather
than usize. Both fixed in the imports / debug_assert.
Headless test fixture now also init_resource::<Assets<Image>>()
since MinimalPlugins doesn't pull AssetPlugin — same pattern
settings_plugin's tests already use.
Two new tests (1183 → 1185): build_scanline_image_has_expected_2x2_rgba_bytes
locks the texture pixels literally, scanline_overlay_spawns_and_fades_with_splash
asserts spawn placement under SplashRoot and the new fade-images
branch's correctness end-to-end.
This closes Option B from the SESSION_HANDOFF Resume prompt — both
splash polish pieces (cursor pulse + scanline overlay) shipped.
Closes the cursor-pulse half of the splash polish arc deferred in
cacb19c. The "▌ ready_" boot-log line now ends with a 6×12 px cyan
Node that pulses on a 1 s sine cadence — matching the mockup at
docs/ui-mockups/splash-mobile.html. The pulse alpha is multiplied
with the global splash fade timeline rather than fighting it: the
cursor can't reach full alpha while the rest of the splash is still
fading in, and it fades out cleanly with everything else.
Implementation:
- New SplashCursorPulse marker on the trailing Node. Carries
SplashFadableBg too so it picks up the global fade for free; the
pulse system overwrites the per-tick BackgroundColor afterward
(last writer wins, both values are commensurate so the override
is correct, not a fight).
- New pulse_splash_cursor system, scheduled .chain()'d AFTER
advance_splash so the pulse multiplication is the final write.
No-op when no SplashRoot exists (post-despawn or under a test
fixture without one).
- New pure helper cursor_pulse_factor(age, period, min) returns a
sine-driven multiplier in [min..1.0]. Defensive zero/negative
period guard returns 1.0 so a misconfiguration produces a
steady cursor instead of a divide-by-zero NaN.
- Two splash-local consts: MOTION_PULSE_PERIOD_SECS = 1.0 (terminal-
blink cadence) and PULSE_ALPHA_MIN = 0.4 (the cursor never fully
extinguishes — matches a real terminal's blink that dips but
stays visible).
Used Node-with-explicit-dimensions rather than a `█` text glyph so
the 6×12 px size doesn't drift with line font; the leading `▌`
glyph stays a character (textual) while the trailing pulse is a
Node (geometric) — different primitives for different intents.
One new test (1182 → 1183): cursor_pulse_factor_corners pins the
peak (factor = 1 at age = period/4), trough (factor = min at age =
period * 3/4), and the defensive zero/negative-period guard.
Scanline overlay (the other half of cacb19c's skipped polish)
remains open — separate commit.
Bookkeeping pass after 54005d5 (GAME #YYYY-DDD caption) and e080b49
(MOVE N/M chip restyle) shipped — both pieces of the Option C
banner-local enrichments arc are now done.
Changes:
- "Since the v0.20.0 cut": added per-commit narratives for 54005d5
and e080b49 with the implementation notes worth preserving past
the commit log (the BANNER_HEIGHT 48→60 bump rationale, the Bevy
0.18 BorderColor::all() correction, the "marker on the leaf, not
the wrapper" ECS-design choice).
- "Open punch list" → "Replay-overlay enrichments beyond the scrub
bar": pivoted from "tractable banner additions still open" to
"all banner-local pieces shipped; remaining are cross-plugin or
multi-session". Reflects current state without erasing the
forward-looking work.
- Resume prompt → Option C: marked closed with a forward pointer to
the cross-plugin/multi-session items that should get their own
decision tree next time.
- Resume prompt → test count: dropped the hardcoded "1180 tests
pass" (already stale at 1182) for "~1180+; check with
`cargo test --workspace`" — same dynamic-reference pattern as
44f5972's commit-count fix, applied to the next aggregate that
was vulnerable to it.
Closes the centre-text half of the replay-overlay enrichments arc.
The plain "Move N of M" text becomes a 1px ACCENT_PRIMARY-bordered
chip containing "MOVE N/M" — uppercase + slash separator reads as a
Terminal output line and matches the floating-chip motif in
docs/ui-mockups/replay-overlay-mobile.html. The chip lives in-banner
rather than floating above the focused card; the screen-takeover
treatment that requires plumbing cursor → card identity remains
deferred per SESSION_HANDOFF.
Implementation: the centre Text spawn is now wrapped in a Node with
1px border + axes(VAL_SPACE_2, VAL_SPACE_1) padding and no background
fill (Terminal aesthetic gets depth from borders + tonal layering,
not shadows). The ReplayOverlayProgressText marker stays on the
inner Text so update_progress_text continues to repaint contents
unchanged. format_progress now returns "MOVE N/M" for Playing and
"REPLAY COMPLETE" for Completed (uppercase to match the chip's
typographic treatment); Inactive still returns "" since the overlay
shouldn't be spawned in that state.
Used BorderColor::all(ACCENT_PRIMARY) — Bevy's BorderColor is per-side
in 0.18, no longer the tuple struct it was earlier.
Module-level docstring + ReplayOverlayScrubFill doc comment both
updated to quote the new "MOVE N/M" string. Test
overlay_progress_text_reflects_cursor swapped its assertion to match.
1182 tests still pass; clippy clean.
This closes Option C from the SESSION_HANDOFF Resume prompt's banner-
local enrichments. The full screen-takeover redesign (mini-tableau,
playback controls, move-log scroll, WIN MOVE marker requiring a
win_move_index field on Replay) remains the multi-session item.
Adds the right-anchored game-identifier piece of the replay-overlay
mockup (docs/ui-mockups/replay-overlay-mobile.html), adapted to live
under the existing "▌ replay" headline rather than as a separate
top-bar surface — the screen-takeover redesign is intentionally
deferred per the SESSION_HANDOFF punch list.
The caption reads `GAME #{year}-{ordinal:03}` (e.g. `GAME #2026-122`
for a replay recorded 2026-05-02), matching the mockup's
`GAME #2024-127` motif. Year + chrono ordinal gives a compact,
monotonically-increasing identifier that's grep-friendly across
replay files. TYPE_CAPTION (11 px) / TEXT_SECONDARY paint so the
caption reads as subordinate metadata, not a callout.
Implementation: new ReplayOverlayGameCaption marker, new pure
helper `format_game_caption(state) -> Option<String>` (None for
Inactive / Completed since the replay is consumed in those branches),
left-side label spawn restructured into a column container holding
the headline + caption with a 2 px row gap. BANNER_HEIGHT bumped
48 → 60 px so the column fits without overflow (16 px vertical
padding + 1 px scrub + ~39 px content; +12 px banner mass is the
deliberate cost of the new content).
Two new tests (1180 → 1182): format_game_caption_covers_state_corners
pins the three branches (Inactive / Completed / Playing) plus the
zero-pad-to-3-digits invariant for early-January ordinals; and
overlay_game_caption_shows_replay_date drives ReplayPlaybackState
end-to-end and asserts the caption text on spawn and that the
overlay stays spawned through Playing → Completed.
MOVE chip restyle from the same mockup is the next commit.
The "4 commits ahead" / explicit-HEAD-SHA lines in SESSION_HANDOFF.md
were stale the moment 13ae160 (the prior touch-up commit) landed —
docs that count themselves are a recursion trap. Replaced four sites
with pointers to `git log --oneline origin/master..HEAD` and
`git rev-parse HEAD` so future docs-only edits don't immediately
stale the handoff.
Sites updated:
- "Last updated:" preamble.
- "Status at pause" → HEAD locally + ahead-count bullets.
- "Canonical remote" → push reminder.
- Resume prompt → branch state.
The narrative entries under "Since the v0.20.0 cut" still name SHAs
explicitly because those *are* the per-commit anchors readers grep
against; only the rolling totals were brittle.
Pure docs; no code changes, no test impact.
Three small clarity touch-ups to SESSION_HANDOFF.md so a future-session
reader doesn't have to reconstruct intent from git log alone:
- The c84d9f4 narrative listed "header text treatment" as still open;
6204db8 closed it the same session. Added a parenthetical pointer.
- The cacb19c "Skipped" sub-section now cross-links each item to its
follow-up status in the punch list (scanline + cursor pulse → still
open; "RUSTY SOLITAIRE" wordmark → closed, the in-engine wordmark
stays "Solitaire Quest").
- 6204db8 adopted "▌ replay" instead of the mockup's literal
"▌replay.tsx" — the .tsx was a Stitch/React prototyping leak.
Documented the deviation alongside the existing RUSTY SOLITAIRE
precedent so the in-engine string isn't second-guessed later.
Pure docs; no code changes, no test impact.
The prior handoff (f2d2119) was written when [Unreleased] was
accumulating v0.20 candidates. v0.20.0 is now cut at 41a009a and
tagged; four post-cut commits sit on top locally — 39b8496
desktop-adaptation spec, cacb19c splash boot-screen port, c84d9f4
replay-overlay scrub bar finish, 6204db8 replay banner ▌ cursor-
block label — none yet pushed. Working tree is clean.
Rewrites the handoff to:
- Distinguish local-master (6204db8) from origin-master (41a009a)
so the next session doesn't assume git push has happened.
- Document each of the four post-cut commits in its own subsection
under "Since the v0.20.0 cut" — the cycle is closed; these are
early entries in whatever cuts next.
- Name docs/ui-mockups/desktop-adaptation.md as the canonical
geometry reference for future plugin ports — applies to every
screen including the 8 still-unported missing-plugin surfaces.
- Note the Stitch generate_variants reliability issue
(timed out on layout-only adaptation prompts) so a future
session reaches for generate_screen_from_text instead.
- Refresh the SplashFadable scaffolding pattern to the process
notes (introduced in cacb19c) — the reusable shape for any
future overlay that fades N >> 3 elements together.
- Refresh the Resume Prompt's A–F options: push / v0.20.1 cut
decision (A), splash polish (B), replay-overlay enrichments
beyond the scrub bar (C), card artwork regeneration (D), app
icon round (E), APK launch verification + JNI bridges (F).
Tests: 1180 passing / 0 failing. Build clippy-clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aligns the replay overlay's headline with the splash boot-screen idiom
landed in cacb19c — the cursor block (`▌`, U+258C) prefixed to a
lowercased label reads as a Terminal output line rather than a
generic UI title. "Replay" → "▌ replay" and "Replay complete" →
"▌ replay complete" in both the spawn-time path and the per-frame
update_banner_label updater. Doc comments that quote the literal
strings updated in lockstep so the next reader doesn't grep for an
absent literal.
Tests adjusted to match (banner_text assertions in
overlay_spawns_when_playback_starts and overlay_text_changes_on_completed).
The existing 1178 unit tests still pass; clippy clean.
Move-log scroll, MOVE chip, and WIN MOVE callout from the same mockup
remain open — separate commits.
Closes the spawn-time half of the replay-overlay redesign open in
SESSION_HANDOFF.md by adding the 1px cyan scrub bar called for in
docs/ui-mockups/replay-overlay-mobile.html. A track in BORDER_SUBTLE
spans the bottom edge of the banner and the cyan ACCENT_PRIMARY fill
mirrors cursor / total via a new ReplayOverlayScrubFill component +
update_scrub_fill system. The pure scrub_pct helper is shared between
the spawn path (initial fill width) and the per-frame updater so the
first paint already reflects state instead of popping 0 → cursor on
the first tick — same shape as the existing format_progress /
update_progress_text split.
Two new tests (1176 → 1178): scrub_pct_covers_state_corners pins the
helper's four corners (Inactive / cursor=0 / midpoint / Completed) and
overlay_scrub_fill_tracks_cursor drives ReplayPlaybackState end-to-end
and asserts Node.width on the unique scrub-fill entity. Same change-
detection guard as the text updaters, so an idle replay leaves the
node untouched.
Header text treatment, move-log scroll, MOVE chip, and WIN MOVE callout
from the same mockup are still open — separate commits.
Implements the full mockup-spec splash from
docs/ui-mockups/splash-mobile.html plus the desktop adaptation rules
from docs/ui-mockups/desktop-adaptation.md. The header (cursor block,
wordmark, divider, "TERMINAL EDITION" subtitle), boot log (three
✓ check rows + "▌ ready_"), progress bar (1px track with full-width
cyan fill + "DONE · 247 ASSETS" caption), and footer
(BASE16-EIGHTIES label, eight palette swatches, version) all land
together. Rules-driven sizing: boot-log column capped at 480 px on
desktop (otherwise 70 % viewport), progress bar capped at 720 px
(otherwise 80 %), per the desktop-adaptation spec.
Refactored the alpha-fade scaffold from per-marker queries
(SplashTitle / SplashSubtitle / SplashCursor) to a single
SplashFadable { base_color: Color } + SplashFadableBg variant.
~15 fadable elements now share one global query each; adding more
elements is one component-attach, not three new query types.
Skipped (each its own potential follow-up):
- Scanline overlay — needs a tiled-pattern asset or a custom
shader; both are out of scope for a UI-Node port.
- Pulsing cursor on the "ready_" line — would fight the global
fade timeline; stays static.
- "RUSTY SOLITAIRE" wordmark from the mockup — actual product is
"Solitaire Quest"; the mockup leaked the repo name.
Tests: 8 carried + 2 new (Terminal boot-screen content present;
fadables start transparent and reach full alpha).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the spec gap flagged after v0.20.0: the 24 mockups in
docs/ui-mockups/ are 23 mobile + 1 desktop, but desktop is still
the primary delivery surface. Stitch's variant generation kept
timing out on layout-only adaptation prompts, so the deterministic
fix is rules-based: a markdown spec that captures (a) the desktop
viewport assumptions, (b) seven universal adaptation rules that
apply to every screen, and (c) per-screen geometry rules for the
priority surfaces (Game Table, Win Summary, Settings, Help, Pause,
Home, Splash, Stats, Profile / Achievements / Theme Picker / Daily
Challenge).
Why rules > visual mockups for this gap:
- Apply uniformly to every screen — including the 9 missing-plugin
surfaces (splash, challenge, time-attack, weekly-goals, leader-
board, sync, level-up, replay-overlay, radial-menu) that have
only mobile mockups today.
- Reference-able from code comments and commit messages without
loading an image.
- Layout-agnostic by construction: tells the engine "use percent /
flex / min(720, 50%) widths" instead of pinning a specific
desktop pixel layout.
- Cheaper than re-running Stitch generation per screen, which is
flaky for layout-only adaptation work.
Cross-check confirms that v0.20.0's port (modal scaffold, toasts,
table chrome, card chrome, gameplay-feedback, splash cursor) is
already layout-agnostic — the spec gap mattered for *next* ports,
not the work that just shipped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Promotes the [Unreleased] section to [0.20.0] dated 2026-05-07
and opens a fresh empty [Unreleased]. The cycle's two through-
lines:
- **Terminal visual-identity port.** ui_theme token system
(0d477ac) is load-bearing; downstream chrome migrations cover
the modal scaffold, gameplay-feedback layer (ceec4fc), toasts
with a new ToastVariant enum (a137607), table chrome (651f406),
card chrome (d752870), splash cursor (cdcadda), and final
hint-source / dest pairing (9891ae4). Card-face / suit / card-
back palette intentionally NOT migrated — those track PNG
artwork that hasn't been regenerated yet. The 24 Stitch-rendered
mockups and design-system.md spec landed in fa7f98a.
- **Android persistence shim.** solitaire_data::data_dir
routes through a per-platform shim (4b51e50) closing the
CLAUDE.md §10 dirs::data_dir() = None pitfall on Android.
Settings, stats, achievements, replays, game-state, time-attack
sessions, and user themes now persist on a real APK.
Also closes three v0.19.0 punch-list candidates that landed
earlier in the cycle (pull_failure flake at 67c150b, smart-window-
size opt-out at e1b8766, Shareable badge at 9b065e5).
Tests: 1176 passing / 0 failing (six new this cycle: ui_theme
invariant guards, toast-variant-border-mapping, palette-tracking
guards on MARKER_VALID / HINT_PILE_HIGHLIGHT_COLOUR /
RIGHT_CLICK_HIGHLIGHT_COLOUR / toast-border distinctness).
SESSION_HANDOFF.md refreshed: HEAD pointer, test count, the
v0.20.0 changelog summary, the open punch list (Phase Android
runtime gaps, visual-identity follow-ups including the artwork
regeneration item), the updated design-direction box (was
Midnight Purple + Balatro yellow; now base16-eighties Terminal),
and a refreshed Resume Prompt offering A–F next-step options.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the spec the recent visual-identity port pass referenced:
- design-system.md — base16-eighties palette, type scale, spacing
scale, motion budget, component library, accessibility notes
(color-blind toggle, high-contrast mode, glyph differentiation),
and the canonical "Terminal" card-back theme.
- 24 Stitch-rendered mockups (HTML + PNG): 12 redesigned existing
screens, 1 desktop home variant, 2 onboarding steps, and 9
missing-plugin screens (splash, challenge, time-attack,
weekly-goals, leaderboard, sync, level-up, replay, radial-menu).
These mockups are the source the engine plugins were ported
against in commits 0d477ac through 9891ae4 (token system,
modal scaffold, gameplay-feedback layer, toasts, table chrome,
card chrome, splash cursor, hint highlight). Future plugin work
should diff against the matching mockup before touching pixels.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- input_plugin's hint-source card tint moves from raw bright-yellow
`srgba(1.0, 1.0, 0.4, 1.0)` to the design-system STATE_WARNING
token, so the source card and the destination pile (which already
uses STATE_WARNING via HINT_PILE_HIGHLIGHT_COLOUR) wear the same
attention colour as a coherent pair.
- replay_overlay had two stale doc comments referencing the old
"loud yellow accent" — Primary is now cyan (ACCENT_PRIMARY).
Comments updated; no behaviour change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splash now renders the design system's signature `▌` cyan terminal-
cursor glyph (96px) above the wordmark, matching docs/ui-mockups/
splash-mobile.html. The cursor uses ACCENT_PRIMARY and fades on the
same per-frame alpha schedule as the title and subtitle so the
brand beat still dissolves as a single layer.
Did NOT pull in the mockup's full boot-loader treatment (scanline
overlay, ✓ check log lines, progress bar, ROOT@SOLITAIRE prompt) —
those are aesthetic features that warrant their own commit, not
this token-port pass. The splash already consumed every relevant
ui_theme token; the cursor glyph is the single highest-signal
visual element the spec called for.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Drag-elevation shadow now sources its colour from CARD_SHADOW_COLOR
+ CARD_SHADOW_ALPHA_DRAG, so the Terminal "no box-shadow" policy
disables the stack shadow in lockstep with the per-card shadows.
Re-enabling shadows for a future palette swap is now a one-line
edit in ui_theme, not a hunt across plugins.
- RIGHT_CLICK_HIGHLIGHT_COLOUR retuned from raw `srgba(0.2, 0.8, 0.2, 0.6)`
to STATE_SUCCESS's RGB at 60% alpha. Spelled as a literal because
Alpha::with_alpha isn't const on stable; a new test pins the RGB
to STATE_SUCCESS so a palette swap can't drift the two apart.
- Drop the duplicated PILE_MARKER_DEFAULT_COLOUR const — import the
promoted const from table_plugin instead. STOCK_NORMAL_COLOUR is
now an alias of that const so all idle pile-marker tints track a
single source of truth.
- Stock recycle "↺" text changed from raw `srgba(1.0, 1.0, 1.0, 0.7)`
to TEXT_PRIMARY at 0.7 alpha, picking up the off-white foreground
used elsewhere in the Terminal UI.
Card-face / suit / card-back palette constants are intentionally
NOT migrated: the runtime path renders PNG artwork that's still on
the previous "white card" palette, so swapping the fallback
constants ahead of artwork regeneration would mix two visual
systems for any code path where image loading fails.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit 0d477ac (the Terminal token system) pinned both
CARD_SHADOW_ALPHA_IDLE and CARD_SHADOW_ALPHA_DRAG to 0.0 because the
Terminal design system explicitly disallows box-shadow ("depth via
1px borders and tonal layering"). The existing invariant
\`drag_alpha > idle_alpha\` then fails — \`0.0 > 0.0\` is false.
Loosen the assertion to \`drag_alpha >= idle_alpha\` and document the
intent: under Terminal both are 0; under any future palette that
re-enables shadows, drag still must not be weaker than idle. The
useful regression-guard (catching an accidental swap of the two
constants) is preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Promote `marker_colour` to module-level const PILE_MARKER_DEFAULT_COLOUR
and re-export it. cursor_plugin::MARKER_DEFAULT now imports the const
directly, replacing the prior duplicated literal kept in sync only by
doc comment. Drift becomes a compile error instead of a stale claim.
- Empty-tableau "K" placeholder text now uses TEXT_PRIMARY at 0.35 alpha
(was raw `Color::srgba(1.0, 1.0, 1.0, 0.35)`) so it picks up the
Terminal off-white foreground.
- HINT_PILE_HIGHLIGHT_COLOUR retuned from bright `srgb(1.0, 0.85, 0.1)`
to the design-system STATE_WARNING token (`#ddb26f`). Spelled as a
literal because Alpha::with_alpha is not yet const on stable; a new
test pins the RGB to STATE_WARNING so a palette swap can't drift the
two apart silently.
- The existing "is gold" character test was hardcoded to the old bright
palette (red ≥ 0.9). Loosened to "warmer than cool" + ranges that the
Terminal muted gold satisfies, with exact-RGB tracking handled by the
new STATE_WARNING test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Toasts now follow `docs/ui-mockups/design-system.md`:
- Bottom-anchored absolute position (was top / mid-screen)
- Opaque BG_ELEVATED fill (was translucent black-at-alpha)
- 1px accent border keyed off a new ToastVariant enum
- TYPE_BODY_LG caption (was 22 / 32 px literals)
- RADIUS_MD corners
ToastVariant exposes Info / Warning / Error / Celebration, each
mapped to its design-system token via border_color(). Variants are
threaded through every spawn_toast call site:
- Achievement / Level-up / XP / Daily / Weekly / Challenge → Celebration
- Goal-announcement / Time-attack / Settings volume / Auto-complete → Info
Queued banner and fire-and-forget toasts use slightly different
bottom anchors (6% vs. 14%) so a celebration toast spawned in the
same frame as a queued info banner layers above it instead of
overlapping. Two new tests pin variant→border mapping to the
design tokens and require all four borders to be visually distinct.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Selection-highlight tints in selection_plugin and the valid-drop
marker tint in cursor_plugin were hand-tuned RGB literals from the
prior Premium-Solitaire palette. Migrate them to the semantic
state tokens introduced in ui_theme:
- keyboard-drag source highlight (picking) → ACCENT_PRIMARY
- keyboard-drag source highlight (lifted) → STATE_WARNING
- keyboard-drag destination highlight → STATE_SUCCESS
- cursor_plugin::MARKER_VALID → STATE_SUCCESS @ 0.55α
`MARKER_VALID` stays a Color literal (Alpha::with_alpha is not yet
const on stable); a new tracking test pins its RGB to STATE_SUCCESS
so a future palette swap can't drift the two apart silently.
Also fix three stale doc comments in ui_modal that still described
the previous yellow / magenta palette ("Loud yellow CTA",
"Primary swaps to the magenta secondary accent"). Cyan and lavender
now, matching the actual token values.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the prior Premium-Solitaire palette and ad-hoc constants
with the full Terminal (base16-eighties) token set: near-black
surface ramp, cyan primary CTA, lime/lavender/gold/teal/pink
semantic accents, 5-rung type scale, 7-rung 4-multiple spacing
scale, 3-step radius, 14-rung z-index hierarchy, and a complete
motion budget. Card drop-shadow alphas pinned to 0 — Terminal
depth is 1px borders + tonal layering, not box-shadow.
Tokens stay as `pub const` so static contexts (default Sprite
colours etc.) keep compiling; a future UiTheme resource can layer
runtime switching on top without breaking the constant API. Four
unit tests pin the spacing/type/z-index invariants so a careless
edit can't silently break the scale. Plugin-by-plugin migration
to consume these tokens follows in subsequent commits.
Spec: docs/ui-mockups/design-system.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dirs::data_dir() returns None on Android, which silently disabled
every persistence path (settings, stats, achievements, replays,
game-state, time-attack sessions, user themes). New
solitaire_data::platform::data_dir() shim falls through to
dirs::data_dir() on desktop and returns the per-app sandbox at
/data/data/com.solitairequest.app/files on Android — no JNI needed,
since the package id is pinned in
[package.metadata.android].
CLAUDE.md §10 already flagged this as a known pitfall; the shim
pays it down at the one chokepoint instead of per feature.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The v0.19.0 handoff had drifted material across seven commits:
HEAD pointer was wrong (still claimed 6037596; actually 59424a3),
"Tags on origin" still claimed v0.19.0 wasn't pushed, the
known-flake list still mentioned `pull_failure_sets_error_status`
(fixed in 67c150b), and three of four v0.19.0 punch-list
"candidates" had silently shipped without the doc tracking it.
The Android build target landing in fb8b2ac wasn't mentioned at
all despite being the largest single change in the cycle.
CHANGELOG [Unreleased] populated with all seven commits grouped
into Added / Fixed:
Added:
- Android build target — first working APK (fb8b2ac)
- Android developer setup + build runbook (59424a3)
- F3 FPS / frame-time overlay (690e1d2)
- "Smart window size" Settings toggle (e1b8766)
- "Shareable" badge on Latest-win caption (9b065e5)
- Help: M / P / Win-Summary-Enter rows (35516d3)
Fixed:
- pull_failure_sets_error_status flake (67c150b)
SESSION_HANDOFF.md fully rewritten:
- Status section reflects HEAD 59424a3, clean working tree (apart
from this commit's docs), 1170 passing tests, no known flakes
- "Where we are" tracks v0.19.0 candidates' close status (3 of 4
shipped, App icon still open and now blocked on a re-export)
- New v0.20 candidates table covers all seven commits
- New "Phase Android" punch-list section captures the unblocked-
by-fb8b2ac work: APK launch verification, dirs::data_dir port,
JNI ClipboardManager, Android Keystore, gpgs integration, the
cosmetic cargo-apk panic workaround
- Process notes call out the async-test starvation pattern
(seen twice now), the bin→lib+shim refactor as a reusable
pattern, and target-gating-by-default for cross-platform deps
- Resume prompt's A–D menu refreshed to reflect actually-open
work: APK verification, Phase-Android persistence, app icon,
and a v0.20 cut
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1170 passing / 0 failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the toolchain install for Debian 13 (the path Quat ran on
this dev box, including the JDK 21 / unzip / SDK-licence prompts),
the `cargo apk build` invocation, the cosmetic post-pass panic
workaround, and the table of what's wired vs. stubbed for the
android target. Runnable on a fresh box from a clone — no
machine-local context required.
Pairs with the workspace cfg-gating in fb8b2ac. Future Phase-Android
work (dirs::data_dir port, JNI ClipboardManager, Android Keystore,
gpgs) is listed as the not-yet-done section so a contributor can
pick it up without re-deriving the punch list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the workspace through `cargo apk build`. After this commit
`cargo apk build -p solitaire_app --target x86_64-linux-android`
produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk`
containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable
on the AVD or a physical x86_64 device.
The five gating points discovered by iterating compile cycles:
1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib`
to bundle as `libmain.so`; pure-bin crates panic with
"Bin is not compatible with Cdylib". `src/lib.rs` carries the
ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim
that delegates for the desktop `cargo run` path.
2. `[package.metadata.android]` pins target SDK 34 / min SDK 26
so cargo-apk doesn't probe for whatever default it ships
(which on this machine was an uninstalled API 30). `assets =
"../assets"` lets the same asset directory feed both desktop
and APK.
3. Workspace `bevy` features add `android-native-activity` (the
Bevy-side glue that pairs with cargo-apk's NativeActivity
wrapper). The feature is target-gated inside bevy_internal so
desktop builds compile it out.
4. `arboard` (clipboard, used by Stats's "Copy share link") has
no Android backend — `cargo apk build` fails with E0433 on
`platform::Clipboard` if unconditional. Target-gated to
`cfg(not(target_os = "android"))`; the system surfaces an
informational toast on Android until JNI ClipboardManager is
wired in the Phase-Android round.
5. `keyring` + `keyring-core` cannot compile for android — the
transitive `rpassword` uses `libc::__errno_location` which
bionic doesn't expose. Both crates target-gated; `auth_tokens`
ships a stub on Android that returns `KeychainUnavailable` for
every call, matching how callers already handle a Linux box
without Secret Service.
Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed
when it tries to also wrap the bin target. The APK on disk is
unaffected. Working around this with `cargo apk build --lib` is
the next small step.
What's verified:
- Desktop `cargo build`, `cargo clippy --workspace --all-targets`,
and `cargo test --workspace` all clean.
- `cargo apk build -p solitaire_app --target x86_64-linux-android`
produces 54 MB debug APK with libsolitaire_app.so + assets.
What's NOT yet verified:
- Whether the APK actually launches on the AVD / a phone (next
step: `adb install` + `adb logcat` against the bevy_test AVD).
- Whether `dirs::data_dir()` on Android returns a usable path
(sync / persistence will surface this if not).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Performance work for the upcoming Android port needs a numeric
baseline we can quote across desktop and mobile, instead of "feels
slow". DiagnosticsHudPlugin wraps Bevy's FrameTimeDiagnosticsPlugin
and renders a tiny corner readout the developer can toggle with F3.
- Hidden by default — production builds ship the plugin but the
overlay starts invisible.
- F3 reads ButtonInput<KeyCode> directly (not gated by pause /
modal state); diagnostics should always be reachable.
- Reads `smoothed()` FPS + frame_time so the cell isn't a jittery
per-frame scoreboard. Format: "FPS NN \u{2022} M.MM ms".
- Anchored top-right at z = Z_SPLASH + 100 so the readout sits
above every modal / toast / splash layer.
- Update system bails when hidden so we don't pay the
diagnostic-store lookup or text mutation when nobody's looking.
Next up on the perf track: get the Android build target wired so we
can put real numbers in this readout from a phone or emulator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Help (F1) modal's Overlays section listed S/A/L/O but skipped
two post-v0.18 entries — M (Home / Mode launcher) and P (Profile) —
and never mentioned the recently-shipped Enter accelerator that
dismisses the Win Summary.
Help is the canonical keyboard-discovery surface. Three new rows
cover the gap so a player who opens F1 sees every overlay-toggle
key, plus the contextual Enter shortcut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Copy share link button on the Stats overlay only produces a URL
when the displayed replay has a `share_url` populated; otherwise it
surfaces a toast explaining the upload prerequisite. Players had no
way to know the button would work without clicking it.
Adds a "\u{2022} Shareable" suffix to the Latest-win caption when
the displayed replay carries a share_url, matching the format the
v0.19.0 handoff sketched ("Replay 3 / 8 \u{2022} Shareable") for
the future Prev/Next selector. The Prev/Next markers exist in
stats_plugin but no spawn site renders them today, so the live
fix is on the existing single-replay caption.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
launch sizing
Players who specifically prefer the literal 1280×800 baseline on
every fresh-install launch had no way to opt out of the v0.19.0
smart-default sizer. Adds a Gameplay-section toggle (mirrors the
"Winnable deals only" pattern) so they can flip it off.
- New `Settings::disable_smart_default_size: bool` field with
`#[serde(default)]` so legacy `settings.json` files load to the
shipped behaviour (smart sizer enabled).
- Settings panel gains a "Smart window size" row with ON/OFF label
inverting the negative flag, and a tooltip clarifying that saved
window geometry always wins over both branches.
- `solitaire_app::main` reads the flag once at startup and skips
the `apply_smart_default_window_size` registration when it's set.
Mid-session changes apply on next launch (documented on the
field).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fixed 5-update budget in `pull_failure_sets_error_status` was
the last test still subject to the AsyncComputeTaskPool starvation
mode that v0.19.0's auto-save fix already cleared. Under heavy
parallel cargo-test load, 5 updates wasn't always enough for the
failing pull task to surface its Err and flip
SyncStatusResource to Error.
Pumps updates in a loop bounded by a 5-second deadline (with
std::thread::yield_now between iterations to give the task pool a
chance to run), exiting as soon as the status flips. Mirrors the
auto-save flake fix shape — a healthy run hits the assertion in a
handful of frames, while a starved run gets the budget it needs
without hanging the suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes [Unreleased] to [0.19.0]. The release closes v0.18.0's
punch list (async H-key hint, persistent replay share URLs),
expands desktop platform fit (Wayland session support +
monitor-aware default window size), polishes the win-celebration
and double-click animation paths, and clears two test-flake
contributors. The Rusty Pixel pixel-art card theme arc was
prototyped and reverted in the same window — the engine plumbing
(pixel_art ThemeMeta field, PNG manifest face support, second
embedded:// theme channel) was fully reverted and is not part of
this release.
SESSION_HANDOFF.md refreshed to reflect the v0.19.0 ship:
v0.18.0 punch-list items B and D marked shipped; new Open punch
list documents the Rusty Pixel arc as historical, calls out the
desktop-packaging follow-through (app icon next), the
pull_failure_sets_error_status flake (next-round candidate),
and a settings-UI item for the smart-default-size opt-out.
Resume prompt refreshed with the post-v0.19.0 A-D decision menu.
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1170 passing / 0 failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A successful double-click was rendering the slide-to-destination
animation twice — once from the first press's MoveRequestEvent
landing, and again from the release's StateChangedEvent racing the
in-flight CardAnim and replacing it from the mid-animation
position.
The frame trace:
Frame N (second press):
handle_double_click → MoveRequestEvent (queued)
start_drag → DragState set, drag.committed = false
(start_drag never mutates Transform; the
card is still visually in place)
handle_move → applies the move, fires StateChangedEvent
sync_cards_on_change → cur ≠ target, inserts CardAnim slide
(animation #1 starts)
Frames N+1, N+2, …:
follow_drag idles (drag uncommitted, cursor not moving)
CardAnim animates the card from old to new pile
Frame N+K (release):
end_drag → drag.committed = false branch:
drag.clear() + StateChangedEvent ← CULPRIT
sync_cards_on_change → sees the card mid-CardAnim
(cur ≠ target), replaces CardAnim
with a fresh one starting at the
current mid-position (animation #2
visibly restarts the slide)
The fix is one line: drop the StateChangedEvent write in the
uncommitted-drag branch of end_drag. The defensive resync was
never needed there — start_drag only mutates the DragState
resource on press, never card transforms, so an uncommitted drag
has no visual side effect to undo. The committed-drag branch (line
762) keeps its StateChangedEvent write since snap-back from a
real drag does need a resync.
Existing tests pass unchanged. The bug only manifested in the
specific timing of double-click → quick-release before
animation-complete; an integration test would require driving
mouse press/release across several frames with a dispatched
GameMutation pass between, which is heavier than the fix
warrants.
Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
handle_double_click had a coverage gap. The flow was:
- Priority 1: try moving the single top card to its best
destination (foundation, then tableau).
- Priority 2: if Priority 1 failed AND the player clicked the
base of a multi-card stack, try moving the whole stack.
`MoveRejectedEvent` was only fired inside the Priority 2 else-branch
— so a double-click on a single card with no legal destination
fell through both priorities silently: no card_invalid.wav, no
shake animation on the source pile, the player got zero feedback
that the click was acknowledged.
The fix collapses both priorities' failure paths into one
unconditional `MoveRejectedEvent` write at the end of the
double-click branch. Single-card miss now plays the same feedback
as multi-card-stack miss. The early `return` on each successful
move keeps the rejection branch from firing on the success path.
Pre-fix, a player double-clicking the 7♠ buried under a 6♥ on
column 5 (no foundation slot for 7s; no tableau column accepting
black 7) saw nothing happen. Post-fix, the source pile shakes
and the invalid-move sound plays, exactly like a drag-and-drop
rejection.
Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related platform-fit fixes for desktop launch:
1. Wayland session compatibility. The workspace Cargo.toml's
Bevy feature list previously enabled only `x11`, leaving
winit-on-Wayland to fall through to XWayland — the game
rendered inside an X11 frame stitched into the Wayland
compositor instead of as a native Wayland client. Adding
the `wayland` feature lets winit prefer Wayland when
WAYLAND_DISPLAY is set on the session, falling back to X11
when it isn't. Costs a few hundred KB of binary for the
libwayland-client bindings; comment in Cargo.toml explains
the trade.
2. Smart default window sizing. The fallback window size for
first launches (no saved geometry) was a fixed 1280x800. On
a 4K monitor that's a comparatively tiny window in one
corner; the game's cards then occupy a small physical area
even though the screen has plenty of room. New
`apply_smart_default_window_size` Update system queries
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
primary window to ~70% of the monitor's *logical* size on
the first frame. Logical size already factors in the OS's
HiDPI scale factor, so:
- 1920x1080 / 1.0 scale → 1344x756 target
- 2560x1440 / 1.0 scale → 1792x1008 target
- 3840x2160 / 1.0 scale → 2688x1512 target
- 2880x1800 / 2.0 scale (Retina) → 1008x630 target
(same physical size as 1080p)
Clamped to the existing 800x600 minimum so old systems
don't get sub-minimum windows. Skipped entirely when saved
geometry was applied — the player's chosen size always
wins. Uses `Local<bool>` for one-shot semantics; the early-
exit per tick costs nothing once `*applied` is true.
Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The post-win UI was firing TWO celebration surfaces on every
GameWonEvent:
- animation_plugin::handle_win_cascade spawned a 4-second toast:
"You Win! Score: {score} Time: {m}:{ss}"
- win_summary_plugin spawned the proper "You Won!" modal with
score breakdown, time bonus, achievements unlocked, XP earned,
and a Play Again button
Both rendered on top of each other — in screenshots the toast
banner was partially clipped behind the modal card, peeking out
on either side. The toast predates the WinSummary modal; the
modal carries strictly more information so the toast is dead
weight.
handle_win_cascade keeps the cards-fly-off animation
(MotionCurve::Expressive cascade with per-card rotation drift) —
that's the visual celebration, distinct from the textual
celebration the modal owns. The system still gates on the same
GameWonEvent message reader; it just doesn't write a toast
afterward. WIN_TOAST_SECS const removed (no remaining callers).
Workspace: 1172 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The pixel-art card theme generated via Claude Design (53 PNGs at
256x384, ~340 KB total) now ships embedded in the binary alongside
the existing default SVG theme. Players see the new theme in the
picker out of the box without needing to drop files into
~/.local/share/solitaire_quest/themes/.
solitaire_engine/assets/themes/rusty-pixel/:
- 53 PNGs (52 face cards + 1 back) at 256x384
- theme.ron declaring meta.id = "rusty-pixel",
card_aspect = (2, 3), pixel_art = true
assets/sources.rs:
- New constants RUSTY_PIXEL_THEME_MANIFEST_URL,
RUSTY_PIXEL_THEME_MANIFEST_PATH,
RUSTY_PIXEL_THEME_MANIFEST_BYTES.
- New embed_rusty_pixel_png! macro mirroring embed_default_svg!.
- New RUSTY_PIXEL_THEME_PNGS table — 53 entries, one per file.
- New rusty_pixel_theme_png_bytes(filename) lookup helper
mirroring default_theme_svg_bytes for the thumbnail cache.
- New populate_embedded_rusty_pixel_theme(app) registers the
manifest + every PNG into Bevy's EmbeddedAssetRegistry.
- AssetSourcesPlugin::build now calls both populate functions
so the picker has both themes loadable from the binary alone.
theme/registry.rs:
- New rusty_pixel_entry() returns the bundled metadata.
- build_registry now inserts default + rusty-pixel ahead of the
user-dir scan, and filters user themes whose id collides with
a bundled built-in. Bundled wins on collision because it's
guaranteed complete; the user's overriding copy may be partial
or stale.
- Updated existing tests for the new len()=2-instead-of-1 baseline.
- New test user_theme_id_collision_with_bundled_is_dropped pins
the dedup contract.
theme/plugin.rs:
- load_initial_theme + react_to_settings_theme_change now both
consult a new manifest_url_for(theme_id) helper that routes
bundled built-ins through embedded:// and unknown ids through
themes://. Drops the previous hard-coded "default →
DEFAULT_THEME_MANIFEST_URL else themes://" branch.
- read_theme_preview_bytes also checks the rusty-pixel embed
table before falling through to the user-dir filesystem read,
so the picker chip's thumbnail works on a fresh install where
the user-dir doesn't exist.
Workspace: 1172 passing tests / 0 failing, was 1171 (+1 net from
the new collision test). cargo clippy --workspace --all-targets
-- -D warnings clean. Binary grows by ~340 KB (the 53 bundled
PNGs).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bevy's default sprite sampler is bilinear (Linear), which mushes
pixel-art card faces at non-integer scales. The rusty-pixel theme
ships 256x384 source PNGs that get displayed at ~150-200px wide on
typical desktop windows — an aggressive downscale where bilinear
visibly blurs the pixel grid.
Globally flipping ImagePlugin to default_nearest() would also affect
the SVG-rasterised default theme, where bilinear's smoothing is
actually desired (the SVG rasteriser produces a high-res 512x768
pixmap that the GPU has to downscale at draw time).
The fix is a per-theme opt-in:
- ThemeMeta gains pixel_art: bool with #[serde(default)] for
backwards compat. Older manifests load with `false`, preserving
SVG-default behaviour.
- sync_card_image_set_with_active_theme inspects theme.meta.pixel_art
after a theme finishes loading. When true, walks every face +
back Handle<Image> in the active CardTheme and rewrites its
sampler to ImageSampler::Descriptor(ImageSamplerDescriptor::nearest()).
The Modified asset event triggers a GPU re-upload with the new
sampler descriptor.
- The 12 ThemeMeta struct literals across the engine
(settings_plugin, card_plugin, theme/{plugin,mod,manifest,
importer,registry}) all gain `pixel_art: false` to match the
new field.
The deployed rusty-pixel theme.ron at
~/.local/share/solitaire_quest/themes/rusty-pixel/ now sets
pixel_art: true, so the player's switch-to-pixel-art chip flips to
nearest sampling on the spot.
Workspace: 1171 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The theme picker chip's thumbnail loader hardcoded `.svg`
filenames (`spades_ace.svg`, `back.svg`) — a holdover from when
every shipped theme was vector-art. Raster-art user themes (e.g.
the v0.19 pixel-art theme generated via Claude Design and dropped
into ~/.local/share/solitaire_quest/themes/rusty-pixel/) had real
PNGs in their directory but the picker rendered placeholders
because it never tried the PNG sibling.
The fix is scoped to the thumbnail-cache pipeline. In-game card
rendering already worked via Bevy's standard PNG asset loader on
manifest-declared face/back paths — only the picker's small
preview chip was affected.
Changes in solitaire_engine/src/theme/plugin.rs:
- PREVIEW_FACE_FILENAME / PREVIEW_BACK_FILENAME (with embedded
`.svg` suffix) replaced by PREVIEW_FACE_BASENAME /
PREVIEW_BACK_BASENAME ("spades_ace" / "back"). The function
appends the extension itself.
- read_theme_preview_svg_bytes -> read_theme_preview_bytes
returns ThemePreviewBytes::{Svg, Png}. For "default" the
embedded table stays SVG-only. For user themes the function
tries `<basename>.svg` first (matching the bundled
convention) and falls back to `<basename>.png` second.
- rasterize_preview_to_handle gains a Png branch that calls a
new decode_png_for_thumbnail helper (Bevy's
Image::from_buffer with ImageType::Format(ImageFormat::Png)).
PNGs decode at native dimensions; the picker chip's UI
layout scales them at draw time. SVGs continue to rasterise
at the fixed 100x140 thumbnail size as before.
- generate_thumbnail_pair_for is unchanged in shape; just
threads the new enum through.
Tests:
- read_default_theme_preview_returns_some_for_canonical_files
updated to match the new function signature and assert on
the Svg variant explicitly.
- New png_only_user_theme_generates_real_thumbnails creates a
temp theme dir, writes a 2x3 PNG (encoded at runtime via the
`image` dev-dep so the bytes are guaranteed valid), and
asserts both ace + back yield non-default Handle<Image>.
Cleans up the temp dir afterward.
solitaire_engine/Cargo.toml: image = "0.25" added as a
dev-dependency for the test's runtime PNG encoding. Already a
transitive Bevy dep so the build graph is unchanged.
Workspace: 1171 passing tests / 0 failing, was 1170 (+1 new).
cargo clippy --workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three commits sit on top of v0.18.0 — async H-key hint
(3e11e9e), persistent replay share URLs (42d90b1), and the
auto-save flake fix (91b7605). [Unreleased] now describes them
as Changed / Fixed bullets ready to promote to a [0.19.0]
section whenever the next cut feels right. SESSION_HANDOFF.md
marks v0.18.0 punch-list items B and D as shipped, preserves C
(desktop packaging) as still gated on artwork + signing certs,
and refreshes the resume prompt's A–D menu around the
v0.19.0-cut decision. The previous handoff's
`-c user.name=...` workflow note is replaced with a pointer to
the system git config (which is now correct on this machine via
the v0.18.0 push session's `gh auth setup-git`).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
auto_save_writes_after_30_seconds intermittently failed under
heavy parallel cargo-test load. Two contributing factors, both
fixable in test fixtures alone:
1. GamePlugin::build() reads dirs::data_dir()/.../game_state.json
before per-test resource overrides apply. If a real
game_state.json exists on the dev machine, it's loaded into
PendingRestoredGame, and auto_save_game_state's pending guard
(`pending.0.is_some()`) silently skips the save. test_app now
resets PendingRestoredGame(None) after plugin build so the
production save state can't leak into per-test world state.
2. Time::delta_secs() on the first MinimalPlugins frame can be
0.0 (nominal) or, under cargo-test parallelism, large enough
to consume the 0.1 s pre-seeded margin past the threshold.
The test now re-arms AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS +
1.0) every iteration in a 16-frame bounded loop, breaking
the moment the file appears. Robust against first-frame Time
variance with no behaviour-contract change.
No production-code change. Verified: 3 back-to-back single-test
runs all pass. Full workspace test suite: 1170 passing / 0 failing.
cargo clippy --workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The v0.18.0 share-link affordance lived in an in-memory
LastSharedReplayUrl resource that was wiped on quit; the player had
to re-open Stats and re-share within the same session of the win.
The Stats overlay's Prev/Next selector also surfaced older replays
that had no share link at all even when those wins had been
uploaded successfully.
This bundles the URL with the replay it belongs to:
- Replay (solitaire_data) gains share_url: Option<String> with
#[serde(default)]. No REPLAY_SCHEMA_VERSION bump — older
replays.json files load unchanged with share_url == None on
every entry. Replay::new() defaults the field to None.
- poll_replay_upload_result (sync_plugin) writes the resolved URL
into ReplayHistoryResource::0.replays[0].share_url and persists
the updated history via save_replay_history_to. The
cancel-on-replace contract in push_replay_on_win guarantees
replays[0] is the win whose URL the task is carrying — at most
one upload is ever in flight, and it's always the most recent
win.
- handle_copy_share_link_button (stats_plugin) reads from
history.0.replays[selected.0].share_url instead of
LastSharedReplayUrl, so the Prev/Next selector's currently-
displayed replay drives the clipboard contents. Each historical
win keeps its own URL.
- LastSharedReplayUrl resource removed entirely — its only role
was bridging the upload-poll system to the Copy button, and
that channel is now the share_url field on the replay record.
Tests:
- solitaire_data: replay_loads_when_share_url_field_is_absent
pins backwards-compat — a pre-v0.19.0 Replay JSON without the
field deserialises with share_url == None.
- solitaire_engine sync_plugin: upload_result_writes_share_url_into_replay_and_persists
drives a pre-resolved AsyncComputeTaskPool task into
PendingReplayUpload, pumps update() until the poll system
resolves it, and asserts both the in-memory replays[0]
carries the URL and a fresh load_replay_history_from(path)
picks it up.
Workspace: 1170 passing tests / 0 failing, was 1168 (+2 net).
cargo clippy --workspace --all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the last solver-on-main-thread hot path. The synchronous
v0.17.0 hint flow called solitaire_core::solver::try_solve_from_state
inline on every H press; median latency was ~2 ms but pathological
positions hit the SolverConfig::default() cap at ~120 ms — a visible
input stall on the same frame the player presses H.
Mirrors the d489e7a PendingNewGameSeed pattern. New module
pending_hint.rs holds:
- PendingHintTask resource carrying an Option<HintTask> with
handle: Task<HintTaskOutput> plus move_count_at_spawn for
staleness detection.
- HintTaskOutput enum: SolverMove { from, to } when the verdict
is Winnable + a first_move; NeedsHeuristic when the solver
returns Unwinnable or Inconclusive.
- poll_pending_hint_task system: polls the task each frame and
surfaces the result via the now-public emit_hint_visuals (or
runs find_heuristic_hint on the live state for the
NeedsHeuristic branch). Discards the result when
GameState.move_count has advanced past move_count_at_spawn.
- drop_pending_hint_on_state_change system: any
StateChangedEvent drops the in-flight task. Cooperatively
cancels via Bevy's Task Drop at the next await point.
- PendingHintTask::spawn implements cancel-on-replace — a fresh
H press while a previous task is in flight overwrites the
handle, dropping the prior task.
input_plugin changes:
- handle_keyboard_hint becomes a thin spawn point. Snapshots
the live state, asks the solver via PendingHintTask::spawn,
returns. No card-entity query, no event writers for the
hint visual / toast — the polling system owns those.
- emit_hint_visuals promoted to pub so pending_hint can call it.
- find_heuristic_hint extracted as a pub helper for the
NeedsHeuristic poll path.
- InputPlugin registers PendingHintTask + the two new systems.
drop-on-state-change is chained .before() poll so a move
applied this frame cancels any in-flight task before its
result can be surfaced.
Tests:
- input_plugin: pressing_h_spawns_pending_hint_task (1) — pins
the H-key wiring at one-frame granularity.
- pending_hint: winnable_solver_emits_hint_after_async_completes,
state_change_drops_in_flight_task,
second_spawn_drops_first_in_flight_task (3) — drives the
AsyncComputeTaskPool with a wall-clock-bounded loop mirroring
the winnable_seed_search_* template.
- Removed two now-stale synchronous tests
(hint_uses_solver_when_winnable,
hint_falls_back_to_heuristic_when_solver_inconclusive) — the
behaviours they pinned now live in pending_hint::tests at the
correct layer.
Workspace: 1168 passing tests / 0 failing, was 1166 (net +2:
removed 2 stale, added 4 new). cargo clippy --workspace
--all-targets -- -D warnings clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CHANGELOG.md gains a [0.18.0] section synthesising the 24 commits
since v0.17.0: the Restore prompt + auto-show Home picker launch
flow, MSSC-style picker (header chips, draw-mode chips, picture
tiles with FiraMono-covered glyphs, Today's Event callout), the
last solver hot path moving onto AsyncComputeTaskPool with
cancel-on-replace, "Won before" HUD chip, "Copy share link" Stats
button via arboard, the N-key flow finally routing through the
real Confirm/Cancel modal, Esc-on-modal layering fixes, and the
unified-3.0 Claude rule set adoption.
SESSION_HANDOFF.md (root) refreshed to reflect HEAD at
v0.17.0-24-gc497c31, the carryover punch list trimmed (items B
and C shipped, A partially shipped, D unchanged), and a new
Process notes section describing the test-discipline prune and
the smaller-port template the async hint work should follow.
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1166 passing / 0 failing (one flake on
auto_save_writes_after_30_seconds reproduced clean on re-run;
passes in isolation).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The HUD's elapsed-time counter ticked from the moment the default
Classic deal landed at startup, even though the auto-show Home
picker was still up — so the player saw "0:11" before they had
chosen a mode. Time Attack had the same issue when M was pressed
mid-session: the 10-minute countdown burned while the player browsed
modes.
`tick_elapsed_time` and `advance_time_attack` now also gate on the
absence of `HomeScreen`, mirroring their existing `PausedResource`
check. The Home modal already covers input via its scrim, so this
purely freezes the timer without coupling to the pause-overlay
ownership of `PausedResource`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking the new Home header chip opens Profile on top of Home.
Pressing Esc then closed Home (because handle_home_cancel_button
fired on Esc with no awareness of layered modals) and left Profile
orphaned over the game — the player had to press P afterwards just
to dismiss what they meant to dismiss in the first place.
Two changes restore the standard "Esc closes the topmost modal"
contract:
- profile_plugin: split P/button (toggle) from Esc (close-only).
Esc only fires when Profile is currently open.
- home_plugin: handle_home_cancel_button now skips its Esc branch
when any other ModalScrim exists, deferring to whichever modal
is on top. Click on the explicit Cancel button is unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
triangles inconsistently)
Quat: ▶ (U+25B6) rendered as tofu even though ▲ (U+25B2) from the
same Geometric Shapes block works. FiraMono evidently ships the
up/down triangles but not the left/right siblings.
Swapped to U+2192 (RIGHTWARDS ARROW) from the Arrows block, which
is part of every dev-oriented monospace font's core coverage. Reads
as "go / fast-forward" for the timed mode and is visually distinct
from the other 4 tile glyphs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bundled face is FiraMono-Medium (assets/fonts/main.ttf), and its
glyph table covers card suits (U+2660-2666) plus basic Geometric
Shapes (U+25xx) but not Dingbats / Misc Symbols. The previous round
of "BMP fallbacks" still picked from blocks FiraMono doesn't cover,
so 4 of 5 tiles continued to render as tofu.
Re-picked from ranges FiraMono actually has:
- Daily: U+25C6 (BLACK DIAMOND)
- Zen: U+25CB (WHITE CIRCLE) — Zen enso
- Challenge: U+25B2 (BLACK UP-POINTING TRIANGLE) — climbing
- TimeAttack: U+25B6 (BLACK RIGHT-POINTING TRIANGLE) — play / FF
- Classic keeps U+2663 (BLACK CLUB SUIT)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions Quat caught in screenshot review of the picture-tile
rework:
1. Tofu boxes for 4 of 5 tiles. The earlier emoji picks (calendar,
cherry-blossom, lightning, stopwatch) live in Unicode planes that
most Linux desktop fonts don't cover, so they rendered as
missing-glyph rectangles. Swapped to BMP / Dingbats codepoints
that the system-default font fallback always has:
- Daily: \u{2605} (BLACK STAR)
- Zen: \u{2740} (WHITE FLORETTE)
- Challenge: \u{2726} (BLACK FOUR-POINTED STAR)
- TimeAttack: \u{231A} (WATCH, Misc Symbols / Unicode 1.1)
Classic keeps its club (\u{2663}) — already rendered correctly.
2. Cancel button pushed off the bottom of the viewport. The 3-row
tile grid alone is ~540 px; on the 800x600 minimum window the
modal exceeded the screen. Wrapped chips + draw row + grid in a
`HomeScrollable` Node with `max_height: 70vh` and `Overflow::scroll_y()`,
adding a `scroll_home_panel` system to drive `ScrollPosition` from
`MouseWheel`. Mirrors the existing Settings / Leaderboard /
Achievements scrollable pattern. Cancel sits outside the scroll
so it's always reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B step 2 of the MSSC-inspired Home rework. Mode cards become a
wrapping 2-up grid with a centred Unicode-glyph centrepiece per tile,
standing in for real per-mode artwork until that lands.
- HomeMode::glyph() returns the placeholder codepoint for each mode:
♣ Classic, calendar Daily, cherry-blossom Zen, lightning Challenge,
stopwatch TimeAttack. Cherry-blossom is used over lotus-position
because the latter renders inconsistently across desktop fonts.
- The mode-card loop is wrapped in a FlexWrap::Wrap row container.
Tiles set `width: 48%` + `min_height: 180px`; the 5-mode grid
wraps to a third row of one tile, mirroring the half-cell asymmetry
in MSSC's screenshot.
- The glyph paints in ACCENT_PRIMARY when the mode is unlocked and
TEXT_DISABLED when locked, so the gate reads at a glance.
- When real art lands, swap the Text node for an Image node — the
rest of the tile layout, focus order, click handling, and chip
rendering are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B step 1 of the MSSC-inspired Home rework — surfaces today's
daily-challenge metadata on the Daily card so the picker reads as
"there's something fresh waiting" rather than a generic mode label.
- Date line "Today, May 6" pulled from DailyChallengeResource. Reads
in STATE_INFO blue while the run is still open.
- Server-fetched goal (when SyncPlugin is wired) appears underneath
as "Goal: Win in under 5 minutes", matching the toast that already
fires when the player presses C.
- Once the player has recorded today's completion, the date flips
to "Today, May 6 \u{2022} Done" in ACCENT_PRIMARY so the picker
reads as a reward state rather than a TODO.
Headless tests omit DailyChallengePlugin, so HomeContext.daily_today
defaults to None and the card falls back to its baseline layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A of the Microsoft-Solitaire-Collection-inspired launch picker
rework. Three additive changes inside the Home modal, no core / asset
work:
- Player-stats header strip showing Level / XP / Lifetime Score using
a compact formatter (1.2M / 12.3K / 1,234). The whole strip is a
Button — click fires ToggleProfileRequestEvent so Profile opens on
top of Home; closing it returns to the picker.
- Draw-mode chip row above the mode cards lets the player flip
Draw 1 / Draw 3 from the picker itself rather than diving into
Settings. Active chip uses ACCENT_PRIMARY background; the click
persists settings.json and respawns the modal so the active state
repaints cleanly.
- Per-mode score/streak chip on each card — "Best 12,345" for
Classic / Zen / Challenge, "Streak N" for Daily. Hidden on a 0
best so a fresh profile doesn't read "Best 0" everywhere.
`HomeContext` bundle pulls live data from ProgressResource /
StatsResource / SettingsResource with safe defaults so headless
tests under MinimalPlugins still build cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolving the Welcome-back / Restore prompt (either Continue or New
game) cleared `PendingRestoredGame` and despawned the modal, but the
launch-time Home auto-show then fired the next frame and stacked
itself over the player's chosen path — clicking "New game" would deal
a fresh game AND immediately pop the mode picker on top.
`LaunchHomeShown` becomes pub so `handle_restore_prompt` can flip it
to `true` after either resolution; `M` still re-opens the picker on
demand. Headless tests already pre-set the flag to true via
`HomePlugin::headless()`, so they're unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Home and Restore-prompt previously ignored Esc, which after the last
fix meant Esc just did nothing on those screens. Now both honor the
"Esc closes the modal" convention every other modal already follows.
- Home: Esc behaves like the Cancel button — despawns the modal so
the player keeps the underlying default deal.
- Restore: Esc maps to Continue rather than New Game; a reflexive
dismiss press preserves the saved game, matching how the primary
action already advertises the Enter accelerator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A single Esc press while the Confirm New Game / Restore / Home /
Onboarding / Settings modals were open would both close the modal
(via its own input handler) and spawn the Pause overlay on top in
the same frame, dumping the player on a screen they didn't ask for.
toggle_pause now skips when any non-Pause `ModalScrim` is in the
world. The HUD-button path is gated too — clicking Pause while
another modal is up is almost always an accident.
The four modal queries are bundled into a `PauseModalQueries`
SystemParam to stay under Bevy's 16-parameter cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-win modal's "Play Again" was click-only — keyboard-only
players had to reach for the mouse to leave the celebration screen,
and the button advertised no accelerator the way every other modal
button does.
- handle_win_summary_keyboard reads Enter while WinSummaryOverlay is
in the world; despawns the overlay and writes the same
NewGameRequestEvent the click handler takes.
- The button label gains a trailing return-key glyph so the keyboard
path is discoverable on first sight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Home (mode picker) was only reachable via M during gameplay, so
players who hadn't discovered the hotkey never saw the Daily / Zen /
Challenge / Time Attack entry points after the splash cleared.
- HomePlugin gains an `auto_show_on_launch` flag (default true) and a
matching `headless()` test constructor that disables it.
- spawn_home_on_launch flips a one-shot LaunchHomeShown flag once the
splash has cleared, gated on RestorePromptScreen / PendingRestoredGame
so the Welcome-back flow still takes precedence on machines with a
saved game.
- App entry uses HomePlugin::default(); both headless test fixtures
switch to HomePlugin::headless() so per-test worlds start clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a first N press during an active game showed a "Press N
again" toast and started a 3-second countdown — a UI-first violation
since the only continuation was another keystroke. The HUD New Game
button already routed through `ConfirmNewGameScreen` with real Cancel
/ New game buttons; this change makes keyboard N do the same.
- handle_keyboard_core fires NewGameRequestEvent::default() directly;
handle_new_game's existing active-game check spawns the modal.
- Shift+N keeps the keyboard power-user bypass (confirmed: true).
- N is suppressed while the confirm modal or restore prompt is open
so those modals' own input handlers can process N (cancel /
start-new-game) without us re-firing the same frame they close.
- KeyboardConfirmState, NEW_GAME_CONFIRM_WINDOW, NewGameConfirmEvent,
and the "Press N again" toast handler are removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Leaderboard empty state: replace single muted line with a two-tier
"Be the first on the leaderboard." headline + body invite.
- Achievements panel: surface a first-launch hint above the grid until
the player unlocks anything, so the greyed-out rows aren't context-free.
- Volume hotkeys ([/]): emit an InfoToastEvent with the new percentage so
off-panel adjustments give visible feedback (previously silent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat: opening Modes / Menu showed a solid dark-purple block in the
top-right with no readable content. Cause: the auto-fade system on
the top-level action bar was fading the popover rows too — they
share the `ActionButton` marker so `paint_action_buttons` can still
paint hover/press, but `apply_action_fade` matched the same marker
and dropped their alpha to whatever the cursor-position-based
fade happened to be (typically 0 because the cursor was inside the
opened popover, well below the top reveal zone). The popover
container stayed at full opacity (its background is `BG_ELEVATED`,
not driven by the fade), so what the player saw was the empty
rounded box with no labels.
Fix: new `PopoverRow` marker on the rows in `spawn_modes_popover`
and `spawn_menu_popover` (both share the same row-spawn shape).
`apply_action_fade` excludes `PopoverRow` via `Without<PopoverRow>`.
Hover / press paint still applies — the popover rows just opt out
of the cursor-position auto-fade since they only render when the
player has explicitly opened the dropdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat reported the volume UI overlapped with adjacent UI elements in
the Settings panel. The five slider/toggle row helpers
(volume_row × 2, tooltip_delay_row, time_bonus_multiplier_row,
replay_move_interval_row, toggle_row) all used the same flex pattern:
Node {
flex_direction: Row,
align_items: Center,
column_gap: VAL_SPACE_2,
}
with no width constraint and no justify_content. Result: every
child packed against the left edge with 8 px gaps. As the value text
varied in width (e.g. "0.80" → "1.00", or "Instant" vs "1.5 s") the
+/− buttons shifted sideways frame to frame, and on narrow windows
the row's natural width could exceed the modal interior, pushing
elements past the right edge or visually merging with neighbours.
Restructured all five helpers to a label-spacer-cluster layout:
[Label] [Value] [-] [+]
└────── flex-grow=1 ──────┘ └─ cluster ─┘
with `width: Val::Percent(100.0)` on the row so it spans the body
width. The flex-grow spacer absorbs all slack horizontal space; the
controls cluster (value + buttons) sits flush against the right
edge regardless of value-text length. Existing tests still pass —
no behaviour change, just stable layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat: replay sharing as the next punch-list item.
End-to-end:
1. Player wins a game on a server-backed sync backend.
2. `sync_plugin::push_replay_on_win` spawns the upload task on
`AsyncComputeTaskPool` and stores the handle in the new
`PendingReplayUpload` resource. The previous in-flight task (if
any) is dropped — the most recent win is the one whose share link
the player will care about.
3. `poll_replay_upload_result` harvests the task on the main thread
each frame; on success writes `<server>/replays/<id>` to
`LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider)
is silently absorbed; real network/auth errors warn-log.
4. The Stats overlay's action bar gains a "Copy share link" button.
Click writes `LastSharedReplayUrl` to the OS clipboard via
`arboard` and surfaces a "Copied: <url>" toast.
Trait change: `SyncProvider::push_replay` now returns `Result<String,
SyncError>` (the share URL) instead of `Result<(), SyncError>`. The
default (`UnsupportedPlatform`) is unchanged for non-server backends;
`SolitaireServerClient` parses the response body's `id` field and
composes `<base_url>/replays/<id>`. Both call paths (initial + 401
retry) go through the new `share_url_from_response` helper so the
parse logic isn't duplicated.
New deps:
- `arboard` (~10 KB, cross-platform clipboard) added to workspace +
`solitaire_engine`. `default-features = false` keeps the X11/Wayland
binary-feature deps off the dependency graph; arboard handles the
fallback. Approved per the ASK BEFORE rule.
Persistence: the URL is in-memory only — the player must share within
the session of the win. A future revision can persist it alongside
the replay history file if cross-session sharing is needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the current deal's (seed, draw_mode, mode) triple matches an
entry in the rolling ReplayHistory, the HUD's tier-2 context row
now shows "✓ Won before" in the success-green colour. Cleared when
the active game itself is won (the on-screen victory cue is enough)
and on fresh deals the player hasn't beaten before.
The indicator answers a question the rolling-history feature
implicitly raised: when a new game starts on a seed the player has
already conquered, surface that fact so they know they can try for
a faster / higher-scoring win on the same layout. Seed re-rolls in
"Winnable deals only" + system-time seeds make this a natural pace
for the indicator to fire — usually empty, occasionally lit.
Implementation: new `HudWonPreviously` marker spawned in tier-2
alongside Mode / Challenge / DrawCycle. Driven by a separate
`update_won_previously` system rather than threading the marker
through `update_hud`'s ten-way query disambiguation. Reads the
existing `ReplayHistoryResource` from `stats_plugin`; gracefully
no-ops in headless tests that don't load StatsPlugin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quat reported the restore prompt didn't appear and noticed their
save file ended up with move_count 0 — diagnosed as a destructive
overwrite. The flow:
1. Player exits with moves; game_state.json has move_count > 0.
2. Player relaunches. Plugin build sees moves > 0, holds the saved
game in `PendingRestoredGame`, seeds `GameStateResource` with a
fresh deal so the board doesn't show the half-played game until
the player picks Continue.
3. The restore prompt should appear. (Why it didn't on Quat's run
is still TBD — needs a fresh test.)
4. Player exits. `save_game_state_on_exit` writes
`GameStateResource` (the fresh-deal placeholder) to disk,
overwriting the meaningful saved game with move_count 0.
Both `save_game_state_on_exit` and `auto_save_game_state` now check
`PendingRestoredGame`: if it still holds an unanswered saved game,
they save THAT (or skip entirely in the auto-save path). The real
saved game on disk is preserved across launches no matter how many
times the player exits without answering the prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the engine silently restored any saved in-progress game
from `game_state.json` on startup. Players who launched expecting a
fresh deal got dropped back into a half-played game with no signal
that a save had been picked up; players who wanted to continue had
no clear acknowledgement either way.
Now: when launching with a saved game that has at least one move
and isn't already won, the engine holds the saved state in a new
`PendingRestoredGame` resource and seeds `GameStateResource` with
a fresh deal. Once the splash overlay finishes, a modal appears:
Welcome back
You have an in-progress game. Continue where you left off, or
start a new one?
[New game] [Continue]
- Continue (Enter / C / click) — swaps the saved game into
`GameStateResource` and fires `StateChangedEvent`. Card sprites
resync to the restored layout.
- New game (N / click) — drops the saved state, fires
`NewGameRequestEvent { confirmed: true }`. The existing
`handle_new_game` flow then deletes `game_state.json` and deals.
Save files with `move_count == 0` (a fresh deal that was never
played) skip the prompt and load directly — there's nothing
meaningful to "continue" there. Won games skip too (the existing
flow already deletes their save file on win).
The spawn system gates on `SplashRoot` being absent so the modal
doesn't pop up over the brand splash on first launch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Winnable deals only" used to call `choose_winnable_seed` inline on
the main thread inside `handle_new_game`. Each rejected attempt costs
~120 ms (`SolverConfig::default()` budget); the loop caps at
`SOLVER_DEAL_RETRY_CAP` = 50, so a pathological run could stall the
UI for ~6 s on a New Game click. Quat flagged this as the highest-
impact UX regression left in the engine.
Reorganised so the solver runs on `AsyncComputeTaskPool`:
- New `PendingNewGameSeed` resource holds an `Option<PendingSeedTask>`
carrying the in-flight `Task<u64>` plus the request's `mode` and
`confirmed` flags so the polling system can replay them on a
synthetic `NewGameRequestEvent` once the task resolves.
- `handle_new_game` now writes to that resource (and `continue`s)
for the winnable-only / Classic / random-seed branch, instead of
calling `choose_winnable_seed` synchronously.
- `poll_pending_new_game_seed` runs `.before(GameMutation)` so the
synthetic event lands in the same frame's `handle_new_game` —
the player sees no extra-frame visual lag once the solver
completes.
- Cancel-on-replace: when a fresh `NewGameRequestEvent` arrives
while a previous task is in flight, `pending_seed.inner = None`
drops the old task (Bevy's `Task` Drop cancels cooperatively at
the next await point) before processing the new request.
Two tests:
- `winnable_seed_search_runs_async_and_completes_eventually` —
spawns the task, drives `app.update()` in a wall-clock-bounded
loop with `std::thread::yield_now()` so the shared
`AsyncComputeTaskPool` gets a chance to schedule between polls.
- `winnable_seed_search_drops_in_flight_task_on_new_request` —
fires a winnable-only request, then before the task can complete
fires an explicit-seed request that bypasses the solver entirely.
Asserts the explicit seed wins, verifying the cancel-on-replace
contract.
Existing solver tests pass unchanged: explicit-seed paths skip the
new branch and run synchronously like before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopts the four-file rule set the player added to the working tree:
- CLAUDE.md grows from a 114-line pointer doc to the 571-line
`unified-3.0` rulebook: hard global constraints (§2), engine
rules (§3), asset rules (§4), code standards (§5), build +
verification (§6), git workflow (§7), the change-control
ASK BEFORE list (§8), and the Context Injection System (§14).
- CLAUDE_SPEC.md — formal architecture spec: crate dependency
graph with forbidden_deps, data ownership map, state-machine
invariants ("52 cards always exist", "no duplicate IDs",
"all cards belong to exactly one pile"), sync merge contract,
server contract, validation checklist.
- CLAUDE_WORKFLOW.md — two-agent Builder/Guardian pipeline with
hard-fail patterns that auto-reject (core uses IO/Bevy/network,
GameState mutated outside GameLogicSystem, blocking async on
main thread, duplicate logic, merge altered incorrectly).
- CLAUDE_PROMPT_PACK.md — task-type templates.
Three duplicate rule passages removed:
- CLAUDE_SPEC.md §0 dropped no_panics_in_core / core_is_pure /
event_driven_engine — already canonical in CLAUDE.md §2.1, §2.3,
§3.1. Kept single_source_of_truth and sync_is_additive (those
describe data flow, not in CLAUDE.md).
- CLAUDE_SPEC.md §11 Prohibited Patterns now references CLAUDE.md
§11 instead of restating the same five forbidden items.
- ARCHITECTURE.md Design Principles dropped the pure-core /
no-panics / UI-first bullets — those are enforcement constraints
living in CLAUDE.md §2.1, §2.3, §3.3; this file describes the
design that motivates them. Kept the offline-first, one-language,
and plugin-based-Bevy bullets (those are descriptive, not
enforcement).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Quat-flagged "≥3 tests per feature" inflation produced 43 tests
that don't earn their existence — default-value, serde-derive
round-trips on plain structs, single-field clamp tests, near-
duplicates, and trivial constant-equals-itself tests. None pin a
behaviour contract or a regression on a real bug.
Removed across `solitaire_data` and `solitaire_core`:
settings.rs −22 default-value, round-trip, legacy-format,
and per-field sanitized clamp tests. Adjust
and load-error tests retained — those exercise
real method logic.
progress.rs −1 generic round-trip on plain struct.
challenge.rs −1 challenge_count() returns CHALLENGE_SEEDS.len()
literally — testing it asserts the implementation
against itself.
game_state.rs −3 undo_count starts at 0, GameMode default is
Classic, time_attack score starts at 0 — all
default-value tests on freshly-constructed state.
card.rs −5 rank_value_ace + rank_value_king subsumed by
rank_values_are_sequential; suit_red + suit_black
consolidated into one complementarity test;
card_face_up_field_reflects_construction was
testing the struct literal.
Workspace: 1208 → 1165 passing tests (−43). clippy --workspace
--all-targets clean.
Future work: brief sub-agents for tests that pin a behaviour
contract or regression on a real bug, not a count of N. See
`feedback_test_discipline.md` in auto-memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up commits on top of v0.16.0:
- 87275bf: H-key hint asks the v0.15.0 solver for the actual best
first move, with the existing heuristic kept as fallback.
- 53e3b81: Settings → Gameplay slider tunes replay playback rate
(0.10–1.00 s, default 0.45 s) read per frame from SettingsResource.
Adds the [0.17.0] CHANGELOG section, folds the post-v0.16.0
provisional table into a v0.17.0 shipped table in SESSION_HANDOFF,
prunes the now-stale "Cut v0.17.0" item from the punch list, and
re-letters the resume-prompt decision options A–D.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-v0.16.0 table marked the replay-rate slider as `(pending)`
but 53e3b81 already shipped it. Resume prompt said "HEAD at v0.16.0
/ 1196 tests" while the same doc above said HEAD was post-v0.16.0
with two follow-ups and 1208 tests.
Updates the slider row to reference 53e3b81, refreshes the resume
prompt's HEAD/test counts, and rewrites the "DECISION TO ASK THE
PLAYER FIRST" list — drops the smoke-test and "solver hints" bullets
(both already covered) and pulls forward the actual open items
(cut v0.17.0, solver-on-AsyncComputeTaskPool, won-previously,
replay sharing, packaging).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the two follow-ups landed on top of v0.16.0 (solver-driven
hints in 87275bf, replay-rate slider in this commit's parent) and
notes that an async-solver attempt was rolled back when a sub-agent
was interrupted leaving 3 failing tests. Async-solver is still
worth doing but needs smaller scoping next round.
Also records the process note raised this session: agent briefs had
been mandating ≥3 tests per feature, which produced low-value
coverage on trivial settings fields (Default trait arithmetic,
serde derive round-trips, stdlib clamp). Future briefs should ask
only for tests that pin behaviour contracts or regressions on real
bugs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The replay overlay's per-move tick rate has been hardcoded at
REPLAY_MOVE_INTERVAL_SECS = 0.45 s/move since the in-engine
playback shipped. Power users want to scrub faster through older
wins. Adds a Settings slider that tunes the interval 0.10–1.00 s in
0.05 s steps; default 0.45 s preserves existing feel.
Settings.replay_move_interval_secs uses #[serde(default)] so legacy
files load to 0.45. sanitized() clamps out-of-range values.
tick_replay_playback now reads SettingsResource per frame and falls
back to the constant when the resource is absent (test fixtures).
The slider takes effect on the very next playback tick — no need to
restart playback.
Mirrors the existing tooltip-delay slider exactly: SettingsButton::
ReplayMoveIntervalUp/Down variants, the same `slider_row` pattern,
the same per-tick repaint system shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The H-key hint now asks the v0.15.0 Klondike solver for the actual
best first move from the current game state instead of the existing
heuristic. The heuristic stays as the fallback path so hints still
work when the solver bails Inconclusive on the player's budget.
solitaire_core::solver gains a path-recording variant. The internal
DFS already enumerated moves on each frame; recording the root_move
on the stack frame is +16 bytes and one unwrap_or per expansion —
the new-game retry loop sees no measurable slowdown.
New public API (additive — try_solve unchanged):
pub struct SolverMove { source, dest, count }
pub struct SolveOutcome { result: SolverResult, first_move: Option<SolverMove> }
pub fn try_solve_with_first_move(seed, draw_mode, &cfg) -> SolveOutcome
pub fn try_solve_from_state(&GameState, &cfg) -> SolveOutcome
The internal solver-move enum was renamed InternalMove so the public
SolverMove can use engine-friendly (source, dest, count) types
instead of the compact internal form.
Engine wiring: handle_keyboard_hint calls try_solve_from_state on
the live GameStateResource. On Winnable + first_move, the hint
surfaces that exact move (no cycling — a single, optimal hint).
Unwinnable or Inconclusive falls through to the existing all_hints
cycling heuristic so hints remain useful in deals the solver gives
up on.
A new HintSolverConfig resource lets tests inject tight budgets to
force the fallback path; production uses SolverConfig::default()
and median solve time stays at 2 ms per H press.
Six new tests pin the contract: 4 in solitaire_core (Winnable
returns first_move, Unwinnable returns None, deterministic, seed
and state forms agree); 2 in solitaire_engine (hint uses solver
when Winnable, falls back to heuristic when Inconclusive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG gains a [0.16.0] section covering the modal-feel polish
round: per-modal Overflow::scroll_y on Achievements / Help / Stats /
Profile / Leaderboard, pointer cursor on hover for every Button,
same-frame focus on modal open (attach + auto_focus moved to
PostUpdate), and click-outside-to-dismiss for the six read-only
modals via a new ScrimDismissible marker.
The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1196.
SESSION_HANDOFF rewritten for the post-v0.16.0 state. Punch list
collapsed to two release-prep items (smoke-test, desktop packaging)
plus the carryover from v0.15.0's next-round candidates that didn't
ship this round (solver-driven hints, replay-rate slider, solver
progress overlay, async solver, "won previously" indicator, replay
sharing). Resume prompt asks A–E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to a54201e. The previous commit added ScrimDismissible to
Stats, Achievements, and Help; this one extends the same one-line
opt-in to the remaining three read-only modals so the click-outside-
to-close gesture is consistent across every informational surface.
Each modal now has the same shape: capture the scrim from
spawn_modal, attach ScrimDismissible after the build closure
returns. Three lines per file plus the import; no behaviour change
to the modal content itself.
Settings, Onboarding, Pause, Forfeit confirm, ConfirmNewGame, and
the win/game-over modals continue to opt OUT — all carry unsaved
or destructive state where an accidental scrim click would lose
work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a ScrimDismissible marker to ui_modal that opts a modal into
the standard "click outside the card to close" gesture. The new
dismiss_modal_on_scrim_click system fires on a left-mouse press
whose cursor falls on the scrim and outside every ModalCard, then
despawns the topmost dismissible scrim — Bevy's hierarchy despawn
cascades to the card and its children.
Marker design is opt-in per modal so destructive / state-mutating
modals (Settings saves on close, Onboarding requires explicit
acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed
intent) don't lose work to an accidental scrim click. Three
read-only modals opt in this round:
- Stats — informational; press S or click outside to dismiss.
- Achievements — read-only list.
- Help — keyboard reference.
Profile, Leaderboard, and Home will opt in the same way in a
follow-up; they were left out to keep this commit's scope tight.
The hit-test path uses each ModalCard's UiGlobalTransform +
ComputedNode bounding box so stacked modals close cleanly: the
topmost dismissible scrim is the only candidate per click. Tests
spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for
the resolved-border slots Bevy's UI module re-exports) so the
geometry hit-tests deterministically without running the full UI
layout pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously when a click-handler in Update spawned a modal,
attach_focusable_to_modal_buttons and auto_focus_on_modal_open ran
in the same Update — but with no ordering edge to the click handler
the deferred Commands wouldn't materialise in time, so attach saw
no entities, FocusedButton stayed empty, and the very next Tab/Enter
press wasted itself moving focus from None to the primary instead
of activating it.
Moves attach_focusable_to_modal_buttons + auto_focus_on_modal_open
from Update to PostUpdate. The schedule boundary itself supplies
the sync point: every modal spawned anywhere in Update is
materialised before PostUpdate runs, attach can find the new
ModalButtons, and FocusedButton is populated before app.update()
returns. handle_focus_keys stays in Update so it observes input on
the frame it occurs, reading FocusedButton written by the previous
tick's PostUpdate.
Two new tests pin the contract:
- primary_button_is_focused_on_modal_spawn_same_frame uses a
production-shaped spawner system (no chain edge to UiFocusPlugin)
and asserts FocusedButton.0 is Some after a single update —
fails without the fix, passes with it.
- first_tab_after_modal_open_advances_to_secondary guards against a
regression where focus arrives but the very first Tab moves from
None to primary instead of from primary to secondary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the cursor stayed the default arrow over every clickable
UI element (modal buttons, HUD action bar, mode-launcher cards,
settings toggles). Adds the standard "this is clickable" hand
affordance: while not dragging a card, hovering any entity with
Interaction::Hovered (or Pressed — keeps the pointer through a
click-and-hold) sets the window cursor to SystemCursorIcon::Pointer.
The new branch sits between the existing drag handlers in
update_cursor_icon: Grabbing wins when actively dragging, then
Pointer when a button is hovered, then Grab when a draggable card
is hovered, then Default. Card-drag affordance unchanged.
A pure pick_cursor_icon(is_dragging, any_button_hovered,
any_card_hovered) helper makes the priority logic unit-testable
without standing up a full Window + Camera fixture; four new tests
pin every branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.
Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.
Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
weekly goals + unlocks + Time Attack readout + replay caption is
enough content to overflow once the player has any progress;
StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
unlocked achievements + Stats summary overflows once a few
achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
with long display names; LeaderboardScrollable +
scroll_leaderboard_panel (max_height = 50vh — the ranking section
is the only variable-length part).
Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.
Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.
Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
of the same frame; first Tab press selects rather than focus
already being on the primary.
These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous v0.15.0 doc commit only landed CHANGELOG — the
SESSION_HANDOFF write silently no-op'd due to a Write tool param
mix-up. This commit lands the matching handoff refresh:
- Status block updated to v0.15.0 / HEAD / 1178 tests
- New v0.15.0 changelog table covering the seven feature commits
(Bevy trim, replay playback core + overlay + Stats wiring,
rolling replay history, Cinephile achievement, solver + toggle)
- Open punch list collapsed to two release-prep items (smoke-test,
desktop packaging) and six fresh next-round candidates
(solver-driven hints — now unblocked, replay-rate slider, solver
progress overlay, async solver, "won previously" indicator,
replay sharing)
- Resume prompt asks A–E
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG gains a [0.15.0] section covering 7 commits since
v0.14.0: Bevy default-features trim (51 transitive crates dropped),
in-engine replay playback core + overlay banner + Stats button
wiring, rolling replay history (last 8 wins) with selector UI,
"Cinephile" achievement (#19), and the Klondike solver + "Winnable
deals only" toggle.
The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1178.
SESSION_HANDOFF rewritten for the post-v0.15.0 state. Open punch
list collapsed to two release-prep items (smoke-test, desktop
packaging) and six fresh next-round candidates: solver-driven
hints (now unblocked), playback-rate slider, solver progress
overlay, solver-on-async-compute, per-deal "won previously"
indicator, replay sharing. Resume prompt asks A–E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Quat investigation #1. Today some Klondike deals are
unwinnable from the start and the player has no signal that the
deal they were given is solvable. A new Settings → Gameplay toggle
"Winnable deals only" (default off) makes the engine retry seeds
at deal-time until the solver returns Winnable, up to a cap.
Solver
solitaire_core::solver is a hand-rolled iterative-DFS solver with
memoisation on a 64-bit canonical state hash. Move enumeration is
priority-ordered: foundation moves first (zero choice when an Ace
or rank-up exists), inter-tableau moves second, waste-to-tableau
third, stock-draw last. The draw is skipped when the cycle counter
shows we've recirculated the entire stock without progress —
Klondike's deterministic stock cycle means further draws can't
unlock anything new.
Two budget knobs (move_budget = 100k, state_budget = 200k by
default) cap pathological cases at Inconclusive; the caller treats
Inconclusive as "winnable" so the player isn't penalised for the
solver giving up. Median solve time is 2 ms; pathological
inconclusives top out near 120 ms.
Switched from recursive to iterative DFS after a real-deal solve
overflowed Rust's default 8 MB thread stack. Behaviour identical;
the change is invisible to callers.
Pure logic — solitaire_core has no Bevy or I/O. Same input always
yields the same SolverResult.
Settings
Settings.winnable_deals_only is a #[serde(default)] bool; legacy
files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry
loop. The Settings → Gameplay toggle reads as "Winnable deals only"
with a "(may take a moment when on)" caption.
Engine integration
handle_new_game's seed-selection path now branches on the toggle.
When on AND mode is Classic AND no specific seed was requested
(daily challenges, replays, and explicit-seed requests bypass the
solver), choose_winnable_seed walks seed N, N+1, N+2, … calling
try_solve until it finds Winnable or Inconclusive. If the cap is
hit without a verdict, the latest tried seed is used so the player
always gets a deal rather than spinning forever.
19 new tests (11 solver, 3 settings, 5 engine including the
choose_winnable_seed unit). Two ignored bench/scan helpers
(solver_bench, find_unwinnable) for ad-hoc profiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 19th achievement: "Cinephile — Watch a saved replay all the
way through." Unlocks the first time ReplayPlaybackState transitions
Playing → Completed (i.e. the move list runs out without the player
pressing Stop). Discoverability nudge for the replay feature itself.
The achievement uses the existing event-driven unlock pattern
(condition closure returns false; an unlock system fires
AchievementUnlockedEvent on the right state transition) rather than
the standard condition-evaluation path, mirroring how other
non-stat-driven achievements work.
The unlock system distinguishes natural completion from Stop-button
abort by watching for the specific Playing → Completed transition;
Stop transitions Playing → Inactive directly without going through
Completed, so it doesn't fire the achievement. Already-unlocked
state is checked via AchievementsResource so the achievement can't
double-fire on subsequent replays.
README's "18 Achievements" → "19 Achievements". ARCHITECTURE.md §11
gains a Cinephile entry alongside the existing 18.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes replay storage from a single overwriting slot at
latest_replay.json to a rolling list of the most recent 8 wins at
replays.json so the player can revisit a memorable game even after
winning more recently.
Storage layer
solitaire_data::replay gains ReplayHistory (schema_version=1, Vec<Replay>
capped at REPLAY_HISTORY_CAP = 8) plus save_replay_history_to,
load_replay_history_from, append_replay_to_history, and
replay_history_path. append_replay_to_history inserts at the front,
drops the oldest when the cap is hit, and persists atomically via
the existing .tmp + rename pattern. The legacy single-slot helpers
are #[deprecated] but kept for one release as a migration safety
net via the new migrate_legacy_latest_replay helper.
Engine integration
game_plugin's record_replay_on_win now appends to the history
instead of overwriting latest_replay.json. On Startup, if a legacy
latest_replay.json exists but replays.json doesn't, the migration
helper seeds the new file from the legacy entry — so the player's
last v0.14.0 replay carries forward.
Stats UI
LatestReplayResource → ReplayHistoryResource holding the full
history. New SelectedReplayIndex resource (default 0 = most
recent) drives a Prev / Next / "Replay N / M" selector at the top
of the Stats overlay. ReplayPrevButton, ReplayNextButton, and
ReplaySelectorCaption marker components let the repaint system
update the caption as the selection changes. The Watch button
launches the selected replay rather than always the most recent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.
The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.
Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visible UI for the in-engine replay playback that just landed: a
thin top banner anchored to the window edge while
ReplayPlaybackState is Playing or Completed, surfacing the player's
current position in the move list and a way to abort.
Layout: full-width banner ~48 px tall with three children — a
"Replay" label in ACCENT_PRIMARY left-aligned, "Move N of M"
progress text centred, and a Tertiary Stop button right-aligned via
the existing spawn_modal_button helper so it gets focus rings and
hover/press states for free.
Z_REPLAY_OVERLAY = Z_DROP_OVERLAY + 5 (= 55) sits above HUD but
well below modal scrim (≥200), so Settings, Pause, and Help still
render on top of the overlay during a replay — the player can
adjust audio or pause mid-playback.
State-driven: the spawn system reacts to Changed<ReplayPlaybackState>
transitions, swapping the banner text to "Replay complete" when
state moves Playing → Completed and despawning entirely when state
returns to Inactive (either via the Stop button, completion linger
expiry, or external reset).
Five tests cover spawn-on-Playing, progress text, stop-button
clears state and despawns, despawn-on-Inactive, and Completed
banner text swap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the replay feature from disk-only to a real in-engine
playback path. A new ReplayPlaybackState resource models a three-
state machine (Inactive / Playing / Completed); start_replay_playback
resets the live game to the recorded deal via
GameState::new_with_mode(seed, draw_mode, mode) and a tick system
fires the canonical MoveRequestEvent / DrawRequestEvent for each
recorded move at REPLAY_MOVE_INTERVAL_SECS (0.45s).
The reset path bypasses NewGameRequestEvent because the existing
event always sources draw_mode from Settings — a Draw-1 replay
would silently coerce to Draw-3 (or vice versa) on a player whose
preference doesn't match the recording. Inserting GameStateResource
directly applies the recording's exact draw_mode and sidesteps the
abandon-current-game confirmation modal that would otherwise block
playback.
Recording suppression during playback is non-invasive: a sibling
system snapshots RecordingReplay's length on entry to playback and
truncates the buffer back to that mark every frame while is_playing
or is_completed. game_plugin's recording append paths are
untouched.
Completion lingers for REPLAY_COMPLETION_LINGER_SECS (5s) so the
overlay can show "Replay complete" before the auto-clear flips
state to Inactive.
Six new tests cover the state transitions, tick cadence, canonical
event firing, completion, stop-clears-state, and the
recording-suppression contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Quat investigation #2. The project uses kira for audio
(cpal 0.17 + alsa 0.10), but Bevy's default feature set still pulled
bevy_audio → rodio → cpal 0.15 + alsa 0.9 + symphonia codecs — about
50 transitive crates the binary never executes.
Workspace Cargo.toml's bevy entry now declares default-features =
false plus an explicit allow-list of the features actually used
(default_app subset + default_platform desktop subset + common_api +
2D + UI rendering). The list is derived analytically from the leaves
of Bevy 0.18's 2d and ui meta-features; built cleanly on the first
try with no missing-symbol errors.
Features intentionally omitted vs Bevy default:
- bevy_audio (kira handles audio directly)
- bevy_animation (custom CardAnimation, not Bevy's)
- bevy_gilrs, bevy_gizmos, bevy_picking variants, bevy_post_process,
scene, hdr, sysinfo_plugin (none used)
- webgl2, web, android-* (desktop-only; solitaire_wasm is Bevy-free
and uses wasm-bindgen + solitaire_core directly)
- wayland (X11 chosen; Wayland can be added later if requested)
Dependency-tree size for solitaire_app drops from 628 unique crates
to 577 (-51). Verified gone: bevy_audio, rodio, cpal 0.15. The
remaining cpal 0.17 and symphonia 0.5 are pulled by kira, not Bevy.
solitaire_wasm needed no changes — it doesn't depend on bevy.
All 1134 tests pass; clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG gains a [0.14.0] section covering 18 commits since v0.13.0
across three threads: the v0.13.0-era UX candidates that missed the
v0.13.0 tag (theme thumbnails, daily-challenge calendar, Time Attack
auto-save, per-mode bests, time-bonus slider), Quat's three bug
fixes from a smoke-test round (multi-card lift validation, softlock
detection, deal-tween information leak), and the major new replay
pipeline (record → persist → upload → web viewer with a new
solitaire_wasm crate).
The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1134.
SESSION_HANDOFF rewritten as the session 9 / post-v0.14.0 doc.
The session 8 changelog table is preserved alongside a new "v0.14.0
shipped" rollup. The next-round candidates list seeds six fresh
ideas (deferred Bevy audio trim, solver toggle, in-engine replay
playback, per-replay history, solver-driven hints, "won via
replay" achievement). Resume prompt asks A–F about smoke-test,
audio trim, solver toggle, in-engine playback, fresh UX, or
packaging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The replay viewer's renderer used to wipe and rebuild every card
from scratch on every step (`board.replaceChildren()`). Each step
was a discrete redraw — fine for correctness, abrupt for the eye.
Restructured to a persistent card-element model:
- `#board` is now a positioned context (relative) instead of a
CSS grid. The dashed empty-pile placeholders are absolutely-
positioned `.slot` elements painted once at bootstrap.
- Each card lives as a sibling of the slots, absolutely-positioned
with `transform: translate(x, y)`. The CSS transition on
`transform` (280 ms cubic-bezier) runs every move as a flight
rather than a redraw.
- `cardEls: Map<id, HTMLElement>` persists across renders. Cards
unchanged between steps don't re-create their DOM at all.
- Z-index is set per-render from the card's pile index so a card
flying out from the bottom of a tableau passes behind the cards
above it.
- Newly-spawned cards (rare — only on Restart) fade in at their
target position via a `requestAnimationFrame` opacity flip;
cards that disappear (also rare) fade out and despawn after the
220 ms fade.
- `will-change: transform` lets the browser composite the
animation, keeping it smooth on low-spec hardware.
Restart now drops every existing card before resetting so the
fresh deal looks like a new game, not a continuation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new integration tests against the in-process Axum router +
in-memory SQLite, covering the full HTTP transport + database layer
that the web replay viewer depends on:
- `replay_upload_then_fetch_round_trips_payload` — register → POST →
GET → assert the fetched JSON matches the upload byte-for-byte.
Canonical "the web viewer can play back what the desktop client
uploaded" coverage.
- `replay_fetch_unknown_id_returns_404` — exercises the
`AppError::NotFound` mapping (not a 500).
- `replay_recent_lists_newest_first_with_username` — two uploads,
asserts received_at DESC ordering and that the username join
populates the `username` field.
- `replay_upload_without_auth_returns_401` — guards against the
upload endpoint accidentally accepting anonymous inserts.
- `replay_upload_malformed_body_returns_400` — header projector
rejects payloads missing required fields with 400, not 500.
Schema-correctness (round-trip, version gate, atomic write) is still
covered by `solitaire_data::replay`'s unit tests; this file is
strictly for the HTTP transport.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the WASM module from the previous commit into a minimal web
viewer served at <server>/replays/<id>. Two new server routes:
- `GET /replays/:id` — returns the same embedded HTML page for any
id; the page itself reads the path from window.location in JS and
fetches the replay JSON via /api/replays/:id.
- `/web/*` — ServeDir for the static assets (replay.css, replay.js,
and the wasm-bindgen-generated pkg/).
Web layer:
- index.html — header, board, controls, status. Module script.
- replay.css — midnight-purple palette matching the desktop client,
dark felt board, CSS-grid pile layout, tableau fan via per-card
inline `top` offset.
- replay.js — fetches the replay, instantiates the wasm
ReplayPlayer, drives state(), step(). Controls: Restart, Play/Pause
toggle, Step. Auto-tick at 600 ms.
- pkg/ — generated by wasm-bindgen (committed so deployers don't
have to install wasm-bindgen-cli + the wasm32 target).
`tower-http = "0.6"` added to solitaire_server with the `fs` feature
for ServeDir.
To regenerate pkg/ after a solitaire_wasm change:
RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
cargo build -p solitaire_wasm \
--target wasm32-unknown-unknown --release
wasm-bindgen --target web \
--out-dir solitaire_server/web/pkg --no-typescript \
target/wasm32-unknown-unknown/release/solitaire_wasm.wasm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new `cdylib + rlib` workspace member that wraps `solitaire_core::
GameState` for use from JavaScript. The web replay viewer fetches a
replay JSON, hands it to `ReplayPlayer::new`, and steps through
moves one at a time — same Rust rules engine the desktop client
uses, so the two implementations cannot drift.
The crate intentionally does NOT depend on `solitaire_data` (which
pulls dirs/keyring/reqwest, none wasm-friendly). Instead it defines
a minimal `Replay` mirror with the same serde shape; the JSON wire
format is the contract.
Public surface (#[wasm_bindgen]):
- `ReplayPlayer::new(json)` — parse + rebuild deal from seed/mode
- `state()` / `step()` — return JS-friendly StateSnapshot
- `total_steps()` / `step_idx()` / `is_finished()` — progress helpers
Native-callable mirror (`from_json`, `step_native`) lets unit tests
exercise the state machine without going through `serde_wasm_bindgen`,
which panics off-target. 3 tests cover construction, step advance,
and invalid-JSON handling.
`getrandom` needs the `wasm_js` feature on the wasm32 target;
configured via the cfg target dep table so non-wasm builds aren't
affected.
Build pipeline (executed from the repo root):
rustup target add wasm32-unknown-unknown
RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
cargo build -p solitaire_wasm \
--target wasm32-unknown-unknown --release
wasm-bindgen --target web \
--out-dir solitaire_server/web/pkg --no-typescript \
target/wasm32-unknown-unknown/release/solitaire_wasm.wasm
The generated bindings land in solitaire_server/web/pkg/ and are
committed alongside the web UI (next commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`push_replay_on_win` listens for `GameWonEvent` and spawns a
fire-and-forget `AsyncComputeTaskPool` task that calls
`SyncProvider::push_replay`. The game loop never blocks on the
network round-trip; failures log a warning but never abort the win
flow because the replay is already persisted locally by
`game_plugin::record_replay_on_win`.
`UnsupportedPlatform` (LocalOnlyProvider) is silently absorbed in
the same way the existing `push_on_exit` path handles it — local
players don't see a server error every time they win.
Empty-recording guard mirrors `record_replay_on_win`: synthesised
win events from XP / streak / weekly-goal tests must not trigger an
upload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API surface for the web replay viewer to come:
- `POST /api/replays` — auth required; persists the JSON body
verbatim, mints a server-side UUID, returns `{id}`. Three columns
(final_score, time_seconds, recorded_at) are projected out of the
payload at insert time so list endpoints don't have to scan blobs.
- `GET /api/replays/recent` — public; returns the N most-recent
replays across users (limit defaults to 20, capped at 50). Joins
the username so the feed reads as "AliceWon · 2:14 win".
- `GET /api/replays/:id` — public; returns the full replay JSON
the desktop client uploaded.
Migration `002_replays.sql` adds the `replays` table with indexes
on `received_at DESC` (recent feed) and `user_id` (per-user views).
Schema-version compatibility is the playback side's responsibility,
matching the desktop's existing `schema_version` gate — the server
just stores and serves whatever JSON came in.
`AppError::NotFound` added so `GET /api/replays/:id` can return a
proper 404 instead of an internal-server-error.
`.sqlx` cache regenerated for the new `query!` invocations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cosmetic-only player setting (default 1.0, range 0.0-2.0, step 0.1)
that scales the time-bonus row shown in the win-summary modal's
score breakdown. Achievement thresholds, lifetime score totals, and
leaderboard submissions still use the raw values produced by
`solitaire_core::scoring`, so the multiplier never affects what gets
recorded — just what the player sees on the win screen.
- New `Settings::time_bonus_multiplier` field with `#[serde(default)]`
+ `sanitized()` clamp so older settings.json files load cleanly.
- New constants `TIME_BONUS_MULTIPLIER_{MIN,MAX,STEP}` re-exported
through `solitaire_data::lib`.
- `settings_plugin` adds a slider row under the Gameplay header
matching the existing tooltip-delay control.
- `win_summary_plugin` applies the multiplier when rendering the
time-bonus row of the score breakdown; "Off" label when 0.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifetime stats now also track best score and fastest win per game
mode (Classic, Zen, Challenge), additive on top of the existing
all-modes-combined `best_single_score` and `fastest_win_seconds`.
Time Attack is intentionally excluded — its scoring model is
session-level (count of wins inside a 10-minute window) so a
per-game best wouldn't compose. Daily Challenge inherits Classic
scoring and contributes through the Classic row.
- `solitaire_sync::StatsSnapshot` gains six fields (`{mode}_best_score`,
`{mode}_fastest_win_seconds` × {Classic, Zen, Challenge}). All are
`#[serde(default)]` so older save files load cleanly to zeros.
- `solitaire_sync::merge` propagates the per-mode bests through the
same max/min logic as the global counterparts.
- `solitaire_data::StatsExt::update_per_mode_bests` is the engine's
entry point — called from `update_stats_on_win` alongside the
existing `update_on_win`.
- Stats overlay grows a "Per-mode bests" section with three rows
(Classic / Zen / Challenge) tagged with `PerModeBestsRow`. Empty
rows render an em-dash, matching the first-launch zero-state
treatment used by the primary cells.
- 3 new tests cover the rendering, the Classic-mode update path,
and the Zen-mode update path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Stats screen now shows the most recent winning replay's caption
("M:SS win on YYYY-MM-DD") and a Watch Replay button. Until the web
viewer is fully wired the click fires a toast pointing the player at
the upcoming `<server>/replays/<id>` URL; once the upload + page
ship the toast is replaced with an actual link.
- New resources `LatestReplayResource(Option<Replay>)` and
`LatestReplayPath(Option<PathBuf>)` populated at plugin build time
from the platform-default `latest_replay.json`. Headless mode
disables I/O the same way `StatsResource` does.
- `refresh_latest_replay_on_win` re-loads from disk after every
`GameWonEvent` so opening the modal a second time reflects the
most recent victory rather than a stale snapshot.
- `format_replay_caption` is a pure helper exposed for both the
Stats button label and (later) toast messaging.
- `WatchReplayButton` marker added to `solitaire_engine`'s public
re-exports so the future web-side click integrations can match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New `RecordingReplay` resource (in `game_plugin`): in-memory move
buffer that accumulates atomic player inputs as they're applied to
`GameState`. Cleared on every `NewGameRequestEvent` so a fresh deal
starts from an empty list.
- `handle_move` and `handle_draw` push the corresponding `ReplayMove`
on success only — invalid / rejected events never enter the buffer.
`Undo` is intentionally not recorded; the replay represents the
canonical path to victory, not the missteps that were rolled back.
- `record_replay_on_win` listens for `GameWonEvent`, freezes the
buffer into a `Replay` (seed + draw_mode + mode + score + duration
+ today's date + the move list), and persists atomically to
`<data_dir>/solitaire_quest/latest_replay.json` via the new
`ReplayPath` resource.
- Empty-recording guard: synthesised win events from XP / streak /
weekly-goal tests must not clobber the developer's real replay
file. A real win always has at least one recorded move.
- 5 dedicated tests cover ordering, rejected-move skipping, undo
skipping, new-game clearing, and the freeze→save round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `solitaire_data::replay` module:
- `Replay` struct: seed + draw_mode + mode + ordered move list +
presentation metadata (time / score / date). Replays are
reconstructed by rebuilding `GameState::new_with_mode` and applying
the move list in order — a deterministic state machine driven by
atomic player inputs, no per-step snapshots stored.
- `ReplayMove`: one variant per atomic player input. `Move {from, to,
count}` covers card moves; `StockClick` covers every click on the
stock (the engine resolves draw-vs-recycle deterministically from
current state during both record and playback).
- Schema-versioned (`REPLAY_SCHEMA_VERSION = 2`); legacy files are
rejected via the version gate so older replays just disappear from
the UI rather than half-loading.
- Atomic save (.tmp -> rename), `dirs::data_dir()`-based path
resolution. 5 round-trip / atomic / version-gate / corruption tests.
Sync trait extension:
- `SyncProvider::push_replay(&Replay)` — default returns
`UnsupportedPlatform` so `LocalOnlyProvider` is silently no-op'd by
the future push-on-win path. Mirrors the existing `pull` / `push`
default-impl pattern.
- `SolitaireServerClient::push_replay` — `POST /api/replays`, same
401-refresh-and-retry shape as `push`.
The wire format is the contract: `solitaire_wasm` (added in a later
commit) parses the JSON via its own minimal mirror struct so it can
compile to wasm32 without pulling the desktop client's transitive
deps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the three bug-fix commits (move validation, deal-tween leak,
softlock detection), notes that bug #3's "no end-game screen" was
downstream of the softlock-detection bug and is now resolved, and
records the two investigation findings (audio-stack feature trim,
solver-at-deal toggle) as deferred decisions for the player.
Updates HEAD/test counts (origin at 2716472, 1126 tests passing).
Cleans the next-round candidates list — calendar / thumbnails /
Time-Attack auto-save shipped between v0.13.0's doc commit and
session 8; replay is WIP in the working tree.
Resume prompt now offers six choices (A–F) covering finish-replay,
smoke-test, audio-feature trim, solver toggle, other UX, packaging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous heuristic returned true whenever stock or waste held any
cards. Quat hit a state with 4 cards remaining and the stock kept
cycling but nothing was ever playable — the existing detection
counted "draw is available" as a legal move and the game just sat
there.
Replace the early return with a single pass over every card that
could ever be a move source: every Stock card, every Waste card, and
the face-up top of every Tableau column. For each, check whether it
can currently land on any Foundation or Tableau. Return true only if
*some* card anywhere can land *somewhere* — otherwise the player is
genuinely stuck no matter how many times they recycle the deck.
Tightened the existing fresh-game test name to reflect what it
actually proves (a fresh deal has playable moves, not "stock is
non-empty"). Added one new test reproducing Quat's exact case.
Reported by Quat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported leak: when a new game starts, every card sprite tweens from
its previous-game Transform to its new dealt position. A careful
observer can track those origin points and deduce face-down cards in
the new layout — the tween's start frame literally renders the prior
game's geometry.
Fix: in handle_new_game, after replacing the GameState, snap every
existing card Transform to the stock pile's position before writing
StateChangedEvent. The downstream slide tween in card_plugin then
reads the stock position as its source, so all 52 cards animate out
from a single point — reads as "dealing from the deck" with no
information leak.
No layout reach in headless test contexts so the snap is gated on
Option<Res<LayoutResource>>.
Reported by Quat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
move_cards only checked that the *bottom* card of a moved stack landed
legally on the destination — the cards above the bottom went through
unverified. A player could lift an arbitrary selection from one column
and drop it on another whenever the bottom happened to match, even if
the upper cards didn't form a descending alternating-colour sequence.
Adds is_valid_tableau_sequence(&[Card]) -> bool to rules.rs (4 lines)
and one call site in move_cards's tableau-destination branch. One
focused test covering single-card / valid-run / same-colour /
rank-gap cases.
Reported by Quat: "stack 4 onto stack 2" was accepted when illegal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Classic, Zen, and Challenge already auto-saved correctly via the
existing game_state.json path — GameState carries mode and the
save/restore systems are mode-agnostic. Time Attack was the gap:
the per-deal GameState round-tripped fine, but the session-level
TimeAttackResource (10-minute countdown + accumulated wins)
defaulted on every launch, so closing mid-session reset the timer
and erased the win count.
Adds a sibling time_attack_session.json next to game_state.json,
atomic .tmp + rename via the existing save pattern. The new
TimeAttackSession struct carries remaining_secs, wins, and
saved_at_unix_secs (wall-clock anchor for stale-session detection).
load_time_attack_session_from_at takes an injectable now() so
tests can drive deterministic clock scenarios.
Load logic: if now_unix - saved_at_unix_secs > remaining_secs the
window expired in real time while the app was closed — return None
so the player isn't dropped into a session whose timer ran out
behind their back. Otherwise restore remaining_secs minus the
real-world elapsed delta. Handles clock-running-backwards (NTP
correction, VM clock drift) by clamping the elapsed delta at zero.
time_attack_plugin wires four new systems: load on Startup, clear
stale file when a fresh session starts (rare — only matters when
the previous session was abandoned + a new one started without
exit/relaunch), 30-second auto-save while a session is active,
delete file on natural expiry, and save on AppExit. The save file
is removed every time the session ends so a stale "session exists"
state can't pollute the next launch.
No GameState schema bump needed — the per-mode session lives in
its own file. stats / progress / achievements / settings unaffected.
8 new storage tests cover round-trip, expired-discard, time-decay,
atomic-write, missing-file, corrupt-file, delete idempotency, and
clock-backwards. 6 new plugin tests cover exit-persists,
exit-clears, auto-save-cadence, auto-save-noop-when-inactive,
new-session-clears-stale, and natural-expiry-clears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The daily challenge already updated streak counters, but past
completions were invisible — the player had no in-game surface to
see streak length or the actual day-by-day record. Adds a 14-dot
horizontal calendar above the Profile modal's achievements section
with a "Current streak: N · Longest: M" caption.
Each dot represents a day in the trailing 14-day window ending
today. Today's dot gets a 2-px Balatro-yellow ring; completed days
fill STATE_SUCCESS; missed days fill BG_ELEVATED. Geometry: 14 ×
12 px + 13 × 6 px gap ≈ 246 px — fits comfortably inside the
modal's 360 px min_width even on the 800 px window minimum.
PlayerProgress gains two #[serde(default)] fields:
- daily_challenge_history: Vec<NaiveDate> capped at 365 entries
(one year of history; older entries pushed off when the cap is
hit). Sorted ascending, deduped on insert so same-day re-runs
don't bloat the list.
- daily_challenge_longest_streak: u32, updated whenever streak
exceeds the previous max.
Legacy progress.json files load to empty/0 via #[serde(default)].
solitaire_sync::merge unions histories from local + remote (sorted,
capped) and takes max(longest_streak), with a clamp to ensure
longest is never below the merged current streak — guards against
legacy payloads where longest=0 but current is mid-streak.
13 new tests across solitaire_sync (record_daily history append,
chronological order, dedupe, cap, longest update, merge union,
merge cap, max longest, clamp), solitaire_data (history append,
longest update, legacy deserialise), and solitaire_engine
(modal renders 14 dots, today marker on rightmost only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings → Cosmetic's theme picker showed only the theme name. Now
each chip carries a small Ace-of-Spades + back preview pair so the
player can see what each theme looks like before switching.
A new ThemeThumbnailCache resource keys per-theme by id and stores
two Handle<Image>s (ace + back) rasterised at thumbnail resolution
via the existing rasterize_svg path. Generation runs once per
theme registration in theme_plugin; subsequent picker re-spawns
just look up the cached handles. Themes that lack one of the
preview SVGs (broken user theme) get a Handle::default() placeholder
rather than crashing — the placeholder renders as a transparent
rectangle the same size as the missing thumbnail.
The picker chip spawn loop in settings_plugin reads the cache and
renders the pair as two child sprites above the chip text. The
selected-theme chip's existing STATE_SUCCESS tint sits behind the
thumbnails; contrast stays readable.
Asset-source plumbing in assets/sources.rs and assets/mod.rs picks
up the new bytes-loading helper that the thumbnail generator uses
for embedded:// theme assets at startup time (before AssetServer is
fully initialised).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG gains a [0.13.0] section covering the third UX iteration
round on top of v0.12.0:
- Tooltip-delay slider, streak fire, score-breakdown reveal
- Card backs follow active theme
- Drag-with-keyboard
- Right-click radial menu
Plus two code-review fixes (Removed: sccache wiring, Fixed: bundled-
only font handling).
The bottom-of-file compare links thread the new tag into the
existing chain. Test count updated to 1053.
SESSION_HANDOFF gains a "Session 7 round 3" table summarising the
six commits and rolls the punch list forward — UX candidate list
exhausted again, fresh six-item list seeded for a future round
(daily-challenge calendar, theme-picker thumbnails, per-mode high
scores, in-progress auto-save for Zen/Time Attack, configurable
scoring weights, win replays). Resume prompt asks A/B/C/D about
push, smoke-test, next round, or packaging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Power-user shortcut: hold right-click on a face-up card, a small
ring of icons appears around the cursor with one entry per legal
destination, release over an icon to fire MoveRequestEvent. Skips
the drag motion entirely while preserving the existing
RightClickHighlight tint on the actual pile markers.
A new RadialMenuPlugin owns the flow. RightClickRadialState is a
two-state enum (Idle / Active) carrying the source pile, lifted
cards, pre-computed legal destinations + their world anchors, the
ring centre, and the currently hovered icon index. Four chained
systems handle press → cursor track → release/cancel → redraw, in
that order so a single-tick test can't observe a half-state.
Mutual exclusion with the left-button mouse drag is implicit —
RadialMenuPlugin only listens to MouseButton::Right while the
existing drag pipeline only listens to Left. RightClickHighlight
co-exists at a lower z (50) than the radial overlay (Z_RADIAL_MENU
= 60), so the brief pile-marker tint reads as the same set of legal
destinations the radial offers.
Cancel paths: release the right button outside any icon, press Esc,
or press the left button. All three reset state to Idle without
dispatching a move.
Visual: a centre dot at the press location plus N icons at radius
80 px around it. For one destination the icon sits at 12 o'clock;
for N icons they spread evenly clockwise. Hovered icon scales to
1.15× and tints STATE_SUCCESS so the focused choice is unambiguous.
Twelve new tests pin the contract — five system-level (open on
press over face-up card, release over destination fires move event,
release in dead space cancels, Esc cancels, face-down doesn't
open), seven on the pure helpers (radial_anchor_for_index,
radial_hovered_index, legal_destinations_for_card). Tests inject
cursor positions through a RadialCursorOverride resource so they
work under MinimalPlugins where there's no PrimaryWindow or Camera.
help_plugin's controls reference gains a new "Mouse" section
covering double-click auto-move, right-click highlight, and the
new "Hold RMB" radial. Onboarding slide 3 is intentionally left
keyboard-only — the radial is a power-user discovery, not a
first-run teach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.
A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:
Idle (Tab/Enter/auto-move via SelectionState)
Lifted {
source_pile, count, cards,
legal_destinations, pre-computed at lift time via
destination_index, can_place_on_foundation/_tableau
}
Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.
The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.
help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.
Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Themes already shipped a back.svg in their manifest but card_plugin
ignored it — face-down cards always rendered with the legacy
back_N.png picker, so swapping themes only swapped the faces. Now
the active theme's back rasterises alongside its faces and feeds
into the face-down sprite path; the legacy back_N.png picker remains
the fallback when a theme doesn't ship its own back (e.g. a
user-imported theme that only redefines faces).
theme/plugin.rs caches the active theme's back Handle<Image> in the
ActiveTheme resource on theme-load and theme-switch. card_plugin's
face-down branch reads ActiveTheme first; missing theme back →
legacy back_N.png path indexed by Settings.selected_card_back.
Settings → Cosmetic's card-back picker section gains a caption
("Active theme provides its own back") that surfaces when the
override is in effect, plus the swatch row dims to communicate the
read-only state. Settings file format unchanged — selected_card_back
still round-trips and only takes effect when the theme leaves the
back undefined.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UX improvements bundled because they share ui_theme token
edits.
Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
via "−" / "+" icon buttons next to a value readout. Range
[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
is absent (test path). New tooltip_should_show(elapsed, delay)
pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
load. Five round-trip / default / legacy-deserialise tests.
Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
when win_streak_current crosses any of [3, 5, 10] (only the
threshold crossing — not every subsequent win). HUD streak readout
scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
(0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.
Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
per-component reveal: Base score, Time bonus (m:ss), No-undo
bonus, Mode multiplier, separator, Total. Rows fade in over
MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
it animates. Skipped rows: zero time bonus, undo-tainted no-undo
bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
GameWonEvent.score, time bonus from
solitaire_core::scoring::compute_time_bonus, no-undo from a +25
constant when undo_count == 0, mode multiplier from GameMode (Zen
zeros the total). 9 new tests cover the math and the reveal
cadence.
Test count net: +25 across the workspace (1007 → 1031).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review feedback: sccache shouldn't be a per-project build
dependency. Cargo's incremental cache already covers what sccache
offers for a single project, and forcing rustc-wrapper = "sccache"
project-wide means every contributor has to install sccache or
prepend RUSTC_WRAPPER= to bypass the wrapper.
.cargo/config.toml only existed to wire sccache and pin SCCACHE_DIR
to a project-local cache. Removing the file entirely so plain
`cargo build` works without any extra setup. The .cargo directory
is empty after the deletion and removed too. .gitignore's
/.sccache-cache line is harmless cruft and stays — players who
already have a populated .sccache-cache directory keep it ignored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review feedback: the SVG rasteriser mixed three font-resolution
layers (load_system_fonts + bundled FiraMono + lenient resolver
appending CSS generics), which made card text rendering depend on
which fonts the host machine happened to have. The Bevy UI face
loaded separately at runtime via AssetServer. Picking option (a)
from the review and applying it consistently: bundle FiraMono via
include_bytes!() in BOTH layers, no system fallback anywhere.
solitaire_engine/src/font_plugin.rs now embeds main.ttf at compile
time and registers it with Assets<Font>. A parse failure aborts
with "bundled FiraMono failed to parse — binary is corrupt"; the
MinimalPlugins early-return stays as a "this plugin doesn't apply
in headless tests" check (consumers query Option<Res<FontResource>>
and degrade cleanly), not a production fallback.
solitaire_engine/src/assets/svg_loader.rs drops load_system_fonts
entirely, drops the lenient_font_resolver, and drops the five
set_*_family pins. The new bundled_font_resolver ignores the SVG's
font-family request and always returns the single bundled face —
the bundled card SVGs reference Arial / Bitstream Vera Sans by name
and we deliberately don't ship those, so routing every query to
FiraMono keeps rasterisation deterministic. shared_fontdb asserts
the embedded bytes parsed.
The two layers now embed the same path
(assets/fonts/main.ttf) independently, so they can't drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:33:54 +00:00
548 changed files with 47329 additions and 23245 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",
"query":"SELECT\n r.id AS \"id!: String\",\n u.username AS \"username!: String\",\n r.seed AS \"seed!: i64\",\n r.draw_mode AS \"draw_mode!: String\",\n r.mode AS \"mode!: String\",\n r.time_seconds AS \"time_seconds!: i64\",\n r.final_score AS \"final_score!: i64\",\n r.recorded_at AS \"recorded_at!: String\",\n r.received_at AS \"received_at!: String\"\n FROM replays r\n JOIN users u ON u.id = r.user_id\n ORDER BY r.received_at DESC\n LIMIT ?",
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,22 +43,22 @@ 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
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
---
## 2. Workspace Structure
```
solitaire_quest/
ferrous_solitaire/
│
├── Cargo.toml # Workspace manifest
├── .env.example # Server environment variable template
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` 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`.
@@ -262,6 +277,8 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
| `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 |
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
### Evaluation Timing
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
---
## 12. Progression System
@@ -823,7 +907,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+ |
@@ -943,6 +1027,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` |
`adb shell input tap X Y` takes PHYSICAL coordinates.
To convert from what you see on screen (logical): multiply by 1.20.
# Lint — MUST pass clean (zero warnings)
cargo clippy --workspace -- -D warnings
## 15.3 Android-specific test checklist
# Run sync server locally
cargo run -p solitaire_server
Before shipping any Android build:
- [ ] Safe area insets arrive and shift HUD correctly (check after 3s)
- [ ] All modal Done buttons are above the gesture bar
- [ ] No Geometric Shapes glyphs in UI text
- [ ] HUD band does not overlap the top status bar
- [ ] Touch drag-and-drop works on all pile types
# Check a single crate
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
---
# 16. Context Injection System (AUTOMATIC SCOPE FILTER)
## 16.1 Purpose
Before generating any response, Claude MUST construct a **minimal relevant context set**.
This prevents:
* architectural drift
* irrelevant spec loading
* over-engineering
* cross-crate confusion
---
## 16.2 Input Classification Step (MANDATORY)
Every request MUST be classified into exactly one task type:
```text id="task_types"
feature
bugfix
refactor
system_design
bevy_system
core_logic
sync
optimization
test
debug
```
If uncertain → ask clarification.
---
## 16.3 Context Selection Engine
After classification, Claude MUST include ONLY the relevant sections below.
---
## 16.4 Context Map (CORE RULESET)
### feature
Include:
* §2 Hard Global Constraints
* §3 Engine Rules
* ARCHITECTURE.md (crate of target feature only)
* relevant data models (GameState, SyncPayload if needed)
---
### bugfix
Include:
* §2 Hard Global Constraints
* §5 Code Standards
* affected crate boundaries
* relevant system (engine/core/sync only)
---
### refactor
Include:
* §3 Engine Rules
* §5 Code Standards
* §11 Forbidden Patterns
* target crate boundaries
---
### system_design
Include:
* ARCHITECTURE.md (FULL)
* §9 Mental Model
* §1 System Architecture Mapping
---
### core_logic
Include:
* solitaire_core rules only
* GameState model
* MoveError model
* §2.1–2.3 constraints
---
### bevy_system
Include:
* §3 Engine Rules
* ECS rules (Events/Resources/Components)
* UI-first constraint
* relevant plugin system only
---
### sync
Include:
* SyncProvider trait
* merge strategy rules
* solitaire_sync models
* §2.6 Sync Rules
---
### optimization
Include:
* target crate only
* §5.4 Performance Rules
* hot path constraints
---
### test
Include:
* §6 Build Rules
* relevant module
* expected invariants
---
### debug
Include:
* target file/module only
* §2.3 Error Policy
* runtime assumptions relevant to failure
---
## 16.5 Context Compression Rules
Claude MUST obey:
* never include full ARCHITECTURE.md unless system_design
* max 2 crates per response unless explicitly required
* prefer function-level context over file-level context
* exclude unrelated plugins/systems
---
## 16.6 Context Priority Order
When space is limited:
1. Hard Constraints (§2)
2. Target crate rules
3. Data models
4. Only then: architecture snippets
---
## 16.7 “No Context Pollution” Rule
Claude must NOT include:
* unrelated crates
* unrelated plugins
* unused data models
* full architecture dumps
* speculative systems
---
## 16.8 Self-Check Before Execution
Before writing code, Claude MUST verify:
* [ ] Is only relevant context included?
* [ ] Is at least one hard constraint present?
* [ ] Am I touching more than one crate unnecessarily?
* [ ] Am I duplicating ARCHITECTURE.md content?
If any fail → revise context selection.
---
## 16.9 Injection Output Format (Internal Model)
Claude should behave as if it constructed:
```text id="ctx_format"
[SELECTED TASK TYPE]
[MINIMAL REQUIRED RULES]
[MINIMAL ARCHITECTURE SLICES]
[RELEVANT MODELS]
[REQUEST]
```
---
## Hard Rules
## 16.10 Relationship to ARCHITECTURE.md
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
- `cargo test --workspace` must pass after every change.
* ARCHITECTURE.md = source of truth
* CLAUDE.md = execution constraints
* THIS SECTION = filtering layer between them
---
## Code Style
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
- Prefer `Into<T>` over concrete types in public API function parameters.
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
---
## Bevy Conventions
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
- Resources own shared state. Events communicate between systems. Components own per-entity data.
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
---
## Git Workflow
- Commit after each passing phase, not after every file change.
- Never commit with failing tests or clippy warnings.
- Never commit secrets, `.env` files, or `*.db` files.
---
## Ask Before Doing
- Adding a new crate dependency (discuss alternatives first).
- Changing a type in `solitaire_sync` (breaking change on both client and server).
- Altering the database schema (requires a new sqlx migration).
- Introducing `unsafe` code anywhere.
- Changing the merge strategy in `solitaire_sync::merge()`.
---
## Lessons Learned
> Add entries here when Claude makes a mistake so it isn't repeated.
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and
@@ -22,7 +22,7 @@ optional self-hosted sync so your stats follow you across machines.
move within picker rows, Enter activates; works across every modal and
the HUD action bar
- **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones
- **19 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the
same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server
@@ -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
**Last updated:** 2026-05-02 (session 7, late) — Second UX iteration round complete. Six small UX feel items shipped on top of v0.11.0 and the README/CHANGELOG refresh that should have ridden along. Ready to tag v0.12.0.
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
## Status at pause
---
- **HEAD:**`7dba772` plus the impending CHANGELOG/handoff doc commits. Local master is **3 commits ahead** of `origin/master` (`9887343`, `ca5788f`, `7dba772` unpushed); doc commits to follow.
- **Working tree:** clean apart from this doc + CHANGELOG, both intentional.
- **Tests:** 1277 passing / 0 failing across the workspace
v0.11.0 shipped the headline structural changes (card themes, HUD overhaul, four UX feel wins, font fallback). The second UX round — six smaller items — is also done now. v0.12.0 is the right slice for them; together with the README refresh and CHANGELOG add it makes a clean release.
---
The post-v0.11.0 UX candidate list is exhausted. Direction is open again.
## What shipped since the last handoff (v0.23.0 → v0.35.1)
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (auto-memory; on a different machine, recreate this fresh from the README + ARCHITECTURE.md).
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there. (Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
| Commit | Summary |
|--------|---------|
| `eb6c93f` | Silence B0004 by adding Transform to ModalScrim |
| `6f5cebd` | Fire WarningToastEvent on sync pull failure (was InfoToastEvent) |
| `87aec5b` | Gate all decorative motion animations under `reduce_motion_mode` |
`reduce_motion_mode` now gates: score pulse, score floater, streak flourish
(hud_plugin), card-shake on rejected move, foundation completion flourish
(feedback_anim_plugin). Pattern: gate at the trigger/start system, never at
the tick system — if the component isn't inserted, the tick path never runs.
| Area | Commit | What landed |
|---|---|---|
| Font fallback | `fdb6c2e` | FiraMono bundled into the SVG fontdb so cards render rank/suit text on machines without Bitstream Vera Sans / Arial. Surfaced when a second-machine pull lost glyphs. |
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)` → `Foundation(u8)` with claim derived from the bottom card. Save schema 1 → 2; pre-v2 saves silently fall through to fresh game. |
| Drop overlay | `f6c9166` | Soft fill + 3 px outline drawn ABOVE stacked cards for every legal target during drag. Replaces the hidden pile-marker tint. |
| Drop shadows | `f712b89` | Each card casts a 25 % black shadow; lifts to 40 % with bigger offset/halo while in the active drag set. |
| Stock badge | `655dfde` | "·N" chip at the top-right of the stock so players can see how close they are to a recycle. Hides at zero. |
### v0.35.1 — Leaderboard bug fixes (2026-05-18)
Tagged as `v0.11.0` (commit `063269c` plus URL refresh).
| Commit | Summary |
|--------|---------|
| `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced |
| Aspect ratio | `13aa0fd` | `CARD_ASPECT` 1.4 → 1.4523 to match hayeah SVG dimensions; cards no longer ~3.6 % squashed. Vertical-budget math adapts via the constant. |
| Foundation flourish | `69ce9af` | King-on-foundation celebration: scale-pulse on the King, marker tints `STATE_SUCCESS`, synthesised C6→E6→G6 bell ping (~240 ms). New `FoundationCompletedEvent`. |
| Drag-cancel tween | `525fe0f` | Illegal drops glide each card back to its origin over 150 ms with a quintic ease-out (Responsive curve, zero overshoot). Audio cue still fires. ShakeAnim retained for non-drag rejection paths. |
| Focus pulse | `9887343` | Focus ring breathes at 1.4 s sin period over [0.65, 1.0] of native alpha. Static under `AnimSpeed::Instant`. |
| Achievement onboarding | `ca5788f` | First-win toast "First win! Press A to see your achievements." plus persisted `shown_achievement_onboarding` flag so the cue fires exactly once. |
| Docs (rode along) | `d8c7034`, `9f095c4` | README refresh for v0.11.0 features and corrected controls table; CHANGELOG.md added covering v0.9.0–v0.11.0. |
1. **Wrong toast type on error** — `poll_opt_in_task` / `poll_opt_out_task` error
branches now fire `WarningToastEvent` instead of `InfoToastEvent`.
The first three items in this round (`13aa0fd`, `69ce9af`, `525fe0f`) shipped before the v0.11.0 tag's commit window closed; treating them as v0.12.0 since v0.11.0 was already cut at `063269c`.
2. **Display name not pushed to server on change** — `Settings` gains
`leaderboard_opted_in: bool` (serde-defaulted `false`). Set `true`/`false` when
opt-in/out tasks succeed and persisted to `settings.json`. `handle_display_name_confirm`
now spawns an `opt_in_leaderboard` task when already opted in — the server's upsert
endpoint updates only `display_name` without re-opting-in.
## Open punch list — release prep
3. **"Public name" label stale after name change** — `LeaderboardPublicNameText` marker
component added to the label node. `update_leaderboard_public_name_label` system
rewrites the text each frame the panel is open; O(0) cost when panel is closed.
1. **Tag v0.12.0** — meaningful slice since v0.11.0: six UX feel items + the README/CHANGELOG refresh. Tag at the doc-commit HEAD that closes this round.
2. **Push to origin** — three-plus commits unpushed.
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
5 new regression tests cover all three bugs.
## Open punch list — UX iteration (next-round candidates)
---
The v0.12.0 list is exhausted. Candidates for a future round:
## Open punch list
- **Card-back theme support** — the current theme system swaps face SVGs but not the back. Players asked for animated backs in passing.
- **Streak fire animation** in the HUD when win-streak crosses 3, 5, 10. Foundation flourish suggests the per-suit completion pattern; streak milestones are the lifetime equivalent.
- **Score-breakdown reveal** at win — show base / time-bonus / no-undo bonus / mode multiplier as the score animates up. Currently the win modal just shows the final number.
- **Right-click radial menu** for power users: hold right-click on a card → quick-drop options without dragging.
- **Drag-with-keyboard** — Tab to a card, Enter to "lift", arrow keys to choose destination, Enter to drop. Keyboard-only completion of a game.
- **Settings: tooltip-delay slider** so power users can disable the 0.5 s hover delay. Cheap.
### 1. CHANGELOG documentation debt
## Card-theme system (CARD_PLAN.md, fully shipped)
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
are missing. Low priority (git log is authoritative) but worth closing before the
next release.
Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0:
### 2. Android APK launch verification (Option A)
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive and atomically unpacks.
- **Picker UI** in Settings → Cosmetic.
Physical device test: install the latest APK on a real Android device (not AVD),
confirm:
- App launches without crash
- Safe area insets arrive and shift HUD correctly after ~3 frames
- All modal Done buttons are above the gesture bar
- Drag-and-drop works on all pile types
- Leaderboard panel opens and the "Public name" label updates correctly after
using "Set Name"
## Resume prompt
This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
touch events, so physical-device smoke testing is the only gate.
```
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine — local
directory may still be named Rusty_Solitare from earlier; that's fine>.
Branch: master. Direction is OPEN — both UX iteration rounds shipped
and v0.12.0 is ready to tag.
### 3. Matomo analytics wiring
State: HEAD at the doc-commit closing session 7 round 2. Local master
is several commits ahead of origin and unpushed. Working tree clean
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.