Commit Graph

254 Commits

Author SHA1 Message Date
funman300 17f9b518f1 fix(engine): bundle fonts only and drop system-font fallback
Code-review feedback: the SVG rasteriser mixed three font-resolution
layers (load_system_fonts + bundled FiraMono + lenient resolver
appending CSS generics), which made card text rendering depend on
which fonts the host machine happened to have. The Bevy UI face
loaded separately at runtime via AssetServer. Picking option (a)
from the review and applying it consistently: bundle FiraMono via
include_bytes!() in BOTH layers, no system fallback anywhere.

solitaire_engine/src/font_plugin.rs now embeds main.ttf at compile
time and registers it with Assets<Font>. A parse failure aborts
with "bundled FiraMono failed to parse — binary is corrupt"; the
MinimalPlugins early-return stays as a "this plugin doesn't apply
in headless tests" check (consumers query Option<Res<FontResource>>
and degrade cleanly), not a production fallback.

solitaire_engine/src/assets/svg_loader.rs drops load_system_fonts
entirely, drops the lenient_font_resolver, and drops the five
set_*_family pins. The new bundled_font_resolver ignores the SVG's
font-family request and always returns the single bundled face —
the bundled card SVGs reference Arial / Bitstream Vera Sans by name
and we deliberately don't ship those, so routing every query to
FiraMono keeps rasterisation deterministic. shared_fontdb asserts
the embedded bytes parsed.

The two layers now embed the same path
(assets/fonts/main.ttf) independently, so they can't drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:33:54 +00:00
funman300 61d891fb76 docs: CHANGELOG + SESSION_HANDOFF refresh for v0.12.0
CHANGELOG gains a [0.12.0] section covering the second UX iteration
round on top of v0.11.0:
- Foundation completion flourish
- Drag-cancel return tween
- Focus ring breathing
- First-win achievement onboarding toast
- Mode Launcher digit shortcuts
- Card aspect-ratio fix (1.4 → 1.4523)
- Plus the README and CHANGELOG-add docs that rode along

The bottom-of-file compare links thread the new tag into the
existing chain (Unreleased → 0.12.0 → 0.11.0 → ...). Test count
updated to 1007.

SESSION_HANDOFF now distinguishes session 7 round 1 (v0.11.0,
morning) from round 2 (v0.12.0, afternoon) — keeping the audit
trail readable instead of conflating them. The release-prep punch
list collapses to the three tag/push/packaging items; the UX
iteration list opens with six fresh candidates for whoever picks
the next round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.12.0
2026-05-02 03:08:46 +00:00
funman300 7dba772e67 feat(engine): digit shortcuts (1-5) launch modes from inside the Mode Launcher
Pressing M already opens the Home modal (which is the Mode Launcher
post-v0.11) and Tab cycles focus through the cards. The remaining
gap was direct keyboard activation of a specific mode — players had
to tab-and-enter or click. A new modal-scoped digit handler closes
that gap:

  1 → Classic (NewGameRequestEvent)
  2 → Daily Challenge (StartDailyChallengeRequestEvent)
  3 → Zen (StartZenRequestEvent, gated at level 5)
  4 → Challenge (StartChallengeRequestEvent, gated at level 5)
  5 → Time Attack (StartTimeAttackRequestEvent, gated at level 5)

handle_home_digit_keys runs only when HomeScreen exists and short-
circuits otherwise — the digit keys can't accidentally launch a
mode mid-game. Locked modes (level < CHALLENGE_UNLOCK_LEVEL) silent-
no-op rather than firing a toast, mirroring the click-on-locked-card
behaviour without the InfoToastEvent (the click path's toast is the
authoritative "level too low" surface).

The HomePlugin Update tuple is now .chain()ed because the Bevy 0.18
parallel scheduler would otherwise let handle_home_card_click,
handle_home_cancel_button, and the new digit handler all queue a
HomeScreen despawn concurrently — the second buffer apply panics
on the already-despawned entity.

help_plugin gains a new "Mode Launcher (M)" section with the digit
rows and a level-5 unlock note. onboarding's slide-3 hotkey table
gets one new line ("M — Open Mode Launcher (then 1-5 to pick)") so
first-run players see the full path. The help-modal canonical list
now mirrors the onboarding teach.

Four new headless tests pin the contract: Digit1 launches Classic
and closes the modal; Digit3 at level 0 is a no-op (modal stays
open); Digit3 at unlock level launches Zen and closes; digit keys
outside the modal fire no events at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:01:41 +00:00
funman300 ca5788f714 feat(engine): one-shot achievement-onboarding toast on first win
After the player's very first win the engine now writes
"First win! Press A to see your achievements." via InfoToastEvent,
then flips a persisted Settings.shown_achievement_onboarding flag so
the cue never re-fires. Mentions the A hotkey by name so the toast
is actionable on its own.

The toast path runs after StatsUpdate so games_won has been
incremented to 1 when the system reads it; .after(GameMutation)
keeps the post-move state visible. Three guards: first win only,
flag was false, GameWonEvent fired this tick.

Persistence mirrors onboarding_plugin's complete_onboarding pattern:
save via save_settings_to with the existing
SettingsStoragePath/Option<&PathBuf> graceful-fallback shape.
Atomic .tmp+rename writes are unchanged.

Settings gains a single bool field with #[serde(default)] so legacy
settings.json files deserialize cleanly to false. The field is
local-only by design — it's about UI teaching for THIS device, not
progression — so SyncPayload and merge logic are untouched.

Seven new tests pin the contract: default value is false, field
round-trips through save/load, legacy JSON without the field
deserializes to false, first win fires the toast and flips the
flag, subsequent wins are silent, the fifth win on a synced device
is silent (won't fire when games_won has been bumped via sync), and
no win event means no toast.

Toast duration is the existing animation_plugin
QUEUED_TOAST_SECS = 2.5 s — InfoToastEvent is a tuple struct with
no duration parameter, so the agent kept the existing event shape
rather than expanding it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:01:18 +00:00
funman300 9887343d8b feat(engine): focus ring breathes at 1.4 s — gentle pulse instead of flat
The keyboard focus ring rendered as a static yellow outline. A new
pulse_focus_overlay system modulates the overlay's BorderColor alpha
with a sin curve over MOTION_FOCUS_PULSE_SECS (1.4 s), breathing the
visible alpha between 0.65× and 1.0× of FOCUS_RING's native value.
The motion is slow enough to read as a calm heartbeat in peripheral
vision rather than a competing animation, and a focus change still
draws the eye because the ring re-attaches at full brightness on
the next pulse cycle.

The pulse honours AnimSpeed::Instant by reading SettingsResource
and skipping the modulation entirely (static FOCUS_RING colour) for
reduced-motion users — matches the convention used elsewhere for
animation gating.

A pure focus_ring_pulse_factor(elapsed_secs) helper is unit-tested
for the curve shape: 0.825 at t=0 (mid-point), 1.0 at the
quarter-period peak, 0.65 at the three-quarter-period trough, and a
sweep across two full periods stays within the [0.65, 1.0] range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:40 +00:00
funman300 525fe0fe76 feat(engine): drag-cancel return tween — smooth ease-out instead of shake
Illegal drops previously snapped each dragged card to its origin
slot and ran a horizontal ShakeAnim wiggle for negative feedback —
which read as punitive on every misclick. The rejection now plays
a 150 ms quintic ease-out glide from the drop location back to the
resting slot. The audio cue (card_invalid.wav) still fires so the
player gets clear "no" feedback; the visual is just gentler.

Both rejection paths in input_plugin (mouse end_drag and touch
end_drag) construct a CardAnimation::slide(drag_pos → target_pos)
with MotionCurve::Responsive — the curve module's own docs
recommend Responsive specifically for invalid snap-back because its
zero overshoot reads forgiving rather than jittery.

card_plugin's update_card_entity gates its snap path on
CardAnimation absence so the StateChangedEvent that follows a
rejection no longer fights the in-flight tween. Mirrors how
resize_cards_in_place already drops in-flight tweens during a
window resize.

ShakeAnim itself stays in feedback_anim_plugin — the right-click
invalid-target and double-click in-place rejection paths still use
it because there's no movement to interpolate, just a "no" wiggle.
Only the drag-rejection path swaps to the smooth tween.

Six new rejection-tween tests pin the contract: CardAnimation is
inserted on every dragged card, start/end positions and z values
match the drag-to-resting transition, duration matches the new
MOTION_DRAG_REJECT_SECS token, and the curve is Responsive. The
two legacy ShakeAnim drag-rejection tests are removed since their
contract is intentionally inverted by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:34:12 +00:00
funman300 69ce9afab9 feat(engine): foundation completion flourish — King-on-foundation celebration
Now that foundations are unlocked and "completing" one is a real
moment (rather than a foregone conclusion based on suit assignment),
each Ace-through-King run gets its own small celebration when the
King lands.

Three layers fire on a single FoundationCompletedEvent emitted by
game_plugin's handle_move when a successful move leaves a
PileType::Foundation pile holding 13 cards:

1. King card scale-pulse via a new FoundationFlourish component.
   Triangular curve 1.0 → 1.15 → 1.0 over MOTION_FOUNDATION_FLOURISH
   _SECS (0.4s) — same shape as the existing ScorePulse so the feel
   matches.
2. Pile-marker tint flourish via FoundationMarkerFlourish — the
   foundation marker's sprite colour lerps to STATE_SUCCESS for the
   first half of the duration then fades back. Reuses the existing
   success-signal palette; no new colour token.
3. Audio cue: foundation_complete.wav, a synthesised C6→E6→G6 triad
   with 2nd-harmonic warmth and AR decay (~240 ms). Sits an octave
   above win_fanfare's root so the layered fourth-completion + win
   cascade reads cleanly. Generated via solitaire_assetgen's
   foundation_complete() function and embedded via include_bytes!().

The visual systems run .after(GameMutation) so the post-move pile
state is visible when the King is identified. Both flourish
components remove themselves once elapsed time exceeds duration —
no animation queue or scheduler integration needed.

Pure foundation_flourish_scale(elapsed, duration) helper is
unit-tested for the curve, edge clamps, and zero-duration safety.
Three integration tests on the firing logic verify the event fires
exactly once when a King completes a foundation, doesn't fire for
non-foundation moves, and doesn't fire when the foundation is at 12
cards.

The fourth completion still co-occurs with the win cascade — the
two layer cleanly because the flourish's scale is on the King card
sprite while the cascade is a screen-shake + per-card rotation, and
the foundation_complete ping is a higher octave than the win
fanfare's root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:19:50 +00:00
funman300 13aa0fd833 fix(engine): match CARD_ASPECT to hayeah SVG dimensions (1.4 → 1.4523)
Cards rendered ~3.6 % squashed vertically because layout.rs assumed a
1.4 height/width ratio while the bundled hayeah/playing-cards-assets
SVGs are natively 167.087 × 242.667 (= 1.4523). The mismatch meant
every face was scaled to fit a too-short box; pip arrangements and
court-card art read slightly compressed.

Bumps CARD_ASPECT to 1.4523 to match the SVG. The vertical-budget
math in compute_layout (the height-based card_width candidate) uses
CARD_ASPECT algebraically, so the tableau-fits-on-screen check
adapts automatically — slightly smaller cards on aspect-ratio-tight
windows, no visible regression on standard 16:9.

Doc comments referencing the old 1.4 literal updated to point at
CARD_ASPECT instead so this can't drift again.

All 982 tests pass — the existing layout/test sentinel
(card_size.y / card_size.x - CARD_ASPECT) used the constant by name
and adapted for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:03:12 +00:00
funman300 9f095c4039 docs: add CHANGELOG.md covering v0.9.0 through v0.11.0
The CHANGELOG didn't exist; v0.11.0 felt too meaty to land without one
and starting from v0.10.0+ would have made the file feel rootless. The
format follows Keep a Changelog 1.1.0 with the standard Added /
Changed / Fixed / Removed sections per release plus a Migration block
when relevant.

v0.11.0 (2026-05-02) — full coverage of the card-theme system, HUD
overhaul, drag-feel polish (drop overlay, drop shadows, stock count
badge, unlocked foundations), the FiraMono fontdb fix, and the
schema-version bump that invalidates pre-v2 game_state.json saves on
launch. 982 tests, zero clippy.

v0.10.0 (2026-04-29) — PNG art pipeline, Bevy 0.15 → 0.18 migration,
kira 0.9 → 0.12 migration, Rust edition 2024 + MSRV 1.95, custom
font, JWT-secret-at-startup fix, SmartIpKeyExtractor, MessageReader
touch-input fix.

v0.9.0 (2026-04-28) — initial public-tagged release: workspace
structure, modal scaffold, design-token system, four-tier HUD,
progression, sync server, splash, focus rings, tooltips,
achievement integration tests, all the foundation work that
predates the card-theme rewrite.

README gains a Changelog section linking to the new file.

The bottom-of-file compare links use the corrected
github.com/funman300/Rusty_Solitaire URL so the rendered page on
GitHub auto-generates the correct diff views once the tags are
pushed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:59:13 +00:00
funman300 d8c70341f4 docs: refresh README for v0.11.0 — card themes, HUD overhaul, drag feel
The README hadn't been touched since before the card-theme system
landed and was missing every UX feel improvement from v0.11.0.
Anyone discovering the repo on the GitHub release page would have
seen pre-theme copy.

Features list now covers card themes (bundled default + user
zip-installable), the modern HUD (reserved band + action-bar
auto-fade), and the four drag-feel improvements (drop highlights,
drop shadows, stock count badge, unlocked foundations).

Controls table fixes three real discrepancies: Undo is U not
Z/Ctrl+Z (the README inverted the bindings), Help is F1 not H, and
Z actually toggles Zen mode. Adds the previously undocumented Tab /
Shift+Tab focus cycle, Enter activation, F11 fullscreen, double-
click to auto-move, and the G forfeit shortcut. Notes that every
action is also a visible UI button so the keyboard list is
optional-accelerator only — matches the project's UI-first rule.

Adds a small Card Themes section explaining how to install a theme
(drop a directory or zip-import via Settings → Cosmetic) without
diving into SVG technicals.

Test count updated to 982 to reflect v0.11.0 baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:56:24 +00:00
funman300 063269c70e docs: update repo URL references to corrected Rusty_Solitaire spelling
The GitHub repo was renamed from Rusty_Solitare to Rusty_Solitaire
(adding the missing 'i'). The local origin remote has been updated
via `git remote set-url`; this commit updates the three doc
references that hardcoded the old URL.

SESSION_HANDOFF.md's "Canonical remote" section now names the new
URL and explains the rename for future readers, including the note
that local clone directories may still be named Rusty_Solitare —
that's a local-only name and works fine, only the GitHub repo URL
changed.

docs/SESSION_HANDOFF.md (older snapshot, unchanged otherwise) gets
its single URL line corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.11.0
2026-05-02 00:36:06 +00:00
funman300 b126df82b2 docs: refresh SESSION_HANDOFF for session 7 UX-iteration round complete
Session 6 closed with a four-item UX punch list (unlock foundations,
drop shadows, drop-target highlights, stock badge). All four shipped
in session 7, plus an unrelated font-fallback fix surfaced by a
second-machine smoke test that landed before the UX work.

Refreshes the doc to reflect:
- HEAD: 655dfde, 3 commits ahead of origin
- 982 tests pass (was 962)
- Session 7 changelog table summarising the five commits
- UX punch-list entirely closed; release-prep items still on the
  table but un-deferred (player gets a directional choice next session)
- New "next-round candidates" UX list (animated focus ring,
  achievement onboarding, mode-switch keyboard shortcut, aspect-ratio
  fidelity, foundation completion flourish, drag-cancel tween)
- Resume prompt asks A/B/C: tag v0.11.0, README/CHANGELOG first, or
  start a new UX round

Length 120 → 109 (-11) by trimming the spent priority list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:33:42 +00:00
funman300 655dfde736 feat(engine): stock-pile remaining-count badge
Players were recycling the stock blind — there's no in-world
indicator of how many cards are left before the recycle. A small
"·N" chip now sits at the top-right corner of the stock pile,
showing the remaining count.

The badge is a top-level world entity whose Transform.translation is
recomputed each tick from the live LayoutResource (so window resizes
and theme switches don't strand it), parented to neither the
PileMarker nor any card. update_stock_count_badge spawns the entity
on the first frame, then on every subsequent frame reads the stock
pile's card count, writes the formatted text into the child Text2d,
and toggles Visibility::Hidden when the count drops to zero — the
same state where StockEmptyLabel's existing ↺ icon takes over, so
the two never co-render.

Z_STOCK_BADGE = 30 sits above stock cards (z ≈ 1) and below
Z_DROP_OVERLAY = 50, so the badge stays visible during normal play
but green drop-target washes still cover it while a card is being
dragged. Card drop shadows live at negative local z relative to
each card and don't compete with the badge plane.

Tokens (STOCK_BADGE_BG, STOCK_BADGE_FG, Z_STOCK_BADGE) were already
present in ui_theme from prior work; this commit only wires them up.
The chip itself is 28×16 px, rendered with TYPE_CAPTION text in
ACCENT_PRIMARY against BG_ELEVATED_HI.

Four new tests pin the contract: badge shows "·24" on a fresh deal,
hides when the stock empties, updates as the count drops, and the
stock_card_count helper reports 0 when the pile is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:31:15 +00:00
funman300 f712b89fe4 feat(engine): drop shadows on cards with lifted state during drag
Cards previously read as flat stickers on the felt — no separation
cue, no sense the play surface had any depth. Each CardEntity now
spawns a CardShadow child sprite: neutral black at 25 % alpha, sized
to card_size + 4 px halo, offset (2, -3) and rendered at local z
-0.05 so it sits behind its card.

Cards in the active drag set switch to a lifted shadow: alpha 40 %,
offset (4, -6), padding (8, 8). update_card_shadows_on_drag runs
every Update and snaps each shadow to the right state based on
DragState membership — no lerp, no animation cost. The pure
card_shadow_params(is_dragged) helper is unit-tested for the four
parameter values.

resize_cards_in_place gains a third query for shadows so the
in-place resize keeps shadows cheap (no Sprite regeneration); the
shadow's current alpha is read to preserve idle vs lifted padding
across a resize. update_card_entity's despawn_related call is
followed by a fresh add_card_shadow_child so the shadow re-attaches
when the card is repainted (face flip, settings change, theme
swap). The pre-existing bulk drag-shadow under the whole lifted
stack is untouched — per-card shadows complement it.

All shadow values flow through eight new ui_theme tokens
(CARD_SHADOW_COLOR, alphas, offsets, paddings, local z) so the
visual is tunable in one place. Color is neutral black so the
shadows don't conflict with color-blind mode's red/blue suit tints.

Four new tests pin the contract: shadow params for idle and drag
states, every CardEntity spawns with exactly one CardShadow child,
and dragging shifts only the dragged shadow's offset while leaving
unrelated shadows on the idle offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:21:28 +00:00
funman300 f6c916641a feat(engine): visible drop-target overlay during drag
The existing update_drop_highlights system tinted PileMarker sprites
green for valid drops, but the marker is a card-sized rectangle that
sits behind the stack. Once a tableau column had any cards on it the
marker was occluded and the highlight effectively invisible — the
handoff's "drops feel guess-y because there's no preview" point.

A new update_drop_target_overlays system spawns an overlay above every
legal target during drag: a soft DROP_TARGET_FILL rectangle sized to
the pile's actual visible footprint (full fanned column for tableaux,
card-sized for foundations and empty tableaux) plus four thin
DROP_TARGET_OUTLINE edges forming a 3 px border. Z_DROP_OVERLAY = 50
sits above static cards (z ~1) but below the dragged stack (DRAG_Z =
500), so the overlay never occludes the card the player is holding.

The valid-target enumeration mirrors update_drop_highlights exactly so
the rules can't drift, and pile geometry mirrors input_plugin's
pile_drop_rect. The original marker-tint system is untouched; it still
does its job for empty-pile placeholders. The overlay layer is purely
additive — running alongside, not replacing.

Token values reuse the existing STATE_SUCCESS hue (#4ADE80) at 10%
fill / 75% outline so the overlay green matches the rest of the
success-signal palette (foundation completion, sync OK, etc.).

Three headless tests pin the contract: overlay spawns for valid
tableau drops, doesn't spawn for invalid destinations, and despawns
the moment the drag ends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:33:22 +00:00
funman300 95df5421c9 feat(core): unlock foundations — Foundation(u8) slots, suit derived from contents
Standard Klondike behaviour: any Ace can land in any empty foundation,
and that slot then claims the suit until the pile empties. The
previous PileType::Foundation(Suit) variant pre-assigned each of the
four foundations to a fixed suit ("C / D / H / S" placeholders) and
rejected mismatched Aces — non-standard and (per the smoke-test
feedback) confusing.

Replaces the variant payload with a slot index Foundation(u8) (0..=3)
and derives the claimed suit from the bottom card via a new
Pile::claimed_suit() method. The bottom card is, by construction,
the Ace that established the claim; using it directly eliminates an
entire class of "stuck claim after undo" bugs that a separate
claimed_suit field would have introduced.

can_place_on_foundation drops its suit parameter — the rule reduces
to "empty pile accepts any Ace; non-empty pile accepts the next
rank up of the bottom card's suit." Iteration sites across
input_plugin, cursor_plugin, selection_plugin, card_plugin,
auto_complete_plugin, game_plugin, layout, and hud_plugin all swap
the four-suit list for `(0..4u8).map(PileType::Foundation)`.

next_auto_complete_move now prefers a slot whose claimed_suit matches
the candidate card before falling back to the first empty slot for
an Ace — so the same suit consistently auto-targets the same slot
across the whole game, matching player expectations.

The HUD selection label and the hint toast read claimed_suit() and
fall back to "Foundation N" / "move to foundation" only when the
slot is empty. Empty foundation pile markers no longer render the
suit-letter children — they're plain translucent rectangles, matching
empty tableau placeholders.

Save-format invalidation: GameState gains a schema_version field
(serde-default to 1 for back-compat parsing of old files), the
constant is bumped to 2, and load_game_state_from rejects mismatched
schemas. Old in-progress saves silently fall through to "fresh game
on launch" — the user accepted this loss given the mechanic change.
Stats / progress / achievements / settings live in separate files,
contain no PileType data, and are unaffected.

9 new tests pin the contract:
- Pile::claimed_suit returns None for empty / non-foundation, Some
  for non-empty foundation
- Any Ace lands in the first empty foundation; successive Aces
  distribute across slots 0..3
- Claim drops when the slot is emptied via undo
- Auto-complete picks the slot with a matching claim, not the first
  empty slot
- A v1-format game_state.json is rejected; sibling stats save/load
  is unaffected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:17:17 +00:00
funman300 fdb6c2ecfe fix(engine): bundle FiraMono into SVG fontdb as last-resort fallback
The hayeah card SVGs reference Bitstream Vera Sans and Arial by name.
The lenient FontResolver from efa063f appends Family::SansSerif and
Family::Serif so unmatched named families fall through to whatever
the system serves under those CSS generics — which works on machines
with a normal fontconfig setup, and silently fails on minimal Linux
installs, fresh Wayland sessions, or chroots where the generic
aliases don't resolve to anything either. The visible symptom on the
player's second machine was "card font didn't carry over": rank and
suit glyphs vanished from the cards because every lookup path hit a
None.

shared_fontdb now also include_bytes!()s the bundled
assets/fonts/main.ttf into the fontdb after load_system_fonts, and
pins each CSS generic (sans-serif, serif, monospace, cursive,
fantasy) to "Fira Mono". Named-family lookups still prefer the
system db first when those families exist, so machines with a normal
font setup behave identically; only when SansSerif/Serif fall through
does the resolver land on FiraMono — guaranteed present because it's
embedded in the binary.

The bundled font is ~170 KB; the binary already include_bytes!()s the
six audio WAVs and the embedded card-theme SVGs, so this fits the
existing self-contained-binary policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:41:35 +00:00
funman300 9a3d7f3876 docs: refresh SESSION_HANDOFF for session 6 + UX-iteration direction
Captures today's six commits (theme loader fix, exit-warn silence, two
font-warn rounds, HUD band, action fade), updates HEAD/test counts,
records that the player redirected from "cut v0.11.0 / package" to
"keep iterating on UX," and lists the new four-item UX punch list
(unlock foundations, drop shadows, drop highlighting, stock badge).

Resume prompt is rewritten so a fresh agent on a different machine
picks up cleanly: notes GitHub is the canonical remote (Gitea drift
caused commits to silently miss the alex machine earlier in session),
flags that the in-progress save format will invalidate when (1)
lands, and explicitly defers the release-prep items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:24:09 +00:00
funman300 c4970b16ea feat(engine): auto-fade HUD action buttons when cursor leaves the band
Player request: the Menu / Undo / Pause / Help / Modes / New Game
buttons stay visible during play even when the player isn't looking
at them. Fade them out when the cursor is in the play area, fade
back in when it returns to the top of the window.

Implementation mirrors video-player auto-hide UX:
- HudActionFade resource holds (alpha, target). Default both 1.0 so
  the bar starts visible on first launch.
- update_action_fade reads cursor.y each frame, sets target to 1.0
  when the cursor is in the top reveal zone (HUD_BAND_HEIGHT + 32 px)
  or off-window (keyboard navigation), 0.0 otherwise. Lerps alpha
  toward target at 6/sec ≈ 167 ms per full transition.
- apply_action_fade overrides BackgroundColor + child TextColor on
  every ActionButton. Runs in Last so a hover-state change in the
  same frame doesn't blip back to opaque mid-fade.

No interactivity guard needed: hover requires the cursor to be on a
button, and a faded button is geometrically out of reach (cursor must
re-enter the reveal zone, which is exactly the trigger that fades
the bar back in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:08:39 +00:00
funman300 2c72e1fc87 feat(engine): reserve top band for HUD so it stops crowding the cards
Player report: the action button bar (Menu / Undo / Pause / Help /
Modes / New Game) and Score / Moves / Timer text were sharing the
same vertical band as the stock + foundation row, with no visual
separation. The HUD read as part of the play surface.

Two-part fix:

1. layout.rs reserves HUD_BAND_HEIGHT (64 px) at the top of the
   window. Card-grid math takes that off the available vertical
   budget so cards still fit; top_y shifts down by the same amount.
   New layout test pins the reservation. Existing
   worst_case_tableau_fits_vertically tests verify the height-budget
   arithmetic still holds.

2. hud_plugin.rs spawns a translucent purple band (BG_HUD_BAND, new
   token in ui_theme.rs at the BG_BASE hue with 0.70 alpha) filling
   that reserved zone. Z-index sits one rung below Z_HUD so action
   buttons paint on top while the band reads as their container. The
   band's bottom edge lines up with the top edge of the highest
   playable card, so the buttons feel anchored to a "tools strip"
   rather than floating in the play area.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:57:51 +00:00
funman300 efa063fb8f fix(engine): fall through to system default font on unmatched family
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
Replaces the previous LogPlugin-filter approach (which suppresses the
warn message) with a fix at the source: a custom usvg FontResolver
that appends `sans-serif` and `serif` to every family-lookup query.

usvg's default selector queries fontdb with [SVG-requested families,
Serif] and emits `log::warn!("No match for '{family}'")` when the
query returns None. On systems without the SVG's named family (Arial
on Linux, etc.), every text node logs a warn even though the system
has perfectly good fonts available — the warn is a false negative
because fontdb's named-family lookup is exact-match only.

The new resolver appends both `Family::SansSerif` and `Family::Serif`
to the query, both resolved by fontdb (via fontconfig on Linux or
built-in defaults elsewhere) to whatever the system has installed.
The query now finds *some* face on any reasonably configured machine,
so `id.is_none()` is never true and the warn branch never fires. The
visible behaviour: SVGs that request unavailable named families now
silently use the system's default sans-serif font.

Reverts the LogPlugin filter from main.rs — silencing warns at the
log level was the wrong layer; fixing the lookup is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:41:02 +00:00
funman300 78cf30e906 fix(engine): silence usvg font-substitution warn spam
CI / Test & Lint (push) Failing after 6s
CI / Release Build (push) Has been skipped
The bundled hayeah card SVGs declare font-family="Arial" for rank/suit
text. usvg matches family names exactly, so on systems without Arial
installed (every Linux distro by default) every text node bridged a
log::warn! into our tracing output — 50+ lines per launch.

Two-part fix:
- svg_loader now populates a process-wide fontdb with system fonts
  (lazy via OnceLock) so substitution actually has faces to fall
  through to. usvg::Options::default() ships an empty fontdb, which
  meant text glyphs had nothing to fall back on at all.
- LogPlugin extends DEFAULT_FILTER with usvg::text=error so the
  residual "no match" warns drop. The substitution itself works; the
  message is purely informational because Arial truly isn't installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:22:32 +00:00
funman300 9a9026e33a fix(engine): silence benign UnsupportedPlatform warn on exit
CI / Test & Lint (push) Failing after 4s
CI / Release Build (push) Has been skipped
push_on_exit logged every error including LocalOnlyProvider's expected
UnsupportedPlatform response, producing a misleading "sync push on exit
failed" warning on every shutdown in local-only mode. Mirror the pull
path: treat UnsupportedPlatform as silent no-op, warn only on real
errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:04:56 +00:00
funman300 ab1d098877 fix(engine): use resolve_embed for sibling theme assets
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
`AssetPath::resolve` concatenates, so manifest-relative SVG paths
ended up under `…/theme.ron/<name>.svg` and the asset server
reported all 53 references missing. `resolve_embed` is the RFC 1808
sibling-resolution method that strips the base path's last segment
first, giving the intended `…/<name>.svg`. Default theme now loads
cleanly from the embedded:// source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:21:03 +00:00
funman300 160637d1c8 docs: update remote URL reference to github.com/funman300/Rusty_Solitare
CI / Test & Lint (push) Failing after 8s
CI / Release Build (push) Has been skipped
Mirrors the move of the canonical remote from git.aleshym.co to
GitHub. The git remote itself was switched via 'git remote set-url
origin'; this updates the one stale URL in docs/SESSION_HANDOFF.md
that named the old host.
2026-05-01 17:11:55 +00:00
funman300 43f13c615e chore: workspace cleanup after card-theme phase landings
Drops dead deps and stale doc content carried over from the pre-MIT
art swap.

Cargo.toml manifests:
- solitaire_core no longer depends on chrono (no source references it
  since the original sync-payload timestamps moved to solitaire_data).
- solitaire_sync no longer depends on serde_json (the sync types use
  serde-derive with whatever serializer the caller picks; the old
  json-specific helpers were removed earlier).

Cargo.lock pruned by `cargo build` to drop the now-untransitively-
referenced versions.

CREDITS.md redistribution clause: "LGPL and OFL notices" tightened to
"MIT (project + hayeah card art) and OFL (FiraMono)" since the LGPL
art is gone.

SESSION_HANDOFF.md:
- HEAD bumped to 924a1e2; test count to 960; 9 ignored.
- Punch list rewritten — the xCards-URL line is obsolete (we did the
  swap), v0.1.0 tag exists locally, and player smoke-test is the
  current top item.
- New "Card-theme system (CARD_PLAN.md, fully shipped)" section
  summarises the seven-phase end-to-end flow so a future session has
  the integration map without re-reading the plan.
- Optional list gains the SVG-vs-layout aspect-ratio note as a
  cosmetic-only follow-up.

Removed the locked worktree at .claude/worktrees/agent-aa55a94d18c669d70
left behind by a prior Claude session.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 16:41:53 +00:00
funman300 924a1e2af7 feat(engine): card-theme picker in Settings → Cosmetic
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
Wires the runtime theme system (CARD_PLAN.md phases 1–7) into the
visible Settings UI so a player can switch between every theme
discovered by `ThemeRegistry` without restarting.

solitaire_data/src/settings.rs
  Settings gains `selected_theme_id: String` (default "default"),
  guarded by `#[serde(default = "default_theme_id")]` so existing
  settings.json files deserialize cleanly.

solitaire_engine/src/settings_plugin.rs
  - SettingsButton::SelectTheme(String) variant + focus order 85.
  - sync_settings_panel_visibility now reads
    Option<Res<ThemeRegistry>>, snapshots id+display_name pairs, and
    threads them into spawn_settings_panel. When the registry is
    absent (tests under MinimalPlugins) the picker silently skips —
    every existing test continues to pass unchanged.
  - theme_picker_row helper: like picker_row but keyed by String
    rather than usize, with chips wide enough for theme display
    names. Attaches the canonical tooltip ("Choose card-face
    artwork. Imported themes appear here.") and the FocusRow marker
    so Left/Right arrows cycle within the row.
  - Click handler updates settings.selected_theme_id, persists, and
    fires SettingsChangedEvent — same shape as every other picker.

solitaire_engine/src/theme/plugin.rs
  - load_default_theme renamed to load_initial_theme; reads
    SettingsResource on Startup and seeds ActiveTheme from
    settings.selected_theme_id (falling back to embedded default).
  - react_to_settings_theme_change watches SettingsChangedEvent,
    no-ops when the active theme already matches, and otherwise
    swaps ActiveTheme — the existing
    sync_card_image_set_with_active_theme system then refreshes
    every card sprite on the next AssetEvent::LoadedWithDependencies.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 16:24:24 +00:00
funman300 a6b8348332 docs: refresh README + ARCHITECTURE for hayeah art + theme system
CI / Test & Lint (push) Failing after 7s
CI / Release Build (push) Has been skipped
Updates the prose mentions of card-face provenance to point at
hayeah/playing-cards-assets (MIT) instead of xCards (LGPL-3.0), in
sync with the upstream art swap (b98cb8a).

ARCHITECTURE.md decision log gains two new rows: the licence-driven
art swap and the runtime SVG card-theme system landed across
CARD_PLAN.md phases 1–7. README.md credits paragraph rewritten to
match the new attribution.
2026-05-01 16:08:14 +00:00
funman300 b98cb8a99f feat(assets): swap card art to hayeah/playing-cards-assets (MIT)
Replaces the previous xCards-derived card faces (LGPL-3.0) with
hayeah/playing-cards-assets, which itself derives from the
public-domain vector-playing-cards Google Code project. The whole
package is MIT now — see CREDITS.md for the new attribution table
and the simpler license summary.

solitaire_engine/assets/themes/default/
  52 face SVGs (clubs/diamonds/hearts/spades × ace/2-10/jack/queen/
  king) — copied from hayeah, renamed to the canonical
  `{suit}_{rank}.svg` form `CardKey::manifest_name` produces. The
  bundled default theme manifest references each by the same name.
  back.svg — original midnight-purple-themed card back, hand-written
  to match the project's design tokens (BG_BASE / BG_ELEVATED /
  ACCENT_PRIMARY / ACCENT_SECONDARY). MIT, original work.

assets/cards/faces/{RANK}{SUIT}.png
  52 PNGs regenerated from the new SVGs at 750-tall via resvg 0.47.
  These remain the legacy backwards-compat path that
  `card_plugin::load_card_images` reads at startup; once the runtime
  theme system finishes loading the embedded default theme, the
  CardImageSet's face handles are overwritten with the SVG-rendered
  variants and these PNGs become moot. Keeping them in place avoids
  a brief blank-card flash before the async theme load completes.

solitaire_engine/src/assets/sources.rs
  embed_default_svg!() macro + DEFAULT_THEME_SVGS table that bundles
  every face + the back into the binary at compile time via
  include_bytes!. populate_embedded_default_theme now iterates the
  table so the EmbeddedAssetRegistry is populated under the same
  asset paths the manifest references.

CREDITS.md
  License summary collapses from MIT + LGPL-3.0 + OFL-1.1 to MIT +
  OFL-1.1 (the OFL still applies to FiraMono). The hayeah upstream
  URL replaces the previously-blank xCards entry.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 16:06:58 +00:00
funman300 7b59e70192 feat(engine): theme registry + discovery (Card theme phase 6)
CI / Test & Lint (push) Failing after 8s
CI / Release Build (push) Has been skipped
Implements Phase 6 of CARD_PLAN.md — discovers every available card
theme on startup so the future picker UI can list them.

solitaire_engine/src/theme/registry.rs
  ThemeEntry { id, display_name, manifest_url, meta }
  ThemeRegistry — Resource holding the entries; provides
    find(id), iter(), len(), is_empty().
  ThemeRegistryPlugin — Startup system that scans
    user_theme_dir() and populates the registry.
  build_registry(user_dir) — pure helper; takes the dir as a
    parameter so tests use tempfile::tempdir() without touching
    the global OnceLock-based user-theme path.
  refresh_registry(&mut, user_dir) — replaces in-place; called
    after a successful import_theme so a freshly-imported theme
    appears in the picker without an app restart.

The bundled default entry is always inserted (id "default", served
from DEFAULT_THEME_MANIFEST_URL) so the picker has at least one
option even when no user themes exist.

Discovery is best-effort: a directory whose theme.ron is missing,
malformed, or fails ThemeMeta::validate is silently skipped — broken
themes don't poison the registry. Only the meta block is parsed
(via a derive(Deserialize) struct that ignores other manifest
fields), which keeps startup quick even with dozens of themes
installed.

Wired into solitaire_app/main.rs after ThemePlugin so the asset
sources are registered before discovery scans for theme.ron files.

10 new tests covering: empty user dir, nonexistent user dir, valid
user theme registers, full-manifest tolerance via meta-only parser,
malformed theme.ron skipped, invalid-meta theme skipped, directory
without theme.ron ignored, find() returns None for unknown id,
refresh_registry replaces stale entries, default-entry URL matches
the embedded constant.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 06:04:34 +00:00
funman300 7f477b4ad8 feat(engine): ThemePlugin + ActiveTheme integration (Card theme phase 4)
Implements Phase 4 of CARD_PLAN.md — the runtime hook that loads the
default theme on startup and refreshes the card-rendering pipeline
whenever the active theme changes.

solitaire_engine/src/theme/plugin.rs
  ThemePlugin
    init_asset::<CardTheme>, register_asset_loader for SvgLoader and
    CardThemeLoader, Startup load_default_theme, and Update
    sync_card_image_set_with_active_theme.
  ActiveTheme(Handle<CardTheme>)
    Resource pointing at the currently-loaded theme.
  set_theme(commands, asset_server, theme_id)
    Public API for switching themes — formats the URL as
    `themes://<theme_id>/theme.ron` and updates the resource.

Integration approach: rather than refactor every `card_plugin.rs`
spawn site to read from `Assets<CardTheme>` directly, the sync system
writes the theme's face/back image handles into the existing
`CardImageSet` resource on `AssetEvent::LoadedWithDependencies` /
`Modified`, then fires `StateChangedEvent`. The existing
`sync_cards_on_change` pipeline rebuilds card sprites from the new
handles on the next tick — observable behaviour matches the plan's
intent (theme switches propagate immediately) while keeping
card_plugin's 1929-line surface area untouched.

Theme.back is mapped onto `CardImageSet.backs[0]` (the default-back
slot xCards previously occupied); `backs[1..=4]` are the
asset-generator patterns and remain user-selectable independent of
the active theme.

Added to solitaire_app/main.rs as `add_plugins(ThemePlugin)` after
`AssetSourcesPlugin` so the asset sources are registered before the
default-theme load is dispatched.

6 new tests covering suit/rank index mapping (matching the
`card_plugin` doc-commented `[suit][rank]` layout), empty-theme
no-panic, back-slot overwrite, and the URL format from `set_theme`.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (950 passed, 0 failed, 9 ignored).
2026-05-01 05:59:28 +00:00
funman300 ce38b26721 feat(engine): theme zip importer with safety validation (Card theme phase 7)
Implements Phase 7 of CARD_PLAN.md — the entry point that takes a
user-supplied theme zip archive, validates it end-to-end, and
atomically unpacks it into the per-platform user themes directory.

Public API:
  import_theme(zip_path) -> Result<ThemeId, ImportError>
    Resolves user_theme_dir() and unpacks into <user>/<id>/.
  import_theme_into(zip_path, target_root) -> Result<ThemeId, ImportError>
    Test-friendly variant that takes the destination explicitly so
    unit tests never touch the global OnceLock override.

Safety guarantees enforced:
- 20 MB hard cap on archive size (read from the central directory
  before any extraction).
- Zip-slip path traversal rejected via ZipFile::enclosed_name plus a
  Component::Normal-only belt-and-braces check.
- Manifest parsed via ron::de and validated via the existing
  ThemeManifest::validate (Phase 2) — surfaces named diagnostics for
  missing-of-52, unknown keys, duplicate keys, and meta errors.
- Every referenced face + back rasterised through rasterize_svg as a
  structural validity check before any bytes hit the destination.
- Atomic install: writes to <root>/.<id>.tmp/ then std::fs::rename
  into place, with a recursive copy + remove fallback for cross-
  device renames. Failed extraction wipes the staging dir; the user
  themes root is never touched on error.
- Id collision with an existing theme dir rejected up front.

7 new tests covering the happy path plus six failure modes (missing
manifest, missing face, oversized archive, zip-slip, missing-file,
id collision). Tests build zips in tempfile::TempDir so they never
touch the real user themes directory.

Workspace deps: zip 8.6 (default-features off + deflate only),
tempfile 3.27 (dev only).

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because cc disappeared from the sandbox; tests compile
under cargo check --tests and will run on a normal toolchain.
2026-05-01 05:47:30 +00:00
funman300 172d7773f0 feat(engine): asset sources for embedded + user theme dirs (Card theme phase 3)
Implements Phase 3 of CARD_PLAN.md — the embedded:// + themes:// asset
sources the card-theme system loads from. The bundled default-theme
manifest ships in the binary via Bevy's EmbeddedAssetRegistry; user
themes load from user_theme_dir() through a FileAssetReader-backed
source registered as `themes://`.

Registration is split across:
  register_theme_asset_sources(&mut App)
    Called BEFORE DefaultPlugins. Registers `themes://` while
    AssetSourceBuilders is still mutable.
  AssetSourcesPlugin
    Added AFTER DefaultPlugins. Populates the EmbeddedAssetRegistry
    that AssetPlugin's build step would otherwise overwrite.

Constants exposed for downstream consumers:
  USER_THEMES                 = "themes"   (asset-source name)
  DEFAULT_THEME_MANIFEST_URL  = "embedded://solitaire_engine/assets/themes/default/theme.ron"

Includes a stub default theme.ron (52 face slots + back) so
`ThemeManifest::validate()` accepts it today; PROVENANCE.md documents
the plan to drop in real SVG art (hayeah/playing-cards-assets) in a
follow-up.

4 new tests covering source registration, embedded-registry
population, manifest validation against the embedded stub, and the
manifest-URL constant matching the embedded asset path.

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because the C linker (cc) is unexpectedly absent from the
sandbox; the test bodies compile cleanly under cargo check --tests
and will run on a normal toolchain.
2026-05-01 05:47:13 +00:00
funman300 205ad6f646 feat(engine): per-platform user-theme directory (Card theme phase 5)
Implements Phase 5 of CARD_PLAN.md. Phase 3 (asset sources) and
Phase 7 (zip importer) both depend on this so it goes first.

solitaire_engine/src/assets/user_dir.rs
  user_theme_dir() -> PathBuf
    Desktop (Linux/macOS/Windows): joins dirs::data_dir() with
    "solitaire_quest/themes" — same parent as the rest of the
    project's per-user files (settings.json, stats.json, etc.)
    Mobile (Android/iOS): reads a process-wide OnceLock populated
    by set_user_theme_dir() at entry-point bootstrap. Panics with a
    targeted message if the override is missing — there is no
    platform default we can guess that won't be wrong inside iOS
    sandboxing or the Android storage model.
  set_user_theme_dir(PathBuf) -> Result<(), PathBuf>
    First-write-wins. Mobile entry points call this before App::run().

The plan suggested the `directories` crate; reused the existing `dirs`
workspace dep instead to keep the dependency surface minimal — both
crates share an author and the platform behaviour we need is identical.

3 new tests covering pure path composition (desktop nesting + empty
root) and a desktop-target-gated check that the detected data dir is
absolute. The OnceLock override is intentionally not unit-tested
because asserting its semantics would pollute global state for any
sibling test that calls `user_theme_dir()`.
2026-05-01 05:25:21 +00:00
funman300 936d035750 feat(engine): CardTheme asset + manifest loader (Card theme phase 2)
Implements Phase 2 of CARD_PLAN.md — the data types and `.theme.ron`
asset loader that build on Phase 1's SVG rasteriser.

solitaire_engine/src/theme/
  mod.rs        — CardKey { suit, rank } as the HashMap lookup key
                  (distinct from solitaire_core::Card which carries
                  per-deal id + face_up state); CardKey::all() yields
                  the 52 keys in suit-major / rank-ascending order;
                  manifest_name() and parse_manifest_name() round-trip
                  via the canonical "{suit}_{rank}" form.
                  ThemeMeta with structural validation (id non-empty,
                  no path separators, non-zero aspect components).
                  CardTheme #[derive(Asset, TypePath)] storing the
                  53 image handles + meta.
  manifest.rs   — ThemeManifest { meta, back, faces } with serde for
                  RON round-trip. validate() returns a strongly-typed
                  HashMap<CardKey, PathBuf>, surfacing precise errors
                  for unknown face keys, missing-of-52 entries, and
                  duplicate keys (RON silently keeps the last; brittle
                  for a release).
  loader.rs     — AssetLoader for .theme.ron. Validates manifest, then
                  composes sibling SVG paths via AssetPath::resolve so
                  the same loader works for both embedded:// and
                  themes:// asset sources (Phase 3 territory).
                  Schedules every face + back load through SvgLoader
                  with target_size derived from meta.card_aspect.

24 new tests covering: 52-key enumeration uniqueness, manifest-name
round trip, garbage-name rejection, complete/missing/unknown/duplicate
manifest validation, RON round-trip integrity, target-size aspect
math (2:3 → 512x768; non-standard; degenerate 1:10000 clamps to 1px).

Workspace deps added: ron 0.12.

cargo build / clippy --workspace --all-targets -- -D warnings / test
all green (937 passed total — +24 from Phase 2 vs the +7 from
Phase 1's b8fb3fb baseline).
2026-05-01 05:19:12 +00:00
funman300 13d1d013e9 chore: route rustc through sccache for cold-build wins
Adds .cargo/config.toml setting `rustc-wrapper = "sccache"` so cold
rebuilds (CI, fresh checkouts, post-`cargo clean`) replay previously-
compiled crates from disk instead of recompiling. Warm incremental
builds are unaffected — cargo's own target/ cache dominates there.

Cache lives at `.sccache-cache/` inside the project (gitignored). The
[env] entry uses `force = false` so a developer-set $SCCACHE_DIR in
their shell wins, matching whichever directory the sccache daemon
already adopted.

Requires sccache on PATH. Install: `pacman -S sccache`,
`brew install sccache`, or `cargo install sccache --locked`. Bypass
without editing: `RUSTC_WRAPPER= cargo build`.
2026-05-01 05:15:59 +00:00
funman300 b8fb3fbd6e feat(engine): SVG → Image asset loader (Card theme phase 1)
Implements the runtime SVG rasterisation pipeline that the card-theme
system (CARD_PLAN.md) is built on. Bevy 0.18 has no native SVG support;
this loader bridges usvg (parser) + resvg (renderer) + tiny-skia (CPU
pixmap) so the rest of the engine consumes themes as plain
Handle<Image>. Rasterisation happens once per (asset, settings) pair at
load time — Bevy's asset cache absorbs the cost.

solitaire_engine/src/assets/
  mod.rs           — module entrypoint
  svg_loader.rs    — SvgLoader (AssetLoader for .svg → Image)
                     SvgLoaderSettings { target_size: UVec2 } default 512×768
                     SvgLoaderError (Io / Parse / PixmapAlloc) via thiserror
                     rasterize_svg() helper exposed for non-asset-graph
                     callers (the future zip-importer validation step)

The rasteriser scales-to-fit while preserving aspect ratio, centring
the SVG inside the target box so a non-2:3 source doesn't pin to the
top-left corner.

7 new unit tests — default + custom target size, zero-dimension reject,
malformed-input reject, RGBA byte-count, extension advertisement, and
a compile-time guard that SvgLoaderSettings still satisfies the
AssetLoader::Settings trait bounds.

Workspace deps added: usvg 0.47, resvg 0.47, tiny-skia 0.12 (latest
minor versions; CARD_PLAN.md called out the placeholder numbers
needed verification).

cargo build / cargo clippy --workspace --all-targets -- -D warnings
/ cargo test --workspace all green (913 passed, 0 failed, 9 ignored —
+7 from the new loader tests).
2026-05-01 05:05:30 +00:00
funman300 e510e90b95 docs: refresh SESSION_HANDOFF for Phase 5 smoke-test fixes and bonus polish
CI / Test & Lint (push) Failing after 16s
CI / Release Build (push) Has been skipped
Phase 4 ended at 5d57b67 with the doc reflecting "16 commits ahead,
working tree dirty, splash always shows". The next session opened the
binary, found three real bugs (resize lag, off-screen cards,
mis-aligned hit-test), fixed them, and layered four bonus polish
items on top: persisted window geometry, achievement tooltips, a
clippy::pedantic sweep, client-side sync round-trip tests, and the
splash skip on subsequent launches.

Rewrites the doc to reflect the post-push state:
- HEAD: 902560c, branch up to date with origin
- 906 tests pass, working tree clean
- Phase 5 table summarising the nine new commits
- Stale "in-flight" / "deferred" items removed where they landed
- Punch list shrunk to xCards URL, v0.1.0 tag, desktop packaging

Length drops 142 → 121 lines (-21) by trimming the prior session's
"original Track G prompt" callbacks and recovery options that no
longer apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 04:22:39 +00:00
funman300 902560cd68 fix(engine): hit-test face-down fan offset matches sprite layout
CI / Test & Lint (push) Failing after 32s
CI / Release Build (push) Has been skipped
Smoke-test report: the user could only initiate a drag from the bottom
strip of a tableau card, not its visible face. Root cause was a fan-
step mismatch between rendering and hit-testing.

card_plugin::card_positions steps face-down cards by
TABLEAU_FACEDOWN_FAN_FRAC (0.12) and face-up cards by TABLEAU_FAN_FRAC
(0.25), so a column with 6 face-down + 1 face-up at the bottom
renders the face-up card at base.y - 0.72 * card_h. input_plugin's
card_position used a uniform 0.25 step for every position, computing
the same card's hit-test centre as base.y - 1.5 * card_h — almost a
full card height below the visible sprite. The hit-test AABB and the
sprite AABB overlapped only over the bottom 0.61 * card_h, which
matches the user's observation that only the bottom of the card
responds to clicks.

card_position now mirrors card_plugin's exact logic: walk the pile's
preceding cards and step by TABLEAU_FACEDOWN_FAN_FRAC for face-down,
TABLEAU_FAN_FRAC for face-up. TABLEAU_FACEDOWN_FAN_FRAC is now public
for the same reason TABLEAU_FAN_FRAC already was — the renderer and
the hit-tester have to agree by construction or this regression
returns.

Updates the existing find_draggable_skips_face_down_cards test that
relied on the old uniform-fan geometry, and adds
find_draggable_hits_face_up_card_with_face_down_cards_above_it as a
regression test that fails without this fix.

The during-drag rendering and pile_drop_rect still use the uniform
TABLEAU_FAN_FRAC because the cards being dragged are guaranteed
face-up, and a slightly oversized drop target reads as forgiving
rather than wrong. Those call sites are intentionally untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 03:33:46 +00:00
funman300 912b08c719 feat(engine): skip splash on subsequent launches
CI / Test & Lint (push) Failing after 34s
CI / Release Build (push) Has been skipped
The 1.6 s brand beat is delightful on first launch and tedious on
every subsequent one. spawn_splash now reads SettingsResource and
returns early when first_run_complete is true — the player has
already seen the splash at least once and the onboarding flow that
follows it, so dropping straight into gameplay is the right move.

Reuses the existing first_run_complete signal rather than introducing
a separate splash_seen field; the two states ("I've been here") line
up naturally and avoid carrying a one-shot flag forever.

The first run, a save reset (settings.json deleted), or a headless
test fixture that doesn't register SettingsResource all still see the
full splash — Option<Res<SettingsResource>> defaults to "show" when
absent, so the existing test fixture observes the same spawn it
always did.

Two new tests pin the split: splash_skipped_when_first_run_complete
asserts no SplashRoot spawns when settings say so, and
splash_still_shows_when_first_run_incomplete asserts the first-run
path is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 03:15:36 +00:00
funman300 3ef4ecb747 test(data): client-side sync round-trip integration tests
CI / Test & Lint (push) Failing after 6m45s
CI / Release Build (push) Has been skipped
Server-side endpoint tests already exist in solitaire_server. This
adds the client-side counterpart: five integration tests in
solitaire_data/tests/sync_round_trip.rs that drive
SolitaireServerClient against an in-process axum::serve harness with
an in-memory SQLite database, covering:

- register_login_push_pull_round_trip — happy path: register, push
  non-default stats, pull from a fresh client, assert the merged
  payload reflects the pushed values
- pull_after_concurrent_pushes_merges_correctly — two clients on one
  user push different games_played values, verify the server-side
  merge returns the max
- unauthenticated_pull_returns_authentication_error — pull without
  tokens surfaces SyncError::Auth as expected
- jwt_refresh_on_401_succeeds — replace the access token with one
  whose exp is two hours stale (same signing key), pull triggers
  401 → /api/auth/refresh → retry, asserts the call ultimately
  succeeds
- pull_after_account_deletion_returns_default_or_error — register,
  push, delete via the trait, confirm the next push surfaces a
  result rather than panicking

keyring_core's mock store is installed once per process via Once;
each test uses a unique username so the shared store doesn't
cross-contaminate. Production code in sync_client.rs needed no
changes — the Box<dyn SyncProvider> design plus the mock keyring
were sufficient to drive every flow from outside.

solitaire_server is added as a path dev-dependency along with the
direct crates the harness needs (axum, sqlx, jsonwebtoken, uuid,
chrono, solitaire_sync); no runtime deps changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:46:48 +00:00
funman300 4b9d008be2 refactor(workspace): sweep low-risk clippy::pedantic findings
Conservative cleanup pass — applied only the high-signal pedantic
lints whose fixes either remove genuine waste or read more naturally,
skipping anything stylistic that would bloat the diff.

- map_unwrap_or: 29 .map(...).unwrap_or(...) sites collapsed to
  .map_or / .is_some_and / .map_or_else equivalents
- uninlined_format_args: 7 production format!/write!/println! sites
  rewritten to the inline-argument style; assert! sites in test code
  intentionally untouched
- match_same_arms: 2 redundant arms collapsed where the bodies were
  identical and the merger didn't obscure intent

Public API is unchanged. No dependencies added or removed. The
pedantic warning count dropped from 840 to 807 (-33). Out-of-scope
findings — needless_pass_by_value on Bevy Res params, false-positive
explicit_iter_loop on Bevy Query iterators, items_after_statements
inside test mods, and the "ask before changing" merge logic in
solitaire_sync — were intentionally deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:46:32 +00:00
funman300 74482252d1 feat(engine): tooltips on Achievements screen rows
Each achievement row now carries a Tooltip whose text is derived from
the row's unlock state and the AchievementDef's reward, surfacing
information the row layout doesn't already show.

Four-state policy:
- Unlocked + reward → "Reward: <reward>." (e.g. "Reward: Card Back #1.")
- Unlocked + no reward → "Earned!"
- Locked, non-secret → "How to unlock: <description>." plus
  " Reward: <reward>." when one exists
- Locked, secret → no tooltip; the existing row-spawn skip preserves
  the achievement's discovery surprise

The row spawn loop tags each row with a new AchievementRow marker so
tests can locate them; the helper tooltip_for_row keeps the policy in
one place.

Six tests pin the policy: one full-flow test for unlocked + reward
mention, one secret-row negative test that asserts no tooltip
contains the verbatim secret condition or the secret reward, plus
four direct unit tests on tooltip_for_row covering all four states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:18:04 +00:00
funman300 6e7705b256 feat(app): persist window geometry across launches
Settings gains an optional window_geometry field (size + position)
serialized via #[serde(default)] so legacy settings.json files without
the field deserialize cleanly to None. On launch the app restores
the persisted dimensions and position; first run and pre-upgrade
saves keep the existing 1280x800 centered default.

settings_plugin records changes from WindowResized and WindowMoved
into a PendingWindowGeometry resource and writes them to disk through
the existing atomic .tmp+rename path once the events have stayed
quiet for WINDOW_GEOMETRY_DEBOUNCE_SECS (0.5s). A merge_geometry
helper preserves whichever component (size or position) the latest
event burst didn't carry, so a position-only WindowMoved never wipes
the recorded size.

Pure should_persist_geometry and merge_geometry helpers are unit
tested for the boundary cases. Headless integration tests cover the
full flow: a single resize event then a quiet window persists, a
move event after a resize updates only position, a rapid storm
collapses to the final size, and a quiet frame with no events
leaves the geometry untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:17:54 +00:00
funman300 59316de1e9 perf(app): set PresentMode::AutoNoVsync to eliminate window-resize stalls
CI / Test & Lint (push) Failing after 18s
CI / Release Build (push) Has been skipped
Smoke-test report: window resize was still laggy after the card-side
throttle landed. Diagnosis pointed at the wgpu / OS layer rather than
ECS work — Bevy's default PresentMode is AutoVsync (Fifo), which gates
every frame on the monitor's vblank. On X11 / Wayland the compositor
sends WindowResized events at high frequency during a drag and the
vsync gate stalls each one, producing visible lag even when the
downstream systems do almost no work.

AutoNoVsync prefers Mailbox (triple-buffered, no blocking) and falls
back to Immediate when the backend can't honour Mailbox. Either is
fine for solitaire — the frame budget is tiny and the occasional
dropped frame from disabling vsync is imperceptible compared to the
stall this fixes.

Layered with the prior in-place resize updates and the 50ms
ResizeThrottle, this should bring the window-drag feel from "really
laggy" to native-feeling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:35:49 +00:00
funman300 1719fdada0 perf(engine): in-place resize updates and 50ms throttle eliminate drag lag
CI / Test & Lint (push) Failing after 24s
CI / Release Build (push) Has been skipped
Smoke-test report: dragging the window edge to resize was sluggish.
Profiling showed each WindowResized event triggered ~170 entity
mutations across all 52 cards: full Sprite regeneration via
card_sprite plus despawn_related on each card's CardLabel children
followed by a fresh with_children spawn — and WindowResized fires per
pixel of drag, multiplying the cost.

Three fixes layered together:

1. resize_cards_in_place is a new function the resize handler calls
   instead of sync_cards. It mutates Sprite.custom_size, the card's
   Transform.translation, and existing CardLabel TextFont.font_size
   directly — no Sprite replacement, no despawn_related, no child
   rebuild. update_card_entity stays unchanged for non-resize callers
   (deals, moves, flips, settings changes) so the full-repaint path
   they need is preserved.

2. collect_resize_events reads events.read().last() and stashes only
   the latest size into a ResizeThrottle resource each frame, so
   multiple WindowResized events in one frame collapse to one apply.

3. snap_cards_on_window_resize is gated by a 50ms throttle
   (RESIZE_THROTTLE_SECS): work runs at ~20 Hz during a sustained
   drag instead of ~120 Hz. When the user stops resizing the next
   frame flushes the final pending size, so the steady state always
   matches the released window dimensions. should_apply_resize is a
   pure helper unit-tested for the threshold-and-baseline contract.

apply_stock_empty_indicator gained a QueryFilter generic so the new
resize handler can pass a Without<CardEntity> filter — the resize
query already takes &mut Sprite on cards, so the indicator query had
to disjoin to avoid aliasing.

Five new tests pin the contract: should_apply_resize at three
threshold boundaries, plus integration tests that fire WindowResized
and assert no CardLabel entities were despawned and that
TextFont.font_size shrinks in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:06:14 +00:00
funman300 8dda9541a3 fix(engine): constrain card size so worst-case tableau fits vertically
The previous formula card_width = window.x / 9 with card_height = 1.4 *
card_width ignored the window height entirely. On a 1920×1080 window a
13-card face-up tableau column extended ~377 px below the viewport
bottom — visible reproduction in the smoke test.

compute_layout now derives two card_width candidates: one from the
horizontal grid budget (window.x / 9, unchanged) and one from the
vertical budget needed to seat 13 fanned cards plus the foundation
row, vertical_gap, and h_gap bottom margin. The smaller of the two
wins, so width remains the limiter on standard landscape windows and
height takes over on tall or short-wide aspect ratios. The math is
solved algebraically in a single substitution to avoid iteration.

When height is the limiter the original layout would have squished the
grid against the left edge; col_x now folds in a horizontal centring
offset that collapses to the existing geometry whenever width is the
limiter, so no other module needed an update.

Adds MAX_TABLEAU_CARDS = 13.0 (King-down-to-Ace worst case) and a
locally mirrored TABLEAU_FAN_FRAC = 0.25 — the original lives in
card_plugin and importing it would have created a circular dep with
layout. The duplication is doc-flagged so future drift gets noticed.

Four new tests pin both regimes: the height-limiter activates on a
1920×1080 window, stays inactive on a 900×1600 portrait window, and
the worst-case 13-card column fits on both 1280×800 and 1920×1080
within the bottom margin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:05:57 +00:00
funman300 60a80369d4 docs: rewrite SESSION_HANDOFF for completed Phase 4 release-prep state
CI / Test & Lint (push) Failing after 21s
CI / Release Build (push) Has been skipped
The handoff document was written mid-overhaul during Phase 3 / early
Phase 4 and accumulated stale "in-flight" sections, recovery
instructions for a long-completed background agent, and a 7-track
speculative list of future directions that no longer matches reality.
This rewrite halves the document (284 → 142 lines) and reorients it
toward the actual current state: Phase 3 and Phase 4 shipped, every
substantial release-readiness thread landed, working tree clean.

Replaced the pause-state recovery section, original prompt blocks,
and Phase 3 smoke-test checklist with a compact table summarizing
what each Phase 4 commit landed, a chronological commit list as an
audit trail, and a five-item punch list scoping what's left for v1
(xCards URL, smoke test, push, tag v0.1.0, optional polish).

The resume prompt at the end is rewritten to orient a future agent
toward release prep — push, tag, package — rather than ongoing UX
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:55:30 +00:00
funman300 dbe6c60133 feat(engine): tooltips on Modes and Menu popover rows
The earlier HUD tooltip pass deliberately skipped the popover row
content because the spawn helpers were inline and the popovers
ephemeral. Coming back to them now: every row in the Modes popover
(Classic / Daily Challenge / Zen / Challenge / Time Attack) and
every row in the Menu popover (Stats / Achievements / Profile /
Settings / Leaderboard) gets a one-sentence tooltip explaining what
opening that mode or screen does.

The row tuple in each popover spawn helper grew from
(Marker, label) to (Marker, label, tooltip), with Tooltip::new(...)
attached at the spawn site. No public helper signatures changed.

popover_rows_carry_tooltip_strings asserts every row's exact
canonical text by querying (With<ModeOption>, &Tooltip) and
(With<MenuOption>, &Tooltip), spawning the popovers directly via
world.commands() to keep the test independent of headless click
simulation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:55:20 +00:00
funman300 74597a8c84 feat(engine): tooltips on every Settings panel control
Eleven Settings controls — volume up/down for SFX and music, the four
toggle pills (draw mode, animation speed, theme, color-blind), the
two picker rows (card backs, backgrounds), and Sync Now — each gain a
one-sentence tooltip in the established Balatro voice. Static labels,
section headers, and live value readouts are intentionally skipped:
they are not interactive and the action button beside each describes
the action.

icon_button, volume_row, toggle_row, and picker_row gain
&'static str tooltip parameters so the tooltip is required at the
spawn site rather than retrofittable later. The Done button stays
tooltip-free (its label and Esc-equivalent affordance speak for
themselves at a modal-action position).

settings_buttons_carry_tooltip locks down the contract: every
SettingsButton outside the modal Done button has a Tooltip, and
SyncNow's tooltip text is asserted exactly to pin the canonical
microcopy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:55:10 +00:00