# Ferrous Solitaire — Session Handoff

**Last updated:** 2026-06-09 — AVD Android launch smoke passed; physical-device gate remains.

---

## Current state

- **Branch state:** `master` pushed to origin; latest commits are validation runbooks, card-label test coverage, and Android AVD smoke notes.
- **Latest tag:** `v0.39.0`
- **Working tree:** clean. Local `scripts/` helpers are excluded through `.git/info/exclude` and intentionally not committed.
- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; `cargo test -p solitaire_data matomo_client`; `cargo test -p solitaire_engine analytics_plugin`; `cargo test -p solitaire_engine settings_plugin`; `cargo test -p solitaire_engine card_plugin`; `cargo apk build -p solitaire_app --target x86_64-linux-android --lib`; AVD `Pixel_7` install/launch/input smoke.
- **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up.

---

## What shipped since v0.39.0

- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`.
- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up:
  - `5e87358` integrates upstream deps cleanly.
  - `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types.
  - `d864d98` routes klondike/card imports through `solitaire_core`.
  - `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs.
- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed.
- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work and committed in `7fe6ac6`.
- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels.
- Native analytics and Android physical-device validation now have runbooks in
  `docs/analytics-validation.md` and `docs/ANDROID.md`.

---

## Historical notes before v0.39.0

See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work.

---

## What shipped since the last handoff (v0.23.0 → v0.35.1)

### v0.34.0 — Android polish + code-quality sweep (2026-05-16/17)

| Commit | Summary |
|--------|---------|
| `9623bde` | Wire FiraMono to Android corner label; CardImageSet load tests |
| `980312c` | Fix wrong bottom-right suit symbol on JS/QS/KS card assets |
| `04e99a8` | Correct Android waste fan overlap and resume layout desync |
| `3bb3ddb` | Eliminate panics, fix dismiss hit-test scope, guard home respawn |
| `f8f1f26` | Adaptive drop zones, touch event correctness, modal lifecycle guards |
| `1eb4043` | Auth-guard avatar serving; atomic write; user_id assertion in merge |
| `69c6e88` | Deterministic pile serialization, undo skip, url-encode bytes, merge_at |
| `aa7b0f6` | Gate frame-hot ECS systems on resource changes (perf) |
| `6727126` | Consolidate APP_DIR_NAME; add `#[must_use]` on pure fns |
| `a4dfb0c` | Differentiate leaderboard opt-in vs opt-out error toasts (M-12) |
| `7fc98f8` | WASM: state() and step() return Result, errors throw JS exceptions (CR-6) |
| `ffed6b2` | Share Tokio runtime across all network tasks (M-16) |
| `fa84152` | Correct Android help hint label `→` to `!` (M-17) |
| `18d7937` | Derive Copy for DrawMode; drop redundant .clone() calls (M-18) |
| `132fea9` | Use saturating_add for move_count increments (M-19) |
| `0ecc1a9` | Add missing derives to AchievementContext (M-20) |
| `2301cc6` | Align android_keystore temp extension with cleanup glob (M-21) |
| `2e52f54` | Enforce 32-char display_name limit at sync client boundary (M-22) |
| `c8878d6` | Fix stale FOCUS_RING colour comment (M-23) |
| `4aafc0a` | Name HUD popover Z-layers; replace raw Z arithmetic (M-24) |

### v0.35.0 — Accessibility + sync reliability (2026-05-18)

| 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.

### v0.35.1 — Leaderboard bug fixes (2026-05-18)

| Commit | Summary |
|--------|---------|
| `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced |

Three bugs fixed:

1. **Wrong toast type on error** — `poll_opt_in_task` / `poll_opt_out_task` error
   branches now fire `WarningToastEvent` instead of `InfoToastEvent`.

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.

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.

5 new regression tests cover all three bugs.

---

## Open punch list

### 1. Android APK launch verification (Option A)

Physical device test: install the latest APK on a real Android device (not AVD),
and run the checklist in `docs/ANDROID.md`. 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.

Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
filtered logcat showed no Ferrous panic/fatal/ANR.

### 2. Matomo analytics live validation

`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine
consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live
validation against the deployed Matomo instance. Use
`docs/analytics-validation.md` for the native validation checklist and the
current web/WASM decision notes.

---

## Architectural notes for next session

- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
  (the trigger), not the `tick_*` system. If the component is never inserted, the
  tick path never runs. See `hud_plugin.rs::detect_score_change` and
  `feedback_anim_plugin.rs::start_shake_anim` for the canonical pattern.

- **Leaderboard server upsert:** `POST /api/leaderboard/opt-in` is idempotent —
  calling it when already opted in just updates `display_name`. Safe to call from
  `handle_display_name_confirm` without tracking a separate "needs update" flag.

- **`Messages<T>` API (Bevy 0.18.1):** write with
  `resource_mut::<Messages<T>>().write(value)`; read in tests with
  `msgs.get_cursor()` + `cursor.read(msgs).next()`.

- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
  `ButtonInput::just_pressed` state persists across frames unless explicitly cleared
  with `input.release(key); input.clear()` between updates.

- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
  `Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
  (logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
  operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
  the Bevy visual game. This is intentional for automation/invariant checking.

- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
  is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
  on HiDPI displays, causing an immediate wgpu panic on the first resize event.

- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
  target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
  using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
  supports both via `--route play-classic|play`.
