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>