Two unused-for-free upstream card_game/klondike features:
- GameState now exposes the granular KlondikeStats counters
(move_to_foundation_count, move_to_tableau_count,
move_from_foundation_count, flip_up_count) and the win modal shows a
quiet per-move-type recap line built from them (e.g. "21 to
foundation - 14 tableau moves - 9 flips")
- wasm ReplayPlayer gains seek(step): clamped jump to any position,
rewinding via a stored copy of the recorded deal instead of reparsing
the replay JSON; replay.js Prev now uses it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.
- core: SessionRecording newtype delegating to Session<Klondike> serde;
GameState::recording() / from_recording(); from_instructions_unchecked
fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
the existing version gate
- engine: win-recording and sync upload freeze game.recording();
playback rebuilds from the recording; Playing carries the extracted
move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
hand-assembles it (the old game.js path hardcoded schema_version: 2
and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
(Difficulty uploads previously 400'd against the String field)
Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three items the multi-agent sweep flagged, now removed with user
approval (§8 for the solitaire_sync changes):
- WinCascadePlugin: never registered; handle_win_cascade in
AnimationPlugin is the live win cascade and builds its own targets.
Its now-orphaned helpers (win_scatter_targets, cascade_delay,
WIN_CASCADE_INTERVAL_SECS) had no callers outside their own tests
and go with it.
- SyncCompleteEvent: written by the pull-completion system, zero
readers — UI reads SyncStatusResource instead.
- solitaire_sync::ApiError: unused by client and server; the merge_at
crate-root re-export goes too (merge::merge_at stays for the merge
module's own use).
ARCHITECTURE.md updated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Double-click/tap auto-move previously tried the run's top card alone
before the full run headed by the clicked card, so a top card with a
foundation move hijacked the intended whole-stack move. Both handlers
now share auto_move_for_run: a lone top card goes foundation-first, a
multi-card run moves whole to a tableau or not at all. The double-click
key is now the clicked card, so two clicks on different cards of the
same stack no longer register as a double-click.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide
reference greps; three parallel audit agents covered the engine crate, the
other eight crates, and Copilot commit-message-vs-diff drift.
Removed:
- replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also
defined in mod.rs — the glob re-export made the mod.rs copies win and
the file-level #![allow(dead_code)] hid the corpses. The live keyboard/
button handlers and ReplayScrubKeyHold stay; the allow is retired.
- retarget_animation (never called; doc examples were its only refs)
- ScanThemesRequestEvent (never registered/written/read; its doc claimed
a handle_scan_themes consumer that does not exist)
- _VEC3_REFERENCED workaround const + now-unneeded Vec3 import
- solitaire_data: load_stats/save_stats/time_attack_session_with_now
default-path wrappers (the _from/_to variants are the live API) and
surplus re-export names (settings MIN/MAX bounds, token loaders)
- solitaire_core: Session re-export (no external consumer)
- solitaire_wasm: ReplayPlayer::is_finished (no JS caller)
- solitaire_app: build_app wrapper (real entry is run())
Doc fixes:
- audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row,
drop bogus 'placeholder' label, bevy_kira_audio→kira
- ToastVariant::Warning: variant is live (5 writer plugins); dropped the
stale allow(dead_code) and its 'currently unused' comment
Deliberately kept: Spider module (staged forward work), WinCascadePlugin
(documented alternative cascade, pending owner decision), SyncCompleteEvent
and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commit 38e4c03 (shipped in v0.40.0) switched Android's focused_mode to
reactive_low_power(100 ms) on the premise that every animation tick
system writes RequestRedraw while it has active work. The writers were
never actually added — the diff only contained the WinitSettings change,
imports, and add_message registrations. Result: with no touch input,
nothing wakes the winit loop during a card slide except the 100 ms
fallback ceiling, so animations render at ~10 fps on Android. Desktop
and web keep Continuous mode, which is why only Android was affected
(reported by Rhys the day v0.40.0 shipped; confirmed absent on v0.39.1).
Adds the missing MessageWriter<RequestRedraw> to all seven systems the
original commit message named:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade/slides)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish
(FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step-interval keepalive)
Each writes one RequestRedraw per frame while active work exists
(including delay phases, which also need per-frame ticks). Regression
test asserts an active CardAnimation emits RequestRedraw and an idle
board does not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase G of docs/ui-redesign-2026-07.md. The win modal now leads with
actions and reads the stats recap quietly below them:
- Play Again: primary, full-row, Enter accelerator unchanged; still
fires NewGameRequestEvent::default() (same mode; deal options from
Settings) so the rematch is one tap.
- Watch Replay / Share Replay: secondary pair reusing the global
stats_plugin markers (WatchReplayButton / CopyShareLinkButton), so
both act on the just-won replay — SelectedReplayIndex snaps to 0 on
every win. This also fixes the old win-modal handler picking
replays.last(), which after the newest-first history refactor was
the OLDEST replay, not the newest.
- Watch closes the celebration overlay (new close_overlay_on_watch_-
replay system); Share keeps it open and relies on the existing
copy-feedback toasts.
- Stats recap (score breakdown reveal, time, XP, achievements) moves
below the actions; the Time line drops from headline/primary to
body/secondary styling.
Tests: 4 new (action presence, Play Again close+request, Watch closes,
Share keeps open) on a manual-clock fixture that steps the 0.5 s
celebration delay deterministically. Workspace suite green, workspace
clippy -D warnings clean, fmt applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):
- Settings: Esc clears SettingsScreen, gated on being the topmost
modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)
Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.
Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase C of docs/ui-redesign-2026-07.md. The Modes row is gone — Home
owns mode selection, so the popover's Play section carries a Home row
firing the new ToggleHomeRequestEvent (read by toggle_home_screen
alongside the existing M accelerator). Section headers are quiet
caption-size labels inside the existing panel widget, not a new
widget. The action-bar Modes button and its popover are untouched
(their removal is Phase B territory when Home gains hierarchy).
- MenuOption: Modes variant replaced by Home; rows grouped Play (Home)
· You (Profile, Stats, Achievements) · Community (Leaderboard) ·
System (Settings, Help)
- handle_menu_option_click no longer chains into spawn_modes_popover
- Tests: tooltip sweep updated (7 rows still), new
toggle_home_event_opens_home_screen covers the popover's open path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the
modal shell (header, shared tab chips, single Done); each tab's content
is a body builder extracted from its original plugin with every marker
component unchanged, so per-row update/scroll/selector systems keep
working. The replay selector gets its own Replays tab (Watch/Copy
buttons move into the tab body).
- Toggle*RequestEvents + P/S/A accelerators open the hub on the right
tab, switch tabs in place, or toggle closed on a same-tab request;
Esc/Done/scrim-click close
- Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the
hub scrim for the active tab — external queries and tests keep their
meaning
- Standalone toggle/close systems and per-screen Done buttons removed
(ProfileCloseButton, StatsCloseButton, AchievementsCloseButton)
- Tests: 2 new hub lifecycle tests; profile/stats/achievements modal
tests adapted (fixtures add YouHubPlugin; selector tests target the
Replays tab). Engine suite 916 green, clippy -D warnings, fmt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Settings' tab_chip becomes a thin wrapper; the You hub (Phase E) will
reuse the same widget so tabbed modals stay visually identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the 37-row single-scroll settings modal with five tabs
(Audio, Gameplay, Appearance, Accessibility, Account); only the active
tab's rows spawn, so every tab fits without scroll-hunting.
- SettingsTab + ActiveSettingsTab (session-only) + SettingsTabButton
chips under the modal header; rebuild-on-switch mirrors the
leaderboard despawn/respawn pattern via a shared build_panel helper
- Accessibility tab gathers color-blind, high-contrast, reduce-motion,
touch-input, and tooltip-delay from the old Gameplay/Cosmetic mix;
Account = Sync + Privacy
- SettingsButton enum, input handlers, and persistence untouched
- Tests: per-tab Focusable/Tooltip sweeps, tab-switch rebuild test,
picker/thumbnail tests pinned to the Appearance tab
Plan: docs/ui-redesign-2026-07.md (also added, phases A–M)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SolutionPlaybackPlugin: the pause modal's 'Show solution' button
resumes the game and requests a solve of the live position via
GameState::winning_line on AsyncComputeTaskPool (never blocks the main
thread; stale results discarded via a move_count snapshot). The line
then plays back one instruction per 0.45 s through the normal
MoveRequestEvent / DrawRequestEvent pipeline — animations, scoring,
undo history and win detection behave as if the player made the moves.
Tableau run counts are decoded against the live state at step time.
Playback cancels on Esc, pause, undo / new-game requests, and any
rejected move (the signature of the player diverging the board).
Toasts cover search start, line found, unwinnable, and budget-
exhausted outcomes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #152 poller fixes resolution + layout on a missed fold/unfold, but
a transient clip could survive if card sprites held stale visuals after
geometry converged. Emit StateChangedEvent alongside the synthetic
WindowResized so card_plugin re-renders every sprite from scratch —
silent self-heal, no player-facing prompt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).
- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
(additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
download on AsyncComputeTaskPool, atomic .tmp+rename write into
user_theme_dir, then the existing hardened import_theme pipeline and
an in-place registry refresh
New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- set_theme resolved every id to themes:// — the bundled dark/classic
themes live at embedded://, so switching to them via the public API
would NotFound and silently keep the old theme. All three load paths
now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
leftover from v0.33 when classic was the default; it now derives from
Settings::default() so it can't drift from the data crate again, with
a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
only the pre-rename "default" id is rewritten.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Random arm was only unreachable because seeds_for(Random) returns
None in a different function — a future catalog change would turn it
into a shipped runtime panic. Returning a system-time seed is the
correct Random behaviour either way. CLAUDE.md §2.3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
user_theme_dir() panicked on desktop when dirs::data_dir() returned
None (broken $HOME / $XDG_*). Return an empty path instead — exactly
what the wasm32 branch already did — so theme discovery reports 'no
user themes' and the bundled default keeps working. Warns once with
the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in
runtime logic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
winit's Android backend does not forward content-rect changes that happen
while backgrounded (open TODOs in winit 0.30 logged on every resume), so a
fold/unfold cycle can leave Bevy rendering and laying out for the previous
screen: tableau clipped off the left edge, bottom third empty (#130).
Add a continuous decor-view size poller (JNI, every 30 frames) that, on
mismatch with the cached Window resolution: writes the real physical size
into window.resolution, emits a synthetic WindowResized in logical pixels,
and re-arms the safe-area inset poller — covering both the fold-size and
inset-staleness cases in one mechanism, without relying on
AppLifecycle::WillResume being delivered (it may never be; a new evidence
log in rearm_on_resumed settles that question on-device).
Closes#130
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clears the final 47 pairs and turns the ratchet into a hard gate
(AMBIGUITY_BASELINE = 0, assert_eq):
- HudButtons: the 14 HUD button/popover handlers run as one chain,
before ui_focus::FocusKeys — Esc/keyboard consumption order is now
defined (restore prompt → buttons/popovers → focus navigation →
settings toggle) instead of scheduler-dependent.
- HUD text updaters (update_hud, update_selection_hud,
update_won_previously) chained, in UiTextFx, after the new
AutoComplete set (update_hud reads AutoCompleteState).
- restore_hud_on_modal → apply_hud_visibility chained before
UpdateOnResize: HudVisibility writes, application, and the layout
read happen in a fixed order.
- New writer sets UndoRequestWriters / InfoToastWriters (same
self-ambiguous pattern as NewGameRequestWriters).
- Logic-before-paint: check_no_moves and the AutoComplete chain order
before BoardVisuals; SettingsMutation after UpdateOnResize.
- MarkerVisuals set wraps the table painter chain; chrome fx declare
disjointness from it.
- update_hud_typography after BoardVisuals; avatar/settings-toggle
interaction handlers declared disjoint from HudButtons.
302 → 198 → 171 → 47 → 0 in four batches, one day. New systems now
fail CI unless they declare their ordering or their disjointness.
Closes#143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Sprite/Transform cluster (112 pairs) was every board painter
racing every other one. Two structural moves:
- BoardVisuals set (card_plugin): all card/marker painters run as ONE
deterministic chain in data-flow order — layout refinement → card
authority (sync_cards_on_change) → flip anims → shadows → highlights
→ stock indicators → resize snapping → corner labels — with
LayoutSystem::UpdateOnResize ordered before the whole set and the
table plugin's marker painters chained after it. Paint order is now
identical every frame instead of scheduler-dependent.
- UiTextFx set (ui_theme): chrome text effects (score pulse/floater,
streak flourish, modal enter, focus-ring pulse) animate Transform on
UI entities only; they are declared ambiguous with BoardVisuals and
with each other — the entity domains are disjoint by construction.
All chain members are cheap and change-gated; sequential execution is
not a measurable cost for a card game. Existing ordering constraints
(fan-frac before sync, shadows after sync, snap after collect) are
preserved inside the chain.
Baseline ratchets 171 → 47. Remaining: long tail of small subjects
(input, event writers, AutoCompleteState, HudVisibility).
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New SettingsMutation set: the per-frame settings mutators
(handle_volume_keys → record_window_geometry_changes →
persist_window_geometry_after_debounce) run as a deterministic chain
ordered before GameMutation, so every reader already after GameMutation
observes the current frame's settings transitively. The four readers
outside that ordering (modal enter-speed chain, focus-ring pulse, HUD
avatar, and the game plugin's pre-mutation chain) are ordered after the
set explicitly.
SettingsResource ambiguities: 21 → 0. Baseline ratchets 198 → 171.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two structural fixes from the #143 backlog:
- Game-state ordering spine: the three pre-mutation GameStateResource
writers (tick_elapsed_time, sync_settings_to_game,
handle_restore_prompt) are now a deterministic chain before
GameMutation, and the remaining unordered readers
(update_selection_hud, handle_hint_button, tick_hint_highlight,
handle_right_click, snap_cards_on_window_resize,
sync_pile_marker_visibility, auto_save_game_state) are ordered after
it. Readers now see the current frame's moves deterministically
instead of racing the mutators.
- NewGameRequestWriters set: every in-cluster writer of
NewGameRequestEvent (buttons, modals, mode picker, seed poller,
restore prompt) is registered in a shared set marked ambiguous with
itself — writer-vs-writer append order is meaningless since consumers
drain the whole queue. Out-of-cluster writers (home, challenge,
time-attack, win-summary, play-by-seed, difficulty, stats plugins)
can join the set when the test cluster grows.
AMBIGUITY_BASELINE ratchets 302 → 198. Remaining backlog is dominated
by the Sprite (72) / Transform (48) visual-domain cluster, which needs
per-domain set architecture — next batch.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First measurement of schedule hygiene (issue #143): the headless
gameplay cluster (Game/Table/Card/Hud/AutoComplete/UiModal/UiFocus/
Settings) carries 302 system pairs with conflicting data access and no
ordering edge. Too many to triage in one pass and most are likely
benign event/resource writers — but unproven, and nothing stopped the
count from growing.
New schedule_checks test builds the cluster with ambiguity detection
promoted to error, parses the reported pair count, and asserts it never
exceeds AMBIGUITY_BASELINE (302). New ambiguous pairs now fail CI at
the PR; the legacy backlog can be burned down incrementally by adding
.before/.after or .ambiguous_with and lowering the baseline.
Refs #143
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exit push spawned a detached task on AppExit, which process
teardown almost always killed before the network round-trip completed —
the final session's sync silently never happened (2026-07-06 review,
finding M3). Local persistence meant no data loss, but stats stayed
unsynced until the next launch.
push_on_exit now blocks the closing app's final frame for at most
EXIT_PUSH_TIMEOUT (2s) via tokio::time::timeout: long enough for one
healthy round-trip, short enough that quitting never feels hung when
the server is unreachable. Timeout and errors are logged and skipped —
the next launch's pull/push converges as before.
Closes review finding M3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings 1+2 (Quat-underuse lens, 2026-07-06):
- solitaire_core gains pub const FOUNDATIONS / TABLEAUS — the canonical
iteration source for the upstream pile enums (upstream klondike has no
Foundation::ALL, and inherent impls cannot be added to foreign types).
Deletes three identical private const-fn copies (radial_menu,
table_plugin, input_plugin) and the hand-enumerated variants in
card_plugin::sync::all_cards and solitaire_wasm.
- Hand-rolled [Suit; 4] / [Rank; 13] arrays replaced with upstream
Suit::SUITS / Rank::RANKS. The order-sensitive CardImageSet indexing
is re-keyed through canonical card_plugin::{suit_index, rank_index}
helpers that match upstream order, with regression tests asserting
the correspondence — one ordering everywhere instead of three
divergent local ones.
Net -177 lines. No behaviour change; all consumers go through the
canonical helpers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found during Fold 7 on-device verification of v0.41.0: on_window_resized
resized the marker fill sprite but never its children, so after any
resize (fold/unfold, rotation) the outline frame and the A/K watermark
kept their spawn-time size — rendering as oversized grey slabs over the
empty foundation slots.
The resize handler now re-derives both children from the new layout
(outline = card_size + 2*PILE_MARKER_OUTLINE_WIDTH, watermark font =
card_size.x * 0.28 — same formulas as spawn). Adds a regression test
and an Android-gated relayout log line (width/height/insets) as the
evidence channel for the remaining foldable layout bug (#130).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second phase of #118 for settings_plugin: the 2,857-line mod.rs becomes
four focused submodules along existing system boundaries —
mod.rs 561 types, markers, SettingsButton, plugin build, persistence
input.rs 632 button/hotkey handlers, focus attachment, scrolling
updates.rs 495 per-frame value-text updater systems + label helpers
ui.rs 1,231 spawn_settings_panel + row builders + thumbnails
Moved items are pub(super); mod.rs glob-imports the submodules so
system registration and tests keep their bare names. No behaviour
change; all 29 settings tests pass unchanged.
Refs #118
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First increment of the module-split plan: card_plugin.rs becomes
card_plugin/{mod.rs, tests.rs} (2,536 + 1,592 lines), following the
replay_overlay/ pattern. Pure mechanical move via git mv — no runtime
code changes; all 70 tests preserved and passing.
Refs #118
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refresh_insets gated its loop on insets.is_populated(), so once insets
resolved at first launch, rearm_on_resumed's poll-counter reset was a
no-op and JNI was never queried again. Insets that changed while the
app was backgrounded (fold/unfold, rotation, gesture/3-button nav
switch) stayed stale until process restart.
Gate the loop on the poll counter alone and settle a cycle by
exhausting it once a populated reading arrives. The cached resource is
rewritten only when the value actually differs, so resumes where
nothing moved trigger no change detection and no relayout — preserving
the no-flash resume behaviour. Also correct the stale on_app_resumed
doc that still described the old inset-zeroing approach.
Closes#116
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MAX_DYNAMIC_FAN_FRAC 0.6 -> 0.9 so the dynamic tableau fill spreads
further on very tall / narrow viewports (e.g. a foldable cover screen),
which were left ~40% empty at the cap. Fills the unfolded near-square
screen to ~100% and lets the cover screen fill further (and the rest fills
as columns deepen during play). Normal phones are unaffected — their fill
fraction is already below the cap. apply_dynamic_tableau_fan still floors
at TABLEAU_FAN_FRAC and deeper columns drive the fraction down, so nothing
overflows and hit-testing stays in sync.
Vertical centring of the residual was investigated but dropped: a 21:9
phone is aspect-identical to the cover screen, so centring can't be
targeted to foldables without also disconnecting the board from the HUD on
tall phones.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a near-square viewport (e.g. an unfolded Galaxy Fold) a fresh deal left
the bottom ~40% of the screen empty: the dynamic fan (update_tableau_fan_frac)
measured only face-up column depth and returned early at a fresh deal (face-up
depth 1), so it never spread the tableau, and the deep face-down stacks were
ignored. The cold-start deal also never fired StateChangedEvent, and on Android
the safe-area-inset resize (frames 1-3) reset the fan to compute_layout's
sparse worst-case value, so even the post-move fill was wiped.
Move the fill into layout::apply_dynamic_tableau_fan, driven by each column's
TOTAL weighted depth (face-down cards count, scaled by the face-down/face-up
step ratio) so the deepest column fills the available height. Run it in three
places so every path stays filled: PostStartup (cold-start deal), on
StateChangedEvent (moves), and inside on_window_resized after compute_layout
(safe-area resize + fold/unfold). MAX_DYNAMIC_FAN_FRAC caps the spread so a
near-empty column keeps readable overlap; TABLEAU_FAN_FRAC floors it. Deeper
columns drive the fraction down so everything still fits — no overflow.
card_position/card_positions read the same fractions, so hit-testing stays
in sync.
Adds regression tests: cold-start deal fills the fan, and a resize re-fills it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every move fired StateChangedEvent -> sync_cards, which rebuilt the full
visual for all 52 cards: despawning and respawning every child entity
(drop-shadow, border frame, and on Android a Text2d corner label needing
a glyph re-layout) even for the ~50 cards that did not move. That ~250
entity despawn/spawns plus 52 text re-layouts in a single frame spiked
the StateChangedEvent frame and stuttered the slide animation on
high-resolution devices (reported on a Galaxy Fold 7).
Add a CardChildrenKey component capturing the only inputs the child
entities depend on (face_up, card_size, color_blind, high_contrast).
update_card_entity now rebuilds children only when that key changes (a
flip, resize/fold, or accessibility toggle); a position-only move just
updates the Transform. The Sprite is still refreshed every sync (a cheap
handle swap), so theme/card-back image changes need no child rebuild.
Adds a regression test asserting an appearance-neutral StateChangedEvent
no longer despawns/respawns card label children.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The waste fan x-offset was computed two ways: the renderer used
tableau_col_step * 0.224 while the hit-test hard-coded card_size.x * 0.28.
These coincide on desktop (col_step = 1.25*cw) but drift on Android, where
tighter column spacing (H_GAP_DIVISOR=32, col_step ~= 1.03*cw) makes the
renderer fan at ~0.231*cw. The top fanned waste card's sprite then sits
~14px left of its click target, so dragging the visible top card grabs
the card beneath it.
Extract waste_fan_step() and tableau_col_step() as the single source for
both the renderer (card_plugin::card_positions) and the hit-test
(input_plugin::card_position) so they can no longer diverge. Add a
regression test that simulates Android-tight spacing and asserts the hit
target tracks the renderer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds two find_draggable_at tests covering the reported stock/waste
drag bug: clicking the visible top of a multi-card waste must pick the
top index (not the buffer card beneath), and a lone waste card must
still be draggable. Both pass against current logic, pinning the
correct behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handle_stock_click (and handle_touch_stock_tap) hit-tested both the face-down
deck AND the waste slot, so a click/tap on the drawn waste card fired
DrawRequestEvent — drawing the next card instead of playing the waste card, and
swallowing the first click of a double-click so auto-move never triggered.
Only the deck draws now. The waste card is left free to play: double-click /
double-tap to auto-move, or drag it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>