ed152e2d8f87b5836d942b5b09c76083c191370f
397 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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:
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
efa063fb8f |
fix(engine): fall through to system default font on unmatched family
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>
|
||
|
|
78cf30e906 |
fix(engine): silence usvg font-substitution warn spam
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> |
||
|
|
9a9026e33a |
fix(engine): silence benign UnsupportedPlatform warn on exit
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> |
||
|
|
ab1d098877 |
fix(engine): use resolve_embed for sibling theme assets
`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> |
||
|
|
160637d1c8 |
docs: update remote URL reference to github.com/funman300/Rusty_Solitare
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. |
||
|
|
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). |
||
|
|
924a1e2af7 |
feat(engine): card-theme picker in Settings → Cosmetic
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).
|
||
|
|
a6b8348332 |
docs: refresh README + ARCHITECTURE for hayeah art + theme system
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 (
|
||
|
|
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).
|
||
|
|
7b59e70192 |
feat(engine): theme registry + discovery (Card theme phase 6)
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).
|
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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()`.
|
||
|
|
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
|
||
|
|
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`. |
||
|
|
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).
|
||
|
|
e510e90b95 |
docs: refresh SESSION_HANDOFF for Phase 5 smoke-test fixes and bonus polish
Phase 4 ended at |
||
|
|
902560cd68 |
fix(engine): hit-test face-down fan offset matches sprite layout
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> |
||
|
|
912b08c719 |
feat(engine): skip splash on subsequent launches
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>
|
||
|
|
3ef4ecb747 |
test(data): client-side sync round-trip integration tests
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
59316de1e9 |
perf(app): set PresentMode::AutoNoVsync to eliminate window-resize stalls
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> |
||
|
|
1719fdada0 |
perf(engine): in-place resize updates and 50ms throttle eliminate drag lag
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> |
||
|
|
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> |
||
|
|
60a80369d4 |
docs: rewrite SESSION_HANDOFF for completed Phase 4 release-prep state
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
5d57b67934 |
feat(engine): branded splash screen on launch
The window previously snapped straight to a card deal, which read more like a prototype than a finished game. SplashPlugin lays a fullscreen overlay (BG_BASE backdrop, ACCENT_PRIMARY title, version subtitle) on top of the gameplay layer for MOTION_SPLASH_TOTAL_SECS — the board deals behind it so the splash dissolve hands off naturally to the deal animation. Visibility curves through fade-in (300ms), hold (~1s), fade-out (300ms) using a pure splash_alpha helper that gets pinned by a unit test rather than wired to the Bevy clock — Time<Virtual>'s 250ms per-tick clamp makes float-tight alpha assertions around the fade boundary brittle. Any keystroke or mouse-button press jumps the age forward to the fade-out window so the splash dissolves immediately. The dismiss handler is read-only on ButtonInput / Touches, so the same press is still visible to gameplay handlers downstream — pressing Space on the splash both dismisses it and triggers the next-tick stock draw, as verified by dismissal_keypress_is_visible_to_other_systems. Z_SPLASH sits above every other UI rung (Z_TOAST + 100) so the splash owns the viewport for its brief lifetime. The hierarchy test was extended to enforce the new rung's monotonic position. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
220e3f040c |
feat(engine): tooltips on every HUD readout and action button
Applies the tooltip infrastructure to the HUD: ten readouts (Score, Moves, Time, Mode, daily-challenge target, draw cycle, undo count, recycle count, auto-complete badge, keyboard selection chip) and the six action-bar buttons (Menu, Undo, Pause, Help, Modes, New Game) each gain a one-sentence tooltip in the established Balatro voice. The strings earn their keep by surfacing information that isn't visible: the link between the undo counter and the No Undo achievement, the recycle counter and Comeback, the dual count-up / countdown semantics of the timer in Time Attack, and the keyboard shortcuts plus side-effects on action buttons. spawn_action_button now requires a tooltip parameter so every action bar entry gets one — there is no opt-out, by design. The popover Mode and Menu rows are intentionally skipped: they're inside ephemeral overlays whose hover surfaces are brief and already labeled. Adds hud_elements_carry_expected_tooltip_strings, asserting the exact text on each of the 16 instrumented elements. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
54d34972d4 |
feat(engine): tooltip infrastructure with hover delay (foundation only)
A new ui_tooltip module owns a Tooltip(Cow<'static, str>) component that turns any UI node into a hover-revealing help target. Bevy 0.18's required-components attribute auto-inserts an Interaction so callers just attach Tooltip and the rest is wired. A single overlay entity is reparented above the focus ring (new Z_TOOLTIP token = Z_FOCUS_RING + 10) and tracked from the hovered target's GlobalTransform + ComputedNode. The chained Update systems start a hover timer on Interaction::Hovered, show the overlay once MOTION_TOOLTIP_DELAY_SECS (0.5s) has elapsed, hide it the moment hover ends, and refresh the text when the hover target switches without an intervening unhover. Tested headless under MinimalPlugins with a 200ms ManualDuration ticker — Bevy clamps Time<Virtual>'s max_delta to 250ms by default, so a one-shot 1s step doesn't actually advance the clock past the threshold; the tests step five times to exercise both pre- and post-delay invariants. This commit ships the infrastructure only — no entity in the engine has Tooltip attached yet. A follow-up applies tooltips to the HUD readouts and action bar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0c86cac2d5 |
feat(engine): unify destructive-confirm verbs — drop "Yes," prefix
Both confirm modals previously used a "Yes, <verb>" pattern that read
like a question-and-answer dialog ("Are you sure? Yes, forfeit"); the
canonical UX pattern for a destructive confirm is just the bare verb.
The Confirm New Game modal's primary button is now "New game" instead
of "Yes, abandon" — matching the verb the user originally clicked
and framing the action positively rather than as a loss.
The Forfeit Confirm modal's primary button is now "Forfeit" instead
of "Yes, forfeit" — same pattern, less ceremony.
The Pause menu's own Resume / Forfeit buttons are unchanged: it's an
action menu, not a destructive confirm, and bare verbs are already
correct there.
Two doc comments and the ui_modal.rs spawn_modal_button example
docstring are updated to reflect the new copy. Marker symbol names
(ConfirmYesButton, ForfeitConfirmButton) are kept to avoid
unnecessary churn — the rename would ripple into mouse-input handlers
without a matching user-visible benefit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
2e080d02ce |
test(engine): integration coverage for draw_three_master and zen_winner
Closes the audit gap: the two achievements that previously had only unit-level condition tests now also have full-flow tests that fire a GameWonEvent and assert the unlock state through the same plugin ordering production uses (update_stats_on_win runs before evaluate_on_win, so the freshly bumped stat is visible to the condition closure). Four tests, headless under MinimalPlugins: - draw_three_master_fires_on_tenth_draw_three_win — pre-seed 9 wins, fire a Draw3 win, assert unlock - draw_three_master_does_not_fire_at_nine_wins — pre-seed 8, fire a Draw3 win bumping to 9, assert still locked - zen_winner_fires_on_zen_mode_win — Zen-mode win unlocks the badge - zen_winner_does_not_fire_for_classic_win — Classic win in same fixture leaves it locked After this commit every advertised achievement has an integration test that exercises the production unlock path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
73e210b243 |
docs: replace bevy_kira_audio references with kira in ARCHITECTURE.md
§3, §5, and §13 all referenced bevy_kira_audio as the audio dependency, but the workspace Cargo.toml has used kira 0.12 directly since the kira-direct migration. Four mentions updated so the architecture document matches the actual dependency graph. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f866299021 |
docs: drop xCards URL placeholder from CREDITS.md
The textual attribution to Huub de Beer / xCards / LGPL-3.0 already satisfies the LGPL's notice requirement on its own; the unfilled URL placeholder was the only TODO left in the file. Removed rather than guessed — a confirmed upstream URL can be added in a follow-up when the project owner decides which xCards mirror or fork to point at. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |