ec94cb34aa3bdaa3de55c2bed7b1f1bf6b4a91ff
740 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
b78a493a0c |
feat(engine): keyboard focus on Settings panel with arrow-key pickers (Phase 3)
Settings was the last mouse-only surface in the engine. Phase 3 closes that gap and finishes the keyboard-focus rollout. Every interactive button in the settings panel — icon buttons (32px volume, draw mode, color blind, sync now), swatch pickers (5 card backs, 5 backgrounds), and toggle pills — now opts into Focusable via a single ancestry-walking system that mirrors the Phase 1/2 pattern. The Done button continues to be auto-tagged through the modal path. The two picker rows gain a new FocusRow marker. Inside a FocusRow, Left/Right arrow keys cycle the swatches (skipping Disabled, wrapping at endpoints) while Tab/Shift-Tab still escape to the next section's focusable. Outside a FocusRow, arrow keys are explicit no-ops. scroll_focus_into_view runs after the focus overlay updates and adjusts the SettingsPanelScrollable container's ScrollPosition when the focused button sits outside the visible viewport, with a SPACE_2 padding so the focus ring never gets clipped at the viewport edge. The system is a no-op when layout hasn't computed yet, so headless tests are unaffected. After Phase 3 every interactive UI element in the engine is keyboard-navigable: modals (Phase 1), HUD action bar and Home mode cards (Phase 2), Settings bespoke controls and picker rows (Phase 3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
51d3454344 |
feat(engine): keyboard focus on HUD action bar and Home mode cards (Phase 2)
The HUD action bar (Menu / Undo / Pause / Help / Modes / New Game) and the five Home mode-launcher cards now participate in keyboard focus, extending Phase 1's modal-only coverage. The HUD focus group activates only when no modal is open and the mouse is hovering an action-bar button — the design decision avoids stealing Tab from selection_plugin's card-selection nav for the common "playing on the board" case. Once engaged, Tab/Shift-Tab cycles the bar in spawn order and Enter activates. Moving the mouse off the bar clears focus so the ring doesn't linger. Home mode cards opt into FocusGroup::Modal(home_scrim) via an ancestry-walking system that mirrors the Phase 1 attach helper, so spawn_mode_card's signature is unchanged. Locked cards (Zen, Challenge, Time Attack at level <5) get the Disabled marker so Tab skips them and Enter is a no-op — mirroring the existing visual locked state with real keyboard semantics. handle_focus_keys gains a Hud-on-hover branch in its active-group resolver and a clear_hud_focus_on_unhover system. Together they implement the agreed UX: focus follows hover when the bar is active, Tab cycles within the hovered group, and the ring disappears the instant the mouse leaves. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
12789529a1 |
feat(engine): keyboard focus rings on modal buttons (Phase 1)
Every button spawned via spawn_modal_button is now keyboard-navigable. Tab/Shift-Tab cycles focus within the active modal, Enter activates the focused button via the same Interaction::Pressed signal mouse clicks use, and the primary action auto-focuses on modal open. Mouse clicks transfer focus so the two input modes stay in sync. The visual indicator is a single overlay entity that's reparented above the topmost modal scrim and tracks the focused button's GlobalTransform + ComputedNode each frame. Sitting outside the modal-card subtree means the ring isn't affected by the open animation's 0.96→1.0 scale, and sitting outside any scroll container means it can't be clipped by Settings' Overflow::scroll_y. Z-order sits one rung above Z_MODAL_TOP via the new Z_FOCUS_RING token. Existing 11 modals (Help, Stats, Achievements, Settings, Profile, Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new game, Onboarding, Home) get focus support without any call-site changes — attach_focusable_to_modal_buttons walks the ancestry of any ModalButton lacking Focusable to find its scrim and tags it automatically. selection_plugin's Tab handler keeps working when no modal is open; when one is, focus consumes Tab/Enter before the selection system sees them. Phase 1 scope only — HUD action bar, Home mode cards, and Settings bespoke buttons (icon, swatch, toggle) come in Phase 2/3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c1bde18a2c |
feat(engine): repurpose Home as mode launcher
The Home modal was previously a keyboard-shortcut reference card that mostly duplicated Help. It now opens directly into a Mode Launcher: five mode cards (Classic, Daily Challenge, Zen, Challenge, Time Attack) stacked vertically with a Cancel button at the bottom. Each card dispatches the canonical request event already used by the HUD modes-popover (NewGameRequestEvent, StartDailyChallengeRequestEvent, StartZenRequestEvent, StartChallengeRequestEvent, StartTimeAttackRequestEvent), so level gates, daily-seed lookup, and session setup all flow through the existing handlers — Home is just another entry point. The three modes that unlock at level 5 (Zen, Challenge, Time Attack) render with reduced opacity and a "Reach level 5 to unlock" caption when locked; clicking a locked card is a deliberate no-op so the player can pick a different mode without dismissing the modal. The keyboard-shortcut reference is dropped entirely — Help (F1) still covers it. M continues to toggle the modal open and closed. Adds 5 new headless tests covering card spawn, locked-state click, unlocked-state click, Classic launch + close, and Cancel close. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fd7fb7b6da |
docs: add CREDITS.md and link from README
Lists the project's third-party assets and major dependencies with their licenses for v1 release readiness: - xCards @2x card artwork and back_0 (LGPL-3.0) - FiraMono-Medium font (OFL) - Bevy 0.18, kira 0.12, axum, sqlx, tokio, and the rest of the ten most prominent Rust deps (MIT/Apache-2.0 across the board) Generated assets — card backs 1-4, all backgrounds, and every WAV file — are credited as original work produced by the project's own solitaire_assetgen pipeline; the audio synthesis stack and the absence of any external sample sources are documented. The README gains a brief Credits section linking to the full list. Note: the upstream xCards source URL is left as a placeholder pending confirmation; downstream license obligations are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
138436558f |
feat(engine): leaderboard error and idle states plus local-only guard
LeaderboardResource was a tuple struct of Option<Vec<Entry>>: None for pre-fetch and empty Vec for both "actually empty" and "fetch failed" — the user couldn't tell a network error from a legitimately quiet leaderboard. The resource is now a four-state enum (Idle / Error / Loaded), with Loaded covering both populated and empty rows. A transient error no longer wipes a previously populated list, and the panel renders "Couldn't reach the leaderboard. Try again later." when the most recent fetch failed. The Opt In / Opt Out buttons used to render unconditionally and silently no-op under LocalOnlyProvider. The panel now reads the SyncProviderResource backend name and, when no remote is configured, replaces the buttons with a single line directing the player to configure cloud sync in Settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
65d595ad12 |
feat(engine): first-launch polish — em-dash zero stats and welcome line on profile
On first launch the Stats grid previously mixed "0" cells (Games
Played / Won / Lost) with "—" cells (Best Score / Win Rate / Avg
Time), reading as inconsistent. Now every cell renders an em-dash
when games_played == 0, and a "Play a game to start tracking stats."
caption sits above the grid using the existing TYPE_CAPTION /
TEXT_SECONDARY tokens. Once a game has been played the original
formatters resume.
The Profile screen gains a one-line welcome ("Welcome! Play games to
earn XP and unlock achievements.") that renders only when both
total_xp and the daily streak are zero, breaking up the wall of
zero-valued readouts that greeted users on first launch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|