Compare commits

..

79 Commits

Author SHA1 Message Date
funman300 41a009a693 docs: cut v0.20.0 — Terminal design system + Android persistence
Promotes the [Unreleased] section to [0.20.0] dated 2026-05-07
and opens a fresh empty [Unreleased]. The cycle's two through-
lines:

- **Terminal visual-identity port.** ui_theme token system
  (0d477ac) is load-bearing; downstream chrome migrations cover
  the modal scaffold, gameplay-feedback layer (ceec4fc), toasts
  with a new ToastVariant enum (a137607), table chrome (651f406),
  card chrome (d752870), splash cursor (cdcadda), and final
  hint-source / dest pairing (9891ae4). Card-face / suit / card-
  back palette intentionally NOT migrated — those track PNG
  artwork that hasn't been regenerated yet. The 24 Stitch-rendered
  mockups and design-system.md spec landed in fa7f98a.
- **Android persistence shim.** solitaire_data::data_dir
  routes through a per-platform shim (4b51e50) closing the
  CLAUDE.md §10 dirs::data_dir() = None pitfall on Android.
  Settings, stats, achievements, replays, game-state, time-attack
  sessions, and user themes now persist on a real APK.

Also closes three v0.19.0 punch-list candidates that landed
earlier in the cycle (pull_failure flake at 67c150b, smart-window-
size opt-out at e1b8766, Shareable badge at 9b065e5).

Tests: 1176 passing / 0 failing (six new this cycle: ui_theme
invariant guards, toast-variant-border-mapping, palette-tracking
guards on MARKER_VALID / HINT_PILE_HIGHLIGHT_COLOUR /
RIGHT_CLICK_HIGHLIGHT_COLOUR / toast-border distinctness).

SESSION_HANDOFF.md refreshed: HEAD pointer, test count, the
v0.20.0 changelog summary, the open punch list (Phase Android
runtime gaps, visual-identity follow-ups including the artwork
regeneration item), the updated design-direction box (was
Midnight Purple + Balatro yellow; now base16-eighties Terminal),
and a refreshed Resume Prompt offering A–F next-step options.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:58:51 -07:00
funman300 fa7f98ac52 docs(ui): land the Terminal design system + 24-mockup library
Adds the spec the recent visual-identity port pass referenced:

- design-system.md — base16-eighties palette, type scale, spacing
  scale, motion budget, component library, accessibility notes
  (color-blind toggle, high-contrast mode, glyph differentiation),
  and the canonical "Terminal" card-back theme.
- 24 Stitch-rendered mockups (HTML + PNG): 12 redesigned existing
  screens, 1 desktop home variant, 2 onboarding steps, and 9
  missing-plugin screens (splash, challenge, time-attack,
  weekly-goals, leaderboard, sync, level-up, replay, radial-menu).

These mockups are the source the engine plugins were ported
against in commits 0d477ac through 9891ae4 (token system,
modal scaffold, gameplay-feedback layer, toasts, table chrome,
card chrome, splash cursor, hint highlight). Future plugin work
should diff against the matching mockup before touching pixels.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:47:57 -07:00
funman300 9891ae4ba3 refactor(engine): final hint-highlight + replay-overlay token cleanup
- input_plugin's hint-source card tint moves from raw bright-yellow
  `srgba(1.0, 1.0, 0.4, 1.0)` to the design-system STATE_WARNING
  token, so the source card and the destination pile (which already
  uses STATE_WARNING via HINT_PILE_HIGHLIGHT_COLOUR) wear the same
  attention colour as a coherent pair.
- replay_overlay had two stale doc comments referencing the old
  "loud yellow accent" — Primary is now cyan (ACCENT_PRIMARY).
  Comments updated; no behaviour change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:45:02 -07:00
funman300 cdcaddaabe feat(engine): add Terminal cursor block to splash overlay
Splash now renders the design system's signature `▌` cyan terminal-
cursor glyph (96px) above the wordmark, matching docs/ui-mockups/
splash-mobile.html. The cursor uses ACCENT_PRIMARY and fades on the
same per-frame alpha schedule as the title and subtitle so the
brand beat still dissolves as a single layer.

Did NOT pull in the mockup's full boot-loader treatment (scanline
overlay, ✓ check log lines, progress bar, ROOT@SOLITAIRE prompt) —
those are aesthetic features that warrant their own commit, not
this token-port pass. The splash already consumed every relevant
ui_theme token; the cursor glyph is the single highest-signal
visual element the spec called for.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:42:29 -07:00
funman300 d752870007 refactor(engine): migrate card_plugin chrome to Terminal tokens
- Drag-elevation shadow now sources its colour from CARD_SHADOW_COLOR
  + CARD_SHADOW_ALPHA_DRAG, so the Terminal "no box-shadow" policy
  disables the stack shadow in lockstep with the per-card shadows.
  Re-enabling shadows for a future palette swap is now a one-line
  edit in ui_theme, not a hunt across plugins.
- RIGHT_CLICK_HIGHLIGHT_COLOUR retuned from raw `srgba(0.2, 0.8, 0.2, 0.6)`
  to STATE_SUCCESS's RGB at 60% alpha. Spelled as a literal because
  Alpha::with_alpha isn't const on stable; a new test pins the RGB
  to STATE_SUCCESS so a palette swap can't drift the two apart.
- Drop the duplicated PILE_MARKER_DEFAULT_COLOUR const — import the
  promoted const from table_plugin instead. STOCK_NORMAL_COLOUR is
  now an alias of that const so all idle pile-marker tints track a
  single source of truth.
- Stock recycle "↺" text changed from raw `srgba(1.0, 1.0, 1.0, 0.7)`
  to TEXT_PRIMARY at 0.7 alpha, picking up the off-white foreground
  used elsewhere in the Terminal UI.

Card-face / suit / card-back palette constants are intentionally
NOT migrated: the runtime path renders PNG artwork that's still on
the previous "white card" palette, so swapping the fallback
constants ahead of artwork regeneration would mix two visual
systems for any code path where image loading fails.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:39:02 -07:00
funman300 1d1543e4bc test(engine): align card-shadow drag-vs-idle assertion with Terminal "no shadow" intent
Commit 0d477ac (the Terminal token system) pinned both
CARD_SHADOW_ALPHA_IDLE and CARD_SHADOW_ALPHA_DRAG to 0.0 because the
Terminal design system explicitly disallows box-shadow ("depth via
1px borders and tonal layering"). The existing invariant
\`drag_alpha > idle_alpha\` then fails — \`0.0 > 0.0\` is false.

Loosen the assertion to \`drag_alpha >= idle_alpha\` and document the
intent: under Terminal both are 0; under any future palette that
re-enables shadows, drag still must not be weaker than idle. The
useful regression-guard (catching an accidental swap of the two
constants) is preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:33:34 -07:00
funman300 651f4060e6 refactor(engine): migrate table_plugin chrome to Terminal tokens
- Promote `marker_colour` to module-level const PILE_MARKER_DEFAULT_COLOUR
  and re-export it. cursor_plugin::MARKER_DEFAULT now imports the const
  directly, replacing the prior duplicated literal kept in sync only by
  doc comment. Drift becomes a compile error instead of a stale claim.
- Empty-tableau "K" placeholder text now uses TEXT_PRIMARY at 0.35 alpha
  (was raw `Color::srgba(1.0, 1.0, 1.0, 0.35)`) so it picks up the
  Terminal off-white foreground.
- HINT_PILE_HIGHLIGHT_COLOUR retuned from bright `srgb(1.0, 0.85, 0.1)`
  to the design-system STATE_WARNING token (`#ddb26f`). Spelled as a
  literal because Alpha::with_alpha is not yet const on stable; a new
  test pins the RGB to STATE_WARNING so a palette swap can't drift the
  two apart silently.
- The existing "is gold" character test was hardcoded to the old bright
  palette (red ≥ 0.9). Loosened to "warmer than cool" + ranges that the
  Terminal muted gold satisfies, with exact-RGB tracking handled by the
  new STATE_WARNING test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:32:03 -07:00
funman300 a1376075bd feat(engine): port toasts to the Terminal design-system spec
Toasts now follow `docs/ui-mockups/design-system.md`:
- Bottom-anchored absolute position (was top / mid-screen)
- Opaque BG_ELEVATED fill (was translucent black-at-alpha)
- 1px accent border keyed off a new ToastVariant enum
- TYPE_BODY_LG caption (was 22 / 32 px literals)
- RADIUS_MD corners

ToastVariant exposes Info / Warning / Error / Celebration, each
mapped to its design-system token via border_color(). Variants are
threaded through every spawn_toast call site:

- Achievement / Level-up / XP / Daily / Weekly / Challenge → Celebration
- Goal-announcement / Time-attack / Settings volume / Auto-complete → Info

Queued banner and fire-and-forget toasts use slightly different
bottom anchors (6% vs. 14%) so a celebration toast spawned in the
same frame as a queued info banner layers above it instead of
overlapping. Two new tests pin variant→border mapping to the
design tokens and require all four borders to be visually distinct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:26:55 -07:00
funman300 ceec4fc486 refactor(engine): route gameplay-feedback colours through Terminal tokens
Selection-highlight tints in selection_plugin and the valid-drop
marker tint in cursor_plugin were hand-tuned RGB literals from the
prior Premium-Solitaire palette. Migrate them to the semantic
state tokens introduced in ui_theme:

- keyboard-drag source highlight (picking)  → ACCENT_PRIMARY
- keyboard-drag source highlight (lifted)   → STATE_WARNING
- keyboard-drag destination highlight       → STATE_SUCCESS
- cursor_plugin::MARKER_VALID               → STATE_SUCCESS @ 0.55α

`MARKER_VALID` stays a Color literal (Alpha::with_alpha is not yet
const on stable); a new tracking test pins its RGB to STATE_SUCCESS
so a future palette swap can't drift the two apart silently.

Also fix three stale doc comments in ui_modal that still described
the previous yellow / magenta palette ("Loud yellow CTA",
"Primary swaps to the magenta secondary accent"). Cyan and lavender
now, matching the actual token values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:06:57 -07:00
funman300 0d477ac9fd feat(engine): Terminal design-token system in ui_theme
Replaces the prior Premium-Solitaire palette and ad-hoc constants
with the full Terminal (base16-eighties) token set: near-black
surface ramp, cyan primary CTA, lime/lavender/gold/teal/pink
semantic accents, 5-rung type scale, 7-rung 4-multiple spacing
scale, 3-step radius, 14-rung z-index hierarchy, and a complete
motion budget. Card drop-shadow alphas pinned to 0 — Terminal
depth is 1px borders + tonal layering, not box-shadow.

Tokens stay as `pub const` so static contexts (default Sprite
colours etc.) keep compiling; a future UiTheme resource can layer
runtime switching on top without breaking the constant API. Four
unit tests pin the spacing/type/z-index invariants so a careless
edit can't silently break the scale. Plugin-by-plugin migration
to consume these tokens follows in subsequent commits.

Spec: docs/ui-mockups/design-system.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:56:08 -07:00
funman300 4b51e50203 fix(data): route data_dir() through a per-platform shim so Android persists
dirs::data_dir() returns None on Android, which silently disabled
every persistence path (settings, stats, achievements, replays,
game-state, time-attack sessions, user themes). New
solitaire_data::platform::data_dir() shim falls through to
dirs::data_dir() on desktop and returns the per-app sandbox at
/data/data/com.solitairequest.app/files on Android — no JNI needed,
since the package id is pinned in
[package.metadata.android].

CLAUDE.md §10 already flagged this as a known pitfall; the shim
pays it down at the one chokepoint instead of per feature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:55:49 -07:00
funman300 f2d2119db5 docs: refresh handoff + populate CHANGELOG [Unreleased] for v0.20
The v0.19.0 handoff had drifted material across seven commits:
HEAD pointer was wrong (still claimed 6037596; actually 59424a3),
"Tags on origin" still claimed v0.19.0 wasn't pushed, the
known-flake list still mentioned `pull_failure_sets_error_status`
(fixed in 67c150b), and three of four v0.19.0 punch-list
"candidates" had silently shipped without the doc tracking it.
The Android build target landing in fb8b2ac wasn't mentioned at
all despite being the largest single change in the cycle.

CHANGELOG [Unreleased] populated with all seven commits grouped
into Added / Fixed:

  Added:
    - Android build target — first working APK (fb8b2ac)
    - Android developer setup + build runbook (59424a3)
    - F3 FPS / frame-time overlay (690e1d2)
    - "Smart window size" Settings toggle (e1b8766)
    - "Shareable" badge on Latest-win caption (9b065e5)
    - Help: M / P / Win-Summary-Enter rows (35516d3)

  Fixed:
    - pull_failure_sets_error_status flake (67c150b)

SESSION_HANDOFF.md fully rewritten:
  - Status section reflects HEAD 59424a3, clean working tree (apart
    from this commit's docs), 1170 passing tests, no known flakes
  - "Where we are" tracks v0.19.0 candidates' close status (3 of 4
    shipped, App icon still open and now blocked on a re-export)
  - New v0.20 candidates table covers all seven commits
  - New "Phase Android" punch-list section captures the unblocked-
    by-fb8b2ac work: APK launch verification, dirs::data_dir port,
    JNI ClipboardManager, Android Keystore, gpgs integration, the
    cosmetic cargo-apk panic workaround
  - Process notes call out the async-test starvation pattern
    (seen twice now), the bin→lib+shim refactor as a reusable
    pattern, and target-gating-by-default for cross-platform deps
  - Resume prompt's A–D menu refreshed to reflect actually-open
    work: APK verification, Phase-Android persistence, app icon,
    and a v0.20 cut

Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1170 passing / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:28:04 -07:00
funman300 59424a370c docs(android): developer setup + build runbook
Captures the toolchain install for Debian 13 (the path Quat ran on
this dev box, including the JDK 21 / unzip / SDK-licence prompts),
the `cargo apk build` invocation, the cosmetic post-pass panic
workaround, and the table of what's wired vs. stubbed for the
android target. Runnable on a fresh box from a clone — no
machine-local context required.

Pairs with the workspace cfg-gating in fb8b2ac. Future Phase-Android
work (dirs::data_dir port, JNI ClipboardManager, Android Keystore,
gpgs) is listed as the not-yet-done section so a contributor can
pick it up without re-deriving the punch list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:36:36 +00:00
funman300 fb8b2ac684 feat(app): Android build target — first working APK at 54 MB
Wires the workspace through `cargo apk build`. After this commit
`cargo apk build -p solitaire_app --target x86_64-linux-android`
produces a debug-signed APK at `target/debug/apk/solitaire-quest.apk`
containing all assets and `lib/x86_64/libsolitaire_app.so` — runnable
on the AVD or a physical x86_64 device.

The five gating points discovered by iterating compile cycles:

1. solitaire_app split into bin + lib. cargo-apk needs a `cdylib`
   to bundle as `libmain.so`; pure-bin crates panic with
   "Bin is not compatible with Cdylib". `src/lib.rs` carries the
   ECS bootstrap as `pub fn run`; `src/main.rs` is a 3-line shim
   that delegates for the desktop `cargo run` path.

2. `[package.metadata.android]` pins target SDK 34 / min SDK 26
   so cargo-apk doesn't probe for whatever default it ships
   (which on this machine was an uninstalled API 30). `assets =
   "../assets"` lets the same asset directory feed both desktop
   and APK.

3. Workspace `bevy` features add `android-native-activity` (the
   Bevy-side glue that pairs with cargo-apk's NativeActivity
   wrapper). The feature is target-gated inside bevy_internal so
   desktop builds compile it out.

4. `arboard` (clipboard, used by Stats's "Copy share link") has
   no Android backend — `cargo apk build` fails with E0433 on
   `platform::Clipboard` if unconditional. Target-gated to
   `cfg(not(target_os = "android"))`; the system surfaces an
   informational toast on Android until JNI ClipboardManager is
   wired in the Phase-Android round.

5. `keyring` + `keyring-core` cannot compile for android — the
   transitive `rpassword` uses `libc::__errno_location` which
   bionic doesn't expose. Both crates target-gated; `auth_tokens`
   ships a stub on Android that returns `KeychainUnavailable` for
   every call, matching how callers already handle a Linux box
   without Secret Service.

Cosmetic post-pass panic: cargo-apk panics AFTER the APK is signed
when it tries to also wrap the bin target. The APK on disk is
unaffected. Working around this with `cargo apk build --lib` is
the next small step.

What's verified:
- Desktop `cargo build`, `cargo clippy --workspace --all-targets`,
  and `cargo test --workspace` all clean.
- `cargo apk build -p solitaire_app --target x86_64-linux-android`
  produces 54 MB debug APK with libsolitaire_app.so + assets.

What's NOT yet verified:
- Whether the APK actually launches on the AVD / a phone (next
  step: `adb install` + `adb logcat` against the bevy_test AVD).
- Whether `dirs::data_dir()` on Android returns a usable path
  (sync / persistence will surface this if not).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:34:48 +00:00
funman300 690e1d2ad6 feat(engine): F3-toggleable FPS / frame-time overlay
Performance work for the upcoming Android port needs a numeric
baseline we can quote across desktop and mobile, instead of "feels
slow". DiagnosticsHudPlugin wraps Bevy's FrameTimeDiagnosticsPlugin
and renders a tiny corner readout the developer can toggle with F3.

- Hidden by default — production builds ship the plugin but the
  overlay starts invisible.
- F3 reads ButtonInput<KeyCode> directly (not gated by pause /
  modal state); diagnostics should always be reachable.
- Reads `smoothed()` FPS + frame_time so the cell isn't a jittery
  per-frame scoreboard. Format: "FPS NN \u{2022} M.MM ms".
- Anchored top-right at z = Z_SPLASH + 100 so the readout sits
  above every modal / toast / splash layer.
- Update system bails when hidden so we don't pay the
  diagnostic-store lookup or text mutation when nobody's looking.

Next up on the perf track: get the Android build target wired so we
can put real numbers in this readout from a phone or emulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:03:18 +00:00
funman300 35516d31f6 docs(help): add M / P / Win-Summary-Enter to the Overlays section
The Help (F1) modal's Overlays section listed S/A/L/O but skipped
two post-v0.18 entries — M (Home / Mode launcher) and P (Profile) —
and never mentioned the recently-shipped Enter accelerator that
dismisses the Win Summary.

Help is the canonical keyboard-discovery surface. Three new rows
cover the gap so a player who opens F1 sees every overlay-toggle
key, plus the contextual Enter shortcut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:40:44 +00:00
funman300 9b065e5ac6 feat(stats): append "Shareable" badge to the Latest-win caption
The Copy share link button on the Stats overlay only produces a URL
when the displayed replay has a `share_url` populated; otherwise it
surfaces a toast explaining the upload prerequisite. Players had no
way to know the button would work without clicking it.

Adds a "\u{2022} Shareable" suffix to the Latest-win caption when
the displayed replay carries a share_url, matching the format the
v0.19.0 handoff sketched ("Replay 3 / 8 \u{2022} Shareable") for
the future Prev/Next selector. The Prev/Next markers exist in
stats_plugin but no spawn site renders them today, so the live
fix is on the existing single-replay caption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 04:04:55 +00:00
funman300 e1b8766e15 feat(settings): "Smart window size" toggle to opt out of monitor-relative
launch sizing

Players who specifically prefer the literal 1280×800 baseline on
every fresh-install launch had no way to opt out of the v0.19.0
smart-default sizer. Adds a Gameplay-section toggle (mirrors the
"Winnable deals only" pattern) so they can flip it off.

- New `Settings::disable_smart_default_size: bool` field with
  `#[serde(default)]` so legacy `settings.json` files load to the
  shipped behaviour (smart sizer enabled).
- Settings panel gains a "Smart window size" row with ON/OFF label
  inverting the negative flag, and a tooltip clarifying that saved
  window geometry always wins over both branches.
- `solitaire_app::main` reads the flag once at startup and skips
  the `apply_smart_default_window_size` registration when it's set.
  Mid-session changes apply on next launch (documented on the
  field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 04:00:43 +00:00
funman300 67c150bd7b test(engine): wall-clock-bounded loop for pull_failure flake
The fixed 5-update budget in `pull_failure_sets_error_status` was
the last test still subject to the AsyncComputeTaskPool starvation
mode that v0.19.0's auto-save fix already cleared. Under heavy
parallel cargo-test load, 5 updates wasn't always enough for the
failing pull task to surface its Err and flip
SyncStatusResource to Error.

Pumps updates in a loop bounded by a 5-second deadline (with
std::thread::yield_now between iterations to give the task pool a
chance to run), exiting as soon as the status flips. Mirrors the
auto-save flake fix shape — a healthy run hits the assertion in a
handful of frames, while a starved run gets the budget it needs
without hanging the suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 03:54:51 +00:00
funman300 aa2a021712 docs: cut v0.19.0 — punch-list close + Wayland + animation polish
Promotes [Unreleased] to [0.19.0]. The release closes v0.18.0's
punch list (async H-key hint, persistent replay share URLs),
expands desktop platform fit (Wayland session support +
monitor-aware default window size), polishes the win-celebration
and double-click animation paths, and clears two test-flake
contributors. The Rusty Pixel pixel-art card theme arc was
prototyped and reverted in the same window — the engine plumbing
(pixel_art ThemeMeta field, PNG manifest face support, second
embedded:// theme channel) was fully reverted and is not part of
this release.

SESSION_HANDOFF.md refreshed to reflect the v0.19.0 ship:
v0.18.0 punch-list items B and D marked shipped; new Open punch
list documents the Rusty Pixel arc as historical, calls out the
desktop-packaging follow-through (app icon next), the
pull_failure_sets_error_status flake (next-round candidate),
and a settings-UI item for the smart-default-size opt-out.
Resume prompt refreshed with the post-v0.19.0 A-D decision menu.

Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1170 passing / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:06:21 -07:00
funman300 6037596cc0 fix(engine): double-click move animation no longer plays twice
A successful double-click was rendering the slide-to-destination
animation twice — once from the first press's MoveRequestEvent
landing, and again from the release's StateChangedEvent racing the
in-flight CardAnim and replacing it from the mid-animation
position.

The frame trace:

  Frame N (second press):
    handle_double_click → MoveRequestEvent (queued)
    start_drag           → DragState set, drag.committed = false
                            (start_drag never mutates Transform; the
                             card is still visually in place)
    handle_move          → applies the move, fires StateChangedEvent
    sync_cards_on_change → cur ≠ target, inserts CardAnim slide
                            (animation #1 starts)

  Frames N+1, N+2, …:
    follow_drag idles (drag uncommitted, cursor not moving)
    CardAnim animates the card from old to new pile

  Frame N+K (release):
    end_drag             → drag.committed = false branch:
                            drag.clear() + StateChangedEvent  ← CULPRIT
    sync_cards_on_change → sees the card mid-CardAnim
                            (cur ≠ target), replaces CardAnim
                            with a fresh one starting at the
                            current mid-position (animation #2
                            visibly restarts the slide)

The fix is one line: drop the StateChangedEvent write in the
uncommitted-drag branch of end_drag. The defensive resync was
never needed there — start_drag only mutates the DragState
resource on press, never card transforms, so an uncommitted drag
has no visual side effect to undo. The committed-drag branch (line
762) keeps its StateChangedEvent write since snap-back from a
real drag does need a resync.

Existing tests pass unchanged. The bug only manifested in the
specific timing of double-click → quick-release before
animation-complete; an integration test would require driving
mouse press/release across several frames with a dispatched
GameMutation pass between, which is heavier than the fix
warrants.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:00:05 -07:00
funman300 d7ffb16df5 fix(engine): single-card double-click with no destination now plays the reject animation
handle_double_click had a coverage gap. The flow was:

  - Priority 1: try moving the single top card to its best
    destination (foundation, then tableau).
  - Priority 2: if Priority 1 failed AND the player clicked the
    base of a multi-card stack, try moving the whole stack.

`MoveRejectedEvent` was only fired inside the Priority 2 else-branch
— so a double-click on a single card with no legal destination
fell through both priorities silently: no card_invalid.wav, no
shake animation on the source pile, the player got zero feedback
that the click was acknowledged.

The fix collapses both priorities' failure paths into one
unconditional `MoveRejectedEvent` write at the end of the
double-click branch. Single-card miss now plays the same feedback
as multi-card-stack miss. The early `return` on each successful
move keeps the rejection branch from firing on the success path.

Pre-fix, a player double-clicking the 7♠ buried under a 6♥ on
column 5 (no foundation slot for 7s; no tableau column accepting
black 7) saw nothing happen. Post-fix, the source pile shakes
and the invalid-move sound plays, exactly like a drag-and-drop
rejection.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:54:28 -07:00
funman300 b57db017d3 feat(app): Wayland support + monitor-relative default window size
Two related platform-fit fixes for desktop launch:

1. Wayland session compatibility. The workspace Cargo.toml's
   Bevy feature list previously enabled only `x11`, leaving
   winit-on-Wayland to fall through to XWayland — the game
   rendered inside an X11 frame stitched into the Wayland
   compositor instead of as a native Wayland client. Adding
   the `wayland` feature lets winit prefer Wayland when
   WAYLAND_DISPLAY is set on the session, falling back to X11
   when it isn't. Costs a few hundred KB of binary for the
   libwayland-client bindings; comment in Cargo.toml explains
   the trade.

2. Smart default window sizing. The fallback window size for
   first launches (no saved geometry) was a fixed 1280x800. On
   a 4K monitor that's a comparatively tiny window in one
   corner; the game's cards then occupy a small physical area
   even though the screen has plenty of room. New
   `apply_smart_default_window_size` Update system queries
   `Monitor` (with the `PrimaryMonitor` marker) and resizes the
   primary window to ~70% of the monitor's *logical* size on
   the first frame. Logical size already factors in the OS's
   HiDPI scale factor, so:

   - 1920x1080 / 1.0 scale → 1344x756 target
   - 2560x1440 / 1.0 scale → 1792x1008 target
   - 3840x2160 / 1.0 scale → 2688x1512 target
   - 2880x1800 / 2.0 scale (Retina) → 1008x630 target
                  (same physical size as 1080p)

   Clamped to the existing 800x600 minimum so old systems
   don't get sub-minimum windows. Skipped entirely when saved
   geometry was applied — the player's chosen size always
   wins. Uses `Local<bool>` for one-shot semantics; the early-
   exit per tick costs nothing once `*applied` is true.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:49:52 -07:00
funman300 0b3140ad6d Revert "feat(engine): theme thumbnails accept PNG faces alongside SVG"
This reverts commit de4751115f.
2026-05-06 19:38:13 -07:00
funman300 e41def8c89 Revert "feat(engine): per-theme nearest-sampling opt-in for pixel-art themes"
This reverts commit 17e3112502.
2026-05-06 19:38:13 -07:00
funman300 aad8bb9c83 Revert "feat(engine): bundle Rusty Pixel as a built-in theme"
This reverts commit 21ec03b157.
2026-05-06 19:38:13 -07:00
funman300 55c235b55f fix(engine): drop duplicate "You Win" toast — WinSummary modal owns the celebration
The post-win UI was firing TWO celebration surfaces on every
GameWonEvent:

  - animation_plugin::handle_win_cascade spawned a 4-second toast:
    "You Win!  Score: {score}  Time: {m}:{ss}"
  - win_summary_plugin spawned the proper "You Won!" modal with
    score breakdown, time bonus, achievements unlocked, XP earned,
    and a Play Again button

Both rendered on top of each other — in screenshots the toast
banner was partially clipped behind the modal card, peeking out
on either side. The toast predates the WinSummary modal; the
modal carries strictly more information so the toast is dead
weight.

handle_win_cascade keeps the cards-fly-off animation
(MotionCurve::Expressive cascade with per-card rotation drift) —
that's the visual celebration, distinct from the textual
celebration the modal owns. The system still gates on the same
GameWonEvent message reader; it just doesn't write a toast
afterward. WIN_TOAST_SECS const removed (no remaining callers).

Workspace: 1172 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:35:04 -07:00
funman300 21ec03b157 feat(engine): bundle Rusty Pixel as a built-in theme
The pixel-art card theme generated via Claude Design (53 PNGs at
256x384, ~340 KB total) now ships embedded in the binary alongside
the existing default SVG theme. Players see the new theme in the
picker out of the box without needing to drop files into
~/.local/share/solitaire_quest/themes/.

solitaire_engine/assets/themes/rusty-pixel/:
  - 53 PNGs (52 face cards + 1 back) at 256x384
  - theme.ron declaring meta.id = "rusty-pixel",
    card_aspect = (2, 3), pixel_art = true

assets/sources.rs:
  - New constants RUSTY_PIXEL_THEME_MANIFEST_URL,
    RUSTY_PIXEL_THEME_MANIFEST_PATH,
    RUSTY_PIXEL_THEME_MANIFEST_BYTES.
  - New embed_rusty_pixel_png! macro mirroring embed_default_svg!.
  - New RUSTY_PIXEL_THEME_PNGS table — 53 entries, one per file.
  - New rusty_pixel_theme_png_bytes(filename) lookup helper
    mirroring default_theme_svg_bytes for the thumbnail cache.
  - New populate_embedded_rusty_pixel_theme(app) registers the
    manifest + every PNG into Bevy's EmbeddedAssetRegistry.
  - AssetSourcesPlugin::build now calls both populate functions
    so the picker has both themes loadable from the binary alone.

theme/registry.rs:
  - New rusty_pixel_entry() returns the bundled metadata.
  - build_registry now inserts default + rusty-pixel ahead of the
    user-dir scan, and filters user themes whose id collides with
    a bundled built-in. Bundled wins on collision because it's
    guaranteed complete; the user's overriding copy may be partial
    or stale.
  - Updated existing tests for the new len()=2-instead-of-1 baseline.
  - New test user_theme_id_collision_with_bundled_is_dropped pins
    the dedup contract.

theme/plugin.rs:
  - load_initial_theme + react_to_settings_theme_change now both
    consult a new manifest_url_for(theme_id) helper that routes
    bundled built-ins through embedded:// and unknown ids through
    themes://. Drops the previous hard-coded "default →
    DEFAULT_THEME_MANIFEST_URL else themes://" branch.
  - read_theme_preview_bytes also checks the rusty-pixel embed
    table before falling through to the user-dir filesystem read,
    so the picker chip's thumbnail works on a fresh install where
    the user-dir doesn't exist.

Workspace: 1172 passing tests / 0 failing, was 1171 (+1 net from
the new collision test). cargo clippy --workspace --all-targets
-- -D warnings clean. Binary grows by ~340 KB (the 53 bundled
PNGs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:28:53 -07:00
funman300 17e3112502 feat(engine): per-theme nearest-sampling opt-in for pixel-art themes
Bevy's default sprite sampler is bilinear (Linear), which mushes
pixel-art card faces at non-integer scales. The rusty-pixel theme
ships 256x384 source PNGs that get displayed at ~150-200px wide on
typical desktop windows — an aggressive downscale where bilinear
visibly blurs the pixel grid.

Globally flipping ImagePlugin to default_nearest() would also affect
the SVG-rasterised default theme, where bilinear's smoothing is
actually desired (the SVG rasteriser produces a high-res 512x768
pixmap that the GPU has to downscale at draw time).

The fix is a per-theme opt-in:

  - ThemeMeta gains pixel_art: bool with #[serde(default)] for
    backwards compat. Older manifests load with `false`, preserving
    SVG-default behaviour.
  - sync_card_image_set_with_active_theme inspects theme.meta.pixel_art
    after a theme finishes loading. When true, walks every face +
    back Handle<Image> in the active CardTheme and rewrites its
    sampler to ImageSampler::Descriptor(ImageSamplerDescriptor::nearest()).
    The Modified asset event triggers a GPU re-upload with the new
    sampler descriptor.
  - The 12 ThemeMeta struct literals across the engine
    (settings_plugin, card_plugin, theme/{plugin,mod,manifest,
    importer,registry}) all gain `pixel_art: false` to match the
    new field.

The deployed rusty-pixel theme.ron at
~/.local/share/solitaire_quest/themes/rusty-pixel/ now sets
pixel_art: true, so the player's switch-to-pixel-art chip flips to
nearest sampling on the spot.

Workspace: 1171 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:21:53 -07:00
funman300 de4751115f feat(engine): theme thumbnails accept PNG faces alongside SVG
The theme picker chip's thumbnail loader hardcoded `.svg`
filenames (`spades_ace.svg`, `back.svg`) — a holdover from when
every shipped theme was vector-art. Raster-art user themes (e.g.
the v0.19 pixel-art theme generated via Claude Design and dropped
into ~/.local/share/solitaire_quest/themes/rusty-pixel/) had real
PNGs in their directory but the picker rendered placeholders
because it never tried the PNG sibling.

The fix is scoped to the thumbnail-cache pipeline. In-game card
rendering already worked via Bevy's standard PNG asset loader on
manifest-declared face/back paths — only the picker's small
preview chip was affected.

Changes in solitaire_engine/src/theme/plugin.rs:

  - PREVIEW_FACE_FILENAME / PREVIEW_BACK_FILENAME (with embedded
    `.svg` suffix) replaced by PREVIEW_FACE_BASENAME /
    PREVIEW_BACK_BASENAME ("spades_ace" / "back"). The function
    appends the extension itself.
  - read_theme_preview_svg_bytes -> read_theme_preview_bytes
    returns ThemePreviewBytes::{Svg, Png}. For "default" the
    embedded table stays SVG-only. For user themes the function
    tries `<basename>.svg` first (matching the bundled
    convention) and falls back to `<basename>.png` second.
  - rasterize_preview_to_handle gains a Png branch that calls a
    new decode_png_for_thumbnail helper (Bevy's
    Image::from_buffer with ImageType::Format(ImageFormat::Png)).
    PNGs decode at native dimensions; the picker chip's UI
    layout scales them at draw time. SVGs continue to rasterise
    at the fixed 100x140 thumbnail size as before.
  - generate_thumbnail_pair_for is unchanged in shape; just
    threads the new enum through.

Tests:

  - read_default_theme_preview_returns_some_for_canonical_files
    updated to match the new function signature and assert on
    the Svg variant explicitly.
  - New png_only_user_theme_generates_real_thumbnails creates a
    temp theme dir, writes a 2x3 PNG (encoded at runtime via the
    `image` dev-dep so the bytes are guaranteed valid), and
    asserts both ace + back yield non-default Handle<Image>.
    Cleans up the temp dir afterward.

solitaire_engine/Cargo.toml: image = "0.25" added as a
dev-dependency for the test's runtime PNG encoding. Already a
transitive Bevy dep so the build graph is unchanged.

Workspace: 1171 passing tests / 0 failing, was 1170 (+1 new).
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:13:52 -07:00
funman300 9ff48ace5b docs: refresh handoff + populate CHANGELOG [Unreleased] for v0.19.0
Three commits sit on top of v0.18.0 — async H-key hint
(3e11e9e), persistent replay share URLs (42d90b1), and the
auto-save flake fix (91b7605). [Unreleased] now describes them
as Changed / Fixed bullets ready to promote to a [0.19.0]
section whenever the next cut feels right. SESSION_HANDOFF.md
marks v0.18.0 punch-list items B and D as shipped, preserves C
(desktop packaging) as still gated on artwork + signing certs,
and refreshes the resume prompt's A–D menu around the
v0.19.0-cut decision. The previous handoff's
`-c user.name=...` workflow note is replaced with a pointer to
the system git config (which is now correct on this machine via
the v0.18.0 push session's `gh auth setup-git`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:17:07 -07:00
funman300 91b7605b9f fix(engine): clear PendingRestoredGame in test_app + harden auto-save flake
auto_save_writes_after_30_seconds intermittently failed under
heavy parallel cargo-test load. Two contributing factors, both
fixable in test fixtures alone:

  1. GamePlugin::build() reads dirs::data_dir()/.../game_state.json
     before per-test resource overrides apply. If a real
     game_state.json exists on the dev machine, it's loaded into
     PendingRestoredGame, and auto_save_game_state's pending guard
     (`pending.0.is_some()`) silently skips the save. test_app now
     resets PendingRestoredGame(None) after plugin build so the
     production save state can't leak into per-test world state.

  2. Time::delta_secs() on the first MinimalPlugins frame can be
     0.0 (nominal) or, under cargo-test parallelism, large enough
     to consume the 0.1 s pre-seeded margin past the threshold.
     The test now re-arms AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS +
     1.0) every iteration in a 16-frame bounded loop, breaking
     the moment the file appears. Robust against first-frame Time
     variance with no behaviour-contract change.

No production-code change. Verified: 3 back-to-back single-test
runs all pass. Full workspace test suite: 1170 passing / 0 failing.
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:16:32 -07:00
funman300 42d90b199c feat(data,engine): persist replay share URL alongside the replay
The v0.18.0 share-link affordance lived in an in-memory
LastSharedReplayUrl resource that was wiped on quit; the player had
to re-open Stats and re-share within the same session of the win.
The Stats overlay's Prev/Next selector also surfaced older replays
that had no share link at all even when those wins had been
uploaded successfully.

This bundles the URL with the replay it belongs to:

- Replay (solitaire_data) gains share_url: Option<String> with
  #[serde(default)]. No REPLAY_SCHEMA_VERSION bump — older
  replays.json files load unchanged with share_url == None on
  every entry. Replay::new() defaults the field to None.
- poll_replay_upload_result (sync_plugin) writes the resolved URL
  into ReplayHistoryResource::0.replays[0].share_url and persists
  the updated history via save_replay_history_to. The
  cancel-on-replace contract in push_replay_on_win guarantees
  replays[0] is the win whose URL the task is carrying — at most
  one upload is ever in flight, and it's always the most recent
  win.
- handle_copy_share_link_button (stats_plugin) reads from
  history.0.replays[selected.0].share_url instead of
  LastSharedReplayUrl, so the Prev/Next selector's currently-
  displayed replay drives the clipboard contents. Each historical
  win keeps its own URL.
- LastSharedReplayUrl resource removed entirely — its only role
  was bridging the upload-poll system to the Copy button, and
  that channel is now the share_url field on the replay record.

Tests:

- solitaire_data: replay_loads_when_share_url_field_is_absent
  pins backwards-compat — a pre-v0.19.0 Replay JSON without the
  field deserialises with share_url == None.
- solitaire_engine sync_plugin: upload_result_writes_share_url_into_replay_and_persists
  drives a pre-resolved AsyncComputeTaskPool task into
  PendingReplayUpload, pumps update() until the poll system
  resolves it, and asserts both the in-memory replays[0]
  carries the URL and a fresh load_replay_history_from(path)
  picks it up.

Workspace: 1170 passing tests / 0 failing, was 1168 (+2 net).
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:10:16 -07:00
funman300 3e11e9e79a feat(engine): H-key hint runs on AsyncComputeTaskPool
Closes the last solver-on-main-thread hot path. The synchronous
v0.17.0 hint flow called solitaire_core::solver::try_solve_from_state
inline on every H press; median latency was ~2 ms but pathological
positions hit the SolverConfig::default() cap at ~120 ms — a visible
input stall on the same frame the player presses H.

Mirrors the d489e7a PendingNewGameSeed pattern. New module
pending_hint.rs holds:

  - PendingHintTask resource carrying an Option<HintTask> with
    handle: Task<HintTaskOutput> plus move_count_at_spawn for
    staleness detection.
  - HintTaskOutput enum: SolverMove { from, to } when the verdict
    is Winnable + a first_move; NeedsHeuristic when the solver
    returns Unwinnable or Inconclusive.
  - poll_pending_hint_task system: polls the task each frame and
    surfaces the result via the now-public emit_hint_visuals (or
    runs find_heuristic_hint on the live state for the
    NeedsHeuristic branch). Discards the result when
    GameState.move_count has advanced past move_count_at_spawn.
  - drop_pending_hint_on_state_change system: any
    StateChangedEvent drops the in-flight task. Cooperatively
    cancels via Bevy's Task Drop at the next await point.
  - PendingHintTask::spawn implements cancel-on-replace — a fresh
    H press while a previous task is in flight overwrites the
    handle, dropping the prior task.

input_plugin changes:

  - handle_keyboard_hint becomes a thin spawn point. Snapshots
    the live state, asks the solver via PendingHintTask::spawn,
    returns. No card-entity query, no event writers for the
    hint visual / toast — the polling system owns those.
  - emit_hint_visuals promoted to pub so pending_hint can call it.
  - find_heuristic_hint extracted as a pub helper for the
    NeedsHeuristic poll path.
  - InputPlugin registers PendingHintTask + the two new systems.
    drop-on-state-change is chained .before() poll so a move
    applied this frame cancels any in-flight task before its
    result can be surfaced.

Tests:

  - input_plugin: pressing_h_spawns_pending_hint_task (1) — pins
    the H-key wiring at one-frame granularity.
  - pending_hint: winnable_solver_emits_hint_after_async_completes,
    state_change_drops_in_flight_task,
    second_spawn_drops_first_in_flight_task (3) — drives the
    AsyncComputeTaskPool with a wall-clock-bounded loop mirroring
    the winnable_seed_search_* template.
  - Removed two now-stale synchronous tests
    (hint_uses_solver_when_winnable,
    hint_falls_back_to_heuristic_when_solver_inconclusive) — the
    behaviours they pinned now live in pending_hint::tests at the
    correct layer.

Workspace: 1168 passing tests / 0 failing, was 1166 (net +2:
removed 2 stale, added 4 new). cargo clippy --workspace
--all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:01:51 -07:00
funman300 bfcd05fbb5 docs: cut v0.18.0 — launch-experience round + async winnable seeds
CHANGELOG.md gains a [0.18.0] section synthesising the 24 commits
since v0.17.0: the Restore prompt + auto-show Home picker launch
flow, MSSC-style picker (header chips, draw-mode chips, picture
tiles with FiraMono-covered glyphs, Today's Event callout), the
last solver hot path moving onto AsyncComputeTaskPool with
cancel-on-replace, "Won before" HUD chip, "Copy share link" Stats
button via arboard, the N-key flow finally routing through the
real Confirm/Cancel modal, Esc-on-modal layering fixes, and the
unified-3.0 Claude rule set adoption.

SESSION_HANDOFF.md (root) refreshed to reflect HEAD at
v0.17.0-24-gc497c31, the carryover punch list trimmed (items B
and C shipped, A partially shipped, D unchanged), and a new
Process notes section describing the test-discipline prune and
the smaller-port template the async hint work should follow.

Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1166 passing / 0 failing (one flake on
auto_save_writes_after_30_seconds reproduced clean on re-run;
passes in isolation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:20:10 -07:00
funman300 c497c3193c fix(engine): freeze game timers while the Home picker is up
The HUD's elapsed-time counter ticked from the moment the default
Classic deal landed at startup, even though the auto-show Home
picker was still up — so the player saw "0:11" before they had
chosen a mode. Time Attack had the same issue when M was pressed
mid-session: the 10-minute countdown burned while the player browsed
modes.

`tick_elapsed_time` and `advance_time_attack` now also gate on the
absence of `HomeScreen`, mirroring their existing `PausedResource`
check. The Home modal already covers input via its scrim, so this
purely freezes the timer without coupling to the pause-overlay
ownership of `PausedResource`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:29:42 +00:00
funman300 9aa0dd23b1 fix(engine): Esc dismisses the topmost modal when Profile stacks on Home
Clicking the new Home header chip opens Profile on top of Home.
Pressing Esc then closed Home (because handle_home_cancel_button
fired on Esc with no awareness of layered modals) and left Profile
orphaned over the game — the player had to press P afterwards just
to dismiss what they meant to dismiss in the first place.

Two changes restore the standard "Esc closes the topmost modal"
contract:

- profile_plugin: split P/button (toggle) from Esc (close-only).
  Esc only fires when Profile is currently open.
- home_plugin: handle_home_cancel_button now skips its Esc branch
  when any other ModalScrim exists, deferring to whichever modal
  is on top. Click on the explicit Cancel button is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:15:18 +00:00
funman300 d065d49fe7 fix(engine): TimeAttack tile glyph swaps to → (FiraMono ships sideways
triangles inconsistently)

Quat: ▶ (U+25B6) rendered as tofu even though ▲ (U+25B2) from the
same Geometric Shapes block works. FiraMono evidently ships the
up/down triangles but not the left/right siblings.

Swapped to U+2192 (RIGHTWARDS ARROW) from the Arrows block, which
is part of every dev-oriented monospace font's core coverage. Reads
as "go / fast-forward" for the timed mode and is visually distinct
from the other 4 tile glyphs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:06:40 +00:00
funman300 c30b04ec72 fix(engine): Home tile glyphs picked from FiraMono's actual coverage
The bundled face is FiraMono-Medium (assets/fonts/main.ttf), and its
glyph table covers card suits (U+2660-2666) plus basic Geometric
Shapes (U+25xx) but not Dingbats / Misc Symbols. The previous round
of "BMP fallbacks" still picked from blocks FiraMono doesn't cover,
so 4 of 5 tiles continued to render as tofu.

Re-picked from ranges FiraMono actually has:
- Daily: U+25C6 (BLACK DIAMOND)
- Zen:   U+25CB (WHITE CIRCLE) — Zen enso
- Challenge: U+25B2 (BLACK UP-POINTING TRIANGLE) — climbing
- TimeAttack: U+25B6 (BLACK RIGHT-POINTING TRIANGLE) — play / FF
- Classic keeps U+2663 (BLACK CLUB SUIT)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:00:26 +00:00
funman300 40d6e0ab17 fix(engine): Home tile glyphs render + modal fits any viewport
Two regressions Quat caught in screenshot review of the picture-tile
rework:

1. Tofu boxes for 4 of 5 tiles. The earlier emoji picks (calendar,
   cherry-blossom, lightning, stopwatch) live in Unicode planes that
   most Linux desktop fonts don't cover, so they rendered as
   missing-glyph rectangles. Swapped to BMP / Dingbats codepoints
   that the system-default font fallback always has:
   - Daily: \u{2605} (BLACK STAR)
   - Zen:   \u{2740} (WHITE FLORETTE)
   - Challenge: \u{2726} (BLACK FOUR-POINTED STAR)
   - TimeAttack: \u{231A} (WATCH, Misc Symbols / Unicode 1.1)
   Classic keeps its club (\u{2663}) — already rendered correctly.

2. Cancel button pushed off the bottom of the viewport. The 3-row
   tile grid alone is ~540 px; on the 800x600 minimum window the
   modal exceeded the screen. Wrapped chips + draw row + grid in a
   `HomeScrollable` Node with `max_height: 70vh` and `Overflow::scroll_y()`,
   adding a `scroll_home_panel` system to drive `ScrollPosition` from
   `MouseWheel`. Mirrors the existing Settings / Leaderboard /
   Achievements scrollable pattern. Cancel sits outside the scroll
   so it's always reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:52:44 +00:00
funman300 9fe650fa20 feat(engine): Home picker — 2-column picture tiles with Unicode glyphs
Phase B step 2 of the MSSC-inspired Home rework. Mode cards become a
wrapping 2-up grid with a centred Unicode-glyph centrepiece per tile,
standing in for real per-mode artwork until that lands.

- HomeMode::glyph() returns the placeholder codepoint for each mode:
  ♣ Classic, calendar Daily, cherry-blossom Zen, lightning Challenge,
  stopwatch TimeAttack. Cherry-blossom is used over lotus-position
  because the latter renders inconsistently across desktop fonts.
- The mode-card loop is wrapped in a FlexWrap::Wrap row container.
  Tiles set `width: 48%` + `min_height: 180px`; the 5-mode grid
  wraps to a third row of one tile, mirroring the half-cell asymmetry
  in MSSC's screenshot.
- The glyph paints in ACCENT_PRIMARY when the mode is unlocked and
  TEXT_DISABLED when locked, so the gate reads at a glance.
- When real art lands, swap the Text node for an Image node — the
  rest of the tile layout, focus order, click handling, and chip
  rendering are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:45:30 +00:00
funman300 b73d246b4c feat(engine): Today's Event callout on the Home Daily card
Phase B step 1 of the MSSC-inspired Home rework — surfaces today's
daily-challenge metadata on the Daily card so the picker reads as
"there's something fresh waiting" rather than a generic mode label.

- Date line "Today, May 6" pulled from DailyChallengeResource. Reads
  in STATE_INFO blue while the run is still open.
- Server-fetched goal (when SyncPlugin is wired) appears underneath
  as "Goal: Win in under 5 minutes", matching the toast that already
  fires when the player presses C.
- Once the player has recorded today's completion, the date flips
  to "Today, May 6 \u{2022} Done" in ACCENT_PRIMARY so the picker
  reads as a reward state rather than a TODO.

Headless tests omit DailyChallengePlugin, so HomeContext.daily_today
defaults to None and the card falls back to its baseline layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:28:59 +00:00
funman300 ae40a1db7a feat(engine): MSSC-style Home picker — header chips, score chips, draw mode
Phase A of the Microsoft-Solitaire-Collection-inspired launch picker
rework. Three additive changes inside the Home modal, no core / asset
work:

- Player-stats header strip showing Level / XP / Lifetime Score using
  a compact formatter (1.2M / 12.3K / 1,234). The whole strip is a
  Button — click fires ToggleProfileRequestEvent so Profile opens on
  top of Home; closing it returns to the picker.
- Draw-mode chip row above the mode cards lets the player flip
  Draw 1 / Draw 3 from the picker itself rather than diving into
  Settings. Active chip uses ACCENT_PRIMARY background; the click
  persists settings.json and respawns the modal so the active state
  repaints cleanly.
- Per-mode score/streak chip on each card — "Best 12,345" for
  Classic / Zen / Challenge, "Streak N" for Daily. Hidden on a 0
  best so a fresh profile doesn't read "Best 0" everywhere.

`HomeContext` bundle pulls live data from ProgressResource /
StatsResource / SettingsResource with safe defaults so headless
tests under MinimalPlugins still build cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:16:01 +00:00
funman300 b7c3a4996f fix(engine): Restore-prompt resolution suppresses Home auto-show
Resolving the Welcome-back / Restore prompt (either Continue or New
game) cleared `PendingRestoredGame` and despawned the modal, but the
launch-time Home auto-show then fired the next frame and stacked
itself over the player's chosen path — clicking "New game" would deal
a fresh game AND immediately pop the mode picker on top.

`LaunchHomeShown` becomes pub so `handle_restore_prompt` can flip it
to `true` after either resolution; `M` still re-opens the picker on
demand. Headless tests already pre-set the flag to true via
`HomePlugin::headless()`, so they're unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:44:31 +00:00
funman300 d48b9489db feat(engine): Esc dismisses Home / accepts default on Restore prompt
Home and Restore-prompt previously ignored Esc, which after the last
fix meant Esc just did nothing on those screens. Now both honor the
"Esc closes the modal" convention every other modal already follows.

- Home: Esc behaves like the Cancel button — despawns the modal so
  the player keeps the underlying default deal.
- Restore: Esc maps to Continue rather than New Game; a reflexive
  dismiss press preserves the saved game, matching how the primary
  action already advertises the Enter accelerator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:36:09 +00:00
funman300 08b006ff30 fix(engine): Esc on a modal no longer also opens Pause underneath
A single Esc press while the Confirm New Game / Restore / Home /
Onboarding / Settings modals were open would both close the modal
(via its own input handler) and spawn the Pause overlay on top in
the same frame, dumping the player on a screen they didn't ask for.

toggle_pause now skips when any non-Pause `ModalScrim` is in the
world. The HUD-button path is gated too — clicking Pause while
another modal is up is almost always an accident.

The four modal queries are bundled into a `PauseModalQueries`
SystemParam to stay under Bevy's 16-parameter cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:20:39 +00:00
funman300 17e0737a10 feat(engine): Enter dismisses Win Summary and starts a fresh deal
The post-win modal's "Play Again" was click-only — keyboard-only
players had to reach for the mouse to leave the celebration screen,
and the button advertised no accelerator the way every other modal
button does.

- handle_win_summary_keyboard reads Enter while WinSummaryOverlay is
  in the world; despawns the overlay and writes the same
  NewGameRequestEvent the click handler takes.
- The button label gains a trailing return-key glyph so the keyboard
  path is discoverable on first sight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:13:26 +00:00
funman300 dd63261999 feat(engine): auto-show Home / mode picker on launch
The Home (mode picker) was only reachable via M during gameplay, so
players who hadn't discovered the hotkey never saw the Daily / Zen /
Challenge / Time Attack entry points after the splash cleared.

- HomePlugin gains an `auto_show_on_launch` flag (default true) and a
  matching `headless()` test constructor that disables it.
- spawn_home_on_launch flips a one-shot LaunchHomeShown flag once the
  splash has cleared, gated on RestorePromptScreen / PendingRestoredGame
  so the Welcome-back flow still takes precedence on machines with a
  saved game.
- App entry uses HomePlugin::default(); both headless test fixtures
  switch to HomePlugin::headless() so per-test worlds start clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:57:25 +00:00
funman300 93660c2217 feat(engine): N keypress now opens the real Confirm/Cancel modal
Previously a first N press during an active game showed a "Press N
again" toast and started a 3-second countdown — a UI-first violation
since the only continuation was another keystroke. The HUD New Game
button already routed through `ConfirmNewGameScreen` with real Cancel
/ New game buttons; this change makes keyboard N do the same.

- handle_keyboard_core fires NewGameRequestEvent::default() directly;
  handle_new_game's existing active-game check spawns the modal.
- Shift+N keeps the keyboard power-user bypass (confirmed: true).
- N is suppressed while the confirm modal or restore prompt is open
  so those modals' own input handlers can process N (cancel /
  start-new-game) without us re-firing the same frame they close.
- KeyboardConfirmState, NEW_GAME_CONFIRM_WINDOW, NewGameConfirmEvent,
  and the "Press N again" toast handler are removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:57:14 +00:00
funman300 56e2e6f151 feat(engine): empty-state copy + onboarding hints across panels
- Leaderboard empty state: replace single muted line with a two-tier
  "Be the first on the leaderboard." headline + body invite.
- Achievements panel: surface a first-launch hint above the grid until
  the player unlocks anything, so the greyed-out rows aren't context-free.
- Volume hotkeys ([/]): emit an InfoToastEvent with the new percentage so
  off-panel adjustments give visible feedback (previously silent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:16:37 +00:00
funman300 cc635328be fix(engine): popover rows stay visible regardless of action-bar fade
Quat: opening Modes / Menu showed a solid dark-purple block in the
top-right with no readable content. Cause: the auto-fade system on
the top-level action bar was fading the popover rows too — they
share the `ActionButton` marker so `paint_action_buttons` can still
paint hover/press, but `apply_action_fade` matched the same marker
and dropped their alpha to whatever the cursor-position-based
fade happened to be (typically 0 because the cursor was inside the
opened popover, well below the top reveal zone). The popover
container stayed at full opacity (its background is `BG_ELEVATED`,
not driven by the fade), so what the player saw was the empty
rounded box with no labels.

Fix: new `PopoverRow` marker on the rows in `spawn_modes_popover`
and `spawn_menu_popover` (both share the same row-spawn shape).
`apply_action_fade` excludes `PopoverRow` via `Without<PopoverRow>`.
Hover / press paint still applies — the popover rows just opt out
of the cursor-position auto-fade since they only render when the
player has explicitly opened the dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:54:34 +00:00
funman300 a4bc063497 fix(engine): Settings rows use full-width layout to prevent overlap
Quat reported the volume UI overlapped with adjacent UI elements in
the Settings panel. The five slider/toggle row helpers
(volume_row × 2, tooltip_delay_row, time_bonus_multiplier_row,
replay_move_interval_row, toggle_row) all used the same flex pattern:

    Node {
        flex_direction: Row,
        align_items: Center,
        column_gap: VAL_SPACE_2,
    }

with no width constraint and no justify_content. Result: every
child packed against the left edge with 8 px gaps. As the value text
varied in width (e.g. "0.80" → "1.00", or "Instant" vs "1.5 s") the
+/− buttons shifted sideways frame to frame, and on narrow windows
the row's natural width could exceed the modal interior, pushing
elements past the right edge or visually merging with neighbours.

Restructured all five helpers to a label-spacer-cluster layout:

    [Label]                      [Value] [-] [+]
    └────── flex-grow=1 ──────┘  └─ cluster ─┘

with `width: Val::Percent(100.0)` on the row so it spans the body
width. The flex-grow spacer absorbs all slack horizontal space; the
controls cluster (value + buttons) sits flush against the right
edge regardless of value-text length. Existing tests still pass —
no behaviour change, just stable layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:45:16 +00:00
funman300 540869c851 feat(engine): "Copy share link" Stats button — clipboards the replay URL
Quat: replay sharing as the next punch-list item.

End-to-end:

1. Player wins a game on a server-backed sync backend.
2. `sync_plugin::push_replay_on_win` spawns the upload task on
   `AsyncComputeTaskPool` and stores the handle in the new
   `PendingReplayUpload` resource. The previous in-flight task (if
   any) is dropped — the most recent win is the one whose share link
   the player will care about.
3. `poll_replay_upload_result` harvests the task on the main thread
   each frame; on success writes `<server>/replays/<id>` to
   `LastSharedReplayUrl`. `UnsupportedPlatform` (LocalOnlyProvider)
   is silently absorbed; real network/auth errors warn-log.
4. The Stats overlay's action bar gains a "Copy share link" button.
   Click writes `LastSharedReplayUrl` to the OS clipboard via
   `arboard` and surfaces a "Copied: <url>" toast.

Trait change: `SyncProvider::push_replay` now returns `Result<String,
SyncError>` (the share URL) instead of `Result<(), SyncError>`. The
default (`UnsupportedPlatform`) is unchanged for non-server backends;
`SolitaireServerClient` parses the response body's `id` field and
composes `<base_url>/replays/<id>`. Both call paths (initial + 401
retry) go through the new `share_url_from_response` helper so the
parse logic isn't duplicated.

New deps:
- `arboard` (~10 KB, cross-platform clipboard) added to workspace +
  `solitaire_engine`. `default-features = false` keeps the X11/Wayland
  binary-feature deps off the dependency graph; arboard handles the
  fallback. Approved per the ASK BEFORE rule.

Persistence: the URL is in-memory only — the player must share within
the session of the win. A future revision can persist it alongside
the replay history file if cross-session sharing is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:32:57 +00:00
funman300 bdac754b26 feat(engine): "Won before" HUD indicator on rematched seeds
When the current deal's (seed, draw_mode, mode) triple matches an
entry in the rolling ReplayHistory, the HUD's tier-2 context row
now shows "✓ Won before" in the success-green colour. Cleared when
the active game itself is won (the on-screen victory cue is enough)
and on fresh deals the player hasn't beaten before.

The indicator answers a question the rolling-history feature
implicitly raised: when a new game starts on a seed the player has
already conquered, surface that fact so they know they can try for
a faster / higher-scoring win on the same layout. Seed re-rolls in
"Winnable deals only" + system-time seeds make this a natural pace
for the indicator to fire — usually empty, occasionally lit.

Implementation: new `HudWonPreviously` marker spawned in tier-2
alongside Mode / Challenge / DrawCycle. Driven by a separate
`update_won_previously` system rather than threading the marker
through `update_hud`'s ten-way query disambiguation. Reads the
existing `ReplayHistoryResource` from `stats_plugin`; gracefully
no-ops in headless tests that don't load StatsPlugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:23:16 +00:00
funman300 f863d85c35 fix(engine): preserve saved game while restore prompt is unanswered
Quat reported the restore prompt didn't appear and noticed their
save file ended up with move_count 0 — diagnosed as a destructive
overwrite. The flow:

1. Player exits with moves; game_state.json has move_count > 0.
2. Player relaunches. Plugin build sees moves > 0, holds the saved
   game in `PendingRestoredGame`, seeds `GameStateResource` with a
   fresh deal so the board doesn't show the half-played game until
   the player picks Continue.
3. The restore prompt should appear. (Why it didn't on Quat's run
   is still TBD — needs a fresh test.)
4. Player exits. `save_game_state_on_exit` writes
   `GameStateResource` (the fresh-deal placeholder) to disk,
   overwriting the meaningful saved game with move_count 0.

Both `save_game_state_on_exit` and `auto_save_game_state` now check
`PendingRestoredGame`: if it still holds an unanswered saved game,
they save THAT (or skip entirely in the auto-save path). The real
saved game on disk is preserved across launches no matter how many
times the player exits without answering the prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:15:31 +00:00
funman300 3c7a0eb4fb feat(engine): restore prompt on launch — Continue or start fresh
Previously the engine silently restored any saved in-progress game
from `game_state.json` on startup. Players who launched expecting a
fresh deal got dropped back into a half-played game with no signal
that a save had been picked up; players who wanted to continue had
no clear acknowledgement either way.

Now: when launching with a saved game that has at least one move
and isn't already won, the engine holds the saved state in a new
`PendingRestoredGame` resource and seeds `GameStateResource` with
a fresh deal. Once the splash overlay finishes, a modal appears:

    Welcome back
    You have an in-progress game. Continue where you left off, or
    start a new one?
    [New game]   [Continue]

- Continue (Enter / C / click) — swaps the saved game into
  `GameStateResource` and fires `StateChangedEvent`. Card sprites
  resync to the restored layout.
- New game (N / click) — drops the saved state, fires
  `NewGameRequestEvent { confirmed: true }`. The existing
  `handle_new_game` flow then deletes `game_state.json` and deals.

Save files with `move_count == 0` (a fresh deal that was never
played) skip the prompt and load directly — there's nothing
meaningful to "continue" there. Won games skip too (the existing
flow already deletes their save file on win).

The spawn system gates on `SplashRoot` being absent so the modal
doesn't pop up over the brand splash on first launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:57:49 +00:00
funman300 d489e7a31b feat(engine): solver-vetted seed selection on AsyncComputeTaskPool
"Winnable deals only" used to call `choose_winnable_seed` inline on
the main thread inside `handle_new_game`. Each rejected attempt costs
~120 ms (`SolverConfig::default()` budget); the loop caps at
`SOLVER_DEAL_RETRY_CAP` = 50, so a pathological run could stall the
UI for ~6 s on a New Game click. Quat flagged this as the highest-
impact UX regression left in the engine.

Reorganised so the solver runs on `AsyncComputeTaskPool`:

- New `PendingNewGameSeed` resource holds an `Option<PendingSeedTask>`
  carrying the in-flight `Task<u64>` plus the request's `mode` and
  `confirmed` flags so the polling system can replay them on a
  synthetic `NewGameRequestEvent` once the task resolves.
- `handle_new_game` now writes to that resource (and `continue`s)
  for the winnable-only / Classic / random-seed branch, instead of
  calling `choose_winnable_seed` synchronously.
- `poll_pending_new_game_seed` runs `.before(GameMutation)` so the
  synthetic event lands in the same frame's `handle_new_game` —
  the player sees no extra-frame visual lag once the solver
  completes.
- Cancel-on-replace: when a fresh `NewGameRequestEvent` arrives
  while a previous task is in flight, `pending_seed.inner = None`
  drops the old task (Bevy's `Task` Drop cancels cooperatively at
  the next await point) before processing the new request.

Two tests:

- `winnable_seed_search_runs_async_and_completes_eventually` —
  spawns the task, drives `app.update()` in a wall-clock-bounded
  loop with `std::thread::yield_now()` so the shared
  `AsyncComputeTaskPool` gets a chance to schedule between polls.
- `winnable_seed_search_drops_in_flight_task_on_new_request` —
  fires a winnable-only request, then before the task can complete
  fires an explicit-seed request that bypasses the solver entirely.
  Asserts the explicit seed wins, verifying the cancel-on-replace
  contract.

Existing solver tests pass unchanged: explicit-seed paths skip the
new branch and run synchronously like before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:49:19 +00:00
funman300 f2f30c8002 docs: adopt unified-3.0 Claude rule set + trim duplications
Adopts the four-file rule set the player added to the working tree:

- CLAUDE.md grows from a 114-line pointer doc to the 571-line
  `unified-3.0` rulebook: hard global constraints (§2), engine
  rules (§3), asset rules (§4), code standards (§5), build +
  verification (§6), git workflow (§7), the change-control
  ASK BEFORE list (§8), and the Context Injection System (§14).
- CLAUDE_SPEC.md — formal architecture spec: crate dependency
  graph with forbidden_deps, data ownership map, state-machine
  invariants ("52 cards always exist", "no duplicate IDs",
  "all cards belong to exactly one pile"), sync merge contract,
  server contract, validation checklist.
- CLAUDE_WORKFLOW.md — two-agent Builder/Guardian pipeline with
  hard-fail patterns that auto-reject (core uses IO/Bevy/network,
  GameState mutated outside GameLogicSystem, blocking async on
  main thread, duplicate logic, merge altered incorrectly).
- CLAUDE_PROMPT_PACK.md — task-type templates.

Three duplicate rule passages removed:

- CLAUDE_SPEC.md §0 dropped no_panics_in_core / core_is_pure /
  event_driven_engine — already canonical in CLAUDE.md §2.1, §2.3,
  §3.1. Kept single_source_of_truth and sync_is_additive (those
  describe data flow, not in CLAUDE.md).
- CLAUDE_SPEC.md §11 Prohibited Patterns now references CLAUDE.md
  §11 instead of restating the same five forbidden items.
- ARCHITECTURE.md Design Principles dropped the pure-core /
  no-panics / UI-first bullets — those are enforcement constraints
  living in CLAUDE.md §2.1, §2.3, §3.3; this file describes the
  design that motivates them. Kept the offline-first, one-language,
  and plugin-based-Bevy bullets (those are descriptive, not
  enforcement).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:42:24 +00:00
funman300 a49a340a30 chore: prune low-value tests per CLAUDE_SPEC.md §10 + WORKFLOW §8
The Quat-flagged "≥3 tests per feature" inflation produced 43 tests
that don't earn their existence — default-value, serde-derive
round-trips on plain structs, single-field clamp tests, near-
duplicates, and trivial constant-equals-itself tests. None pin a
behaviour contract or a regression on a real bug.

Removed across `solitaire_data` and `solitaire_core`:

  settings.rs   −22  default-value, round-trip, legacy-format,
                     and per-field sanitized clamp tests. Adjust
                     and load-error tests retained — those exercise
                     real method logic.
  progress.rs    −1  generic round-trip on plain struct.
  challenge.rs   −1  challenge_count() returns CHALLENGE_SEEDS.len()
                     literally — testing it asserts the implementation
                     against itself.
  game_state.rs  −3  undo_count starts at 0, GameMode default is
                     Classic, time_attack score starts at 0 — all
                     default-value tests on freshly-constructed state.
  card.rs        −5  rank_value_ace + rank_value_king subsumed by
                     rank_values_are_sequential; suit_red + suit_black
                     consolidated into one complementarity test;
                     card_face_up_field_reflects_construction was
                     testing the struct literal.

Workspace: 1208 → 1165 passing tests (−43). clippy --workspace
--all-targets clean.

Future work: brief sub-agents for tests that pin a behaviour
contract or regression on a real bug, not a count of N. See
`feedback_test_discipline.md` in auto-memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:42:05 +00:00
funman300 27cdf78ce0 docs: cut v0.17.0 — solver-driven hints + replay-rate slider
Two follow-up commits on top of v0.16.0:
- 87275bf: H-key hint asks the v0.15.0 solver for the actual best
  first move, with the existing heuristic kept as fallback.
- 53e3b81: Settings → Gameplay slider tunes replay playback rate
  (0.10–1.00 s, default 0.45 s) read per frame from SettingsResource.

Adds the [0.17.0] CHANGELOG section, folds the post-v0.16.0
provisional table into a v0.17.0 shipped table in SESSION_HANDOFF,
prunes the now-stale "Cut v0.17.0" item from the punch list, and
re-letters the resume-prompt decision options A–D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:11:08 +00:00
funman300 faa6c5efc4 docs: reconcile SESSION_HANDOFF with actually-shipped state
The post-v0.16.0 table marked the replay-rate slider as `(pending)`
but 53e3b81 already shipped it. Resume prompt said "HEAD at v0.16.0
/ 1196 tests" while the same doc above said HEAD was post-v0.16.0
with two follow-ups and 1208 tests.

Updates the slider row to reference 53e3b81, refreshes the resume
prompt's HEAD/test counts, and rewrites the "DECISION TO ASK THE
PLAYER FIRST" list — drops the smoke-test and "solver hints" bullets
(both already covered) and pulls forward the actual open items
(cut v0.17.0, solver-on-AsyncComputeTaskPool, won-previously,
replay sharing, packaging).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:05:03 +00:00
funman300 487b99bbc9 docs: SESSION_HANDOFF refresh — solver hints + replay slider, async deferred
Documents the two follow-ups landed on top of v0.16.0 (solver-driven
hints in 87275bf, replay-rate slider in this commit's parent) and
notes that an async-solver attempt was rolled back when a sub-agent
was interrupted leaving 3 failing tests. Async-solver is still
worth doing but needs smaller scoping next round.

Also records the process note raised this session: agent briefs had
been mandating ≥3 tests per feature, which produced low-value
coverage on trivial settings fields (Default trait arithmetic,
serde derive round-trips, stdlib clamp). Future briefs should ask
only for tests that pin behaviour contracts or regressions on real
bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 53e3b816cf feat(settings,engine): replay-playback rate slider in Settings → Gameplay
The replay overlay's per-move tick rate has been hardcoded at
REPLAY_MOVE_INTERVAL_SECS = 0.45 s/move since the in-engine
playback shipped. Power users want to scrub faster through older
wins. Adds a Settings slider that tunes the interval 0.10–1.00 s in
0.05 s steps; default 0.45 s preserves existing feel.

Settings.replay_move_interval_secs uses #[serde(default)] so legacy
files load to 0.45. sanitized() clamps out-of-range values.
tick_replay_playback now reads SettingsResource per frame and falls
back to the constant when the resource is absent (test fixtures).
The slider takes effect on the very next playback tick — no need to
restart playback.

Mirrors the existing tooltip-delay slider exactly: SettingsButton::
ReplayMoveIntervalUp/Down variants, the same `slider_row` pattern,
the same per-tick repaint system shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:00:59 +00:00
funman300 87275bf340 feat(core,engine): solver-driven hints with heuristic fallback
The H-key hint now asks the v0.15.0 Klondike solver for the actual
best first move from the current game state instead of the existing
heuristic. The heuristic stays as the fallback path so hints still
work when the solver bails Inconclusive on the player's budget.

solitaire_core::solver gains a path-recording variant. The internal
DFS already enumerated moves on each frame; recording the root_move
on the stack frame is +16 bytes and one unwrap_or per expansion —
the new-game retry loop sees no measurable slowdown.

New public API (additive — try_solve unchanged):

  pub struct SolverMove { source, dest, count }
  pub struct SolveOutcome { result: SolverResult, first_move: Option<SolverMove> }
  pub fn try_solve_with_first_move(seed, draw_mode, &cfg) -> SolveOutcome
  pub fn try_solve_from_state(&GameState, &cfg) -> SolveOutcome

The internal solver-move enum was renamed InternalMove so the public
SolverMove can use engine-friendly (source, dest, count) types
instead of the compact internal form.

Engine wiring: handle_keyboard_hint calls try_solve_from_state on
the live GameStateResource. On Winnable + first_move, the hint
surfaces that exact move (no cycling — a single, optimal hint).
Unwinnable or Inconclusive falls through to the existing all_hints
cycling heuristic so hints remain useful in deals the solver gives
up on.

A new HintSolverConfig resource lets tests inject tight budgets to
force the fallback path; production uses SolverConfig::default()
and median solve time stays at 2 ms per H press.

Six new tests pin the contract: 4 in solitaire_core (Winnable
returns first_move, Unwinnable returns None, deterministic, seed
and state forms agree); 2 in solitaire_engine (hint uses solver
when Winnable, falls back to heuristic when Inconclusive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:10:02 +00:00
funman300 56647d7f0d docs: CHANGELOG + SESSION_HANDOFF refresh for v0.16.0
CHANGELOG gains a [0.16.0] section covering the modal-feel polish
round: per-modal Overflow::scroll_y on Achievements / Help / Stats /
Profile / Leaderboard, pointer cursor on hover for every Button,
same-frame focus on modal open (attach + auto_focus moved to
PostUpdate), and click-outside-to-dismiss for the six read-only
modals via a new ScrimDismissible marker.

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1196.

SESSION_HANDOFF rewritten for the post-v0.16.0 state. Punch list
collapsed to two release-prep items (smoke-test, desktop packaging)
plus the carryover from v0.15.0's next-round candidates that didn't
ship this round (solver-driven hints, replay-rate slider, solver
progress overlay, async solver, "won previously" indicator, replay
sharing). Resume prompt asks A–E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:52:08 +00:00
funman300 cbf2483028 feat(engine): opt Profile / Leaderboard / Home into scrim-click dismiss
Follow-up to a54201e. The previous commit added ScrimDismissible to
Stats, Achievements, and Help; this one extends the same one-line
opt-in to the remaining three read-only modals so the click-outside-
to-close gesture is consistent across every informational surface.

Each modal now has the same shape: capture the scrim from
spawn_modal, attach ScrimDismissible after the build closure
returns. Three lines per file plus the import; no behaviour change
to the modal content itself.

Settings, Onboarding, Pause, Forfeit confirm, ConfirmNewGame, and
the win/game-over modals continue to opt OUT — all carry unsaved
or destructive state where an accidental scrim click would lose
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:47:02 +00:00
funman300 a54201e97b feat(engine): click-outside-to-dismiss for read-only modals
Adds a ScrimDismissible marker to ui_modal that opts a modal into
the standard "click outside the card to close" gesture. The new
dismiss_modal_on_scrim_click system fires on a left-mouse press
whose cursor falls on the scrim and outside every ModalCard, then
despawns the topmost dismissible scrim — Bevy's hierarchy despawn
cascades to the card and its children.

Marker design is opt-in per modal so destructive / state-mutating
modals (Settings saves on close, Onboarding requires explicit
acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed
intent) don't lose work to an accidental scrim click. Three
read-only modals opt in this round:

- Stats — informational; press S or click outside to dismiss.
- Achievements — read-only list.
- Help — keyboard reference.

Profile, Leaderboard, and Home will opt in the same way in a
follow-up; they were left out to keep this commit's scope tight.

The hit-test path uses each ModalCard's UiGlobalTransform +
ComputedNode bounding box so stacked modals close cleanly: the
topmost dismissible scrim is the only candidate per click. Tests
spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for
the resolved-border slots Bevy's UI module re-exports) so the
geometry hit-tests deterministically without running the full UI
layout pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:58 +00:00
funman300 48e412177c fix(engine): focus arrives on the same frame a modal opens
Previously when a click-handler in Update spawned a modal,
attach_focusable_to_modal_buttons and auto_focus_on_modal_open ran
in the same Update — but with no ordering edge to the click handler
the deferred Commands wouldn't materialise in time, so attach saw
no entities, FocusedButton stayed empty, and the very next Tab/Enter
press wasted itself moving focus from None to the primary instead
of activating it.

Moves attach_focusable_to_modal_buttons + auto_focus_on_modal_open
from Update to PostUpdate. The schedule boundary itself supplies
the sync point: every modal spawned anywhere in Update is
materialised before PostUpdate runs, attach can find the new
ModalButtons, and FocusedButton is populated before app.update()
returns. handle_focus_keys stays in Update so it observes input on
the frame it occurs, reading FocusedButton written by the previous
tick's PostUpdate.

Two new tests pin the contract:
- primary_button_is_focused_on_modal_spawn_same_frame uses a
  production-shaped spawner system (no chain edge to UiFocusPlugin)
  and asserts FocusedButton.0 is Some after a single update —
  fails without the fix, passes with it.
- first_tab_after_modal_open_advances_to_secondary guards against a
  regression where focus arrives but the very first Tab moves from
  None to primary instead of from primary to secondary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:19 +00:00
funman300 cd54ce1bb0 feat(engine): pointer cursor on hover over interactive buttons
Previously the cursor stayed the default arrow over every clickable
UI element (modal buttons, HUD action bar, mode-launcher cards,
settings toggles). Adds the standard "this is clickable" hand
affordance: while not dragging a card, hovering any entity with
Interaction::Hovered (or Pressed — keeps the pointer through a
click-and-hold) sets the window cursor to SystemCursorIcon::Pointer.

The new branch sits between the existing drag handlers in
update_cursor_icon: Grabbing wins when actively dragging, then
Pointer when a button is hovered, then Grab when a draggable card
is hovered, then Default. Card-drag affordance unchanged.

A pure pick_cursor_icon(is_dragging, any_button_hovered,
any_card_hovered) helper makes the priority logic unit-testable
without standing up a full Window + Camera fixture; four new tests
pin every branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:04 +00:00
funman300 7a3032b74c fix(engine): scroll the modals whose content overflows the viewport
Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.

Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.

Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
  scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
  scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
  weekly goals + unlocks + Time Attack readout + replay caption is
  enough content to overflow once the player has any progress;
  StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
  unlocked achievements + Stats summary overflows once a few
  achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
  with long display names; LeaderboardScrollable +
  scroll_leaderboard_panel (max_height = 50vh — the ranking section
  is the only variable-length part).

Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.

Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.

Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
  need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
  of the same frame; first Tab press selects rather than focus
  already being on the primary.

These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:30:04 +00:00
funman300 89699a8a86 docs: SESSION_HANDOFF refresh for post-v0.15.0 (follow-up)
The previous v0.15.0 doc commit only landed CHANGELOG — the
SESSION_HANDOFF write silently no-op'd due to a Write tool param
mix-up. This commit lands the matching handoff refresh:

- Status block updated to v0.15.0 / HEAD / 1178 tests
- New v0.15.0 changelog table covering the seven feature commits
  (Bevy trim, replay playback core + overlay + Stats wiring,
  rolling replay history, Cinephile achievement, solver + toggle)
- Open punch list collapsed to two release-prep items (smoke-test,
  desktop packaging) and six fresh next-round candidates
  (solver-driven hints — now unblocked, replay-rate slider, solver
  progress overlay, async solver, "won previously" indicator,
  replay sharing)
- Resume prompt asks A–E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:08:46 +00:00
funman300 70165da103 docs: CHANGELOG + SESSION_HANDOFF refresh for v0.15.0
CHANGELOG gains a [0.15.0] section covering 7 commits since
v0.14.0: Bevy default-features trim (51 transitive crates dropped),
in-engine replay playback core + overlay banner + Stats button
wiring, rolling replay history (last 8 wins) with selector UI,
"Cinephile" achievement (#19), and the Klondike solver + "Winnable
deals only" toggle.

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1178.

SESSION_HANDOFF rewritten for the post-v0.15.0 state. Open punch
list collapsed to two release-prep items (smoke-test, desktop
packaging) and six fresh next-round candidates: solver-driven
hints (now unblocked), playback-rate slider, solver progress
overlay, solver-on-async-compute, per-deal "won previously"
indicator, replay sharing. Resume prompt asks A–E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:07:15 +00:00
funman300 8a5fa8751c feat(core,engine): Klondike solver and "Winnable deals only" toggle
Closes Quat investigation #1. Today some Klondike deals are
unwinnable from the start and the player has no signal that the
deal they were given is solvable. A new Settings → Gameplay toggle
"Winnable deals only" (default off) makes the engine retry seeds
at deal-time until the solver returns Winnable, up to a cap.

Solver

solitaire_core::solver is a hand-rolled iterative-DFS solver with
memoisation on a 64-bit canonical state hash. Move enumeration is
priority-ordered: foundation moves first (zero choice when an Ace
or rank-up exists), inter-tableau moves second, waste-to-tableau
third, stock-draw last. The draw is skipped when the cycle counter
shows we've recirculated the entire stock without progress —
Klondike's deterministic stock cycle means further draws can't
unlock anything new.

Two budget knobs (move_budget = 100k, state_budget = 200k by
default) cap pathological cases at Inconclusive; the caller treats
Inconclusive as "winnable" so the player isn't penalised for the
solver giving up. Median solve time is 2 ms; pathological
inconclusives top out near 120 ms.

Switched from recursive to iterative DFS after a real-deal solve
overflowed Rust's default 8 MB thread stack. Behaviour identical;
the change is invisible to callers.

Pure logic — solitaire_core has no Bevy or I/O. Same input always
yields the same SolverResult.

Settings

Settings.winnable_deals_only is a #[serde(default)] bool; legacy
files load to false. SOLVER_DEAL_RETRY_CAP = 50 caps the retry
loop. The Settings → Gameplay toggle reads as "Winnable deals only"
with a "(may take a moment when on)" caption.

Engine integration

handle_new_game's seed-selection path now branches on the toggle.
When on AND mode is Classic AND no specific seed was requested
(daily challenges, replays, and explicit-seed requests bypass the
solver), choose_winnable_seed walks seed N, N+1, N+2, … calling
try_solve until it finds Winnable or Inconclusive. If the cap is
hit without a verdict, the latest tried seed is used so the player
always gets a deal rather than spinning forever.

19 new tests (11 solver, 3 settings, 5 engine including the
choose_winnable_seed unit). Two ignored bench/scan helpers
(solver_bench, find_unwinnable) for ad-hoc profiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:02:22 +00:00
funman300 bf660df971 feat(core,engine): "Cinephile" achievement for completing a replay
Adds a 19th achievement: "Cinephile — Watch a saved replay all the
way through." Unlocks the first time ReplayPlaybackState transitions
Playing → Completed (i.e. the move list runs out without the player
pressing Stop). Discoverability nudge for the replay feature itself.

The achievement uses the existing event-driven unlock pattern
(condition closure returns false; an unlock system fires
AchievementUnlockedEvent on the right state transition) rather than
the standard condition-evaluation path, mirroring how other
non-stat-driven achievements work.

The unlock system distinguishes natural completion from Stop-button
abort by watching for the specific Playing → Completed transition;
Stop transitions Playing → Inactive directly without going through
Completed, so it doesn't fire the achievement. Already-unlocked
state is checked via AchievementsResource so the achievement can't
double-fire on subsequent replays.

README's "18 Achievements" → "19 Achievements". ARCHITECTURE.md §11
gains a Cinephile entry alongside the existing 18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:56 +00:00
funman300 13a8a012ee feat(data,engine): rolling replay history (last 8 wins)
Promotes replay storage from a single overwriting slot at
latest_replay.json to a rolling list of the most recent 8 wins at
replays.json so the player can revisit a memorable game even after
winning more recently.

Storage layer

solitaire_data::replay gains ReplayHistory (schema_version=1, Vec<Replay>
capped at REPLAY_HISTORY_CAP = 8) plus save_replay_history_to,
load_replay_history_from, append_replay_to_history, and
replay_history_path. append_replay_to_history inserts at the front,
drops the oldest when the cap is hit, and persists atomically via
the existing .tmp + rename pattern. The legacy single-slot helpers
are #[deprecated] but kept for one release as a migration safety
net via the new migrate_legacy_latest_replay helper.

Engine integration

game_plugin's record_replay_on_win now appends to the history
instead of overwriting latest_replay.json. On Startup, if a legacy
latest_replay.json exists but replays.json doesn't, the migration
helper seeds the new file from the legacy entry — so the player's
last v0.14.0 replay carries forward.

Stats UI

LatestReplayResource → ReplayHistoryResource holding the full
history. New SelectedReplayIndex resource (default 0 = most
recent) drives a Prev / Next / "Replay N / M" selector at the top
of the Stats overlay. ReplayPrevButton, ReplayNextButton, and
ReplaySelectorCaption marker components let the repaint system
update the caption as the selection changes. The Watch button
launches the selected replay rather than always the most recent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:37 +00:00
funman300 02ababa65f feat(engine): wire Stats Watch Replay button to in-engine playback
Promotes the Stats overlay's Watch Replay button from a stub
InfoToastEvent ("playback coming in a future build") to actually
starting in-engine playback via the new
replay_playback::start_replay_playback API. Pressing the button
when a replay exists resets the game to the recorded deal and the
ReplayOverlayPlugin's banner takes over.

The handler reads ReplayPlaybackState via Option<ResMut<...>> so
headless test fixtures that don't register ReplayPlaybackPlugin
keep compiling — they fall back to a descriptive "Replay ready"
toast. The "no replay yet" branch still surfaces the existing
"win a game first" toast.

Plugin registration in solitaire_app/src/main.rs picks up
ReplayPlaybackPlugin and ReplayOverlayPlugin alongside the existing
StatsPlugin. They run in any order — the playback plugin owns the
state resource, the overlay plugin spawns/despawns based on state
changes, and Stats's button handler dispatches into the playback
plugin's API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:48 +00:00
funman300 9c36b49729 feat(engine): replay-playback overlay banner with Stop button
Visible UI for the in-engine replay playback that just landed: a
thin top banner anchored to the window edge while
ReplayPlaybackState is Playing or Completed, surfacing the player's
current position in the move list and a way to abort.

Layout: full-width banner ~48 px tall with three children — a
"Replay" label in ACCENT_PRIMARY left-aligned, "Move N of M"
progress text centred, and a Tertiary Stop button right-aligned via
the existing spawn_modal_button helper so it gets focus rings and
hover/press states for free.

Z_REPLAY_OVERLAY = Z_DROP_OVERLAY + 5 (= 55) sits above HUD but
well below modal scrim (≥200), so Settings, Pause, and Help still
render on top of the overlay during a replay — the player can
adjust audio or pause mid-playback.

State-driven: the spawn system reacts to Changed<ReplayPlaybackState>
transitions, swapping the banner text to "Replay complete" when
state moves Playing → Completed and despawning entirely when state
returns to Inactive (either via the Stop button, completion linger
expiry, or external reset).

Five tests cover spawn-on-Playing, progress text, stop-button
clears state and despawns, despawn-on-Inactive, and Completed
banner text swap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:36 +00:00
funman300 8e90574437 feat(engine): in-engine replay playback core
Promotes the replay feature from disk-only to a real in-engine
playback path. A new ReplayPlaybackState resource models a three-
state machine (Inactive / Playing / Completed); start_replay_playback
resets the live game to the recorded deal via
GameState::new_with_mode(seed, draw_mode, mode) and a tick system
fires the canonical MoveRequestEvent / DrawRequestEvent for each
recorded move at REPLAY_MOVE_INTERVAL_SECS (0.45s).

The reset path bypasses NewGameRequestEvent because the existing
event always sources draw_mode from Settings — a Draw-1 replay
would silently coerce to Draw-3 (or vice versa) on a player whose
preference doesn't match the recording. Inserting GameStateResource
directly applies the recording's exact draw_mode and sidesteps the
abandon-current-game confirmation modal that would otherwise block
playback.

Recording suppression during playback is non-invasive: a sibling
system snapshots RecordingReplay's length on entry to playback and
truncates the buffer back to that mark every frame while is_playing
or is_completed. game_plugin's recording append paths are
untouched.

Completion lingers for REPLAY_COMPLETION_LINGER_SECS (5s) so the
overlay can show "Replay complete" before the auto-clear flips
state to Inactive.

Six new tests cover the state transitions, tick cadence, canonical
event firing, completion, stop-clears-state, and the
recording-suppression contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:34:16 +00:00
funman300 95fcdad5d2 chore: disable Bevy default features to drop unused audio stack
Closes Quat investigation #2. The project uses kira for audio
(cpal 0.17 + alsa 0.10), but Bevy's default feature set still pulled
bevy_audio → rodio → cpal 0.15 + alsa 0.9 + symphonia codecs — about
50 transitive crates the binary never executes.

Workspace Cargo.toml's bevy entry now declares default-features =
false plus an explicit allow-list of the features actually used
(default_app subset + default_platform desktop subset + common_api +
2D + UI rendering). The list is derived analytically from the leaves
of Bevy 0.18's 2d and ui meta-features; built cleanly on the first
try with no missing-symbol errors.

Features intentionally omitted vs Bevy default:
- bevy_audio (kira handles audio directly)
- bevy_animation (custom CardAnimation, not Bevy's)
- bevy_gilrs, bevy_gizmos, bevy_picking variants, bevy_post_process,
  scene, hdr, sysinfo_plugin (none used)
- webgl2, web, android-* (desktop-only; solitaire_wasm is Bevy-free
  and uses wasm-bindgen + solitaire_core directly)
- wayland (X11 chosen; Wayland can be added later if requested)

Dependency-tree size for solitaire_app drops from 628 unique crates
to 577 (-51). Verified gone: bevy_audio, rodio, cpal 0.15. The
remaining cpal 0.17 and symphonia 0.5 are pulled by kira, not Bevy.

solitaire_wasm needed no changes — it doesn't depend on bevy.

All 1134 tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:07:30 +00:00
111 changed files with 18739 additions and 2812 deletions
+5 -3
View File
@@ -47,11 +47,10 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
### Design Principles
- **Offline first.** The local file is always the source of truth. Sync is additive, never destructive.
- **Pure core.** All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
- **No panics in game logic.** Every state transition returns `Result<_, MoveError>`. Panics are only acceptable in startup/configuration code.
- **One language, one repo.** The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
- **Plugin-based Bevy architecture.** Each major feature is a Bevy `Plugin`. Systems are small and single-purpose. Cross-system communication uses Bevy `Event`s.
- **UI-first interaction.** Every player-triggered action — new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, etc. — must be reachable from a visible UI control. Keyboard shortcuts exist only as optional accelerators for power users; they are never the sole entry point. A player using only mouse or touch must be able to perform every action. New gameplay features ship with the UI control alongside the system that backs it.
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
---
@@ -716,11 +715,14 @@ pub struct AchievementDef {
| `speed_and_skill` | ??? | Win < 90s without undo | Yes | Card back #4 |
| `comeback` | ??? | Win after 3+ stock recycles | Yes | Background #4 |
| `zen_winner` | ??? | Win in Zen Mode | Yes | Badge |
| `cinephile` | Cinephile | Watch a saved replay all the way through | No | — |
### Evaluation Timing
Achievement conditions are evaluated by `AchievementPlugin` on every `GameWonEvent` and `StateChangedEvent`. The plugin calls `solitaire_core::check_achievements()` which returns a `Vec<AchievementDef>` of newly unlocked achievements. The plugin then fires `AchievementUnlockedEvent` for each, which the toast and persistence systems handle independently.
A small number of achievements are *event-driven* rather than condition-driven: their `AchievementDef::condition` always returns `false` and their unlock is written from a dedicated observer system instead. `cinephile` is the canonical example — it unlocks when `ReplayPlaybackState` transitions from `Playing` to `Completed` (a saved replay watched to its natural end). The Stop button transitions `Playing → Inactive` directly without entering `Completed`, so manual aborts do not unlock the achievement.
---
## 12. Progression System
+706 -2
View File
@@ -6,7 +6,709 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
_Nothing yet._
No threads in flight. v0.20.0 cut on 2026-05-07; CHANGELOG accumulates
the next cycle here.
## [0.20.0] — 2026-05-07
Two through-lines closed: a full **Android port** (build target,
first 54 MB APK, JNI-free per-app persistence shim) and the
**Terminal visual-identity port** that replaces the prior
Premium-Solitaire palette across every UI surface. The Android
arc opened in `fb8b2ac` (compile + APK), continued in `4b51e50`
(`solitaire_data::data_dir` shim closing the CLAUDE.md §10
`dirs::data_dir() = None` pitfall), and is functional end-to-end
on a real device — though the runtime artwork is still the legacy
white-card palette, and JNI ClipboardManager / keyring bridges
remain stubbed (matching v0.19.0's documented fallback behaviour).
The Terminal port lands as a top-down stack: the `ui_theme` token
API in `0d477ac` is load-bearing, and the rest of the cycle is
downstream applications (modal scaffold, gameplay-feedback,
toasts, table / card chrome, splash cursor, hint-highlight
pairing). The card faces and suit-pip palette are deliberately
NOT migrated — those track PNG artwork that hasn't been
regenerated yet, and swapping the fallback constants ahead of the
artwork would mix two visual systems on any code path where
image loading fails.
The 24 Stitch-rendered mockups in `docs/ui-mockups/` are now
in-tree (`fa7f98a`); future plugin work should diff against the
matching mockup before touching pixels.
Two threads from v0.19.0's punch list also closed in this cycle:
the pull-failure test flake (`67c150b`), the Settings opt-out for
the smart-default window sizer (`e1b8766`), and the share-link
discoverability surfacing (`9b065e5`). The remaining v0.19.0
candidate — the app-icon round — stays open.
### Added
- **`ui_theme` Terminal design-token system** (`0d477ac`). Single
source of truth for the engine's visual identity:
base16-eighties palette (cyan primary CTA, lime/lavender/gold/
teal/pink semantic accents), 5-rung type scale, 7-rung 4-multiple
spacing scale, 3-step radius, 14-rung z-index hierarchy, full
motion budget, and four invariant-pinning unit tests. Every
downstream port commit in this cycle reads from this module —
swapping the palette is now a one-file edit, not a hunt across
~50 plugin files. Card-shadow alphas pinned to 0 (Terminal
achieves depth via 1px borders + tonal layering, no
`box-shadow`); the rendering path is left intact so a future
palette can re-enable shadows without touching consumers.
- **`ToastVariant` enum + Terminal toast styling** (`a137607`).
Toasts now follow `docs/ui-mockups/design-system.md`: opaque
`BG_ELEVATED` fill, 1px accent border keyed off
`Info` / `Warning` / `Error` / `Celebration` variants, 18px
monospaced caption (`TYPE_BODY_LG`), bottom-anchored. All ten
call sites pass their semantic variant: achievement / level-up
/ XP / daily / weekly / challenge → Celebration (lavender);
goal-announcement / time-attack / settings volume / auto-complete
→ Info (teal). Two regression tests pin variant→border mapping
to the design tokens and require all four borders to be visually
distinct. Queued and immediate toasts use slightly different
bottom anchors (6 % vs. 14 %) so a celebration toast spawned
alongside a queued info banner layers above it.
- **Terminal cursor block on the splash overlay** (`cdcadda`).
The launch splash now renders the design system's signature
`▌` cyan (`ACCENT_PRIMARY`) glyph (96 px, hand-tuned literal)
above the wordmark, matching `docs/ui-mockups/splash-mobile.html`.
Cursor fades on the same per-frame alpha schedule as the title
and subtitle so the brand beat still dissolves as a single
layer. Did *not* pull in the mockup's full boot-loader treatment
(scanline overlay, ✓ check log, progress bar, ROOT@SOLITAIRE
prompt) — those are aesthetic features warranting their own
commit.
- **Terminal design-system spec + 24-mockup library** (`fa7f98a`).
`docs/ui-mockups/design-system.md` (palette, type scale, spacing
scale, motion budget, component library, accessibility notes —
color-blind toggle, high-contrast mode, glyph differentiation,
canonical `"Terminal"` card-back theme) and 24 Stitch-rendered
mockups (HTML + PNG): 12 redesigned existing screens, 1 desktop
home variant, 2 onboarding steps, and 9 missing-plugin screens
(splash, challenge, time-attack, weekly-goals, leaderboard,
sync, level-up, replay, radial-menu). The spec the rest of this
cycle ports against; future plugin work diffs here before
touching pixels.
- **Android build target — first working APK** (`fb8b2ac`).
`cargo apk build -p solitaire_app --target x86_64-linux-android`
now produces a 54 MB debug-signed APK at
`target/debug/apk/solitaire-quest.apk`. Five gating points
resolved end-to-end:
- **`solitaire_app` split into bin + lib.** cargo-apk needs a
`cdylib` to bundle as `libmain.so`; pure-bin crates panic
with "Bin is not compatible with Cdylib". `src/lib.rs`
carries the ECS bootstrap as `pub fn run`; `src/main.rs` is
a 3-line shim that delegates for the desktop path.
- **`[package.metadata.android]`** pins target SDK 34 / min
SDK 26 and points `assets = "../assets"` at the workspace
asset directory so desktop and APK share one set.
- **Workspace `bevy` features** add `android-native-activity`
(target-gated inside bevy_internal — desktop builds compile
it out). Pairs with cargo-apk's NativeActivity wrapper.
- **`arboard` target-gated** to `cfg(not(target_os =
"android"))`. The crate has no Android backend; cargo apk
fails with E0433 on `platform::Clipboard` if left
unconditional. Stats's "Copy share link" surfaces an
informational toast on Android until JNI ClipboardManager
lands in the Phase-Android round.
- **`keyring` + `keyring-core` target-gated.** Bionic doesn't
expose `libc::__errno_location` so the transitive
`rpassword` won't compile. `auth_tokens` ships an Android
stub returning `KeychainUnavailable` for every call —
matches the existing fallback for a Linux box without
Secret Service.
- Cosmetic: cargo-apk panics post-sign when it tries to also
wrap the bin target. The APK on disk is unaffected;
`cargo apk build --lib` is the small workaround.
- **Android developer setup + build runbook** (`59424a3`).
Captures Debian 13 toolchain install (JDK 21, unzip, SDK
licence prompts), the `cargo apk build` invocation, the
cosmetic post-sign panic workaround, and a what-is-wired-vs-
stubbed table for the android target. Runnable on a fresh
clone — no machine-local context required.
- **F3-toggleable FPS / frame-time overlay** (`690e1d2`).
`DiagnosticsHudPlugin` wraps Bevy's `FrameTimeDiagnosticsPlugin`
and renders a corner readout the developer toggles with F3.
Hidden by default; F3 is not gated by pause / modal state.
Reads `smoothed()` so the cell isn't a per-frame jittery
scoreboard. Format: `FPS NN \u{2022} M.MM ms`. Anchored
top-right at `z = Z_SPLASH + 100` above every modal / toast /
splash. Update system bails when hidden so the
diagnostic-store lookup is free when nobody's looking.
- **"Smart window size" Settings toggle** (`e1b8766`). Gameplay
section gains an opt-out toggle for v0.19.0's
`apply_smart_default_window_size` system. New
`Settings::disable_smart_default_size: bool` with
`#[serde(default)]` so legacy `settings.json` files load to
the shipped behaviour (smart sizer enabled). `solitaire_app::main`
reads the flag once at startup and skips the system's
registration when set. Saved window geometry still wins over
both branches; tooltip on the row makes that explicit.
- **"Shareable" badge on the Latest-win caption** (`9b065e5`).
The Stats overlay's Latest-win caption now appends
`\u{2022} Shareable` when the displayed replay carries a
populated `share_url`. Players can see at a glance whether the
Copy share link button will produce a URL or surface the
upload-prerequisite toast.
- **Help overlay covers M / P / Win-Summary-Enter** (`35516d3`).
Three new rows in the Overlays section: M (Home / Mode
launcher), P (Profile), and the Enter accelerator that
dismisses the Win Summary modal. Three post-v0.18 entries
that had drifted out of the cheat sheet are now listed.
### Changed
- **Gameplay-feedback colours route through Terminal state
tokens** (`ceec4fc`). Selection-highlight tints in
`selection_plugin` and the valid-drop marker tint in
`cursor_plugin` were hand-tuned RGB literals. Migrated to
semantic state tokens: keyboard-drag picking source →
`ACCENT_PRIMARY` (cyan focus); keyboard-drag lifted source →
`STATE_WARNING` (gold attention); destination → `STATE_SUCCESS`
(lime valid-move); `cursor_plugin::MARKER_VALID` →
`STATE_SUCCESS` at 0.55 α with a tracking test pinning its RGB
to the token. Three stale doc comments in `ui_modal` corrected
("loud yellow CTA" / "magenta secondary accent" → cyan /
lavender to match the actual token values).
- **`table_plugin` chrome migration to Terminal tokens** (`651f406`).
`marker_colour` promoted to module-level `pub const
PILE_MARKER_DEFAULT_COLOUR` so `cursor_plugin::MARKER_DEFAULT`
imports the const directly — replaces the prior
duplicated literal kept in sync only by doc comment with a
compile-enforced invariant. The empty-tableau "K" placeholder
text now uses `TEXT_PRIMARY` at 0.35 α; `HINT_PILE_HIGHLIGHT_COLOUR`
retuned from bright `srgb(1.0, 0.85, 0.1)` to the `STATE_WARNING`
token (`#ddb26f`) with a tracking test, and the existing "is
gold" character test loosened to fit the muted Terminal gold
while still rejecting non-warm colours.
- **`card_plugin` chrome migration to Terminal tokens** (`d752870`).
Drag-elevation shadow now sources its colour from
`CARD_SHADOW_COLOR` + `CARD_SHADOW_ALPHA_DRAG` so the Terminal
"no box-shadow" policy disables the stack shadow in lockstep
with the per-card shadows. `RIGHT_CLICK_HIGHLIGHT_COLOUR`
retuned from raw green to `STATE_SUCCESS` at 0.6 α with a
tracking test. The duplicated `PILE_MARKER_DEFAULT_COLOUR`
const dropped — this plugin now imports the promoted const
from `table_plugin`. Stock recycle "↺" text moved from raw
white-at-0.7-α to `TEXT_PRIMARY.with_alpha(0.7)`. Card-face /
suit / card-back palette constants were intentionally NOT
migrated (the runtime path renders PNG artwork that's still on
the previous "white card" palette).
- **Hint-source card tint matches the destination pile**
(`9891ae4`). `input_plugin`'s hint-source card tint moved from
raw bright-yellow `srgba(1.0, 1.0, 0.4, 1.0)` to `STATE_WARNING`,
so the source card and the destination pile (which already uses
`STATE_WARNING` via `HINT_PILE_HIGHLIGHT_COLOUR`) wear the same
attention colour as a coherent pair.
### Fixed
- **`solitaire_data::data_dir` shim closes the Android persistence
gap** (`4b51e50`). `dirs::data_dir()` returns `None` on Android,
which silently disabled every persistence path (settings, stats,
achievements, replays, game-state, time-attack sessions, user
themes). New `solitaire_data::platform::data_dir()` shim falls
through to `dirs::data_dir()` on desktop and returns the per-app
sandbox at `/data/data/com.solitairequest.app/files` on Android
— no JNI needed, since the package id is pinned in
`[package.metadata.android]`. Six call sites across
`solitaire_data` plus `solitaire_engine/assets/user_dir.rs`
migrated. CLAUDE.md §10 already flagged this as a known
pitfall; the shim pays it down at the one chokepoint instead
of per feature.
- **`card_shadow_params` test aligned with Terminal "no shadow"
intent** (`1d1543e`). The Terminal token system pinned both
`CARD_SHADOW_ALPHA_IDLE` and `CARD_SHADOW_ALPHA_DRAG` to 0.0,
which made the prior `drag_alpha > idle_alpha` assertion fail
(`0 > 0` is false). Loosened to `drag_alpha >= idle_alpha`
with a comment naming the new invariant: under Terminal both
are 0; under any future palette that re-enables shadows, drag
still must not be weaker than idle. The useful regression-guard
(catching an accidental swap of the two constants) is preserved.
- **`pull_failure_sets_error_status` test flake** (`67c150b`).
The fixed 5-update budget was the last test still subject to
the AsyncComputeTaskPool starvation mode that v0.19.0's
auto-save fix already cleared. Replaced with a wall-clock-
bounded loop (5-second deadline, `std::thread::yield_now`
between iterations) that exits as soon as the status flips.
Mirrors the auto-save flake fix shape.
### Stats
- **1176 passing tests / 0 failing** across the workspace
(six new tests this cycle: four `ui_theme` invariant guards
for the type / spacing / z-index scales + `scaled_duration`,
one toast-variant-border-mapping pair, and four palette-
tracking guards on `MARKER_VALID` / `HINT_PILE_HIGHLIGHT_COLOUR`
/ `RIGHT_CLICK_HIGHLIGHT_COLOUR` / toast-border distinctness).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.19.0] — 2026-05-06
Closes the v0.18.0 punch list (items B and D — async hint and
persistent replay share URLs), expands desktop platform fit
(Wayland session support + monitor-aware default window size for
HiDPI / 4K displays), polishes the win-celebration and
double-click animation paths, and clears two test-flake
contributors. A short-lived "Rusty Pixel" pixel-art card theme
was prototyped and reverted in the same window — the engine
plumbing it touched (`pixel_art` field on `ThemeMeta`, PNG
manifest face support, second `embedded://` theme channel) was
fully reverted and is not part of this release.
### Changed
- **H-key hint runs on `AsyncComputeTaskPool`** (`3e11e9e`). The
synchronous `try_solve_from_state` call on every H press is gone;
`handle_keyboard_hint` now spawns a task whose result the new
`pending_hint::poll_pending_hint_task` system surfaces one frame
later. New `PendingHintTask` resource carries the in-flight handle
plus `move_count_at_spawn` for staleness detection;
`drop_pending_hint_on_state_change` cancels the task whenever the
game state shifts; `PendingHintTask::spawn` implements
cancel-on-replace so two quick H presses keep at most one task in
flight. Mirrors the v0.18.0 `PendingNewGameSeed` template.
`emit_hint_visuals` and `find_heuristic_hint` are extracted as
`pub` helpers so the polling system can call them.
- **Persistent replay share URLs** (`42d90b1`). v0.18.0's
`LastSharedReplayUrl` was an in-memory resource wiped on quit —
the player had to share within the session of the win.
`solitaire_data::Replay` now carries a `share_url: Option<String>`
field with `#[serde(default)]` (no `REPLAY_SCHEMA_VERSION` bump
needed; older `replays.json` files load unchanged with `share_url
== None` on every entry). `poll_replay_upload_result` writes the
resolved URL into `replays[0].share_url` and persists the updated
history via `save_replay_history_to`. The Stats overlay's
"Copy share link" button reads from
`history.0.replays[selected.0].share_url`, so the Prev/Next
selector's currently-displayed replay drives the clipboard
contents — each historical win keeps its own URL.
`LastSharedReplayUrl` removed (its role is now subsumed by the
`share_url` field on the replay record).
### Added
- **Wayland session support** (`b57db01`). The workspace
`Cargo.toml` Bevy feature list now enables `wayland` alongside
`x11`. winit prefers Wayland when `WAYLAND_DISPLAY` is set on the
session, falling back to X11 when it isn't. Pre-fix, a Wayland
desktop environment fell through to XWayland, rendering the
game inside an X11 frame stitched into the Wayland compositor.
Post-fix, the game opens as a native Wayland surface. Costs a
few hundred KB of binary for the libwayland-client bindings;
cross-distro friendly because winit dlopen-probes the libraries
rather than hard-linking them.
- **Monitor-relative default window size** (`b57db01`). On launches
with no saved geometry, the new
`apply_smart_default_window_size` Update system queries
`Monitor` (with the `PrimaryMonitor` marker) and resizes the
primary window to ~70 % of the monitor's *logical* size on the
first frame. Before, every fresh launch opened at 1280×800
regardless of monitor; on a 4K monitor that's a comparatively
tiny window in one corner. Logical size already accounts for
the OS's HiDPI scale factor, so a Retina display reporting
scale_factor 2.0 yields the same physical inches as a 1080p
display reporting 1.0. Skipped entirely when saved geometry was
applied — the player's chosen size always wins.
### Fixed
- **Duplicate "You Win" toast on game-won** (`55c235b`). The
post-win UI was firing two celebration surfaces: a 4-second
toast banner ("You Win! Score: X Time: Y") on top of the
`win_summary_plugin`'s "You Won!" modal. In screenshots the
toast banner was partially clipped behind the modal card,
peeking out on either side. The toast predated the modal and is
strictly subsumed by it; removed. The cards-fly-off cascade
animation (`MotionCurve::Expressive` per-card rotation drift)
is unchanged — that's the visual celebration, distinct from
the textual celebration the modal owns. `WIN_TOAST_SECS` const
removed.
- **Double-click on a single card with no destination now plays
the reject animation** (`d7ffb16`). `handle_double_click` only
fired `MoveRejectedEvent` for multi-card stacks with no
destination; a double-click on a single card whose top didn't
fit any foundation or tableau slot produced zero feedback —
no `card_invalid.wav`, no source-pile shake. Both priorities'
failure paths now converge on a single rejection at the end of
the double-click branch, so single-card and stack misses get
the same feedback shape as drag-and-drop rejections.
- **Double-click move animation no longer plays twice**
(`6037596`). On a successful double-click, the slide-to-
destination animation rendered twice — once from the move's
`StateChangedEvent` landing, then again from the release's
`end_drag` firing a redundant `StateChangedEvent` mid-slide.
`sync_cards_on_change` saw the card mid-CardAnim (`cur ≠
target`) and replaced the in-flight tween with a fresh one
starting at the mid-position, visibly restarting the slide. The
defensive `StateChangedEvent` write in `end_drag`'s
uncommitted-drag branch is removed; `start_drag` only mutates
`DragState` (never card transforms), so an uncommitted drag
has no visual side effect to undo. The committed-drag branch
keeps its `StateChangedEvent` since real drag snap-backs do
need a resync.
- **`auto_save_writes_after_30_seconds` test flake** (`91b7605`).
The test's single-frame `app.update()` was sensitive to
first-frame `Time::delta_secs()` variance under heavy parallel
cargo-test load, and to production-disk
`~/.local/share/solitaire_quest/game_state.json` state leaking
into the test world via `GamePlugin::build`'s load path.
`test_app` now resets `PendingRestoredGame(None)` after plugin
build (preventing the dev machine's saved-game state from
tripping the auto-save guard) and the test re-arms the timer in
a small bounded loop until the file appears (robust against
first-frame Time variance). No production-code change.
### Stats
- 1170 passing tests (was 1166 at v0.18.0 close — net +4 from
the persistent share URL backwards-compat test, the three
async-hint tests, minus the dropped synchronous hint tests).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.18.0] — 2026-05-06
The launch-experience round. The engine used to drop the player on a
silent default Classic deal whether they had unfinished work or not;
v0.18.0 replaces that with two stacked decision points — a Restore
prompt for in-progress saves, then an MSSC-style Home / mode picker
that surfaces Daily / Zen / Challenge / Time Attack as picture tiles
with live stats. The same round closes the last solver-on-main-thread
hot path (winnable-only seed selection moves to
`AsyncComputeTaskPool`), wires "Copy share link" into Stats, lights a
"Won before" HUD chip on re-deals of beaten seeds, and tidies the
unified-3.0 rule set across CLAUDE.md / CLAUDE_SPEC.md /
CLAUDE_WORKFLOW.md / CLAUDE_PROMPT_PACK.md.
### Added
- **Restore prompt on launch** (`3c7a0eb`). When `game_state.json`
holds an in-progress game (`move_count > 0`, not won), the engine
now seeds `GameStateResource` with a fresh deal and holds the saved
game in a new `PendingRestoredGame` resource. After the splash
clears, a "Welcome back" modal offers **Continue** (Enter / C /
click) or **New game** (N / click). Fresh-deal saves
(`move_count == 0`) skip the prompt and load directly.
- **Save preservation while the prompt is unanswered** (`f863d85`).
Both `save_game_state_on_exit` and `auto_save_game_state` consult
`PendingRestoredGame` first: if it still holds a pending saved
game, that's what gets persisted (or the auto-save is skipped),
so exiting before answering the prompt no longer overwrites the
meaningful save with the placeholder fresh deal.
- **Home / mode picker auto-shows on launch** (`dd63261`). The mode
picker was only reachable via **M** during gameplay; players who
hadn't discovered the hotkey never saw the Daily / Zen / Challenge
/ Time Attack entry points after the splash cleared. `HomePlugin`
gains an `auto_show_on_launch` flag (default true) and a
one-shot `LaunchHomeShown` gate. Skips when the Restore prompt is
on screen so Welcome-back still takes precedence.
- **MSSC-style Home picker — header / chips / score chips / draw
mode** (`ae40a1d`). Player-stats header strip (Level / XP /
Lifetime Score, compact-formatted as `1.2M` / `12.3K` / `1,234`)
acts as a clickable shortcut to Profile. Draw-mode chip row above
the mode cards lets the player flip Draw 1 / Draw 3 from the
picker itself; persists `settings.json` and respawns the modal so
the active state repaints cleanly. Per-mode best-score / streak
chips on each card; hidden on a 0 best so a fresh profile doesn't
read "Best 0" everywhere.
- **Today's Event callout on the Daily card** (`b73d246`). "Today,
May 6" date line plus the server-fetched goal (when SyncPlugin is
wired). Once today's daily is recorded as completed, the date
flips to `Today, May 6 • Done` in `ACCENT_PRIMARY` so the picker
reads as a reward state rather than a TODO.
- **Picture-tile mode cards** (`9fe650f` + glyph-picking follow-ups
`40d6e0a`, `c30b04e`, `d065d49`). Mode cards become a wrapping
2-up grid (`FlexWrap::Wrap`, tiles 48 % wide, `min_height: 180px`)
with a centred Unicode-glyph centrepiece per tile. Final glyph set
picked from FiraMono-Medium's actual coverage: ♣ Classic, ◆ Daily,
○ Zen, ▲ Challenge, → TimeAttack. `ACCENT_PRIMARY` when the mode is
unlocked, `TEXT_DISABLED` when locked. Centrepiece is a `Text` node
for now — when real per-mode artwork lands, swap to `Image` without
touching tile layout, focus order, or chip rendering.
- **Solver-vetted seed selection on `AsyncComputeTaskPool`**
(`d489e7a`). Closes the worst-case 6 s UI stall on a New Game
click with "Winnable deals only" enabled. New `PendingNewGameSeed`
resource holds the in-flight `Task<u64>` plus the original
request's `mode` / `confirmed` flags. `poll_pending_new_game_seed`
runs `.before(GameMutation)` and replays a synthetic
`NewGameRequestEvent` once the task resolves — the player sees no
extra-frame visual lag. Cancel-on-replace: a fresh
`NewGameRequestEvent` while a task is in flight drops the old
task, letting Bevy's `Task` Drop cancel cooperatively at the next
await point.
- **"Won before" HUD indicator** (`bdac754`). When the current
deal's `(seed, draw_mode, mode)` triple matches an entry in the
rolling `ReplayHistory`, the HUD's tier-2 context row shows
**✓ Won before** in `STATE_SUCCESS`. Cleared on win (the on-screen
victory cue is enough) and on first-time deals. New
`HudWonPreviously` marker driven by a separate
`update_won_previously` system; gracefully no-ops in headless
tests that don't load `StatsPlugin`.
- **"Copy share link" Stats button** (`540869c`). End-to-end replay
sharing on a server-backed sync backend:
`sync_plugin::push_replay_on_win` spawns the upload on
`AsyncComputeTaskPool` and stores the handle in
`PendingReplayUpload` (drops any in-flight predecessor — the most
recent win is what the player wants the link for);
`poll_replay_upload_result` writes `<server>/replays/<id>` to
`LastSharedReplayUrl` on success; the Stats overlay's action bar
gains a button that writes the URL to the OS clipboard via
`arboard` and surfaces a "Copied: \<url\>" toast. URL is in-memory
only — sharing must happen within the session of the win.
- **Empty-state copy + onboarding hints** (`56e2e6f`). Leaderboard
empty state: two-tier "Be the first on the leaderboard." headline
+ body invite. Achievements panel: first-launch hint above the
grid until the first unlock. Volume hotkeys (`[` / `]`) now emit
an `InfoToastEvent` with the new percentage so off-panel
adjustments give visible feedback (previously silent).
- **Enter dismisses the Win Summary and starts a fresh deal**
(`17e0737`). The post-win modal's "Play Again" was click-only;
keyboard-only players had to reach for the mouse to leave the
celebration screen. The button label gains a trailing return-key
glyph so the keyboard path is discoverable on first sight.
- **`N` opens the real Confirm/Cancel modal** (`93660c2`). The old
"Press N again" double-tap pattern was a UI-first violation (only
continuation was another keystroke). `N` now fires
`NewGameRequestEvent::default()` directly; `handle_new_game`'s
active-game check spawns the existing `ConfirmNewGameScreen`. The
HUD button already routed through the same modal — keyboard and
mouse paths are unified. `Shift+N` keeps the keyboard power-user
bypass (`confirmed: true`).
### Changed
- **Settings row layout** (`a4bc063`). All five
slider/toggle row helpers (volume × 2, tooltip delay, time-bonus
multiplier, replay-move interval, generic toggle) restructured to
a label-spacer-cluster layout (`width: 100%`, label gets
`flex-grow: 1`, controls cluster sits flush right). Stable across
varying value-text widths ("0.80" → "1.00", "Instant" vs "1.5 s")
and narrow windows.
- **Docs adopt the unified-3.0 rule set** (`f2f30c8`). `CLAUDE.md`
grows from a 114-line pointer doc to a 571-line rulebook (hard
global constraints §2, engine rules §3, asset rules §4, code
standards §5, build + verification §6, git workflow §7, the ASK
BEFORE list §8, Context Injection System §14). New companions:
`CLAUDE_SPEC.md` (formal architecture spec — crate dependency
graph, data ownership, state-machine invariants, sync merge /
server contracts, validation checklist),
`CLAUDE_WORKFLOW.md` (two-agent Builder/Guardian pipeline with
hard-fail patterns), `CLAUDE_PROMPT_PACK.md` (task-type
templates). Three duplicate rule passages removed across
`CLAUDE_SPEC.md` and `ARCHITECTURE.md`.
- **Test discipline pruning** (`a49a340`). Removed 43 low-value
tests across `solitaire_data` and `solitaire_core` (default-value
tests, serde-derive round-trips on plain structs, single-field
clamp tests, near-duplicates, constant-equals-itself tests). None
pinned a behaviour contract or a regression on a real bug. Future
agent briefs request tests for behaviour contracts or real-bug
regressions, not a count of N.
### Fixed
- **Esc on a modal no longer opens Pause underneath** (`08b006f`).
A single Esc press on Confirm New Game / Restore / Home /
Onboarding / Settings used to both close the modal and spawn the
Pause overlay on top in the same frame. `toggle_pause` now skips
when any non-Pause `ModalScrim` is in the world; the HUD-button
path is gated too. The four modal queries are bundled into a
`PauseModalQueries` `SystemParam` to stay under Bevy's
16-parameter cap.
- **Esc dismisses Home / accepts the Restore-prompt default**
(`d48b948`). Both screens previously ignored Esc, leaving the
player no keyboard-only escape after the previous fix. Home: Esc
behaves like Cancel (despawns the modal, keeps the underlying
default deal). Restore: Esc maps to Continue (preserves the saved
game, matching how the primary action already advertises Enter).
- **Esc dismisses the topmost modal when Profile stacks on Home**
(`9aa0dd2`). Clicking the Home header chip opens Profile on top
of Home; Esc used to close Home (because
`handle_home_cancel_button` fired with no awareness of layered
modals) and leave Profile orphaned over the game.
`profile_plugin` now splits P/button (toggle) from Esc
(close-only); `handle_home_cancel_button` skips its Esc branch
when any other `ModalScrim` exists.
- **Restore-prompt resolution suppresses Home auto-show**
(`b7c3a49`). Resolving the Welcome-back prompt cleared
`PendingRestoredGame` and despawned the modal, but the
launch-time Home auto-show then fired the next frame and stacked
itself over the player's chosen path. `LaunchHomeShown` becomes
`pub` so `handle_restore_prompt` flips it to `true` after either
resolution; **M** still re-opens the picker on demand.
- **Game timers freeze while the Home picker is up** (`c497c31`).
The HUD's elapsed-time counter ticked from the moment the default
Classic deal landed at startup, even though the auto-show Home
picker was still up — the player saw "0:11" before they had
chosen a mode. `tick_elapsed_time` and `advance_time_attack` now
also gate on the absence of `HomeScreen`, mirroring their
existing `PausedResource` check.
- **Popover rows stay visible regardless of action-bar fade**
(`cc63532`). Opening Modes / Menu showed a solid dark-purple
block in the top-right with no readable content — the action-bar
auto-fade was matching the popover rows by their shared
`ActionButton` marker and dropping their alpha to the
cursor-position-based fade value (typically 0). New `PopoverRow`
marker on rows in `spawn_modes_popover` / `spawn_menu_popover`;
`apply_action_fade` excludes them via `Without<PopoverRow>`.
### Stats
- 1166 passing tests (was 1208 at v0.17.0 close — 43 net removals
from the test-discipline prune plus 1 net-new test from the
async-seed work, no behaviour regressions).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.17.0] — 2026-05-06
A short follow-up round on top of v0.16.0: the H-key hint is no
longer a heuristic guess but the actual best first move suggested by
the v0.15.0 solver, and the in-engine replay player now has a
player-tunable playback rate.
### Added
- **Replay-rate slider** in Settings → Gameplay. Tunes
`replay_move_interval_secs` from 0.10 s to 1.00 s in 0.05 s steps;
default 0.45 s. `tick_replay_playback` reads the value from
`SettingsResource` per frame so the slider takes effect on the
next playback tick — no restart required.
### Changed
- **Solver-driven hints.** Pressing **H** used to surface a
heuristic-best move (foundation moves preferred, then
tableau-to-tableau by depth-of-flip-revealed). It now asks the
v0.15.0 solver for the actual provably-best first move via the
new `solitaire_core::solver::try_solve_with_first_move` /
`try_solve_from_state` APIs. When the solver returns inconclusive
(rare deals where the bound runs out before a result), the old
heuristic remains the fallback. Median 2 ms per H press.
### Stats
- 1208 passing tests (was 1196 at v0.16.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.16.0] — 2026-05-06
A modal-feel polish round. Every overlay screen now scrolls when its
content overflows the 800×600 minimum window, every clickable button
shows a hand cursor on hover, keyboard focus lands on the primary
button on the same frame the modal opens, and read-only modals
dismiss when the player clicks the scrim outside the card.
### Added
- **Pointer cursor on hover** for every interactive `Button` entity
(modal buttons, HUD action bar, mode-launcher cards, settings
toggles, Stats selectors). `update_cursor_icon` gains a fourth
branch sitting between Grabbing (active drag) and Grab
(draggable card hover): when no drag is active and any
`Interaction::Hovered`/`Pressed` button is detected, the window
cursor swaps to `SystemCursorIcon::Pointer`. A pure
`pick_cursor_icon` helper makes the priority logic
unit-testable.
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
Achievements, Help, Profile, Leaderboard, Home. New
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
topmost dismissible scrim on a left-mouse press whose cursor
lands on the scrim and outside every `ModalCard`. Bevy's
hierarchy despawn cascades to the card and children.
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
Game intentionally don't opt in — they carry unsaved or
destructive state.
### Fixed
- **Modal content scrolls when it overflows** (Achievements, Help,
Stats, Profile, Leaderboard). Each modal's body Node now
carries `Overflow::scroll_y()` plus a `max_height` constraint
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
leaderboard's variable-length ranking section) and a marker
component (`AchievementsScrollable`, `HelpScrollable`,
`StatsScrollable`, `ProfileScrollable`,
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
per modal routes `MouseWheel` events into the body's
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
pattern. Home modal intentionally not scrolled — its five
mode cards + Cancel are sized to fit at 800×600 by design.
- **Modal focus arrives on the same frame the modal opens.**
Previously `attach_focusable_to_modal_buttons` and
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
click-handlers that spawn modals; with no ordering edge,
Bevy's deferred `Commands` queued the new entities but the
attach system couldn't see them on the same tick. Both systems
moved to `PostUpdate` so the schedule boundary itself supplies
the sync point — `FocusedButton` is always populated before
`app.update()` returns. The very next Tab/Enter press lands on
a populated resource instead of wasting itself moving focus
from None to the primary.
### Stats
- 1196 passing tests (was 1178 at v0.15.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.15.0] — 2026-05-02
In-engine replay playback, the Klondike solver + "Winnable deals
only" toggle, a 19th achievement, rolling replay history, and a
significant build-time / binary-size win from disabling Bevy's
default audio stack.
### Added
- **In-engine replay playback** for the Stats overlay's Watch Replay
button. New `ReplayPlaybackPlugin` runs a state machine
(Inactive / Playing / Completed) that resets the live game to the
recorded deal and ticks through `replay.moves` at
`REPLAY_MOVE_INTERVAL_SECS` (0.45 s) firing the canonical
`MoveRequestEvent` / `DrawRequestEvent` per recorded move.
Recording is suppressed during playback so replays don't re-record
themselves.
- **Replay overlay banner** (`ReplayOverlayPlugin`) anchored to the
top of the window during playback. Shows "Replay" label, "Move N
of M" progress, and a Stop button. Z-order leaves modals
(Settings, Pause, Help) free to render on top so the player can
adjust audio mid-replay.
- **Rolling replay history** at `<data_dir>/replays.json` capped at
8 entries. Replaces the single-slot `latest_replay.json` (legacy
file is migrated forward on first launch via
`migrate_legacy_latest_replay`). Stats overlay gains a Prev / Next
selector and a "Replay N / M" caption so the player can revisit
older wins.
- **"Cinephile" achievement** (#19). Unlocks the first time
`ReplayPlaybackState` transitions Playing → Completed (i.e. the
replay played out to its end without the player pressing Stop).
Stop transitions Playing → Inactive directly so it doesn't count.
- **Klondike solver** in `solitaire_core::solver`. Iterative-DFS
with memoisation on a 64-bit canonical state hash, two budget
knobs (move_budget + state_budget) for pathological cases, and a
three-state `SolverResult` (Winnable / Unwinnable / Inconclusive).
Median solve time 2 ms; pathological inconclusives cap near
120 ms. Pure logic — `solitaire_core` keeps no Bevy or I/O.
- **"Winnable deals only" toggle** in Settings → Gameplay (default
off). When on, `handle_new_game` walks seed N, N+1, N+2, …
through `try_solve` until it finds Winnable or Inconclusive,
capped at `SOLVER_DEAL_RETRY_CAP` (50) attempts. Daily
challenges, replays, and explicit-seed requests bypass the
solver — only random Classic deals are gated.
### Changed
- **Bevy default-feature trim** (`bevy = { default-features = false,
features = [...] }` in workspace Cargo.toml) drops 51 transitive
crates including the `bevy_audio` → rodio → cpal 0.15 + symphonia
chain that the project doesn't use (kira handles audio directly).
The retained feature list is curated to exactly what the engine
uses; `solitaire_wasm` is unaffected because it doesn't depend on
bevy.
### Stats
- 1178 passing tests (was 1134 at v0.14.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.14.0] — 2026-05-02
@@ -405,7 +1107,9 @@ with no PNG artwork yet.
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
client-side sync round-trip integration tests.
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...HEAD
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
[0.12.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.11.0...v0.12.0
+531 -74
View File
@@ -1,114 +1,571 @@
# Solitaire Quest — Claude Code Instructions
# CLAUDE.md
See @ARCHITECTURE.md for full project design, crate responsibilities, data models, and API reference.
version: unified-3.0
---
## Project Layout
# 0. Role of This File
```text
solitaire_core/ # Pure Rust game logic — NO Bevy, NO network, NO I/O
solitaire_sync/ # Shared API types — NO Bevy, serde/uuid/chrono only
solitaire_data/ # Persistence + SyncProvider trait + server client
solitaire_engine/ # Bevy ECS systems, components, plugins
solitaire_server/ # Axum sync server binary
solitaire_app/ # Thin binary entry point
assets/ # Source assets — embedded at compile time via include_bytes!()
This document defines:
* **Execution rules (what Claude must do)**
* **System constraints (what Claude must never violate)**
* **Operational architecture (how code is structured)**
For full system design details:
`ARCHITECTURE.md` (authoritative source of truth)
This file overrides all conversational assumptions.
---
# 1. System Architecture (Authoritative Mapping)
## 1.1 Crates
```text id="crate_map"
solitaire_core/ # PURE logic (no IO, no Bevy, deterministic)
solitaire_sync/ # Shared API + merge logic
solitaire_data/ # Persistence + sync client
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
solitaire_server/ # Axum backend (optional sync layer)
solitaire_app/ # Entry binary
assets/ # Runtime assets (except audio)
```
---
## Build & Test Commands
## 1.2 Architecture Source of Truth
```bash
# Dev run (fast compile via dynamic linking)
cargo run -p solitaire_app --features bevy/dynamic_linking
* Full system design: `ARCHITECTURE.md`
* This file NEVER redefines system design
* This file ONLY enforces behavior
# Release build
cargo build --workspace --release
---
# All tests — MUST pass before any commit
# 2. Hard Global Constraints (NON-NEGOTIABLE)
These override all other instructions.
## 2.1 Core Determinism
* `solitaire_core` MUST:
* be deterministic
* be side-effect free
* never depend on Bevy / IO / async
---
## 2.2 Sync Isolation
* `solitaire_sync`:
* no Bevy
* no IO
* no engine dependencies
* merge logic must be pure functions only
---
## 2.3 Error Policy
* NO `unwrap()`
* NO `panic!()` in runtime/game logic
* All state transitions:
```rust id="err_model"
Result<T, MoveError>
```
---
## 2.4 Threading Rules
* Sync must run on `AsyncComputeTaskPool`
* NEVER block Bevy main thread
---
## 2.5 Persistence Rules
* atomic writes only:
* write `.tmp`
* rename atomically
* no partial state writes allowed
---
## 2.6 Security Rules
* credentials ONLY via `keyring`
* NEVER store secrets in:
* files
* logs
* source code
---
## 2.7 Sync System Rules
* All sync backends implement:
```rust id="sync_trait"
trait SyncProvider
```
* `SyncPlugin` MUST be backend-agnostic
* NEVER match on backend inside ECS systems
---
# 3. Engine Rules (Bevy Layer)
## 3.1 ECS Design
* systems = single responsibility
* communication = Events only
* shared state = Resources only
* per-entity state = Components only
---
## 3.2 Game State Authority
* ONLY `GameStateResource` can mutate game state
* UI systems MUST NOT directly modify core logic
---
## 3.3 UI-First Constraint (CRITICAL)
Every player action MUST:
* have a visible UI control
* NOT rely solely on keyboard shortcuts
Keyboard shortcuts are:
→ optional accelerators only
---
## 3.4 Layout System
* recompute on `WindowResized`
* no fixed resolution assumptions
---
# 4. Asset System Rules
## 4.1 Runtime Assets (AssetServer)
Loaded via:
* `CardImageSet`
* `BackgroundImageSet`
* `FontResource`
Includes:
* cards
* backgrounds
* fonts
---
## 4.2 Embedded Assets
Only audio:
```text id="audio_rule"
include_bytes!()
```
---
## 4.3 Test Compatibility Rule
All asset loaders MUST accept:
```rust id="asset_fallback"
Option<Res<AssetServer>>
```
Must degrade gracefully under `MinimalPlugins`.
---
# 5. Code Standards
## 5.1 Error Handling
* use `thiserror`
* no `Box<dyn Error>` in libraries
---
## 5.2 Public API Rules
* prefer `Into<T>` over concrete types
* all public items require doc comments
---
## 5.3 Derive Order
```rust id="derive_order"
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
```
---
## 5.4 Performance Rules
* NO `clone()` in hot paths
* profile before optimizing
---
## 5.5 SQL Rules
* ONLY `sqlx::query!`
* NO raw SQL strings
---
# 6. Build & Verification Rules
These are mandatory before ANY commit.
```bash id="build_rules"
cargo test --workspace
# Lint — MUST pass clean (zero warnings)
cargo clippy --workspace -- -D warnings
# Run sync server locally
cargo run -p solitaire_server
# Check a single crate
cargo test -p solitaire_core
cargo clippy -p solitaire_core -- -D warnings
```
---
## Hard Rules
# 7. Git Workflow Rules
- `solitaire_core` and `solitaire_sync` must never gain Bevy or network dependencies.
- No `unwrap()` or `panic!()` in game logic. All state transitions return `Result<_, MoveError>`.
- Audio assets are embedded at compile time using `include_bytes!()` in `audio_plugin.rs`.
- Card faces (52 PNGs in `assets/cards/faces/`), card backs (`assets/cards/backs/back_N.png`), board backgrounds (`assets/backgrounds/bg_N.png`), and the UI font (`assets/fonts/main.ttf`) are loaded at runtime via `AssetServer::load()` and stored as `Handle<Image>`/`Handle<Font>` in the `CardImageSet`, `BackgroundImageSet`, and `FontResource` resources. The `assets/` directory must ship alongside the binary.
- Asset-loading systems take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` (tests). When `CardImageSet` is absent, `card_plugin` falls back to a `Text2d` rank+suit overlay; when `BackgroundImageSet` is absent, the board falls back to a solid colour.
- Atomic file writes only: write to `filename.json.tmp`, then `rename()`.
- Passwords and tokens are stored in the OS keychain via the `keyring` crate — never in plaintext files or logs.
- Sync runs on `AsyncComputeTaskPool` — never block the Bevy main thread.
- All sync backends implement the `SyncProvider` trait. The `SyncPlugin` is backend-agnostic — never `match` on `SyncBackend` inside a Bevy system.
- `cargo clippy --workspace -- -D warnings` must pass clean after every change.
- `cargo test --workspace` must pass after every change.
## Commit format
```text id="commit_fmt"
type(scope): description
```
Examples:
* feat(core): add draw-three rules
* fix(engine): correct drag z-order
* test(core): undo boundary cases
---
## Code Style
## Commit conditions
- Use `thiserror` for error types. Never `Box<dyn Error>` in library crates.
- Prefer `Into<T>` over concrete types in public API function parameters.
- All public items must have doc comments (`///`). Private items: comment only when non-obvious.
- Derive order convention: `#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]`
- Bevy systems: one responsibility per system. Use `Events` for cross-system communication, never shared mutable state.
- SQL queries: use `sqlx::query!` macros (compile-time checked), not raw string queries.
- No `clone()` calls in hot paths (game loop systems). Profile before optimising elsewhere.
* tests must pass
* clippy must be clean
NEVER commit otherwise
---
## Bevy Conventions
# 8. Change Control (ASK BEFORE DOING)
- One `Plugin` per major feature: `CardPlugin`, `AudioPlugin`, `AchievementPlugin`, `UIPlugin`, `SyncPlugin`.
- Resources own shared state. Events communicate between systems. Components own per-entity data.
- All UI screens are built with Bevy UI (`bevy::ui`). Never mix UI layout and game logic in the same system.
- Layout is recomputed on `WindowResized` — never assume a fixed window size.
- **UI-first.** Every player-triggered action (new game, undo, draw, pause, open stats / settings / help / profile / leaderboard, switch mode, etc.) must be reachable from a visible UI control. Keyboard shortcuts are optional accelerators — never the sole entry point. New gameplay features ship with the UI control alongside the system that backs it; do not merge a feature that is keyboard-only.
Claude must request confirmation before:
* adding dependencies
* modifying `solitaire_sync`
* changing DB schema
* introducing `unsafe`
* changing merge strategy
---
## Git Workflow
# 9. System Mental Model (IMPORTANT)
- Commit after each passing phase, not after every file change.
- Commit message format: `type(scope): description`
- `feat(core): add draw-three mode validation`
- `fix(engine): card z-order during drag`
- `test(core): undo stack boundary conditions`
- `chore(server): add sqlx migration 002`
- Never commit with failing tests or clippy warnings.
- Never commit secrets, `.env` files, or `*.db` files.
```text id="mental_model"
Core (rules + deterministic logic)
Engine (Bevy orchestration)
Data layer (persistence + sync)
Server (optional external system)
```
Core is always the source of truth.
---
## Ask Before Doing
# 10. Known Platform Pitfalls
- Adding a new crate dependency (discuss alternatives first).
- Changing a type in `solitaire_sync` (breaking change on both client and server).
- Altering the database schema (requires a new sqlx migration).
- Introducing `unsafe` code anywhere.
- Changing the merge strategy in `solitaire_sync::merge()`.
Must always be handled explicitly:
* Bevy `Time` uses `f32`
* `sqlx::migrate!()` path is crate-relative
* `dirs::data_dir()` may return `None`
* Linux may lack keyring backend
---
## Lessons Learned
# 11. Forbidden Patterns
> Add entries here when Claude makes a mistake so it isn't repeated.
* game logic inside Bevy systems
* duplication across crates
* blocking async calls in ECS
* insecure credential storage
* bypassing core logic layer
- Bevy's `Time` resource uses `f32` seconds; convert to `u64` only when writing to `StatsSnapshot`.
- `sqlx::migrate!()` macro path is relative to the crate root, not the workspace root.
- `keyring` on Linux requires a running secret service (e.g. GNOME Keyring or KWallet) — handle `Error::NoStorageAccess` gracefully and fall back to prompting the user.
- `dirs::data_dir()` returns `None` on some minimal Linux environments — always handle the `None` case explicitly, do not unwrap.
---
# 12. Execution Rules for Claude
When generating code:
1. respect crate boundaries
2. minimize diff size
3. do not expand scope
4. follow existing patterns
5. preserve invariants
If unclear:
→ ask before acting
---
# 13. Relationship to ARCHITECTURE.md
| File | Role |
| --------------- | ------------------------- |
| CLAUDE.md | execution + constraints |
| ARCHITECTURE.md | system design truth |
| Both combined | full system understanding |
---
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
## 14.1 Purpose
Before generating any response, Claude MUST construct a **minimal relevant context set**.
This prevents:
* architectural drift
* irrelevant spec loading
* over-engineering
* cross-crate confusion
---
## 14.2 Input Classification Step (MANDATORY)
Every request MUST be classified into exactly one task type:
```text id="task_types"
feature
bugfix
refactor
system_design
bevy_system
core_logic
sync
optimization
test
debug
```
If uncertain → ask clarification.
---
## 14.3 Context Selection Engine
After classification, Claude MUST include ONLY the relevant sections below.
---
## 14.4 Context Map (CORE RULESET)
### feature
Include:
* §2 Hard Global Constraints
* §3 Engine Rules
* ARCHITECTURE.md (crate of target feature only)
* relevant data models (GameState, SyncPayload if needed)
---
### bugfix
Include:
* §2 Hard Global Constraints
* §5 Code Standards
* affected crate boundaries
* relevant system (engine/core/sync only)
---
### refactor
Include:
* §3 Engine Rules
* §5 Code Standards
* §11 Forbidden Patterns
* target crate boundaries
---
### system_design
Include:
* ARCHITECTURE.md (FULL)
* §9 Mental Model
* §1 System Architecture Mapping
---
### core_logic
Include:
* solitaire_core rules only
* GameState model
* MoveError model
* §2.12.3 constraints
---
### bevy_system
Include:
* §3 Engine Rules
* ECS rules (Events/Resources/Components)
* UI-first constraint
* relevant plugin system only
---
### sync
Include:
* SyncProvider trait
* merge strategy rules
* solitaire_sync models
* §2.6 Sync Rules
---
### optimization
Include:
* target crate only
* §5.4 Performance Rules
* hot path constraints
---
### test
Include:
* §6 Build Rules
* relevant module
* expected invariants
---
### debug
Include:
* target file/module only
* §2.3 Error Policy
* runtime assumptions relevant to failure
---
## 14.5 Context Compression Rules
Claude MUST obey:
* never include full ARCHITECTURE.md unless system_design
* max 2 crates per response unless explicitly required
* prefer function-level context over file-level context
* exclude unrelated plugins/systems
---
## 14.6 Context Priority Order
When space is limited:
1. Hard Constraints (§2)
2. Target crate rules
3. Data models
4. Only then: architecture snippets
---
## 14.7 “No Context Pollution” Rule
Claude must NOT include:
* unrelated crates
* unrelated plugins
* unused data models
* full architecture dumps
* speculative systems
---
## 14.8 Self-Check Before Execution
Before writing code, Claude MUST verify:
* [ ] Is only relevant context included?
* [ ] Is at least one hard constraint present?
* [ ] Am I touching more than one crate unnecessarily?
* [ ] Am I duplicating ARCHITECTURE.md content?
If any fail → revise context selection.
---
## 14.9 Injection Output Format (Internal Model)
Claude should behave as if it constructed:
```text id="ctx_format"
[SELECTED TASK TYPE]
[MINIMAL REQUIRED RULES]
[MINIMAL ARCHITECTURE SLICES]
[RELEVANT MODELS]
[REQUEST]
```
---
## 14.10 Relationship to ARCHITECTURE.md
* ARCHITECTURE.md = source of truth
* CLAUDE.md = execution constraints
* THIS SECTION = filtering layer between them
---
# END CONTEXT INJECTION SYSTEM
+497
View File
@@ -0,0 +1,497 @@
# CLAUDE_PROMPT_PACK.md
version: 1.0
---
# 0. GLOBAL INSTRUCTION (prepend to every prompt)
```
You must follow CLAUDE_SPEC.md strictly.
Rules:
- Do not expand scope beyond what is defined
- Do not refactor unrelated code
- Do not introduce new dependencies
- Prefer minimal, surgical changes
- Use existing patterns in the codebase
- Return minimal diffs or changed functions only
Before writing code:
1. List relevant constraints from CLAUDE_SPEC.md
2. Identify risks
3. Then implement
```
---
# 1. FEATURE IMPLEMENTATION
```
# TASK: Feature Implementation
feature: "<name>"
goal:
"<clear outcome>"
scope:
crates: []
systems: []
files: []
non_goals:
- ""
constraints:
- must follow CLAUDE_SPEC.md
- event-driven architecture required
- no blocking operations
- no cross-crate leakage
acceptance_criteria:
- ""
- ""
edge_cases:
- ""
---
## Required Patterns
Use this pattern for systems:
<PASTE EXISTING SYSTEM SNIPPET HERE>
---
## Output Format
intent:
plan:
constraints_used:
risks:
code_changes:
(minimal diffs only)
notes:
```
---
# 2. BUGFIX
```
# TASK: Bug Fix
bug_description:
"<what is broken>"
expected_behavior:
"<correct behavior>"
root_cause_hint (optional):
""
scope:
crates: []
files: []
constraints:
- minimal fix only
- no refactors unless required
- must add regression protection if applicable
---
## Requirements
1. Identify root cause
2. Fix it minimally
3. Preserve all invariants
4. Do not change unrelated logic
---
## Output Format
analysis:
root_cause:
fix_strategy:
code_changes:
(minimal diff)
regression_test (only if high-value):
notes:
```
---
# 3. REFACTOR
```
# TASK: Refactor
target:
"<what is being improved>"
goal:
"<what improves>"
scope:
crates: []
files: []
non_goals:
- no behavior changes
- no new features
constraints:
- must preserve behavior exactly
- must respect crate boundaries
- must not duplicate logic
---
## Refactor Type
- [ ] simplify logic
- [ ] reduce duplication
- [ ] improve readability
- [ ] performance (non-invasive)
---
## Output Format
analysis:
issues_found:
refactor_plan:
code_changes:
(diff only)
verification:
- behavior unchanged: yes/no
- invariants preserved: yes/no
notes:
```
---
# 4. SYSTEM DESIGN (NEW FEATURE)
```
# TASK: System Design
feature:
"<name>"
goal:
"<what problem it solves>"
constraints:
- must fit existing architecture
- must follow plugin + event model
- must not violate crate boundaries
---
## Required Output
design:
components:
- plugins:
- systems:
- events:
- resources:
data_flow:
(step-by-step)
integration_points:
- where it connects to existing systems
risks:
- ""
tradeoffs:
- ""
---
## DO NOT
- write full implementation
- modify unrelated systems
```
---
# 5. NEW BEVY SYSTEM
```
# TASK: Add Bevy System
system_name:
""
trigger:
(event or condition)
reads:
[Resources]
writes:
[Resources]
emits:
[Events]
constraints:
- must be event-driven
- must not directly mutate unrelated state
- must be single responsibility
---
## Output Format
system_signature:
implementation:
(code only)
notes:
```
---
# 6. CORE LOGIC FUNCTION (solitaire_core)
```
# TASK: Core Logic Implementation
function:
"<name>"
goal:
"<what it does>"
rules:
- no IO
- no async
- no Bevy
- deterministic
invariants:
- ""
- ""
errors:
- ""
---
## Output Format
constraints_checked:
implementation:
(code only)
edge_case_handling:
notes:
```
---
# 7. SYNC / MERGE LOGIC
```
# TASK: Sync Logic
goal:
"<what is being merged or synced>"
constraints:
- must be deterministic
- must be idempotent
- must be lossless
- must not delete data
rules:
- counters → max
- times → min
- collections → union
---
## Output Format
analysis:
merge_logic:
code_changes:
invariants_verified:
- deterministic
- idempotent
- lossless
notes:
```
---
# 8. PERFORMANCE OPTIMIZATION
```
# TASK: Optimization
target:
"<what is slow>"
constraints:CLAUDE_WORKFLOW.md
- no behavior change
- no architecture change
- minimal code changes
---
## Output Format
analysis:
bottleneck:
optimization_strategy:
code_changes:
impact_estimate:
notes:
```
---
# 9. TEST GENERATION (STRICT MODE)
```
# TASK: Test Generation
target:
"<function/system>"
reason:
- bugfix | complex logic | invariant protection
constraints:
- no redundant tests
- must test real behavior
- must fail if logic breaks
---
## Output Format
test_cases:
- ""
test_code:
notes:
```
---
# 10. DEBUGGING / INVESTIGATION
```
# TASK: Debug
problem:
"<symptom>"
context:
"<relevant code or system>"
---
## Required Steps
1. List possible causes
2. Narrow down most likely
3. Suggest verification steps
4. Provide minimal fix
---
## Output Format
hypotheses:
most_likely:
verification_steps:
fix:
notes:
```
---
# 11. HARD CONSTRAINT OVERRIDE (RARE)
```
# TASK: Exception Handling
reason:
"<why constraints must be bent>"
requested_exception:
"<rule being broken>"
justification:
"<why unavoidable>"
---
## Output Format
analysis:
alternatives_considered:
final_decision:
risk:
```
---
# 12. STOP CONDITIONS (always append)
```
Stop when:
- acceptance criteria are met
- code is minimal and correct
Do NOT:
- expand scope
- refactor unrelated code
- optimize prematurely
```
---
# END
+292
View File
@@ -0,0 +1,292 @@
# CLAUDE_SPEC.md
version: 1.0
---
## 0. Global Rules
(Core determinism, panic policy, and event-driven engine constraints live in CLAUDE.md §2.1, §2.3, §3.1. Listed here only when they add information CLAUDE.md doesn't carry.)
rules:
* id: single_source_of_truth
description: "GameStateResource is the only mutable game state in runtime"
* id: sync_is_additive
description: "Remote data must never destructively overwrite local data"
---
## 1. Crate Graph
crates:
solitaire_core:
depends_on: [rand, serde, chrono]
forbidden_deps: [bevy, reqwest, tokio, std::fs]
solitaire_sync:
depends_on: [serde, serde_json, uuid, chrono]
role: "shared_types"
solitaire_data:
depends_on: [solitaire_core, solitaire_sync, reqwest, tokio, keyring]
role: "persistence_and_sync"
solitaire_engine:
depends_on: [bevy, kira, solitaire_core, solitaire_data]
role: "runtime_engine"
solitaire_server:
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
role: "backend"
solitaire_app:
depends_on: [solitaire_engine]
role: "entrypoint"
---
## 2. Data Ownership
ownership:
GameState:
owner: solitaire_core
mutable_in: solitaire_engine
access_pattern: "via GameStateResource only"
StatsSnapshot:
owner: solitaire_data
PlayerProgress:
owner: solitaire_data
AchievementRecord:
owner: solitaire_data
SyncPayload:
owner: solitaire_sync
---
## 3. State Transitions
state_machine:
GameState:
transitions:
- action: move_cards
returns: Result<GameState, MoveError>
```
- action: draw
returns: Result<GameState, MoveError>
- action: undo
returns: Result<GameState, MoveError>
invariants:
- "52 cards always exist"
- "no duplicate card IDs"
- "all cards belong to exactly one pile"
```
---
## 4. Event System
events:
input:
- MoveRequestEvent
- DrawRequestEvent
- UndoRequestEvent
- NewGameRequestEvent
state:
- StateChangedEvent
- GameWonEvent
meta:
- AchievementUnlockedEvent
- SyncCompleteEvent
rules:
* "Input events trigger core logic"
* "Core logic emits state events"
* "UI reacts to state events only"
---
## 5. Sync Contract
sync:
provider_trait:
methods:
- pull() -> SyncPayload
- push(payload) -> SyncResponse
guarantees:
- "non-blocking during gameplay"
- "blocking allowed on exit only"
merge:
rules:
counters: "max"
best_times: "min"
collections: "union"
achievements: "never removed"
```
properties:
- deterministic
- idempotent
- lossless
```
---
## 6. Persistence
storage:
format: json
files:
- stats.json
- progress.json
- achievements.json
- settings.json
- game_state.json
guarantees:
- atomic_write: true
- crash_safe: true
---
## 7. Engine Rules
engine:
mutation_rules:
- "Only GameLogicSystem mutates GameState"
- "UI systems are read-only"
threading:
- "sync runs on AsyncComputeTaskPool"
- "main thread must never block"
plugins:
pattern: "feature_isolation"
communication: "events"
---
## 8. Server Contract
server:
auth:
method: jwt
access_expiry: 24h
refresh_expiry: 30d
endpoints:
- POST /api/auth/register
- POST /api/auth/login
- GET /api/sync/pull
- POST /api/sync/push
limits:
payload_max: 1MB
rate_limit: "10 req/min auth routes"
---
## 9. Achievement System
achievements:
definition_location: solitaire_core
state_location: solitaire_data
types:
- condition_based
- event_driven
rule:
- "achievements cannot be revoked"
---
## 10. Testing Rules
testing:
philosophy:
- "test real failures"
- "avoid redundant tests"
required_coverage:
solitaire_core:
- move_validation
- undo_integrity
- win_detection
```
solitaire_sync:
- merge_correctness
- idempotency
```
---
## 11. Prohibited Patterns
(See CLAUDE.md §11 for the canonical forbidden-patterns list.)
---
## 12. Extension Points
extensibility:
sync_backends:
pattern: "implement SyncProvider"
game_modes:
location: solitaire_core::GameMode
plugins:
rule: "new feature = new plugin"
---
## 13. Validation Checklist (for Claude)
validation:
* check: "crate dependency rules respected"
* check: "no panics in core"
* check: "events used for cross-system communication"
* check: "GameState mutations centralized"
* check: "merge function properties preserved"
* check: "no blocking operations in main loop"
---
## 14. Mental Model
model:
layers:
- core
- engine
- data
- server
flow:
- input -> engine -> core -> engine -> ui
- data <-> sync <-> server
+335
View File
@@ -0,0 +1,335 @@
# CLAUDE_WORKFLOW.md
version: 1.0
---
## 0. Overview
This workflow defines a **two-agent system**:
* **Builder Agent** → writes and modifies code
* **Guardian Agent** → enforces architecture + rejects invalid changes
No code is considered valid unless it passes Guardian validation.
---
## 1. Agent Roles
### 1.1 Builder Agent
role: "code_generation"
responsibilities:
* implement features
* refactor code
* generate tests (only when justified)
* follow CLAUDE_SPEC.md
constraints:
* cannot bypass validation
* must declare intent before writing code
output_contract:
must_produce:
- change_summary
- files_modified
- reasoning (short)
- code_diff
---
### 1.2 Guardian Agent
role: "architecture_enforcement"
responsibilities:
* validate against CLAUDE_SPEC.md
* detect violations
* reject or approve changes
* suggest minimal fixes (not full rewrites)
constraints:
* no feature implementation
* no large rewrites
* must be deterministic
output_contract:
must_produce:
- status: APPROVED | REJECTED
- violations[]
- required_fixes[]
- optional_improvements[]
---
## 2. Workflow Pipeline
```text
User Request
Builder Agent (proposal + code)
Guardian Agent (validation)
IF approved → commit
IF rejected → feedback → Builder retry
```
---
## 3. Builder Protocol
### Step 1 — Intent Declaration
Builder MUST start with:
```yaml
intent:
feature: "<name>"
crates_touched: []
systems_affected: []
risk_level: low|medium|high
```
---
### Step 2 — Plan
```yaml
plan:
- step: "..."
- step: "..."
```
---
### Step 3 — Implementation
* Only modify declared crates
* Follow ownership rules
* Use events for cross-system communication
---
### Step 4 — Output
```yaml
change_summary: "..."
files_modified:
- path: ...
change: "..."
violations_self_check:
- none | list
notes: "short reasoning"
```
---
## 4. Guardian Protocol
### Step 1 — Spec Validation
Check against:
* crate boundaries
* mutation rules
* event system usage
* sync guarantees
* forbidden patterns
---
### Step 2 — Invariant Validation
Must verify:
* GameState invariants preserved
* no new panic paths
* no blocking calls in engine
* merge properties unchanged
---
### Step 3 — Output Decision
#### APPROVED
```yaml
status: APPROVED
notes:
- "no violations"
```
---
#### REJECTED
```yaml
status: REJECTED
violations:
- id: core_purity_violation
file: "solitaire_core/src/..."
reason: "uses std::fs"
required_fixes:
- "move IO to solitaire_data"
optional_improvements:
- "simplify event naming"
```
---
## 5. Enforcement Rules
### Hard Fail (automatic rejection)
* core crate uses IO / Bevy / network
* GameState mutated outside GameLogicSystem
* blocking async on main thread
* duplicate logic across crates
* merge function altered incorrectly
---
### Soft Fail (allowed but flagged)
* unnecessary complexity
* redundant tests
* minor architectural drift
---
## 6. Iteration Loop
Max attempts per task: **3**
```text
Attempt 1 → Reject → Fix
Attempt 2 → Reject → Fix
Attempt 3 → Final decision
```
If still failing:
→ escalate to user
---
## 7. Diff Strategy
Builder MUST produce:
* minimal diffs
* no unrelated refactors
* no formatting-only changes
---
## 8. Test Strategy Integration
Builder rules:
* only add tests if:
* fixing a bug
* protecting complex logic
* validating invariants
Guardian rejects:
* redundant tests
* no-op tests
---
## 9. Optional Extensions
### 9.1 Third Agent (Optimizer)
role: performance + cleanup
runs AFTER approval:
* reduce allocations
* simplify logic
* improve ECS scheduling
---
### 9.2 CI Integration
Pipeline:
```text
Builder → Guardian → cargo check → clippy → tests
```
Guardian runs BEFORE compilation to catch structural issues early.
---
## 10. Example Interaction
### Builder
```yaml
intent:
feature: "undo stack limit fix"
crates_touched: [solitaire_core]
risk_level: low
```
```yaml
change_summary: "limit undo stack to 64 entries"
files_modified:
- solitaire_core/src/game_state.rs
notes: "prevents unbounded memory growth"
```
---
### Guardian
```yaml
status: APPROVED
notes:
- "respects core constraints"
- "no invariant violations"
```
---
## 11. Mental Model
* Builder = **creative**
* Guardian = **strict**
Builder explores
Guardian enforces
Neither replaces the other.
---
## 12. Success Criteria
System is working if:
* architectural violations go to ~0
* code stays consistent across features
* refactors become safe
* complexity grows sub-linearly
Generated
+76 -849
View File
File diff suppressed because it is too large Load Diff
+53 -1
View File
@@ -30,13 +30,65 @@ dirs = "6"
keyring = "4"
keyring-core = "1"
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
arboard = { version = "3", default-features = false }
solitaire_core = { path = "solitaire_core" }
solitaire_sync = { path = "solitaire_sync" }
solitaire_data = { path = "solitaire_data" }
solitaire_engine = { path = "solitaire_engine" }
bevy = "0.18"
# Bevy with `default-features = false` to avoid the unused
# `bevy_audio → rodio + symphonia + cpal 0.15 + alsa 0.9` chain.
# Audio is handled directly by `kira` in `audio_plugin.rs`, so the
# `bevy_audio` feature is intentionally omitted. The features below
# enumerate every leaf of the standard `2d` + `ui` meta-features that
# we actually use; new features should only be added with a
# corresponding use site.
bevy = { version = "0.18", default-features = false, features = [
# default_app
"async_executor",
"bevy_asset",
"bevy_input_focus",
"bevy_log",
"bevy_state",
"bevy_window",
"custom_cursor",
"reflect_auto_register",
# default_platform (desktop subset)
"std",
"bevy_winit",
"default_font",
"multi_threaded",
# winit prefers Wayland when WAYLAND_DISPLAY is set on the
# session and falls through to X11 otherwise. Without `wayland`,
# winit-on-Wayland-session falls back to XWayland which renders
# the game in an X11 frame inside the Wayland compositor.
"wayland",
"x11",
# Android: NativeActivity glue. The feature is target-gated inside
# bevy_internal — desktop builds compile it out, so leaving it on
# the always-on list is harmless on Linux/macOS/Windows. Pairs with
# cargo-apk's NativeActivity wrapper (cargo-apk 0.10+ uses this by
# default). Switch to `android-game-activity` later if we want
# AndroidX GameActivity for Google Play Games integration.
"android-native-activity",
# common_api
"bevy_color",
"bevy_image",
"bevy_mesh",
"bevy_shader",
"bevy_text",
"png",
# 2d rendering
"bevy_camera",
"bevy_render",
"bevy_core_pipeline",
"bevy_sprite",
"bevy_sprite_render",
# UI rendering
"bevy_ui",
"bevy_ui_render",
] }
kira = "0.12"
# SVG rasterisation pipeline for the runtime card-theme system.
+1 -1
View File
@@ -22,7 +22,7 @@ optional self-hosted sync so your stats follow you across machines.
move within picker rows, Enter activates; works across every modal and
the HUD action bar
- **Progression** — XP, levels, unlockable card backs and backgrounds
- **18 Achievements** — including secret ones
- **19 Achievements** — including secret ones
- **Daily Challenge** — server-seeded so every player worldwide gets the
same deal
- **Leaderboard** — opt-in, powered by your own self-hosted server
+244 -102
View File
@@ -1,140 +1,282 @@
# Solitaire Quest — Session Handoff
**Last updated:** 2026-05-02 (session 9, post-v0.14.0 release prep) — v0.14.0 cut. The Quat bug fixes, the rest of the v0.13.0 candidate list, and the entire replay → upload → web-viewer pipeline are all bundled in this release. Direction now opens for the next round.
**Last updated:** 2026-05-07 — v0.20.0 cut. Two through-lines closed
in this cycle: a full **Terminal visual-identity port** (token system
in `ui_theme` plus downstream chrome migrations across modal scaffold,
gameplay-feedback, toasts, and the table / card / splash surfaces)
and the **Android persistence shim** that closes the
`dirs::data_dir() = None` pitfall flagged in CLAUDE.md §10. The
Android *build* target landed earlier in the cycle (`fb8b2ac`); this
session paid down the persistence half so a real APK can survive a
cold start. The 24 Stitch-rendered mockups are now in-tree under
`docs/ui-mockups/`; future plugin work diffs against the matching
mockup before touching pixels.
## Status at pause
- **HEAD on origin:** v0.14.0's tag commit (CHANGELOG + handoff refresh).
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1134 passed / 0 failed** across the workspace.
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`, `v0.13.0`, `v0.14.0`.
- **HEAD on origin:** the v0.20.0 docs commit (the one that lands
this file + CHANGELOG cut). Tag not yet pushed; cut whenever
feels right.
- **Working tree:** clean apart from the still-untracked `artwork/`
directory (intentional — the card PNGs there are mid-flight for
the Terminal aesthetic and committing now would freeze a
transitional state).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean.
- **Tests:** **1176 passing / 0 failing** across the workspace.
Six new tests this cycle: four `ui_theme` invariant guards
(type / spacing / z-index scales + `scaled_duration`), one
toast-variant-border-mapping pair, and four palette-tracking
guards on `MARKER_VALID` / `HINT_PILE_HIGHLIGHT_COLOUR` /
`RIGHT_CLICK_HIGHLIGHT_COLOUR` / toast-border distinctness. No
known flakes.
- **Tags on origin:** `v0.9.0` through `v0.19.0`. v0.20.0 not yet
tagged.
## Where we are
## What shipped in v0.20.0
v0.14.0 is the largest release since the card-theme system. Three threads land together:
### Terminal visual-identity port
1. **The remaining v0.13.0-era UX candidates** — theme thumbnails, daily-challenge calendar, Time Attack auto-save, per-mode bests, time-bonus multiplier slider.
2. **Quat smoke-test bug fixes** — multi-card move validation, softlock detection, deal-tween information leak.
3. **The replay pipeline** — record on win, persist to disk, upload to server, view in browser via a new `solitaire_wasm` crate. The biggest single feature since the card-theme system.
Top-down stack — every commit downstream of the token system
reads from it, so swapping the palette is now a one-file edit:
The card-flight web animations and replay E2E test coverage close out the pipeline.
- **`ui_theme` token system** (`0d477ac`). base16-eighties
palette, 5-rung type scale, 7-rung 4-multiple spacing scale,
3-step radius, 14-rung z-index hierarchy, full motion budget,
4 invariant-pinning unit tests. Card-shadow alphas pinned to 0
(Terminal achieves depth via 1px borders + tonal layering).
- **Modal scaffold already on tokens** — `ui_modal` was ported
in the same commit's wake; three stale "loud yellow" /
"magenta secondary" doc comments fixed.
- **Gameplay feedback → semantic state tokens** (`ceec4fc`).
Selection / valid-drop tints route through `ACCENT_PRIMARY` /
`STATE_WARNING` / `STATE_SUCCESS`.
- **Toasts** (`a137607`). New `ToastVariant` enum
(Info / Warning / Error / Celebration); opaque `BG_ELEVATED`
+ 1px accent border + bottom-anchor. All ten call sites pass
their semantic variant.
- **`table_plugin` chrome** (`651f406`).
`PILE_MARKER_DEFAULT_COLOUR` promoted; `cursor_plugin` imports
it, replacing a "kept in sync" doc comment with a compile-
enforced invariant. `HINT_PILE_HIGHLIGHT_COLOUR`
`STATE_WARNING`.
- **`card_plugin` chrome** (`d752870`). Drag-elevation shadow
routes through `CARD_SHADOW_*` tokens. `RIGHT_CLICK_HIGHLIGHT_COLOUR`
`STATE_SUCCESS`. Stock recycle "↺" text → `TEXT_PRIMARY @ 0.7α`.
Card-face / suit / card-back palette intentionally NOT migrated
(artwork dependency — see open-list item below).
- **Splash cursor** (`cdcadda`). The signature `▌` cyan glyph
(96 px) added above the wordmark, matching the spec.
- **Hint-source / dest pairing** (`9891ae4`). `input_plugin`'s
source-card tint now matches the destination pile's
`STATE_WARNING`.
- **Design system + 24-mockup library** (`fa7f98a`).
`docs/ui-mockups/design-system.md` + 24 Stitch mockups (HTML +
PNG) covering every screen plus 9 missing-plugin surfaces.
- **`card_shadow_params` test aligned** (`1d1543e`). Drag-vs-
idle shadow assertion loosened to `>=` to accept the Terminal
"no shadow" intent without losing the regression-guard.
### Design direction (unchanged)
### Android persistence
- **Tone:** Balatro — chunky readable type, theatrical hierarchy, satisfying micro-interactions.
- **Palette:** Midnight Purple base + Balatro yellow primary + warm magenta secondary.
- See `~/.claude/projects/-home-manage-Rusty-Solitare/memory/project_ux_overhaul_2026-04.md` (machine-local).
- **`solitaire_data::data_dir` shim** (`4b51e50`). New
`solitaire_data::platform::data_dir()` falls through to
`dirs::data_dir()` on desktop and returns the per-app sandbox
at `/data/data/com.solitairequest.app/files` on Android — no
JNI needed (package id pinned in `[package.metadata.android]`).
Six `solitaire_data` callsites + `solitaire_engine/assets/user_dir.rs`
migrated. Settings, stats, achievements, replays, game-state,
time-attack sessions, and user themes now persist on Android.
### Canonical remote
### Inherited from earlier in the cycle (pre-session)
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
## Session 8 + 9 (shipped 2026-05-02) — v0.14.0
### v0.13.0-era UX candidates (had landed but missed v0.13.0's tag)
| Area | Commit | What landed |
|---|---|---|
| Theme thumbnails | `ba527de` | Each Settings → Cosmetic theme chip renders an Ace + back preview pair via `rasterize_svg`. Cached per theme. Missing-SVG themes show a transparent placeholder rather than crashing. |
| Daily-challenge calendar | `1a10476` | 14-dot horizontal calendar in the Profile modal. Today is ringed, completed days fill `STATE_SUCCESS`, missed days fill `BG_ELEVATED`. Caption: "Current streak: N · Longest: M". `PlayerProgress` gains `daily_challenge_history` (capped at 365) and `daily_challenge_longest_streak`. |
| Time Attack auto-save | `0001432` | New sibling `time_attack_session.json` next to `game_state.json`. Atomic .tmp + rename. 30 s auto-save while active + on `AppExit`. Sessions whose 10-min window expired in real time while the app was closed are discarded on load. |
| Per-mode bests | `3984231` | StatsSnapshot gains six `#[serde(default)]` fields (Classic / Zen / Challenge × best_score + fastest_win_seconds). Stats screen renders a "Per-mode bests" section. Lifetime totals continue to roll all modes together. |
| Time-bonus slider | `89c51ab` | Settings → Gameplay slider 0.02.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. |
### Quat smoke-test bug fixes
| Area | Commit | What landed |
|---|---|---|
| Move validation (#1) | `f1aeb24` | `solitaire_core::rules::is_valid_tableau_sequence(&[Card]) -> bool` checks every adjacent pair in a moved stack descends one rank with alternating colour. Wired into `move_cards`. Closes the bug where any multi-card lift could be dropped as long as the bottom landed legally. |
| Deal-tween leak (#4) | `3eabc14` | New-game snaps every card sprite to the stock pile position before writing `StateChangedEvent`, so all 52 cards animate from a single deck point during the deal. Previously sprites started from previous-game positions, briefly revealing the prior deal. |
| Softlock detection (#2) | `2716472` | `has_legal_moves` rewritten: walks every potential move source (every stock card, every waste card, the face-up top of every tableau column) against every foundation and every tableau. Previous heuristic returned `true` whenever stock had cards, hiding genuine softlocks. `GameOverScreen` now actually fires for true softlocks. |
| End-game screen (#3) | — | Resolved as downstream of #2. The pre-existing `GameOverScreen` and `WinSummaryOverlay` already cover the close-out paths; the softlock screen just never spawned because the old `has_legal_moves` lied. |
### Replay pipeline (the major feature)
| Area | Commit | What landed |
|---|---|---|
| Replay storage | `42535f5` | `solitaire_data::replay::Replay` (seed + draw_mode + mode + score + time + recorded date + ordered move list) and atomic save/load helpers under `<data_dir>/latest_replay.json`. Schema v1; `load` returns None for any other version. |
| Engine recording | `57d1c58` | `RecordingReplay` resource + `ReplayPath` settings. Every successful `MoveRequestEvent` / `DrawRequestEvent` appends to recording; `GameWonEvent` freezes the recording into a `Replay` and persists. Undo intentionally not recorded. New game clears the recording. |
| Stats button | `d9f36bf` | Stats overlay surfaces a "Latest win:" caption + "Watch replay" button. Loads from disk via `LatestReplayResource`. (Full in-engine playback deferred — button currently fires an `InfoToastEvent` describing the replay.) |
| Server upload + fetch | `93182fa` | `POST /api/replays` accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated. SQL migration for the new `replays` table. |
| Engine sync | `23c9704` | Engine uploads winning replays automatically when the player has cloud sync configured. Re-uses the existing JWT/refresh-token flow. |
| WASM crate | `5bed43e` | New workspace member `solitaire_wasm` compiles replay-relevant `solitaire_core` types to WebAssembly so a browser can re-execute a replay client-side. `wasm-bindgen` glue. |
| Web viewer | `07b8ecd` | `GET /replays/:id` returns HTML + CSS + the wasm bundle. Browser fetches the replay JSON, rasterises a deal from the seed, and animates the recorded moves. |
| E2E coverage | `3081505` | Server tests covering the full upload → fetch round-trip via `axum::test`. |
| Web flight anim | `1fcd032` | Card-flight tweens on the web side so the browser viewer reads as a real game replay rather than a static dump. |
- Android build target + APK (`fb8b2ac`), runbook (`59424a3`),
F3 FPS overlay (`690e1d2`), Smart Window Size opt-out
(`e1b8766`), Shareable badge (`9b065e5`), Help cheat-sheet
M/P/Enter rows (`35516d3`), `pull_failure_sets_error_status`
flake fix (`67c150b`).
## Open punch list
### Release prep
1. **Smoke-test on the alex machine** after pulling — confirm Quat's three bug fixes hold up in real gameplay, and try the new replay button + web viewer end-to-end.
2. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
### Phase Android (build + persistence shipped; runtime gaps remain)
### UX iteration (next-round candidates)
- **APK launch verification on AVD / device.** `adb install` then
`adb logcat` against the `bevy_test` AVD or an x86_64 device.
The build works and persistence is wired, but no end-to-end
device run has been logged. Shakes out runtime bugs the build +
unit tests can't catch.
- **JNI ClipboardManager bridge.** Replaces the Android stub for
the Stats "Copy share link" toast. `arboard` doesn't ship an
Android backend; small custom JNI call.
- **Android Keystore for credentials.** `keyring` is target-gated
to a stub returning `KeychainUnavailable`; replace with Android
Keystore via JNI when sync auth ships on mobile.
- **Google Play Games (gpgs) integration.** Listed as a
Phase-Android target since Phase 1; now unblocked by the build
target.
- **Cosmetic `cargo apk build --lib` workaround.** Post-sign
panic doesn't affect the APK on disk but produces noisy stderr.
Either upstream a cargo-apk fix or document `--lib` as
canonical in the runbook.
- **Solver-at-deal toggle** (Quat investigation #1, still deferred): add a Settings → Gameplay toggle "Winnable deals only" rather than baking solver-only into every deal. Lightest middle ground.
- **Disable Bevy's default audio feature** (Quat investigation #2, still deferred): one-line `default-features = false` swap on the workspace `bevy =` line, re-enable explicitly the features the engine uses (`render`, `bevy_winit`, `2d`, `bevy_window`, `png`, `bevy_text`, `bevy_ui`, `bevy_log`, `bevy_asset`, `default_font`, `bevy_state`). Drops ~50 transitive crates including the rodio + symphonia stack the project doesn't use (kira handles audio).
- **In-engine replay playback** — promote the "Watch replay" button from a stub toast to a real playback overlay that re-runs the recorded moves with `CardAnimation` tweens. The wasm crate already proves the playback math; the in-engine version reuses the same execute logic against the live game state.
- **Per-replay history** — currently single-slot at `latest_replay.json`. A "best replay per mode" bucket or a recent-N rolling list would let players revisit notable wins.
- **Solver-driven hint system** — extend the existing hint toggle so a deal-time solver provides higher-quality hints (currently a heuristic). Requires the solver from the toggle work above.
- **Achievement: "won via replay path"** — track when a player wins a deal whose previously-saved replay also won the same deal. Mostly fun; trivial scope.
### Visual-identity follow-ups (opened by v0.20.0's port)
## Card-theme system (CARD_PLAN.md, fully shipped)
- **Card-face / suit / card-back artwork regeneration.** The
Terminal spec calls for dark `#1a1a1a` cards with light suit
pips (pink for hearts/diamonds, foreground gray for spades/
clubs); the runtime path still renders the legacy white-card
PNG artwork. The fallback constants in `card_plugin`
(`CARD_FACE_COLOUR`, `RED_SUIT_COLOUR`, `BLACK_SUIT_COLOUR`,
`CARD_FACE_COLOUR_RED_CBM`, `card_back_colour` palette) are
intentionally unmigrated and should swap in lockstep with the
artwork. Largest visible payoff remaining in the visual-
identity arc.
- **Splash boot-loader richness.** The mockup
(`docs/ui-mockups/splash-mobile.html`) calls for a scanline
overlay, ✓ lime check log lines, pulsing cursor, ROOT@SOLITAIRE
prompt, and a loading bar — none of which v0.20.0's
cursor-glyph-only port pulled in. Aesthetic feature, its own
commit.
- **Replay-overlay redesign.** The mockup
(`docs/ui-mockups/replay-overlay-mobile.html`) envisions a
much richer surface (terminal `▌replay.tsx` header, move log
scroll, MOVE 47/87 chip, WIN MOVE callout, status bar) versus
the current top banner. Aesthetic feature.
- **Toast Warning / Error variants.** The new `ToastVariant`
enum has slots for `Warning` (gold) and `Error` (pink) but no
in-engine event uses them yet (the four current toast events
all map to Info or Celebration). Wire when a warning- or
error-flavoured toast event materialises.
Seven phases landed across `b8fb3fb``924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` consumes the per-theme `back.svg`; v0.14.0's `ba527de` adds preview thumbnails. End-to-end:
### Carried forward from v0.19.0
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
- **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present.
- **App icon round.** `Window::icon` not yet wired; no
`.icns` / `.ico` / Linux hicolor PNG hierarchy. The 11-size
icon export the v0.19 handoff referenced is *not* currently
in `artwork/` (current `artwork/` holds the reverted Rusty
Pixel card PNGs and is intentionally untracked); icon-export
needs to be re-run before this item can be picked up.
Half-day task once the PNGs are back in place. No cert
dependency.
### Other small candidates
- **Prev/Next selector chips spawn site.** v0.19.0's `9b065e5`
noted Prev/Next markers exist in `stats_plugin` but no spawn
site renders them today — the Shareable badge therefore lands
on the single-replay caption. If/when Prev/Next is plumbed,
the badge will need to follow.
- **Toast queue / immediate unification.** The two toast paths
(`spawn_queued_toast` for `InfoToastEvent` queue; `spawn_toast`
for fire-and-forget) now share visual treatment but remain
separate functions because they serve different temporal
needs (sequential vs. parallel). If overlap becomes a UX
issue, merge into one queue with priority lanes.
### Process notes
- **Token-port pattern.** v0.20.0's chrome-migration commits
set a reusable shape for "centralized design system applied
across N plugins":
1. Constants module (`ui_theme.rs`) is the source of truth.
2. Const sites that can't call `Alpha::with_alpha` (not yet
`const` on stable) use a literal RGB matching the token,
with a unit test pinning the RGB to the token (e.g.
`MARKER_VALID`, `HINT_PILE_HIGHLIGHT_COLOUR`,
`RIGHT_CLICK_HIGHLIGHT_COLOUR`).
3. Cross-plugin duplication (e.g. `MARKER_DEFAULT`
`PILE_MARKER_DEFAULT_COLOUR`) collapses to a single
promoted const re-exported from one plugin and imported
by the other — replaces "kept in sync" doc comments with a
compile-time invariant.
4. Domain colours (suit pips, card faces, lerp helpers) stay
as literals with a comment naming the rationale; only UI
chrome routes through tokens.
- **Audit before migrating wide.** Before touching any plugin,
grep for the literal pattern (`Color::srgb\(|Color::srgba\(|
Color::WHITE|Color::BLACK`) and classify each hit as domain
vs. chrome. Most plugins after the modal scaffold port turned
out to be 100 % token-correct already; the audit prevents
wasted churn.
### Canonical remote
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
Always push there.
### Design direction (now Terminal — base16-eighties)
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
monospaced-forward typography (JetBrains Mono / FiraMono), tight
16 px edge margins, 8 px card radius.
- **Palette:** near-black surface ramp (`#151515` / `#202020` / `#2a2a2a`
/ `#353535`), cyan primary CTA (`#6fc2ef`), lime success
(`#acc267`), gold warning (`#ddb26f`), pink error / suit-red
(`#fb9fb1`), lavender celebration (`#e1a3ee`), teal info
(`#12cfc0`).
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`. Outlined
glyphs for diamonds & clubs are *always on*; the Settings
"color-blind mode" toggle only swaps red → cyan.
(Was: Midnight Purple base + Balatro yellow primary + warm magenta.
Replaced this cycle.)
## Resume prompt
```
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine — local
directory may still be named Rusty_Solitare from earlier; that's fine>.
Branch: master. Direction is OPEN — v0.14.0 just shipped covering the
Quat bug fixes, the v0.13.0 candidate tail, and the entire
replay-pipeline feature.
Working directory: <Rusty_Solitaire clone path on this machine>.
Branch: master. v0.20.0 just cut on 2026-05-07; CHANGELOG's new
[Unreleased] section is empty pending the next cycle's threads.
State: HEAD at v0.14.0. Working tree clean apart from untracked
CARD_PLAN.md (intentional).
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1134 passed / 0 failed.
State: HEAD on the v0.20.0 docs commit. Tag not pushed yet — last
pushed tag is v0.19.0. Working tree clean apart from the
intentionally-untracked `artwork/`.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — v0.14.0 changelog + open punch list
2. CHANGELOG.md — release-by-release record
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
4. ARCHITECTURE.md — crate responsibilities + data flow
5. ~/.claude/projects/<this-project>/memory/MEMORY.md
— saved feedback / project context (machine-local;
may be missing on a fresh machine)
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.20.0] section is the most recent cut
3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow
6. docs/ui-mockups/ — design system + 24-mockup library
(Terminal aesthetic — landed in fa7f98a)
7. docs/android/* — Android setup + build runbook
8. ~/.claude/projects/<this-project>/memory/MEMORY.md
— saved feedback / project context
(machine-local; may be missing on a
fresh machine)
DECISION TO ASK THE PLAYER FIRST:
A. Smoke-test v0.14.0 on the alex machine first to confirm the
three Quat bug fixes hold up in real gameplay and the replay
pipeline works end-to-end (record → upload → web viewer).
B. Take the deferred Bevy-audio-feature trim (Quat investigation
#2) — one-line workspace edit, ~50 fewer transitive crates.
C. Take the deferred solver toggle (Quat investigation #1): add
"Winnable deals only" Settings toggle. Larger.
D. Promote the in-engine "Watch replay" button to real playback.
E. Pick from the remaining "next-round candidates" in this doc.
F. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user).
A. Push v0.20.0 tag — `git tag v0.20.0 && git push --tags`. If
the player wants the cut formalised before any new work.
B. APK launch verification — `adb install` + `adb logcat` on
bevy_test AVD or an x86_64 device. Now that persistence is
wired (4b51e50), shake out remaining runtime bugs.
C. Card-face artwork regeneration — generate Terminal-aesthetic
card PNGs (dark face, light suit pips), then migrate
CARD_FACE_COLOUR / RED_SUIT_COLOUR / BLACK_SUIT_COLOUR /
CARD_FACE_COLOUR_RED_CBM in lockstep. Largest visible
payoff remaining in the visual-identity arc.
D. Splash boot-loader richness — port the scanline overlay,
✓ check log, pulsing cursor, ROOT@SOLITAIRE prompt, and
loading bar from docs/ui-mockups/splash-mobile.html. Pure
polish; no behavioural change.
E. App icon round — re-run artwork/Icon Export.html (the
export PNGs are not currently in `artwork/`), then wire
Window::icon + generate .icns / .ico. Half-day task. No
cert dependency.
F. JNI ClipboardManager / Keystore bridge — replaces the
Android stubs for Stats clipboard share + sync auth.
WORKFLOW NOTES:
- Commits use:
git -c user.name=funman300 -c user.email=root@vscode.infinity \
commit -m "..."
- When attributing playtester feedback in commits/docs, use "Quat"
not "Rhys" (saved feedback memory).
- Use the system git config (already correct).
- When attributing playtester feedback in commits/docs, use
"Quat" not "Rhys" (saved feedback memory).
- Sub-agents stage + verify only; orchestrator commits.
- Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — that is the canonical remote.
- Push to GitHub (origin) — gh auth setup-git wired on
primary dev box; verify on laptop before first push.
OPEN AT THE START: ask which of AF. Don't pick unilaterally.
```
+228
View File
@@ -0,0 +1,228 @@
# Android build — developer setup
This doc captures the toolchain install + build invocation for the
Android target. Steps are runnable on a fresh Debian 13 (trixie) box;
later sections document what's known to compile, what's stubbed, and
the next milestones.
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
> debug-signed `solitaire-quest.apk` for `x86_64-linux-android`. Has
> NOT yet been verified to launch on a device or emulator — that's
> the next milestone.
---
## 1. Toolchain install (Debian 13 / trixie)
Run as one block. Will pull ~15-20 GB of disk between APT, the SDK,
the NDK, the system image, and Rust target sysroots. Requires sudo.
```bash
# 1. JDK 21 (Android tooling needs JDK 17+; Debian 13 default is 21).
sudo apt update && sudo apt install -y openjdk-21-jdk-headless unzip wget
# 2. SDK directory + Google's cmdline-tools bootstrap.
export ANDROID_HOME="$HOME/Android/Sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
wget -O /tmp/cmdline-tools.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
rm /tmp/cmdline-tools.zip
# 3. Persist env vars.
{
echo ''
echo '# Android dev'
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
} >> ~/.bashrc
source ~/.bashrc
# 4. Accept SDK licences (interactive prompts answered by `yes |`).
yes | sdkmanager --licenses
# 5. Platform packages — ~5 GB.
sdkmanager \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0" \
"ndk;26.3.11579264" \
"emulator" \
"system-images;android-34;google_apis;x86_64"
# 6. AVD for testing (one-time).
echo no | avdmanager create avd \
-n bevy_test \
-k "system-images;android-34;google_apis;x86_64" \
-d pixel_7
# 7. Rust cross-compile targets.
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android \
i686-linux-android
# 8. cargo-apk.
cargo install cargo-apk
```
Sanity:
```bash
java --version | head -1 # openjdk 21.0.x
adb --version | head -1 # 35.x or higher
sdkmanager --list_installed | head # build-tools, emulator, ndk, platforms, system-images
avdmanager list avd | head # bevy_test
rustup target list --installed | grep android # 4 targets
cargo apk --help | head -5
```
If `sdkmanager --version` errors with `JAVA_HOME is not set`, the env
section in step 3 didn't apply to your shell — `source ~/.bashrc`
again or open a new terminal.
### Optional: emulator runtime libs
The Android emulator is dynamically linked against X11/GL/audio. If
`emulator -list-avds` works but `emulator -avd bevy_test` complains
about `libX11.so.6`, install:
```bash
sudo apt install -y \
libx11-6 libxcursor1 libxrandr2 libxi6 libxinerama1 libxxf86vm1 \
libgl1 libnss3 libpulse0 libxcomposite1
```
Headless emulator launch:
```bash
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
adb wait-for-device && adb devices
# Stop later:
# adb -s emulator-5554 emu kill
```
Headless + software rendering is fine for "does it boot" smoke tests
but useless for perf measurement — use a physical Pixel-class device
over USB for real numbers.
---
## 2. Build the APK
```bash
cargo apk build -p solitaire_app --target x86_64-linux-android
```
Output:
```
target/debug/apk/solitaire-quest.apk
```
Targets shipped via `[package.metadata.android].build_targets` in
`solitaire_app/Cargo.toml`:
| Target | Use |
|--------|-----|
| `aarch64-linux-android` | Real phones (modern 64-bit ARM) |
| `armv7-linux-androideabi` | Older 32-bit ARM phones |
| `x86_64-linux-android` | The `bevy_test` AVD on this dev box |
Build any of them with `--target <triple>`.
### Known cosmetic warning
After the APK is signed cargo-apk panics with:
```
thread 'main' panicked: Bin is not compatible with Cdylib
```
This happens AFTER the APK is on disk and signed. cargo-apk is
trying to also wrap the desktop `[[bin]]` target. The APK is still
valid. Work around with `--lib`:
```bash
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
```
(Permanent fix to come — likely a `[[bin]] required-features = ["desktop"]`
gate so cargo-apk skips the bin target on Android.)
---
## 3. Install + run
Physical device:
```bash
adb devices # confirm connection
adb install target/debug/apk/solitaire-quest.apk
adb shell am start -n com.solitairequest.app/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
```
Emulator:
```bash
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
adb wait-for-device
adb install target/debug/apk/solitaire-quest.apk
# ... same start + logcat steps as above.
```
If `adb install` errors with `INSTALL_FAILED_NO_MATCHING_ABIS`, the
emulator is x86_64 but the APK was built for arm — rebuild with the
`x86_64-linux-android` target, or add an x86_64 system image to the
AVD.
---
## 4. What's wired vs. what's stubbed
The first build pass (commit `fb8b2ac`) gates four desktop-only
crates / call sites so the workspace cross-compiles. Each gate is
documented at its call site.
| Surface | Desktop | Android |
|---------|---------|---------|
| Bevy windowing | x11 + wayland | `android-native-activity` (NativeActivity glue) |
| Clipboard ("Copy share link") | `arboard` writes URL | Toast surfaces the URL inline |
| OS keychain (JWT tokens) | `keyring` v4 → Secret Service / Keychain / Credential Store | Stub returning `KeychainUnavailable`; sync requires fresh login each launch |
| App entry point | `bin` target → `solitaire_app::run()` | `cdylib` target loaded by NativeActivity |
What's NOT yet ported / not yet measured:
- `dirs::data_dir()` returns `None` on Android. Callers in
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
`achievements.rs`, `settings.rs` all need an Android-aware
helper (likely `/data/data/com.solitairequest.app/files`).
- Touch UX pass — hit-target sizes, modal scaling on small screens,
app lifecycle (suspend / resume), font scaling.
- Android Keystore via JNI for `auth_tokens`.
- JNI ClipboardManager for share links.
- Google Play Games sign-in (the `solitaire_gpgs` crate referenced
in older docs doesn't yet exist).
---
## 5. Iteration loop
```bash
# Edit code…
cargo build -p solitaire_app # desktop sanity
cargo clippy --workspace --all-targets -- -D warnings # gate
cargo test --workspace # gate
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
adb install -r target/debug/apk/solitaire-quest.apk # `-r` reinstalls
adb logcat -c && adb shell am start -n com.solitairequest.app/android.app.NativeActivity
adb logcat | grep -iE "RustStdoutStderr|solitaire"
```
`adb logcat` is the canonical way to see Bevy / Rust panic output —
they end up in the `RustStdoutStderr` tag.
+293
View File
@@ -0,0 +1,293 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Rusty Solitaire - Achievements</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500;700&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed": "#fbd7ff",
"on-tertiary-container": "#683476",
"surface-container-lowest": "#0b0f11",
"error": "#fb9fb1",
"secondary-fixed-dim": "#bad073",
"on-primary-fixed-variant": "#004c69",
"background": "#101417",
"error-container": "#93000a",
"tertiary-container": "#e1a3ee",
"inverse-primary": "#00668a",
"highlight-valid": "#acc267",
"suit-red": "#fb9fb1",
"on-surface-variant": "#bfc8cf",
"on-secondary": "#293500",
"on-primary-container": "#004f6c",
"surface-tint": "#7ed0fe",
"on-surface": "#e0e3e6",
"outline-variant": "#3f484e",
"on-background": "#e0e3e6",
"primary-fixed": "#c4e7ff",
"inverse-surface": "#e0e3e6",
"info": "#12cfc0",
"inverse-on-surface": "#2d3134",
"warning": "#ddb26f",
"on-tertiary-fixed-variant": "#653173",
"on-secondary-container": "#b2c86d",
"on-secondary-fixed-variant": "#3c4d00",
"highlight-celebration": "#e1a3ee",
"surface": "#151515",
"surface-container-highest": "#313538",
"outline": "#505050",
"on-primary": "#003549",
"on-error-container": "#ffdad6",
"surface-variant": "#313538",
"on-error": "#690005",
"suit-black": "#d0d0d0",
"primary": "#a1dcff",
"suit-red-cb": "#6fc2ef",
"surface-bright": "#363a3d",
"on-tertiary": "#4c195b",
"surface-dim": "#101417",
"primary-container": "#6fc2ef",
"tertiary": "#f7c3ff",
"primary-fixed-dim": "#7ed0fe",
"surface-container-high": "#272a2d",
"on-secondary-fixed": "#161e00",
"surface-container": "#1c2023",
"tertiary-fixed-dim": "#f0b0fc",
"secondary-fixed": "#d5ec8c",
"secondary-container": "#435401",
"on-tertiary-fixed": "#340043",
"on-primary-fixed": "#001e2c",
"secondary": "#bad073",
"surface-container-low": "#181c1f"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"stack-overlap": "2rem",
"gutter-card": "0.375rem",
"touch-target-min": "48dp",
"margin-edge": "1rem",
"action-bar-height": "64px"
},
"fontFamily": {
"card-rank": ["JetBrains Mono"],
"body-md": ["Inter"],
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"]
},
"fontSize": {
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
body {
background-color: #151515;
color: #e0e3e6;
-webkit-font-smoothing: antialiased;
}
.scanline {
background: linear-gradient(to bottom, transparent 50%, rgba(0,0,0,0.1) 50%);
background-size: 100% 2px;
pointer-events: none;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="font-body-md text-body-md overflow-x-hidden pb-[action-bar-height]">
<!-- Status Bar -->
<header class="fixed top-0 w-full h-[32px] bg-[#202020] flex items-center justify-between px-margin-edge z-[60] border-b border-outline-variant">
<div class="flex items-center gap-2 font-label-caps text-on-surface">
<span class="text-primary"></span>achievements.json
</div>
<div class="font-label-caps text-[#a0a0a0]">
8/19 UNLOCKED
</div>
</header>
<!-- Top App Bar (Shared Component Reference) -->
<nav class="fixed top-[32px] w-full h-[64px] bg-surface flex items-center justify-between px-margin-edge z-50 border-b border-outline-variant">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-primary" data-icon="terminal">terminal</span>
<h1 class="font-headline text-[20px] text-primary uppercase tracking-widest">Rusty Solitaire</h1>
</div>
<button class="w-10 h-10 flex items-center justify-center hover:bg-surface-container-highest transition-colors">
<span class="material-symbols-outlined text-on-surface-variant" data-icon="settings">settings</span>
</button>
</nav>
<main class="mt-[112px] px-margin-edge">
<!-- Hero Progress Card -->
<section class="w-full h-[100px] bg-[#202020] border border-[#353535] rounded-lg p-4 mb-6">
<div class="flex flex-col justify-between h-full">
<span class="font-label-caps text-[10px] text-[#a0a0a0]">PROGRESS</span>
<div class="flex items-baseline gap-2">
<span class="font-headline text-[28px] font-bold text-[#d0d0d0]">8/19</span>
<span class="font-label-caps text-[14px] text-highlight-celebration">(42%)</span>
</div>
<div class="w-full h-[4px] bg-[#353535] rounded-full overflow-hidden mt-1">
<div class="h-full bg-highlight-celebration" style="width: 42%;"></div>
</div>
</div>
</section>
<!-- Filter Chip Row -->
<section class="flex gap-2 mb-6 overflow-x-auto no-scrollbar">
<button class="h-[32px] px-3 flex items-center justify-center border border-[#6fc2ef] text-[#6fc2ef] rounded-[4px] font-label-caps text-[11px]">
[ ALL ]
</button>
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
UNLOCKED
</button>
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
LOCKED
</button>
<button class="h-[32px] px-3 flex items-center justify-center border border-outline text-[#a0a0a0] rounded-[4px] font-label-caps text-[11px] hover:border-primary hover:text-primary transition-colors">
SECRET
</button>
</section>
<!-- Achievements Grid -->
<section class="grid grid-cols-2 gap-3 mb-10">
<!-- FIRST WIN -->
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="emoji_events" style="font-variation-settings: 'FILL' 1;">emoji_events</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">FIRST WIN</h3>
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Win your first game</p>
</div>
</div>
<!-- SPEED DEMON -->
<div class="h-[100px] bg-[#202020] border border-highlight-celebration p-3 flex flex-col justify-between rounded-sm relative">
<div class="absolute inset-0 border border-highlight-celebration opacity-20 pointer-events-none"></div>
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="speed" style="font-variation-settings: 'FILL' 1;">speed</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">SPEED DEMON</h3>
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Win in under 3:00</p>
</div>
</div>
<!-- STREAK 10 -->
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="bolt" style="font-variation-settings: 'FILL' 1;">bolt</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">STREAK 10</h3>
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">10 wins in a row</p>
</div>
</div>
<!-- DAILY DEFENDER -->
<div class="h-[100px] bg-[#202020] border border-[#353535] p-3 flex flex-col justify-between rounded-sm">
<div class="w-8 h-8 rounded-full bg-[#6fc2ef] flex items-center justify-center">
<span class="material-symbols-outlined text-[#151515] text-[20px]" data-icon="calendar_today" style="font-variation-settings: 'FILL' 1;">calendar_today</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#d0d0d0] leading-none mb-1">DAILY DEFENDER</h3>
<p class="font-label-caps text-[10px] text-[#a0a0a0] leading-tight">Complete 7 daily seeds</p>
</div>
</div>
<!-- PERFECTIONIST (LOCKED) -->
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="undo">undo</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">PERFECTIONIST</h3>
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Win without using undo</p>
</div>
</div>
<!-- CHALLENGE BEATEN (LOCKED) -->
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="military_tech">military_tech</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">CHALLENGE BEATEN</h3>
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Complete CHALLENGE mode</p>
</div>
</div>
<!-- SECRET (LOCKED) -->
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="help_outline">help_outline</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">????</h3>
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">SECRET · Hidden until unlocked</p>
</div>
</div>
<!-- PAR HUNTER (LOCKED) -->
<div class="h-[100px] bg-[#0d0d0d] border border-[#353535] p-3 flex flex-col justify-between rounded-sm opacity-80">
<div class="w-8 h-8 rounded-full border border-[#505050] flex items-center justify-center">
<span class="material-symbols-outlined text-[#505050] text-[20px]" data-icon="golf_course">golf_course</span>
</div>
<div>
<h3 class="font-label-caps text-[13px] font-bold text-[#505050] leading-none mb-1">PAR HUNTER</h3>
<p class="font-label-caps text-[10px] text-[#353535] leading-tight">Beat par on 50 games</p>
</div>
</div>
</section>
</main>
<!-- Footer Status -->
<footer class="fixed bottom-[action-bar-height] w-full h-[24px] bg-background border-t border-outline-variant flex items-center justify-between px-margin-edge z-40 text-[10px] font-label-caps">
<div class="flex items-center">
<span class="text-primary mr-1"></span>
<span class="text-on-surface-variant">NORMAL</span>
<span class="mx-2 text-outline"></span>
<span class="text-on-surface-variant">achievements</span>
</div>
<div class="flex gap-3">
<div><span class="text-[#a0a0a0]">[F]</span> <span class="text-[#505050]">filter</span></div>
<div><span class="text-[#a0a0a0]">[/]</span> <span class="text-[#505050]">search</span></div>
</div>
</footer>
<!-- Bottom Navigation Bar (Shared Component Reference) -->
<nav class="fixed bottom-0 w-full h-action-bar-height bg-surface-container flex justify-around items-center px-margin-edge z-50 border-t border-outline-variant">
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
<span class="material-symbols-outlined" data-icon="power_settings_new">power_settings_new</span>
<span class="font-label-caps text-[10px] mt-1">[Q] QUIT</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
<span class="material-symbols-outlined" data-icon="add_box">add_box</span>
<span class="font-label-caps text-[10px] mt-1">[N] NEW</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
<span class="material-symbols-outlined" data-icon="undo">undo</span>
<span class="font-label-caps text-[10px] mt-1">[U] UNDO</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:text-primary hover:bg-surface-container-highest transition-colors active:scale-95">
<span class="material-symbols-outlined" data-icon="lightbulb">lightbulb</span>
<span class="font-label-caps text-[10px] mt-1">[H] HINT</span>
</button>
</nav>
<!-- CRT Overlay Effect (Visual Decoration) -->
<div class="fixed inset-0 pointer-events-none z-[100] opacity-[0.03] scanline"></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=390, height=844, initial-scale=1.0" name="viewport"/>
<title>Challenge Mode Menu</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"inverse-surface": "#e0e3e6",
"error-container": "#93000a",
"tertiary": "#f7c3ff",
"on-primary-container": "#004f6c",
"on-surface": "#e0e3e6",
"surface-dim": "#101417",
"surface-container-high": "#272a2d",
"surface-container-lowest": "#0b0f11",
"secondary-container": "#435401",
"suit-red": "#fb9fb1",
"on-error": "#690005",
"surface-container-low": "#181c1f",
"surface-variant": "#313538",
"surface-tint": "#7ed0fe",
"primary-container": "#6fc2ef",
"background": "#101417",
"primary": "#a1dcff",
"outline": "#505050",
"suit-black": "#d0d0d0",
"secondary-fixed": "#d5ec8c",
"surface-container": "#202020",
"on-tertiary-fixed": "#340043",
"on-tertiary-fixed-variant": "#653173",
"outline-variant": "#3f484e",
"on-surface-variant": "#bfc8cf",
"error": "#fb9fb1",
"on-primary-fixed": "#001e2c",
"highlight-celebration": "#e1a3ee",
"highlight-valid": "#acc267",
"suit-red-cb": "#6fc2ef",
"primary-fixed-dim": "#7ed0fe",
"tertiary-fixed-dim": "#f0b0fc",
"primary-fixed": "#c4e7ff",
"on-error-container": "#ffdad6",
"tertiary-container": "#e1a3ee",
"on-secondary": "#293500",
"on-tertiary": "#4c195b",
"on-background": "#e0e3e6",
"secondary-fixed-dim": "#bad073",
"secondary": "#bad073",
"inverse-primary": "#00668a",
"surface-bright": "#363a3d",
"surface": "#151515",
"on-tertiary-container": "#683476",
"on-secondary-fixed": "#161e00",
"inverse-on-surface": "#2d3134",
"warning": "#ddb26f",
"info": "#12cfc0",
"surface-container-highest": "#313538",
"on-primary-fixed-variant": "#004c69",
"tertiary-fixed": "#fbd7ff",
"on-secondary-fixed-variant": "#3c4d00",
"on-secondary-container": "#b2c86d",
"on-primary": "#003549"
},
"fontFamily": {
"mono": ["JetBrains Mono", "monospace"],
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"body-md": ["Inter"],
"card-rank": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"headline": ["JetBrains Mono"]
}
}
}
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
body {
background-color: #101417;
font-family: 'JetBrains Mono', monospace;
}
.retro-scanline {
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
background-size: 100% 2px, 3px 100%;
pointer-events: none;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="flex items-center justify-center min-h-screen text-on-background overflow-hidden">
<!-- Mobile Container (390x844) -->
<div class="relative w-[390px] h-[844px] bg-background flex flex-col overflow-hidden border border-outline-variant">
<!-- Status Bar -->
<div class="h-[32px] bg-surface-container flex items-center justify-between px-4 text-[11px] font-mono border-b border-outline-variant shrink-0">
<span class="text-suit-black">▌challenge.tsx</span>
<span class="text-[#a0a0a0]">LV 12 · UNLOCKED</span>
</div>
<!-- Header -->
<header class="h-[80px] px-margin-edge flex flex-col justify-center border-b border-outline-variant shrink-0">
<h1 class="text-[24px] font-bold leading-tight text-suit-black">CHALLENGE MODE</h1>
<p class="text-[12px] text-[#a0a0a0] mt-1">Curated puzzles · Beat par for bonus XP</p>
</header>
<!-- Stats Row -->
<div class="mx-margin-edge mt-4 bg-surface-container rounded-[4px] p-3 flex items-center justify-between border border-outline-variant shrink-0">
<div class="flex items-baseline gap-1">
<span class="text-[14px] font-bold text-suit-black">DONE 8/24</span>
<span class="text-[14px] font-bold text-highlight-celebration">(33%)</span>
</div>
<span class="text-outline-variant text-[14px]"></span>
<div class="text-[14px] font-bold text-suit-black">BEST AVG 03:42</div>
<span class="text-outline-variant text-[14px]"></span>
<div class="text-[14px] font-bold text-highlight-valid">+1240 XP</div>
</div>
<!-- Scrollable List Area -->
<div class="flex-1 overflow-y-auto px-margin-edge pt-4 space-y-3 pb-6">
<!-- Card 1 -->
<div class="h-[80px] bg-surface-container border border-outline rounded-[4px] flex relative overflow-hidden">
<div class="w-[6px] h-full bg-warning"></div>
<div class="flex-1 flex items-center justify-between px-4">
<div class="flex flex-col">
<span class="text-[14px] font-bold text-suit-black">DEEP STACK</span>
<span class="text-[12px] text-on-surface-variant">Win with 0 stock · ★★★☆☆</span>
</div>
<div class="bg-highlight-valid px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
✓ DONE
</div>
</div>
</div>
<!-- Card 2 -->
<div class="h-[80px] bg-surface-container border border-primary rounded-[4px] flex relative overflow-hidden">
<div class="w-[6px] h-full bg-highlight-valid"></div>
<div class="flex-1 flex items-center justify-between px-4">
<div class="flex flex-col">
<span class="text-[14px] font-bold text-suit-black">SPEED RUN</span>
<span class="text-[12px] text-on-surface-variant">Win under 2:30 · ★★☆☆☆</span>
</div>
<div class="bg-primary px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
▶ ACTIVE
</div>
</div>
</div>
<!-- Card 3 -->
<div class="h-[80px] bg-surface-container border border-outline rounded-[4px] flex relative overflow-hidden">
<div class="w-[6px] h-full bg-suit-red"></div>
<div class="flex-1 flex items-center justify-between px-4">
<div class="flex flex-col">
<span class="text-[14px] font-bold text-suit-black">NO UNDO</span>
<span class="text-[12px] text-on-surface-variant">Win without undo · ★★★★☆</span>
</div>
<div class="bg-primary px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
▶ ACTIVE
</div>
</div>
</div>
<!-- Card 4 -->
<div class="h-[80px] bg-surface-container border border-outline rounded-[4px] flex relative overflow-hidden">
<div class="w-[6px] h-full bg-info"></div>
<div class="flex-1 flex items-center justify-between px-4">
<div class="flex flex-col">
<span class="text-[14px] font-bold text-suit-black">FOUR SUITS</span>
<span class="text-[12px] text-on-surface-variant">1 card per suit · ★☆☆☆☆</span>
</div>
<div class="bg-highlight-valid px-2 py-1 rounded-[2px] text-background text-[11px] font-bold">
✓ DONE
</div>
</div>
</div>
<!-- Card 5 (Locked) -->
<div class="h-[80px] bg-surface-container border border-outline-variant rounded-[4px] flex relative overflow-hidden opacity-60">
<div class="w-[6px] h-full bg-highlight-celebration"></div>
<div class="flex-1 flex items-center justify-between px-4">
<div class="flex flex-col">
<span class="text-[14px] font-bold text-suit-black">PERFECT RUN</span>
<span class="text-[12px] text-on-surface-variant">Below par moves · ★★★★★</span>
</div>
<div class="bg-outline px-2 py-1 rounded-[2px] text-on-surface text-[11px] font-bold">
🔒 LOCKED
</div>
</div>
</div>
<!-- Filler Graphic for retro feel -->
<div class="flex items-center justify-center py-4">
<div class="h-[1px] flex-1 bg-outline-variant"></div>
<span class="px-4 text-[10px] text-outline text-label-caps">END OF LIST</span>
<div class="h-[1px] flex-1 bg-outline-variant"></div>
</div>
</div>
<!-- Shared Component: Terminal Context (Used as Footer) -->
<div class="h-[24px] bg-surface px-4 flex items-center justify-between text-[10px] font-mono border-t border-outline-variant shrink-0">
<div class="flex items-center gap-2">
<span class="text-primary">▌ NORMAL</span>
<span class="text-outline"></span>
<span class="text-on-surface-variant uppercase tracking-widest">challenge</span>
</div>
<div class="text-[#a0a0a0] flex items-center gap-3">
<span>[ENTER] select</span>
<span>[F] filter</span>
<span class="text-suit-red">[ESC] back</span>
</div>
</div>
<!-- Retro Scanline Overlay -->
<div class="absolute inset-0 retro-scanline z-50"></div>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+258
View File
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=390, height=844, initial-scale=1.0" name="viewport"/>
<title>Rusty Solitaire - Daily Challenge</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500;600&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
body {
background-color: #101417;
color: #e0e3e6;
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
}
.scanline-bg {
background: linear-gradient(to bottom, transparent 50%, rgba(26, 26, 26, 0.5) 50%);
background-size: 100% 4px;
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-secondary-fixed": "#161e00",
"on-error": "#690005",
"on-primary-fixed": "#001e2c",
"tertiary": "#f7c3ff",
"secondary-fixed-dim": "#bad073",
"primary-container": "#6fc2ef",
"surface-dim": "#101417",
"surface-variant": "#313538",
"on-error-container": "#ffdad6",
"warning": "#ddb26f",
"on-surface": "#e0e3e6",
"inverse-on-surface": "#2d3134",
"surface-tint": "#7ed0fe",
"error-container": "#93000a",
"on-tertiary": "#4c195b",
"info": "#12cfc0",
"tertiary-fixed": "#fbd7ff",
"tertiary-fixed-dim": "#f0b0fc",
"primary": "#a1dcff",
"on-primary": "#003549",
"inverse-surface": "#e0e3e6",
"highlight-valid": "#acc267",
"surface-container-low": "#181c1f",
"surface-container": "#1c2023",
"on-surface-variant": "#bfc8cf",
"secondary-container": "#435401",
"error": "#fb9fb1",
"surface": "#151515",
"primary-fixed": "#c4e7ff",
"outline": "#505050",
"surface-container-highest": "#313538",
"on-secondary": "#293500",
"on-primary-container": "#004f6c",
"secondary-fixed": "#d5ec8c",
"background": "#101417",
"surface-container-high": "#272a2d",
"suit-red-cb": "#6fc2ef",
"surface-container-lowest": "#0b0f11",
"suit-red": "#fb9fb1",
"on-secondary-container": "#b2c86d",
"outline-variant": "#3f484e",
"on-secondary-fixed-variant": "#3c4d00",
"inverse-primary": "#00668a",
"surface-bright": "#363a3d",
"primary-fixed-dim": "#7ed0fe",
"tertiary-container": "#e1a3ee",
"on-background": "#e0e3e6",
"on-tertiary-container": "#683476",
"suit-black": "#d0d0d0",
"on-primary-fixed-variant": "#004c69",
"secondary": "#bad073",
"on-tertiary-fixed-variant": "#653173",
"on-tertiary-fixed": "#340043",
"highlight-celebration": "#e1a3ee"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"gutter-card": "0.375rem",
"stack-overlap": "2rem",
"margin-edge": "1rem",
"action-bar-height": "64px",
"touch-target-min": "48dp"
},
"fontFamily": {
"label-caps": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"body-md": ["Inter"],
"headline": ["JetBrains Mono"]
},
"fontSize": {
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="flex flex-col min-h-screen max-w-[390px] mx-auto overflow-hidden shadow-2xl border-x border-outline">
<!-- 1. Status Bar -->
<div class="h-[32px] bg-[#202020] flex items-center justify-between px-margin-edge border-b border-outline">
<span class="font-hud-timer text-[12px] text-on-surface-variant">▌daily/2024-127.json</span>
<div class="bg-warning/10 border border-warning px-2 py-0.5 rounded-sm">
<span class="font-hud-timer text-[11px] text-warning font-bold tracking-tighter">EXPIRES 11:42:30</span>
</div>
</div>
<!-- Main Content Canvas -->
<main class="flex-1 p-margin-edge space-y-4 overflow-y-auto pb-8">
<!-- 2. Header Card -->
<section class="h-[130px] bg-[#1a1a1a] border border-[#353535] rounded-lg p-4 flex flex-col justify-between">
<div class="flex flex-col">
<span class="font-headline font-bold text-[24px] text-suit-black leading-none">MAY 07 · 2026</span>
<span class="font-headline font-extrabold text-[32px] text-highlight-valid -tracking-[0.01em] leading-tight">#2024-127</span>
</div>
<span class="font-label-caps text-[11px] text-on-surface-variant/70">DRAW-3 · DIFFICULTY ★★★☆☆ · PAR 04:30</span>
</section>
<!-- 3. Primary CTA -->
<button class="w-full h-[64px] bg-primary-container text-surface font-headline font-bold text-[14px] uppercase tracking-wider rounded-lg active:scale-95 transition-transform duration-80 flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[18px]">play_arrow</span>
ATTEMPT TODAY'S SEED
</button>
<!-- 4. Your Attempts Card -->
<section class="h-[96px] bg-[#202020] rounded-lg p-4 flex flex-col justify-between">
<span class="font-label-caps text-[11px] text-on-surface-variant/60 uppercase">YOUR ATTEMPTS</span>
<div class="flex justify-between items-end">
<div class="flex flex-col">
<span class="font-hud-score text-[16px] text-suit-black">BEST 04:12</span>
<div class="flex items-center gap-2 mt-1">
<span class="bg-warning text-surface text-[10px] font-bold px-1.5 py-0.5 rounded-sm">WIN</span>
<span class="font-label-caps text-[11px] text-warning">RANK 17/2843</span>
</div>
</div>
<span class="font-hud-timer text-[13px] text-error mb-1">LAST: FAILED at move 47</span>
</div>
</section>
<!-- 5. Leaderboard Card -->
<section class="bg-[#202020] rounded-lg p-4 flex flex-col flex-grow">
<span class="font-label-caps text-[11px] text-on-surface-variant/60 uppercase mb-4">TOP TODAY · 2,843 PLAYERS</span>
<div class="space-y-0 divide-y divide-[#353535]">
<!-- Row 1 -->
<div class="h-[32px] flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-5 h-5 flex items-center justify-center bg-warning text-surface text-[10px] font-bold rounded-full">01</span>
<span class="font-hud-timer text-[14px]">swift_jaguar</span>
</div>
<span class="font-hud-timer text-[14px] text-on-surface-variant">02:47</span>
</div>
<!-- Row 2 -->
<div class="h-[32px] flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-5 h-5 flex items-center justify-center bg-[#a0a0a0] text-surface text-[10px] font-bold rounded-full">02</span>
<span class="font-hud-timer text-[14px]">base16_fan</span>
</div>
<span class="font-hud-timer text-[14px] text-on-surface-variant">03:12</span>
</div>
<!-- Row 3 -->
<div class="h-[32px] flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-5 h-5 flex items-center justify-center bg-[#7a5d3b] text-surface text-[10px] font-bold rounded-full">03</span>
<span class="font-hud-timer text-[14px]">cli_player</span>
</div>
<span class="font-hud-timer text-[14px] text-on-surface-variant">03:54</span>
</div>
<!-- Row 4 -->
<div class="h-[32px] flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-5 h-5 flex items-center justify-center bg-[#353535] text-on-surface-variant text-[10px] font-bold rounded-full">04</span>
<span class="font-hud-timer text-[14px]">tablejockey</span>
</div>
<span class="font-hud-timer text-[14px] text-on-surface-variant">04:01</span>
</div>
<!-- Row 5 -->
<div class="h-[32px] flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-5 h-5 flex items-center justify-center bg-[#353535] text-on-surface-variant text-[10px] font-bold rounded-full">05</span>
<span class="font-hud-timer text-[14px]">vim_motions</span>
</div>
<span class="font-hud-timer text-[14px] text-on-surface-variant">04:05</span>
</div>
<!-- Row 17 (YOU) -->
<div class="h-[36px] flex items-center justify-between bg-primary-container/10 -mx-4 px-4 border-y border-primary-container/20">
<div class="flex items-center gap-3">
<span class="w-5 h-5 flex items-center justify-center bg-primary-container text-surface text-[10px] font-bold rounded-full">17</span>
<span class="font-hud-timer text-[14px] text-primary-container font-bold">(YOU) anonymous</span>
</div>
<span class="font-hud-timer text-[14px] text-primary-container font-bold">04:12</span>
</div>
</div>
<div class="mt-4 flex-1 border-t border-[#353535] pt-4 flex flex-col items-center justify-center opacity-30 select-none">
<span class="material-symbols-outlined text-[48px]">terminal</span>
<span class="font-label-caps text-[10px] mt-2">END OF VISIBLE LOG</span>
</div>
</section>
</main>
<!-- 6. Footer Navigation -->
<footer class="h-[24px] bg-background border-t border-outline flex items-center justify-between px-3">
<div class="flex items-center gap-2">
<span class="font-label-caps text-[10px] text-on-surface-variant">▌ NORMAL │ daily</span>
</div>
<div class="flex items-center gap-3">
<span class="font-label-caps text-[9px] text-on-surface-variant/70"><span class="text-primary-container">[ENTER]</span> attempt</span>
<span class="font-label-caps text-[9px] text-on-surface-variant/70"><span class="text-primary-container">[L]</span> full leaderboard</span>
<span class="font-label-caps text-[9px] text-on-surface-variant/70"><span class="text-primary-container">[ESC]</span> back</span>
</div>
</footer>
<!-- Shared Component Shell Rendering Logic -->
<header class="w-full top-0 sticky bg-background border-b border-outline flex justify-between items-center px-margin-edge h-action-bar-height hidden">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-primary">terminal</span>
<h1 class="font-headline text-headline text-primary uppercase tracking-widest">RUSTY SOLITAIRE</h1>
</div>
<span class="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors duration-120 cursor-pointer">settings</span>
</header>
<nav class="fixed bottom-0 w-full h-action-bar-height z-50 bg-surface-container border-t border-outline flex justify-around items-center px-2 hidden">
<div class="flex flex-col items-center justify-center text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
<span class="material-symbols-outlined">refresh</span>
<span class="font-label-caps text-label-caps">DEAL</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
<span class="material-symbols-outlined">undo</span>
<span class="font-label-caps text-label-caps">UNDO</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
<span class="material-symbols-outlined">lightbulb</span>
<span class="font-label-caps text-label-caps">HINT</span>
</div>
<div class="flex flex-col items-center justify-center text-primary dark:text-primary-fixed-dim hover:bg-surface-container-highest transition-colors duration-120 cursor-pointer w-full h-full">
<span class="material-symbols-outlined">menu</span>
<span class="font-label-caps text-label-caps">MENU</span>
</div>
</nav>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+278
View File
@@ -0,0 +1,278 @@
---
name: Terminal
colors:
surface: '#151515'
surface-dim: '#0d0d0d'
surface-bright: '#2a2a2a'
surface-container-lowest: '#0a0a0a'
surface-container-low: '#1a1a1a'
surface-container: '#202020'
surface-container-high: '#2a2a2a'
surface-container-highest: '#353535'
on-surface: '#d0d0d0'
on-surface-variant: '#a0a0a0'
inverse-surface: '#d0d0d0'
inverse-on-surface: '#151515'
outline: '#505050'
outline-variant: '#353535'
surface-tint: '#6fc2ef'
primary: '#6fc2ef'
on-primary: '#151515'
primary-container: '#1f3a4a'
on-primary-container: '#a8dcf5'
inverse-primary: '#0e6e99'
secondary: '#acc267'
on-secondary: '#151515'
secondary-container: '#2a3320'
on-secondary-container: '#c5d585'
tertiary: '#e1a3ee'
on-tertiary: '#151515'
tertiary-container: '#3a2a40'
on-tertiary-container: '#eec3f5'
error: '#fb9fb1'
on-error: '#151515'
error-container: '#4a2530'
on-error-container: '#fdc3ce'
background: '#151515'
on-background: '#d0d0d0'
surface-variant: '#353535'
suit-red: '#fb9fb1'
suit-black: '#d0d0d0'
suit-red-cb: '#6fc2ef'
highlight-valid: '#acc267'
highlight-celebration: '#e1a3ee'
highlight-warning: '#ddb26f'
highlight-info: '#12cfc0'
typography:
hud-score:
fontFamily: JetBrains Mono
fontSize: 24px
fontWeight: '700'
lineHeight: 32px
letterSpacing: '-0.02em'
hud-timer:
fontFamily: JetBrains Mono
fontSize: 16px
fontWeight: '400'
lineHeight: 24px
card-rank:
fontFamily: JetBrains Mono
fontSize: 18px
fontWeight: '700'
lineHeight: 18px
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: 24px
label-caps:
fontFamily: JetBrains Mono
fontSize: 12px
fontWeight: '500'
lineHeight: 16px
letterSpacing: '0.08em'
headline:
fontFamily: JetBrains Mono
fontSize: 28px
fontWeight: '700'
lineHeight: 32px
letterSpacing: '-0.01em'
rounded:
sm: 0.125rem
DEFAULT: 0.25rem
md: 0.5rem
lg: 0.75rem
xl: 1rem
full: 9999px
spacing:
margin-edge: 1rem
gutter-card: 0.375rem
stack-overlap: 2rem
touch-target-min: 48dp
---
## Brand & Style
The "Terminal" design system replaces the previous "Premium Solitaire" calm-indie aesthetic with a **retro-terminal / synthwave** identity. The intent is the visual confidence of a well-tuned terminal emulator (think Berkeley Mono dotfiles, base16-eighties, CRT phosphor): monospaced, dense, legible, snappy. It is *not* casino-glitz, *not* skeuomorphic felt, and *not* whimsical.
The personality is **technical, deliberate, slightly playful**. Cards are flat with thin colored strokes; the HUD reads like a status bar; modals look like terminal panes. Motion is short and snap-easing — no bouncy springs. Long-session calm is preserved by keeping the chroma low and reserving saturated accents for *meaning* (CTAs, feedback, celebrations) rather than decoration.
Influences: base16-eighties (Chris Kempson), Berkeley Mono, Vim/Neovim status lines, the iA Writer aesthetic, classic CRT phosphor with no chromatic aberration.
## Palette
The palette is base16-eighties — a 16-slot terminal palette where indices 0007 form a monochrome ramp and 080F provide saturated accents. We map base16 slots to Material Design 3 token roles below.
### Source palette (base16-eighties)
| Slot | Hex | Role |
|---|---|---|
| base00 | `#151515` | background |
| base01 | `#202020` | surface-container |
| base02 | `#303030` | line-highlight (subtle) |
| base03 | `#505050` | outline / muted text |
| base04 | `#b0b0b0` | secondary text |
| base05 | `#d0d0d0` | foreground / on-surface |
| base06 | `#e0e0e0` | bright text |
| base07 | `#f5f5f5` | brightest highlight |
| base08 | `#fb9fb1` | red — used for `error`, `suit-red` |
| base09 | `#ddb26f` | orange — used for warning chips |
| base0A | `#acc267` | yellow/lime — used for `highlight-valid` (drag targets, valid moves) |
| base0B | `#12cfc0` | green/teal — used for `highlight-info` (toasts, neutral status) |
| base0C | `#6fc2ef` | cyan/sky — primary CTA, focus ring, `selection`, `suit-red-cb` (color-blind tinted red) |
| base0D | `#6fc2ef` | (alias) |
| base0E | `#e1a3ee` | violet — used for celebration (level-up, achievement unlock) |
| base0F | `#fb9fb1` | (alias) |
### Semantic assignments
- **CTA / Primary action**: cyan `#6fc2ef`. Reserved for "Play," "New Game," "Save," "Resume," and the focus ring on selected cards. Never used decoratively.
- **Valid-move / drag-target highlight**: lime `#acc267`. Reserved for in-game feedback only. Never appears in chrome.
- **Celebration**: lavender `#e1a3ee`. Used for level-up flashes, achievement unlock cards, and the daily-streak chip when the streak is active. Quiet otherwise.
- **Warning / soft alert**: gold `#ddb26f`. Used for "challenge expires in N minutes" chips, sync-pending status, and the daily-seed countdown.
- **Info**: teal `#12cfc0`. Used for neutral system toasts and the sync-connected indicator.
- **Error**: pink `#fb9fb1`. Used for sync conflict, server unreachable, invalid move shake.
## Suit Colors
**Two-color traditional mapping**, with mandatory color-blind support:
| Suit | Default | Color-blind mode | Glyph differentiation |
|---|---|---|---|
| Hearts | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | Solid filled glyph |
| Diamonds | `#fb9fb1` (pink) | `#6fc2ef` (cyan) | **Outlined glyph (1.5px stroke)** |
| Spades | `#d0d0d0` (foreground) | `#d0d0d0` | Solid filled glyph |
| Clubs | `#d0d0d0` (foreground) | `#d0d0d0` | **Outlined glyph (1.5px stroke)** |
The outlined-glyph treatment is the **primary** differentiation mechanism. Color is supplementary. This means a player viewing the game on a monochrome display, or with severe red-green deficiency, can still distinguish all four suits without context. This is a hard requirement, not an optional setting.
The "color-blind mode" toggle in Settings only swaps red→cyan; it does not turn the outlined glyphs on or off, because outlined glyphs are always on.
## Typography
**Monospace-forward, dual-font system.**
- **JetBrains Mono** is used for: HUD (score, timer, moves), card rank/value text, all labels, all headlines, all numerals anywhere in the app, and any chip-style component. This is the dominant face.
- **Inter** is used only for: long-form body copy (Help screen, Settings descriptions, achievement tooltips, onboarding copy). It is the *exception*, not the default.
Weights: 400 regular, 500 medium for labels, 700 bold for HUD numbers and headlines. No 600 / no italics anywhere — the terminal aesthetic doesn't have them.
Letter spacing: tight (`-0.02em`) on HUD score for visual mass; wide (`+0.08em`) on uppercase labels for readability at 12px. Body uses default (0).
HUD numbers must use **tabular figures** (`font-feature-settings: 'tnum'`) so the timer and score don't reflow as digits change.
## Layout & Spacing
Optimized for **Android portrait, 390×844 (Pixel 6 baseline), API 34**.
- **Margins**: 16px (1rem) edge safety margin. *Tighter than the previous system's 24px.* Eighties palettes are dense by nature; over-padding fights the aesthetic.
- **Tableau**: 7-column layout, 32px (2rem) vertical card overlap. Tighter than before to fit a longer cascade on phone screens.
- **HUD position**: top of screen, in the system safe area. Bottom 64px holds the action bar (Undo / Hint / New Game / Auto-complete). Action bar is **always visible** in-game — no hover-fade — because there is no hover on touch.
- **Touch target minimum**: 48dp on all interactive elements. Cards in the tableau may be smaller visually but use a 48dp invisible hit area centered on the visible glyph.
## Elevation & Depth
Depth is created through **tonal layering and 1px outlines**, not blur shadows. (Synthwave-flat, not Material-soft.)
- **Level 0 (Background)**: the `#151515` base canvas.
- **Level 1 (Tableau slots, empty piles)**: 1px dashed outline in `#353535`. Empty foundations show a faint suit glyph at 12% opacity inside the outline.
- **Level 2 (Cards at rest)**: solid `#1a1a1a` fill, 1px solid border in the suit color (so the suit is detectable at a glance even if the card is partially obscured).
- **Level 3 (Active / dragged card)**: same border, but glow effect: 0 0 12px of `#6fc2ef` at 40% opacity. **No scale transform** — flatness preserved. Z-index lifts above siblings.
- **Modals**: full-screen with backdrop `#151515` at 95% opacity (just enough to dim the table without blurring it). Modal panel is `#202020` with a 1px `#505050` border — like a terminal pane.
- **Toasts**: bottom of screen, `#202020` fill, 1px border in the toast's accent color (info=teal, warning=gold, error=pink, celebration=lavender). 16px monospaced caption.
No `box-shadow` is used anywhere. **All depth is achieved with borders and tonal value.** This is a hard constraint.
## Shapes
The shape language is **soft-rounded but tight**:
- **Cards**: `rounded-md` (8px) — slightly less rounded than the previous system's 16px to read more "technical."
- **Buttons / chips / inputs**: `rounded` (4px) default, `rounded-sm` (2px) for the smallest chips.
- **Modals / sheets**: `rounded-lg` (12px).
- **Avatars / circular indicators**: `rounded-full`.
- **Card-back pattern corners**: matches the card's `rounded-md`.
Selection highlights use a **2px inset stroke** in `#6fc2ef` following the host shape's corner radius. Never an outer stroke — the outer stroke is reserved for the suit-color hairline.
## Motion
**Snappy, no spring.** All transitions use `ease-out` with a 120ms duration unless specified.
- Card lift (start drag): 80ms.
- Card place (drop): 120ms with a 16ms holdframe (no bounce).
- Modal enter: 200ms ease-out, fade + 8px translate-up.
- Modal exit: 120ms ease-in, fade only.
- Selection ring appear: 80ms.
- Win-summary stat reveal: 60ms each, staggered 40ms.
- HUD number tick: instant (no transition) — terminal counters don't ease.
**Optional CRT effect**: a 1-frame scanline sweep across the screen on game-state transitions (start, win, restart). User-toggleable in Settings. Off by default.
## Components
### Game Cards
Flat face design.
- Background: `#1a1a1a`
- Border: 1px solid in suit color (pink for hearts/diamonds, foreground gray for spades/clubs)
- Top-left: rank in JetBrains Mono Bold 18px + small suit glyph (10px)
- Bottom-right: large suit glyph (32px), rotated 180°
- Corner radius: 8px
- Suit differentiation: hearts and spades have **filled** glyphs; diamonds and clubs have **outlined** glyphs (1.5px stroke)
### Card Back ("Terminal" theme)
- Theme name: `"Terminal"`
- Author: `"Rusty Solitaire"`
- Background: `#151515`
- Pattern: horizontal scanlines at 2px pitch in `#1a1a1a` (1px line, 1px gap), full bleed
- Border: 1px solid `#353535`
- Top-left badge: a 12×16px solid `#6fc2ef` block (the "terminal cursor"), 6px from the corner
- Bottom-right monogram: the characters `▌RS` in JetBrains Mono 12px, color `#505050`, 6px from the corner
- Corner radius: 8px (matches face)
### Primary Buttons
Solid `#6fc2ef` fill, `#151515` text, JetBrains Mono Medium 14px uppercase with `+0.08em` tracking. 4px corner radius. Pressed state: darken to `#5aa9d4`. Disabled: `#353535` fill, `#505050` text.
### Secondary Buttons
Transparent fill, 1px `#505050` border, `#d0d0d0` text. Hover/press: border becomes `#6fc2ef`, text becomes `#6fc2ef`.
### HUD Chips
`#202020` fill, no border, 4px radius. Monospaced 16px text. Score chip pulses to `#acc267` for 200ms when score increases.
### Drag Targets
When a card is being dragged over a valid pile, the pile's empty-slot dashed outline becomes:
- Solid 1px in `#acc267`
- Plus a 0 0 8px outer glow in `#acc267` at 30% opacity
This is the *only* place glow effects appear in the system.
### Modals
Full-screen backdrop at 95% opacity. Centered panel: `#202020` fill, 1px `#505050` border, 12px corner radius. Title bar shows the screen name in monospaced 14px, color `#a0a0a0`, with a single `▌` cursor character prefix to reinforce the terminal pane motif.
### Navigation Bar
Fixed at the bottom of in-game screens. Height: 64px. `#202020` fill, 1px top border in `#353535`. Four icon buttons: Undo / Hint / New / Auto-complete. Icons: 24px, 1.5px stroke weight, color `#d0d0d0`. Active/pressed: icon color `#6fc2ef`.
### Status / Sync Indicator
Top-right corner of the HUD: a 6px circular dot.
- Connected & synced: `#12cfc0`
- Pending: `#ddb26f` (pulsing 1.5s)
- Error: `#fb9fb1` (steady)
- Offline: `#505050`
## Accessibility
1. **Color-blind mode** (Settings → Gameplay): swaps red suits' default `#fb9fb1` for `#6fc2ef`. Outlined-glyph differentiation remains active in *all* modes.
2. **High-contrast mode** (Settings → Gameplay): boosts on-surface from `#d0d0d0` to `#f5f5f5`, outline from `#505050` to `#a0a0a0`, suit-red from `#fb9fb1` to `#ff8aa0`.
3. **Reduce-motion mode** (Settings → Gameplay): disables card-lift transition (instant z-lift), disables CRT scanline effect, disables the warning-chip pulse animation.
4. **Tabular figures** are mandatory for any number that updates live (timer, score, moves) so they don't reflow.
5. **Touch targets** are 48dp minimum even when the visual element is smaller.
6. **Text contrast**: all body text on background passes WCAG AA at minimum (`#d0d0d0` on `#151515` = 9.5:1; `#a0a0a0` on `#151515` = 5.7:1).
+253
View File
@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.outlined-glyph {
-webkit-text-stroke: 1.5px currentColor;
color: transparent;
}
.scanline-pattern {
background: repeating-linear-gradient(
0deg,
#1a1a1a,
#1a1a1a 2px,
#151515 2px,
#151515 4px
);
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface": "#151515",
"secondary-fixed": "#d5ec8c",
"warning": "#ddb26f",
"tertiary-fixed": "#fbd7ff",
"on-tertiary-fixed-variant": "#653173",
"on-background": "#e0e3e6",
"on-primary-container": "#004f6c",
"surface-container-lowest": "#0b0f11",
"on-surface": "#e0e3e6",
"error": "#fb9fb1",
"primary-fixed-dim": "#7ed0fe",
"inverse-primary": "#00668a",
"surface-container-high": "#272a2d",
"suit-red-cb": "#6fc2ef",
"surface-bright": "#363a3d",
"on-primary": "#003549",
"on-tertiary": "#4c195b",
"error-container": "#93000a",
"on-tertiary-fixed": "#340043",
"surface-container": "#202020",
"tertiary-container": "#e1a3ee",
"on-primary-fixed-variant": "#004c69",
"surface-container-highest": "#313538",
"highlight-celebration": "#e1a3ee",
"highlight-valid": "#acc267",
"primary": "#a1dcff",
"secondary-fixed-dim": "#bad073",
"on-primary-fixed": "#001e2c",
"on-error-container": "#ffdad6",
"secondary": "#bad073",
"on-tertiary": "#293500",
"on-secondary-container": "#b2c86d",
"inverse-on-surface": "#2d3134",
"on-error": "#690005",
"info": "#12cfc0",
"suit-red": "#fb9fb1",
"surface-dim": "#101417",
"surface-tint": "#7ed0fe",
"background": "#101417",
"secondary-container": "#435401",
"surface-variant": "#313538",
"outline-variant": "#3f484e",
"on-surface-variant": "#bfc8cf",
"primary-fixed": "#c4e7ff",
"on-secondary-fixed-variant": "#3c4d00",
"on-secondary-fixed": "#161e00",
"suit-black": "#d0d0d0"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"touch-target-min": "48px",
"margin-edge": "1rem",
"action-bar-height": "64px",
"stack-overlap": "2rem",
"gutter-card": "0.375rem"
},
"fontFamily": {
"label-caps": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"body-md": ["Inter"],
"hud-score": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"]
},
"fontSize": {
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
}
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface font-body-md overflow-hidden selection:bg-primary selection:text-surface">
<!-- TopAppBar -->
<header class="fixed top-0 w-full flex justify-between items-center px-margin-edge h-[56px] bg-surface-container border-b border-outline dark:border-outline z-50">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-primary">terminal</span>
<h1 class="font-hud-score text-[18px] text-primary">solitaire.sh</h1>
</div>
<div class="flex items-center gap-4">
<div class="w-[6px] h-[6px] rounded-full bg-info"></div>
<span class="material-symbols-outlined text-on-surface-variant">settings</span>
</div>
</header>
<!-- HUD Band -->
<div class="fixed top-[56px] left-0 w-full h-[56px] bg-surface-container border-b border-outline-variant flex items-center justify-around px-margin-edge z-40">
<div class="bg-surface p-1 px-3 rounded flex flex-col items-center">
<span class="font-label-caps text-[10px] text-on-surface-variant">SCORE</span>
<span class="font-hud-score text-primary tabular-nums">247</span>
</div>
<div class="bg-surface p-1 px-3 rounded flex flex-col items-center border border-outline">
<span class="font-label-caps text-[10px] text-on-surface-variant">TIME</span>
<span class="font-hud-timer text-on-surface tabular-nums">12:34</span>
</div>
<div class="bg-surface p-1 px-3 rounded flex flex-col items-center">
<span class="font-label-caps text-[10px] text-on-surface-variant">MOVES</span>
<span class="font-hud-score text-secondary tabular-nums">87</span>
</div>
</div>
<!-- Main Game Table -->
<main class="pt-[124px] px-margin-edge h-screen w-full relative">
<!-- Top Row: Stock, Waste, Foundations -->
<div class="grid grid-cols-7 gap-gutter-card h-[110px]">
<!-- Stock -->
<div class="relative w-full h-full rounded-xl border border-outline-variant bg-surface overflow-hidden scanline-pattern">
<div class="absolute top-1 left-1 w-3 h-4 bg-suit-red-cb"></div>
<div class="absolute bottom-1 right-1 font-label-caps text-[8px] text-suit-black">▌RS</div>
<div class="absolute bottom-[-16px] left-0 w-full text-center font-label-caps text-[10px] text-on-surface-variant">STOCK · 18</div>
</div>
<!-- Waste -->
<div class="relative w-full h-full rounded-xl border border-suit-red bg-[#1a1a1a] flex flex-col justify-between p-1.5">
<div class="font-card-rank text-suit-red leading-none">10<br/><span class="font-normal"></span></div>
<div class="self-end text-[32px] font-card-rank text-suit-red rotate-180"></div>
</div>
<!-- Empty Gap -->
<div></div>
<!-- Foundation S -->
<div class="relative w-full h-full rounded-xl border border-dashed border-outline-variant flex items-center justify-center">
<span class="text-on-surface-variant opacity-20 text-[32px] font-card-rank"></span>
</div>
<!-- Foundation H -->
<div class="relative w-full h-full rounded-xl border border-suit-red bg-[#1a1a1a] flex flex-col justify-between p-1.5">
<div class="font-card-rank text-suit-red leading-none">2<br/><span class="font-normal"></span></div>
<div class="self-end text-[32px] font-card-rank text-suit-red rotate-180"></div>
</div>
<!-- Foundation C -->
<div class="relative w-full h-full rounded-xl border border-dashed border-outline-variant flex items-center justify-center">
<span class="text-on-surface-variant opacity-20 text-[32px] font-card-rank outlined-glyph"></span>
</div>
<!-- Foundation D -->
<div class="relative w-full h-full rounded-xl border border-dashed border-outline-variant flex items-center justify-center">
<span class="text-on-surface-variant opacity-20 text-[32px] font-card-rank outlined-glyph"></span>
</div>
</div>
<!-- Tableau -->
<div class="mt-8 grid grid-cols-7 gap-gutter-card items-start relative h-[400px]">
<!-- Col 1 -->
<div class="relative w-full h-full">
<div class="w-full h-[96px] rounded-xl border border-suit-black bg-[#1a1a1a] p-1.5">
<div class="font-card-rank text-suit-black leading-none">K<br/><span class="font-normal"></span></div>
</div>
</div>
<!-- Col 2 -->
<div class="relative w-full h-full">
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
<div class="absolute top-[32px] w-full h-[96px] rounded-xl border border-suit-red bg-[#1a1a1a] p-1.5">
<div class="font-card-rank text-suit-red leading-none">Q<br/><span class="font-normal"></span></div>
</div>
</div>
<!-- Col 3 -->
<div class="relative w-full h-full">
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
<div class="absolute top-[32px] w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
<div class="absolute top-[64px] w-full h-[96px] rounded-xl border border-suit-red bg-[#1a1a1a] p-1.5">
<div class="font-card-rank text-suit-red leading-none">10<br/><span class="font-normal outlined-glyph"></span></div>
</div>
</div>
<!-- Col 4 -->
<div class="relative w-full h-full">
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern"></div>
<div class="w-full h-[96px] rounded-xl border border-outline bg-[#1a1a1a] scanline-pattern absolute top-[32px]"></div>
<!-- Valid Drop Target Glow -->
<div class="absolute top-[64px] w-full h-[96px] rounded-xl border border-suit-black bg-[#1a1a1a] p-1.5 ring-4 ring-highlight-valid/30">
<div class="font-card-rank text-suit-black leading-none">9<br/><span class="font-normal"></span></div>
</div>
</div>
<!-- Col 5, 6 (Empty/Filler) -->
<div class="relative w-full"></div>
<div class="relative w-full"></div>
<!-- Col 7 -->
<div class="relative w-full">
<!-- Original Position Placeholder -->
<div class="w-full h-[96px] rounded-xl border border-dashed border-outline"></div>
<!-- Being Dragged Card -->
<div class="absolute top-[-20px] left-[30px] w-full h-[96px] rounded-xl border border-suit-red bg-[#1a1a1a] p-1.5 shadow-[0_0_20px_rgba(111,194,239,0.4)] z-50 ring-1 ring-primary/40">
<div class="font-card-rank text-suit-red leading-none">4<br/><span class="font-normal outlined-glyph"></span></div>
<div class="absolute bottom-1 right-1 text-[24px] font-card-rank text-suit-red rotate-180 outlined-glyph"></div>
</div>
</div>
</div>
</main>
<!-- BottomNavBar / Action Bar -->
<nav class="fixed bottom-0 left-0 w-full h-action-bar-height bg-surface-container border-t border-outline-variant flex justify-around items-center px-margin-edge z-50">
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-info transition-colors duration-120 active:opacity-80">
<span class="material-symbols-outlined" data-icon="menu">menu</span>
<span class="font-label-caps text-[10px] mt-1">[ESC] MENU</span>
</button>
<button class="flex flex-col items-center justify-center text-info font-bold active:opacity-80">
<span class="material-symbols-outlined" data-icon="undo">undo</span>
<span class="font-label-caps text-[10px] mt-1">[U] UNDO</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-info transition-colors duration-120 active:opacity-80">
<span class="material-symbols-outlined" data-icon="lightbulb">lightbulb</span>
<span class="font-label-caps text-[10px] mt-1">[H] HINT</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-info transition-colors duration-120 active:opacity-80">
<span class="material-symbols-outlined" data-icon="add_box">add_box</span>
<span class="font-label-caps text-[10px] mt-1">[N] NEW</span>
</button>
</nav>
<!-- Drag & CRT Overlay (Visual Decoration) -->
<div class="pointer-events-none fixed inset-0 z-[100] opacity-[0.03] scanline-pattern mix-blend-overlay"></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+200
View File
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-secondary-container": "#b2c86d",
"secondary-fixed-dim": "#bad073",
"surface-tint": "#7ed0fe",
"on-surface-variant": "#bfc8cf",
"surface-container-low": "#181c1f",
"secondary-fixed": "#d5ec8c",
"primary-fixed-dim": "#7ed0fe",
"secondary": "#bad073",
"tertiary-container": "#e1a3ee",
"inverse-on-surface": "#2d3134",
"surface-container-lowest": "#0b0f11",
"on-error-container": "#ffdad6",
"on-primary-fixed-variant": "#004c69",
"secondary-container": "#435401",
"background": "#101417",
"surface-variant": "#313538",
"on-primary-container": "#004f6c",
"highlight-valid": "#acc267",
"outline-variant": "#3f484e",
"on-background": "#e0e3e6",
"surface-bright": "#363a3d",
"on-tertiary-fixed-variant": "#653173",
"on-secondary-fixed": "#161e00",
"surface-dim": "#101417",
"on-surface": "#e0e3e6",
"info": "#12cfc0",
"on-secondary": "#293500",
"suit-red": "#fb9fb1",
"error": "#fb9fb1",
"error-container": "#93000a",
"surface-container": "#202020",
"primary-fixed": "#c4e7ff",
"warning": "#ddb26f",
"tertiary": "#f7c3ff",
"highlight-celebration": "#e1a3ee",
"tertiary-fixed": "#fbd7ff",
"inverse-surface": "#e0e3e6",
"tertiary-fixed-dim": "#f0b0fc",
"primary-container": "#6fc2ef",
"on-secondary-fixed-variant": "#3c4d00",
"on-tertiary": "#4c195b",
"suit-red-cb": "#6fc2ef",
"surface-container-highest": "#313538",
"on-primary-fixed": "#001e2c",
"surface-container-high": "#272a2d",
"primary": "#a1dcff",
"suit-black": "#d0d0d0",
"on-tertiary-container": "#683476",
"on-error": "#690005",
"inverse-primary": "#00668a",
"on-tertiary-fixed": "#340043",
"outline": "#505050",
"on-primary": "#003549",
"surface": "#151515"
},
"fontFamily": {
"jetbrains": ["JetBrains Mono", "monospace"],
"inter": ["Inter", "sans-serif"]
}
}
}
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
font-size: 16px;
}
.tabular-nums { font-variant-numeric: tabular-nums; }
body { background-color: #151515; }
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4">
<!-- Mobile Container (390x844) -->
<div class="w-[390px] h-[844px] bg-surface flex flex-col overflow-hidden relative border border-outline/20">
<!-- 1. Status Bar -->
<header class="h-[32px] bg-surface-container flex items-center justify-between px-4 shrink-0">
<span class="font-jetbrains text-[12px] font-bold text-suit-black tracking-tight">▌rusty-solitaire(1) · MAN PAGE</span>
<button class="font-jetbrains text-[12px] font-bold text-suit-black/60 hover:text-primary transition-colors">× CLOSE</button>
</header>
<!-- 2. Heading Band -->
<div class="h-[120px] px-4 pt-10 pb-4 shrink-0">
<h1 class="font-jetbrains font-bold text-[24px] text-suit-black leading-none mb-1">GESTURES &amp; SHORTCUTS</h1>
<p class="font-inter text-[13px] text-on-surface-variant/80">Touch gestures and keyboard equivalents.</p>
</div>
<!-- Scrollable Content Section -->
<main class="flex-1 overflow-y-auto px-4 pb-8 space-y-6">
<!-- 3a. TOUCH GESTURES -->
<section class="space-y-3">
<h2 class="font-jetbrains text-[11px] font-medium tracking-widest text-on-surface-variant/60 uppercase">TOUCH GESTURES</h2>
<div class="space-y-1">
<!-- Row 1 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] flex items-center gap-2">
<span class="material-symbols-outlined text-suit-black" data-icon="square">square</span>
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">TAP card</span>
</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Select / unselect for move</div>
</div>
<!-- Row 2 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] flex items-center gap-2">
<span class="material-symbols-outlined text-suit-black" data-icon="east">east</span>
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">DRAG stack</span>
</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Move with translucent ghost preview</div>
</div>
<!-- Row 3 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] flex items-center gap-2">
<span class="material-symbols-outlined text-suit-black" data-icon="double_arrow">double_arrow</span>
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">DOUBLE-TAP</span>
</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Auto-send to best foundation</div>
</div>
<!-- Row 4 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] flex items-center gap-2">
<span class="material-symbols-outlined text-suit-black" data-icon="touch_app">touch_app</span>
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">LONG-PRESS</span>
</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Highlight all legal moves for card</div>
</div>
<!-- Row 5 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] flex items-center gap-2">
<span class="material-symbols-outlined text-suit-black" data-icon="south">south</span>
<span class="font-jetbrains text-[13px] font-medium text-suit-black uppercase">SWIPE DOWN</span>
</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Reveal hidden action bar</div>
</div>
</div>
</section>
<!-- 3b. KEYBOARD SHORTCUTS -->
<section class="space-y-3">
<h2 class="font-jetbrains text-[11px] font-medium tracking-widest text-on-surface-variant/60 uppercase">KEYBOARD SHORTCUTS</h2>
<div class="space-y-1">
<!-- Row 1 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[U]</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Undo last move</div>
</div>
<!-- Row 2 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[H]</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Show hint</div>
</div>
<!-- Row 3 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[N]</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">New game</div>
</div>
<!-- Row 4 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[A]</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Auto-complete (when possible)</div>
</div>
<!-- Row 5 -->
<div class="h-[56px] bg-surface-container rounded-lg px-3 flex items-center border border-outline/10">
<div class="w-[40%] font-jetbrains text-[13px] font-medium text-suit-black uppercase">[ESC]</div>
<div class="w-[60%] font-jetbrains text-[12px] text-on-surface-variant leading-tight">Pause / back</div>
</div>
</div>
</section>
</main>
<!-- 4. Footer -->
<footer class="h-[24px] bg-surface-container border-t border-outline/20 flex items-center justify-between px-2 shrink-0">
<div class="font-jetbrains text-[10px] text-suit-black">
<span class="opacity-80">▌ NORMAL │ help</span>
</div>
<div class="font-jetbrains text-[10px] uppercase tracking-wider flex items-center gap-1">
<span class="text-outline">PRESS</span>
<span class="text-on-surface-variant">[ESC]</span>
<span class="text-outline">OR TAP</span>
<span class="text-on-surface-variant">×</span>
<span class="text-outline">TO RETURN</span>
</div>
</footer>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+343
View File
@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>RS_TERMINAL_OS - Rusty Solitaire</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: 18px;
}
body {
background-color: #151515;
color: #d0d0d0;
font-family: 'JetBrains Mono', monospace;
overflow: hidden;
}
.scanline {
width: 100%;
height: 2px;
background: rgba(26, 26, 26, 0.5);
position: absolute;
pointer-events: none;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #151515;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #353535;
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-tertiary-container": "#683476",
"surface-dim": "#101417",
"primary-fixed": "#c4e7ff",
"on-error": "#690005",
"on-secondary-fixed": "#161e00",
"on-tertiary": "#4c195b",
"primary-fixed-dim": "#7ed0fe",
"outline-variant": "#3f484e",
"tertiary": "#f7c3ff",
"surface": "#151515",
"tertiary-container": "#e1a3ee",
"highlight-celebration": "#e1a3ee",
"background": "#101417",
"surface-container": "#202020",
"primary-container": "#6fc2ef",
"on-secondary-fixed-variant": "#3c4d00",
"on-surface": "#d0d0d0",
"inverse-on-surface": "#2d3134",
"on-error-container": "#ffdad6",
"surface-container-low": "#181c1f",
"on-tertiary-fixed": "#340043",
"on-secondary-container": "#b2c86d",
"on-background": "#e0e3e6",
"secondary-container": "#435401",
"error": "#fb9fb1",
"info": "#12cfc0",
"on-surface-variant": "#bfc8cf",
"warning": "#ddb26f",
"inverse-primary": "#00668a",
"tertiary-fixed-dim": "#f0b0fc",
"surface-tint": "#7ed0fe",
"suit-black": "#d0d0d0",
"tertiary-fixed": "#fbd7ff",
"on-secondary": "#293500",
"on-primary-fixed": "#001e2c",
"surface-container-highest": "#313538",
"error-container": "#93000a",
"surface-container-high": "#272a2d",
"on-primary-container": "#004f6c",
"inverse-surface": "#e0e3e6",
"on-primary": "#003549",
"suit-red-cb": "#6fc2ef",
"on-primary-fixed-variant": "#004c69",
"on-tertiary-fixed-variant": "#653173",
"secondary-fixed": "#d5ec8c",
"highlight-valid": "#acc267",
"surface-variant": "#313538",
"secondary": "#bad073",
"secondary-fixed-dim": "#bad073",
"outline": "#505050",
"surface-container-lowest": "#0b0f11",
"primary": "#a1dcff",
"surface-bright": "#363a3d",
"suit-red": "#fb9fb1"
},
"borderRadius": {
"DEFAULT": "0px",
"lg": "0px",
"xl": "0px",
"full": "0px"
},
"spacing": {
"stack-overlap": "2rem",
"touch-target-min": "48px",
"margin-edge": "1rem",
"gutter-card": "0.375rem",
"action-bar-height": "64px"
},
"fontFamily": {
"card-rank": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"body-md": ["Inter"],
"label-caps": ["JetBrains Mono"]
},
"fontSize": {
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}]
}
},
},
}
</script>
</head>
<body class="bg-surface text-on-surface h-screen flex flex-col antialiased">
<!-- TOP BAR (32px) -->
<header class="h-8 bg-surface-container border-b border-outline flex items-center justify-between px-4 z-50">
<div class="flex items-center gap-2">
<span class="text-primary-container font-bold"></span>
<h1 class="font-headline text-[14px] font-bold tracking-tight text-on-surface">RS_TERMINAL_OS</h1>
</div>
<nav class="flex gap-4 font-label-caps text-[12px] uppercase tracking-widest">
<span class="text-primary-container">[ HOME ]</span>
<span class="text-on-surface-variant hover:text-primary transition-colors cursor-pointer">· PLAY</span>
<span class="text-on-surface-variant hover:text-primary transition-colors cursor-pointer">· STATS</span>
<span class="text-on-surface-variant hover:text-primary transition-colors cursor-pointer">· SETTINGS</span>
</nav>
<div class="flex items-center gap-3 font-label-caps text-[11px] text-on-surface-variant">
<div class="flex items-center gap-1">
<span>LV 12</span>
<span class="text-outline">|</span>
<div class="flex items-center gap-2">
<span>XP 320/500</span>
<div class="w-[60px] h-1 bg-surface-container-highest">
<div class="h-full bg-primary-container w-[64%]"></div>
</div>
</div>
</div>
<span class="text-outline">|</span>
<div class="flex items-center gap-1 text-info">
<span class="w-2 h-2 rounded-full bg-info"></span>
<span class="uppercase">Synced</span>
</div>
<span class="text-outline">|</span>
<span class="text-outline">v0.20.0</span>
</div>
</header>
<!-- MAIN CONTENT AREA -->
<main class="flex-1 flex overflow-hidden">
<!-- LEFT PANE (40%) -->
<section class="w-[40%] border-r border-outline flex flex-col p-8 gap-8 overflow-y-auto custom-scrollbar">
<div class="space-y-1">
<p class="text-outline font-label-caps text-xs">▌play.tsx</p>
<h2 class="font-headline text-[32px] font-bold text-on-surface leading-none uppercase">Ready to play?</h2>
<p class="text-on-surface-variant font-label-caps text-sm tracking-wide">RESUME · 12:34 ELAPSED · DRAW-3</p>
</div>
<button class="w-full h-24 bg-primary-container text-surface font-headline text-[24px] font-bold flex items-center justify-center gap-4 hover:brightness-110 active:scale-[0.98] transition-all">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
CONTINUE GAME
</button>
<div class="grid grid-cols-2 gap-4">
<button class="h-12 border border-outline bg-transparent text-on-surface font-label-caps text-sm hover:border-primary-container hover:text-primary-container transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined">add</span>
NEW GAME
</button>
<button class="h-12 border border-outline bg-transparent text-on-surface font-label-caps text-sm hover:border-primary-container hover:text-primary-container transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined">refresh</span>
RESTART RUN
</button>
</div>
<div class="space-y-4">
<p class="text-outline font-label-caps text-xs uppercase tracking-widest">Game Modes</p>
<div class="grid grid-cols-3 gap-3">
<!-- Zen -->
<div class="aspect-square border border-outline flex flex-col items-center justify-center gap-2 hover:bg-surface-container transition-colors cursor-pointer group">
<span class="material-symbols-outlined text-outline group-hover:text-primary-container">spa</span>
<span class="font-label-caps text-[10px] uppercase">Zen</span>
</div>
<!-- Time Attack -->
<div class="aspect-square border border-outline flex flex-col items-center justify-center gap-2 hover:bg-surface-container transition-colors cursor-pointer group">
<span class="material-symbols-outlined text-outline group-hover:text-primary-container">timer</span>
<span class="font-label-caps text-[10px] uppercase text-center">Time<br/>Attack</span>
</div>
<!-- Locked Challenge -->
<div class="aspect-square bg-[#0d0d0d] border border-outline/30 flex flex-col items-center justify-center gap-2 relative opacity-60">
<span class="material-symbols-outlined text-outline">lock</span>
<span class="font-label-caps text-[10px] uppercase">Challenge</span>
<div class="absolute -top-2 -right-2 bg-warning text-surface px-1 py-0.5 text-[8px] font-bold">LV 5</div>
</div>
</div>
</div>
<!-- VISUAL DECORATION (IMAGE PLACEHOLDER) -->
<div class="mt-auto pt-8">
<div class="w-full h-40 border border-outline overflow-hidden">
<img class="w-full h-full object-cover opacity-40 grayscale hover:grayscale-0 transition-all duration-700" data-alt="A dark, high-contrast digital art piece showing an abstract terminal interface with glowing cyan scanlines and retro-futuristic grid patterns. The composition is geometric and minimalist, following a synthwave aesthetic with deep black backgrounds and crisp cyan light elements. The lighting is moody and artificial, suggesting a high-performance computer screen in a dimly lit server room. Professional, sharp-edged UI design style." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAet8SrRWSacZfwd8ISRQdDC7CDGixBwRnPAVMmMcjbifq1jnHSzCGWgSSL6YPSRfCkLNWr91BxTzV4zigGjMBLlk7rCLo5I7X7F6ydinDrKJVqZkRbvHJeSo90BPANoQwZtzPvhKXVEA9C2DbBaj8KPR4ObCo24Mj25NXPvGNThOE-3BSpuU6MPC-hrUMPVCPJpZnJdI_OmSz8mT021vjTxFERN12S1PFOzXKmNUDleoTDIat-8UifyKmKg4eKilecrBW6sFqaBw"/>
</div>
</div>
</section>
<!-- CENTER PANE (30%) -->
<section class="w-[30%] border-r border-outline flex flex-col p-8 gap-8 overflow-y-auto custom-scrollbar">
<div class="space-y-1">
<p class="text-outline font-label-caps text-xs">▌daily.json</p>
<div class="flex items-center justify-between">
<h3 class="font-headline text-[18px] font-bold text-on-surface">MAY 07 · 2026</h3>
<span class="bg-warning/20 text-warning px-2 py-1 text-[10px] font-bold border border-warning/40">EXPIRES 11:42:30</span>
</div>
</div>
<div class="bg-surface-container p-6 border border-outline space-y-4">
<div class="space-y-1">
<p class="text-on-surface-variant font-label-caps text-[10px] uppercase tracking-tighter">Current Seed</p>
<p class="font-headline text-[24px] font-extrabold text-highlight-valid">#2024-127</p>
</div>
<button class="w-full py-3 bg-primary-container text-surface font-label-caps text-xs font-bold uppercase tracking-widest hover:brightness-110 active:scale-95 transition-all">
▶ Attempt Today
</button>
</div>
<div class="space-y-3">
<p class="text-outline font-label-caps text-xs uppercase tracking-widest">Global Standings</p>
<div class="space-y-1 text-xs font-label-caps">
<div class="flex justify-between py-2 border-b border-outline/30 text-highlight-valid">
<span>01 │ swift_jaguar</span>
<span>02:47</span>
</div>
<div class="flex justify-between py-2 border-b border-outline/30 text-on-surface-variant">
<span>02 │ pixel_drifter</span>
<span>03:12</span>
</div>
<div class="flex justify-between py-2 border-b border-outline/30 text-on-surface-variant">
<span>03 │ null_ptr</span>
<span>03:15</span>
</div>
<div class="flex justify-between py-2 border-b border-outline/30 text-on-surface-variant">
<span>04 │ core_dump_88</span>
<span>03:44</span>
</div>
<div class="flex justify-between py-2 text-primary-container bg-primary-container/10 px-2 -mx-2">
<span>12 │ YOU (anon)</span>
<span>--:--</span>
</div>
</div>
</div>
</section>
<!-- RIGHT PANE (30%) -->
<section class="w-[30%] flex flex-col p-8 gap-8 overflow-y-auto custom-scrollbar">
<div class="space-y-1">
<p class="text-outline font-label-caps text-xs">▌stats.log</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="border border-outline p-4 space-y-1">
<p class="text-on-surface-variant text-[10px] uppercase">Games</p>
<p class="font-hud-score text-[28px] text-on-surface">247</p>
</div>
<div class="border border-outline p-4 space-y-1 text-highlight-valid">
<p class="text-on-surface-variant text-[10px] uppercase">Win Rate</p>
<p class="font-hud-score text-[28px]">61%</p>
</div>
<div class="border border-outline p-4 space-y-1">
<p class="text-on-surface-variant text-[10px] uppercase">Best Time</p>
<p class="font-hud-score text-[28px]">01:54</p>
</div>
<div class="border border-outline p-4 space-y-1 text-primary-container">
<p class="text-on-surface-variant text-[10px] uppercase">Streak</p>
<p class="font-hud-score text-[28px]">7</p>
</div>
</div>
<div class="space-y-3">
<p class="text-outline font-label-caps text-xs uppercase tracking-widest">Achievements (8/19)</p>
<div class="flex flex-wrap gap-2">
<!-- Filled Cyan Dots -->
<div class="w-3 h-3 bg-primary-container"></div>
<div class="w-3 h-3 bg-primary-container"></div>
<div class="w-3 h-3 bg-primary-container"></div>
<div class="w-3 h-3 bg-primary-container"></div>
<div class="w-3 h-3 bg-primary-container"></div>
<div class="w-3 h-3 bg-primary-container"></div>
<div class="w-3 h-3 bg-primary-container"></div>
<div class="w-3 h-3 bg-primary-container"></div>
<!-- Empty Dots -->
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
<div class="w-3 h-3 border border-outline"></div>
</div>
</div>
<div class="mt-auto border border-outline bg-surface-container p-4 flex items-center justify-between hover:border-primary-container transition-colors cursor-pointer group">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary-container text-surface flex items-center justify-center font-bold text-lg">RS</div>
<div class="space-y-0.5">
<p class="text-on-surface font-bold text-xs">anonymous@local</p>
<p class="text-on-surface-variant text-[10px]">Session: Active</p>
</div>
</div>
<span class="material-symbols-outlined text-primary-container group-hover:translate-x-1 transition-transform">arrow_forward</span>
</div>
</section>
</main>
<!-- BOTTOM BAR (24px) -->
<footer class="h-6 bg-surface-container border-t border-outline flex items-center justify-between px-4 text-[10px] font-label-caps">
<div class="flex items-center gap-2">
<span class="text-primary-container">▌ NORMAL</span>
<span class="text-outline"></span>
<span class="text-on-surface-variant">~/rusty-solitaire/home</span>
</div>
<div class="flex items-center gap-4 text-on-surface-variant">
<div class="flex items-center gap-1"><span class="text-primary-container">[SPACE]</span> play</div>
<div class="flex items-center gap-1"><span class="text-primary-container">[D]</span> daily</div>
<div class="flex items-center gap-1"><span class="text-primary-container">[S]</span> settings</div>
<div class="flex items-center gap-1"><span class="text-primary-container">[?]</span> help</div>
</div>
<div class="text-outline">
2026-05-07 17:42 EDT
</div>
</footer>
<!-- GLOBAL SCANLINE EFFECT -->
<div class="fixed inset-0 pointer-events-none z-[100] overflow-hidden opacity-10">
<div class="absolute inset-0" style="background: repeating-linear-gradient(0deg, #151515, #151515 2px, #202020 4px);"></div>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+225
View File
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Rusty Solitaire - Main Menu</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"outline": "#505050",
"suit-red-cb": "#6fc2ef",
"suit-black": "#d0d0d0",
"surface-container-high": "#272a2d",
"primary-fixed": "#c4e7ff",
"on-secondary-container": "#b2c86d",
"secondary-fixed": "#d5ec8c",
"on-tertiary-container": "#683476",
"surface-tint": "#7ed0fe",
"background": "#101417",
"primary-container": "#6fc2ef",
"inverse-surface": "#e0e3e6",
"highlight-celebration": "#e1a3ee",
"surface-container-low": "#181c1f",
"on-surface": "#d0d0d0",
"primary": "#a1dcff",
"on-tertiary-fixed": "#340043",
"secondary-container": "#435401",
"inverse-primary": "#00668a",
"tertiary-fixed": "#fbd7ff",
"surface-bright": "#363a3d",
"on-secondary-fixed-variant": "#3c4d00",
"warning": "#ddb26f",
"tertiary-container": "#e1a3ee",
"suit-red": "#fb9fb1",
"primary-fixed-dim": "#7ed0fe",
"info": "#12cfc0",
"on-primary-fixed": "#001e2c",
"surface-container-lowest": "#0b0f11",
"error": "#fb9fb1",
"surface-variant": "#313538",
"on-error": "#690005",
"surface": "#151515",
"surface-container": "#202020",
"on-primary-container": "#004f6c",
"inverse-on-surface": "#2d3134",
"on-primary-fixed-variant": "#004c69",
"on-secondary": "#293500",
"error-container": "#93000a",
"secondary": "#bad073",
"tertiary": "#f7c3ff",
"outline-variant": "#3f484e",
"on-secondary-fixed": "#161e00",
"secondary-fixed-dim": "#bad073",
"surface-container-highest": "#313538",
"on-surface-variant": "#bfc8cf",
"tertiary-fixed-dim": "#f0b0fc",
"on-tertiary-fixed-variant": "#653173",
"on-error-container": "#ffdad6",
"on-primary": "#003549",
"on-background": "#e0e3e6",
"surface-dim": "#101417",
"on-tertiary": "#4c195b",
"highlight-valid": "#acc267"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"margin-edge": "1rem",
"touch-target-min": "48px",
"stack-overlap": "2rem",
"action-bar-height": "64px",
"gutter-card": "0.375rem"
},
"fontFamily": {
"hud-timer": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"body-md": ["Inter"],
"card-rank": ["JetBrains Mono"]
},
"fontSize": {
"hud-timer": ["16px", { "lineHeight": "24px", "fontWeight": "400" }],
"headline": ["28px", { "lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700" }],
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500" }],
"hud-score": ["24px", { "lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700" }],
"body-md": ["16px", { "lineHeight": "24px", "fontWeight": "400" }],
"card-rank": ["18px", { "lineHeight": "18px", "fontWeight": "700" }]
}
}
}
}
</script>
<style>
.scanline {
background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%, rgba(0,0,0,0.1));
background-size: 100% 4px;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface font-hud-timer min-h-screen flex flex-col relative overflow-hidden">
<!-- Subtle CRT scanline overlay -->
<div class="absolute inset-0 pointer-events-none scanline opacity-20 z-0"></div>
<!-- Status Bar Zone -->
<div class="h-6 w-full flex justify-end items-center px-margin-edge pt-2 z-10 relative">
<div class="w-2 h-2 rounded-full bg-info"></div>
</div>
<!-- Header -->
<header class="px-margin-edge pt-4 pb-6 flex justify-between items-center z-10 relative">
<div class="flex items-center gap-1">
<span class="font-headline text-headline text-on-surface">▌RUSTY SOLITAIRE</span>
<div class="w-2 h-6 bg-primary-container inline-block ml-1 animate-pulse"></div>
</div>
<div class="bg-surface-container px-3 py-1 flex items-center gap-2 border border-outline">
<span class="font-label-caps text-label-caps text-on-surface">LV 12</span>
<div class="w-2 h-2 rounded-full bg-highlight-celebration"></div>
</div>
</header>
<!-- Main Content Canvas -->
<main class="flex-1 px-margin-edge flex flex-col gap-8 z-10 relative pb-24 overflow-y-auto">
<!-- XP Section -->
<section class="flex flex-col gap-2">
<div class="w-full h-1 bg-surface-container border border-outline relative">
<div class="absolute top-0 left-0 h-full bg-primary-container w-[64%]"></div>
</div>
<div class="font-label-caps text-label-caps text-on-surface-variant text-right">
320 / 500 XP
</div>
</section>
<!-- Primary Action -->
<section class="flex flex-col gap-2">
<button class="w-full h-[56px] bg-primary-container text-surface flex items-center justify-center gap-2 hover:bg-surface-tint transition-colors duration-120">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
<span class="font-label-caps text-[14px] uppercase tracking-widest font-bold">PLAY</span>
</button>
<div class="font-label-caps text-label-caps text-on-surface-variant text-center">
RESUME LAST GAME · 12:34 ELAPSED
</div>
</section>
<!-- Daily Challenge Tile -->
<section>
<div class="bg-surface-container border border-outline p-4 flex justify-between items-center hover:bg-surface-container-high transition-colors cursor-pointer group">
<div class="flex flex-col gap-2">
<span class="font-label-caps text-label-caps text-primary">DAILY CHALLENGE</span>
<span class="font-body-md text-body-md text-on-surface">DRAW-3 · SEED #2024-127</span>
<div class="inline-flex">
<span class="bg-surface px-2 py-0.5 border border-warning text-warning font-label-caps text-[10px]">EXPIRES 11:42:30</span>
</div>
</div>
<span class="material-symbols-outlined text-primary group-hover:translate-x-1 transition-transform">chevron_right</span>
</div>
</section>
<!-- Special Modes Grid -->
<section class="flex flex-col gap-4">
<h2 class="font-label-caps text-label-caps text-on-surface-variant">SPECIAL MODES</h2>
<div class="grid grid-cols-3 gap-gutter-card">
<!-- ZEN -->
<button class="aspect-square bg-surface border border-outline flex flex-col items-center justify-center gap-2 hover:border-primary hover:text-primary transition-colors text-on-surface">
<span class="material-symbols-outlined text-[32px]">self_improvement</span>
<span class="font-label-caps text-label-caps">ZEN</span>
</button>
<!-- TIME ATTACK -->
<button class="aspect-square bg-surface border border-outline flex flex-col items-center justify-center gap-2 hover:border-primary hover:text-primary transition-colors text-on-surface">
<span class="material-symbols-outlined text-[32px]">timer</span>
<span class="font-label-caps text-label-caps">TIME ATTACK</span>
</button>
<!-- CHALLENGE (Locked) -->
<button class="aspect-square bg-[#0d0d0d] border border-surface-container-high flex flex-col items-center justify-center gap-2 text-on-surface-variant opacity-75 cursor-not-allowed relative">
<span class="material-symbols-outlined text-[32px]">lock</span>
<span class="font-label-caps text-label-caps">CHALLENGE</span>
<div class="absolute top-2 right-2 bg-surface px-1 py-0.5 border border-warning text-warning font-label-caps text-[10px]">
LV 5
</div>
</button>
</div>
</section>
<!-- Secondary Nav Grid -->
<section class="grid grid-cols-2 gap-y-4 gap-x-6 pb-6">
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start">
<span class="material-symbols-outlined">bar_chart</span>
<span class="font-label-caps text-label-caps">STATS</span>
</button>
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start relative">
<span class="material-symbols-outlined">emoji_events</span>
<span class="font-label-caps text-label-caps">ACHIEVEMENTS</span>
<div class="absolute right-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-highlight-celebration"></div>
</button>
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start">
<span class="material-symbols-outlined">format_list_numbered</span>
<span class="font-label-caps text-label-caps">LEADERBOARD</span>
</button>
<button class="flex items-center gap-3 h-[56px] border-l-2 border-outline pl-3 hover:border-primary hover:text-primary transition-colors text-on-surface justify-start">
<span class="material-symbols-outlined">account_circle</span>
<span class="font-label-caps text-label-caps">PROFILE</span>
</button>
</section>
<!-- Footer Links -->
<footer class="flex flex-col items-center gap-4 mt-auto">
<div class="flex items-center gap-4 font-label-caps text-label-caps text-primary cursor-pointer hover:text-surface-tint">
<span>SETTINGS</span>
<span class="text-on-surface-variant">·</span>
<span>HELP</span>
</div>
<div class="font-label-caps text-[10px] text-on-surface-variant text-center opacity-60">
v0.20.0 — TERMINAL THEME · BUILD 2026.05
</div>
</footer>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+315
View File
@@ -0,0 +1,315 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Rusty Solitaire - Leaderboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
body {
background-color: #151515;
color: #e0e3e6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
.scanline-overlay {
background: linear-gradient(to bottom, rgba(21, 21, 21, 0) 50%, rgba(26, 26, 26, 0.2) 50%);
background-size: 100% 4px;
pointer-events: none;
}
.terminal-glow {
box-shadow: 0 0 10px rgba(111, 194, 239, 0.1);
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"outline": "#505050",
"on-surface-variant": "#bfc8cf",
"secondary-container": "#435401",
"surface-container-lowest": "#0b0f11",
"primary": "#a1dcff",
"secondary-fixed": "#d5ec8c",
"on-secondary-fixed": "#161e00",
"on-error": "#690005",
"inverse-primary": "#00668a",
"surface-container": "#202020",
"highlight-valid": "#acc267",
"suit-black": "#d0d0d0",
"on-secondary-fixed-variant": "#3c4d00",
"on-primary-fixed": "#001e2c",
"on-tertiary-fixed-variant": "#653173",
"primary-fixed": "#c4e7ff",
"inverse-on-surface": "#2d3134",
"secondary-fixed-dim": "#bad073",
"on-secondary": "#293500",
"on-surface": "#e0e3e6",
"on-tertiary-container": "#683476",
"secondary": "#bad073",
"surface-bright": "#363a3d",
"tertiary-container": "#e1a3ee",
"surface-variant": "#313538",
"suit-red": "#fb9fb1",
"primary-fixed-dim": "#7ed0fe",
"surface-container-low": "#181c1f",
"surface": "#151515",
"suit-red-cb": "#6fc2ef",
"on-primary": "#003549",
"primary-container": "#6fc2ef",
"on-background": "#e0e3e6",
"tertiary": "#f7c3ff",
"surface-container-highest": "#313538",
"tertiary-fixed-dim": "#f0b0fc",
"tertiary-fixed": "#fbd7ff",
"info": "#12cfc0",
"error": "#fb9fb1",
"warning": "#ddb26f",
"on-primary-container": "#004f6c",
"surface-container-high": "#272a2d",
"inverse-surface": "#e0e3e6",
"error-container": "#93000a",
"on-tertiary-fixed": "#340043",
"surface-tint": "#7ed0fe",
"on-tertiary": "#4c195b",
"background": "#101417",
"on-error-container": "#ffdad6",
"on-secondary-container": "#b2c86d",
"outline-variant": "#3f484e",
"highlight-celebration": "#e1a3ee",
"surface-dim": "#101417",
"on-primary-fixed-variant": "#004c69"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"touch-target-min": "48px",
"action-bar-height": "64px",
"stack-overlap": "2rem",
"gutter-card": "0.375rem",
"margin-edge": "1rem"
},
"fontFamily": {
"card-rank": ["JetBrains Mono"],
"body-md": ["Inter"],
"headline": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"]
},
"fontSize": {
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="font-body-md overflow-hidden h-[844px] w-[390px] mx-auto relative border-x border-outline/20">
<div class="scanline-overlay absolute inset-0 z-0"></div>
<!-- Top AppBar (Identity Anchor) -->
<header class="fixed top-0 w-full h-action-bar-height z-50 flex items-center px-margin-edge justify-between bg-surface dark:bg-surface text-primary dark:text-primary border-b border-outline dark:border-outline">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-primary">terminal</span>
<h1 class="font-headline text-headline text-primary dark:text-primary uppercase tracking-tighter">Rusty Solitaire</h1>
</div>
<div class="flex items-center gap-4">
<span class="material-symbols-outlined text-on-surface-variant hover:bg-surface-variant transition-colors duration-120 p-2 rounded-lg cursor-pointer">sync</span>
</div>
</header>
<main class="pt-[64px] h-[calc(100%-64px)] flex flex-col z-10 relative">
<!-- Pseudo Status Bar -->
<div class="h-[32px] bg-surface-container flex items-center justify-between px-4 font-label-caps text-[10px] tracking-tight">
<div class="text-[#a0a0a0]">▌leaderboard.tsx</div>
<div class="flex items-center gap-2">
<span class="flex items-center gap-1">
<span class="w-1.5 h-1.5 rounded-full bg-info"></span>
<span class="text-on-surface-variant">SYNCED</span>
</span>
<span class="text-outline">v0.20.0</span>
</div>
</div>
<!-- Tab Strip -->
<nav class="h-[40px] bg-[#1a1a1a] border-b border-[#353535] flex items-center">
<div class="flex-1 flex flex-col items-center justify-center relative">
<span class="font-label-caps text-[11px] text-[#6fc2ef]">[ TODAY ]</span>
<div class="absolute bottom-0 w-full h-[2px] bg-[#6fc2ef]"></div>
</div>
<div class="flex-1 flex items-center justify-center">
<span class="font-label-caps text-[11px] text-[#a0a0a0]">WEEK</span>
</div>
<div class="flex-1 flex items-center justify-center">
<span class="font-label-caps text-[11px] text-[#a0a0a0]">ALL-TIME</span>
</div>
<div class="flex-1 flex items-center justify-center">
<span class="font-label-caps text-[11px] text-[#a0a0a0]">FRIENDS</span>
</div>
</nav>
<div class="flex-1 overflow-y-auto px-margin-edge pt-4 space-y-4 pb-[88px]">
<!-- Hero Podium Card -->
<section class="h-[120px] bg-surface-container border border-[#353535] rounded-lg p-2 flex flex-col justify-between">
<div class="font-label-caps text-[10px] text-[#a0a0a0]">TOP 3 · TODAY</div>
<div class="flex gap-2 items-end justify-between flex-1 mt-1">
<!-- 2nd -->
<div class="flex-1 border border-[#a0a0a0] h-full rounded flex flex-col items-center justify-center relative py-1">
<span class="font-card-rank text-[16px] text-[#a0a0a0]">02</span>
<span class="text-[9px] font-mono text-[#d0d0d0] truncate w-full text-center px-1">base16_fan</span>
<span class="text-[10px] font-mono text-[#a0a0a0]">03:12</span>
</div>
<!-- 1st -->
<div class="flex-[1.2] border border-warning h-[110%] mb-[-2px] rounded-lg bg-surface flex flex-col items-center justify-center relative py-1 terminal-glow">
<span class="absolute top-1 right-1 text-warning material-symbols-outlined text-[14px]">star</span>
<span class="font-card-rank text-[24px] text-warning leading-none">01</span>
<span class="text-[11px] font-mono text-[#d0d0d0] font-bold truncate w-full text-center px-1">swift_jaguar</span>
<span class="text-[12px] font-mono text-[#d0d0d0]">02:47</span>
</div>
<!-- 3rd -->
<div class="flex-1 border border-[#7a5d3b] h-full rounded flex flex-col items-center justify-center relative py-1">
<span class="font-card-rank text-[16px] text-[#7a5d3b]">03</span>
<span class="text-[9px] font-mono text-[#d0d0d0] truncate w-full text-center px-1">cli_player</span>
<span class="text-[10px] font-mono text-[#a0a0a0]">03:54</span>
</div>
</div>
</section>
<!-- Search/Filter Row -->
<div class="flex items-center gap-2 h-[40px]">
<div class="px-3 h-8 border border-outline rounded flex items-center justify-center bg-surface-container-low">
<span class="font-label-caps text-[10px] text-[#6fc2ef]">[ ALL TIMES ]</span>
</div>
<div class="flex-1 h-8 border border-outline rounded flex items-center px-2 bg-surface gap-2">
<span class="font-mono text-[12px] text-outline">/ search players</span>
</div>
</div>
<!-- Leaderboard List -->
<div class="space-y-0.5 font-mono text-[12px]">
<!-- Header -->
<div class="flex justify-between px-2 pb-1 border-b border-outline/20 text-outline text-[10px] uppercase font-bold tracking-widest">
<span>Rank &amp; User</span>
<span>Time</span>
</div>
<!-- Rank 04 -->
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
<div class="flex gap-4">
<span class="text-[#a0a0a0] w-8">004</span>
<span class="text-on-surface">tablejockey</span>
</div>
<span class="text-[#a0a0a0]">04:01</span>
</div>
<!-- Rank 05 -->
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
<div class="flex gap-4">
<span class="text-[#a0a0a0] w-8">005</span>
<span class="text-on-surface">vim_motions</span>
</div>
<span class="text-[#a0a0a0]">04:05</span>
</div>
<!-- Rank 06 -->
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
<div class="flex gap-4">
<span class="text-[#a0a0a0] w-8">006</span>
<span class="text-on-surface">tmux_lover</span>
</div>
<span class="text-[#a0a0a0]">04:18</span>
</div>
<!-- Rank 07 -->
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
<div class="flex gap-4">
<span class="text-[#a0a0a0] w-8">007</span>
<span class="text-on-surface">nvim_dotfiles</span>
</div>
<span class="text-[#a0a0a0]">04:23</span>
</div>
<!-- Rank 08 -->
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
<div class="flex gap-4">
<span class="text-[#a0a0a0] w-8">008</span>
<span class="text-on-surface">dark_theme</span>
</div>
<span class="text-[#a0a0a0]">04:31</span>
</div>
<!-- Spacer for truncated view -->
<div class="flex justify-center py-2 text-outline/30 tracking-[1em]">...</div>
<!-- YOU (Rank 17) -->
<div class="flex items-center justify-between px-2 py-2 bg-[#1f3a4a]/30 border border-[#6fc2ef]/40 rounded-sm">
<div class="flex gap-4">
<span class="text-[#6fc2ef] w-8 font-bold">▶ 017</span>
<span class="text-[#6fc2ef] font-bold">anonymous (YOU)</span>
</div>
<span class="text-[#6fc2ef] font-bold">04:12</span>
</div>
<!-- Rank 18 -->
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
<div class="flex gap-4">
<span class="text-[#a0a0a0] w-8">018</span>
<span class="text-on-surface">bash_brawler</span>
</div>
<span class="text-[#a0a0a0]">05:01</span>
</div>
<!-- Rank 19 -->
<div class="flex items-center justify-between px-2 py-2 border-b border-[#353535]">
<div class="flex gap-4">
<span class="text-[#a0a0a0] w-8">019</span>
<span class="text-on-surface">curl_master</span>
</div>
<span class="text-[#a0a0a0]">05:14</span>
</div>
</div>
</div>
<!-- CLI Style Footer -->
<footer class="fixed bottom-0 w-full h-[24px] bg-[#202020] border-t border-[#353535] px-2 flex items-center justify-between font-mono text-[9px] z-50">
<div class="text-[#a0a0a0]">
<span class="text-info font-bold"></span> NORMAL │ leaderboard
</div>
<div class="text-[#a0a0a0] flex gap-3">
<span>[1-4] tab</span>
<span>[/] search</span>
<span>[ESC] back</span>
</div>
</footer>
<!-- Shared Component: BottomNavBar -->
<nav class="fixed bottom-[24px] w-full h-action-bar-height z-50 flex justify-around items-center bg-surface-container dark:bg-surface-container border-t border-outline dark:border-outline">
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
<span class="material-symbols-outlined">playing_cards</span>
<span class="font-label-caps text-label-caps">DEAL [F1]</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
<span class="material-symbols-outlined">undo</span>
<span class="font-label-caps text-label-caps">UNDO [Z]</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
<span class="material-symbols-outlined">lightbulb</span>
<span class="font-label-caps text-label-caps">HINT [H]</span>
</button>
<button class="flex flex-col items-center justify-center bg-primary-container dark:bg-primary-container text-on-primary-container dark:text-on-primary-container rounded-none p-2 transition-all duration-120 ease-linear active:bg-surface-container-highest">
<span class="material-symbols-outlined">analytics</span>
<span class="font-label-caps text-label-caps">STATS [S]</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant dark:text-on-surface-variant p-2 hover:text-primary dark:hover:text-primary transition-all duration-120 ease-linear active:bg-surface-container-highest">
<span class="material-symbols-outlined">menu</span>
<span class="font-label-caps text-label-caps">MENU [ESC]</span>
</button>
</nav>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+259
View File
@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>ROOT@SOLITAIRE:~ | LEVEL UP</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.scanline-overlay {
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
background-size: 100% 2px, 3px 100%;
pointer-events: none;
}
.card-glow {
box-shadow: 0 0 24px rgba(225, 163, 238, 0.25);
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed": "#fbd7ff",
"secondary-container": "#435401",
"on-tertiary-fixed": "#340043",
"inverse-surface": "#e0e3e6",
"tertiary-container": "#e1a3ee",
"background": "#101417",
"on-primary": "#003549",
"info": "#12cfc0",
"surface-container-highest": "#313538",
"secondary-fixed": "#d5ec8c",
"secondary-fixed-dim": "#bad073",
"on-surface-variant": "#bfc8cf",
"on-secondary": "#293500",
"on-tertiary-fixed-variant": "#653173",
"surface-container-low": "#181c1f",
"surface-container-high": "#272a2d",
"secondary": "#bad073",
"outline-variant": "#3f484e",
"on-surface": "#e0e3e6",
"surface-tint": "#7ed0fe",
"on-tertiary": "#4c195b",
"on-secondary-fixed": "#161e00",
"primary-fixed": "#c4e7ff",
"on-tertiary-container": "#683476",
"on-secondary-container": "#b2c86d",
"surface-container-lowest": "#0b0f11",
"inverse-primary": "#00668a",
"primary-container": "#6fc2ef",
"surface-container": "#1c2023",
"on-background": "#e0e3e6",
"suit-red-cb": "#6fc2ef",
"surface-dim": "#101417",
"on-primary-fixed-variant": "#004c69",
"tertiary": "#f7c3ff",
"on-secondary-fixed-variant": "#3c4d00",
"highlight-celebration": "#e1a3ee",
"on-primary-fixed": "#001e2c",
"primary-fixed-dim": "#7ed0fe",
"tertiary-fixed-dim": "#f0b0fc",
"inverse-on-surface": "#2d3134",
"error": "#fb9fb1",
"error-container": "#93000a",
"surface-bright": "#363a3d",
"on-primary-container": "#004f6c",
"warning": "#ddb26f",
"surface": "#151515",
"suit-black": "#d0d0d0",
"highlight-valid": "#acc267",
"outline": "#505050",
"surface-variant": "#313538",
"on-error-container": "#ffdad6",
"on-error": "#690005",
"primary": "#a1dcff",
"suit-red": "#fb9fb1"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"gutter-card": "0.375rem",
"action-bar-height": "64px",
"stack-overlap": "2rem",
"margin-edge": "1rem"
},
"fontFamily": {
"card-rank": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"body-md": ["Inter"],
"headline": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"]
},
"fontSize": {
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
}
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-background font-body-md overflow-hidden h-screen select-none">
<!-- Top App Bar -->
<header class="fixed top-0 w-full z-50 flex justify-between items-center px-margin-edge h-action-bar-height bg-background dark:bg-background border-b border-outline-variant dark:border-outline-variant">
<div class="font-headline text-headline text-primary dark:text-primary uppercase tracking-tighter">ROOT@SOLITAIRE:~</div>
<div class="flex gap-4">
<span class="material-symbols-outlined text-primary" data-icon="memory">memory</span>
<span class="material-symbols-outlined text-primary" data-icon="settings_ethernet">settings_ethernet</span>
<span class="material-symbols-outlined text-primary" data-icon="wifi_tethering">wifi_tethering</span>
</div>
</header>
<!-- Main Tableau (Dimmed Background) -->
<main class="pt-24 px-4 flex flex-col gap-8 opacity-20 filter grayscale">
<!-- HUD Chips -->
<div class="flex justify-between items-center">
<div class="bg-surface-container p-3 flex flex-col">
<span class="font-label-caps text-label-caps text-on-surface-variant uppercase">SCORE</span>
<span class="font-hud-score text-hud-score text-primary">04,820</span>
</div>
<div class="bg-surface-container p-3 flex flex-col items-end">
<span class="font-label-caps text-label-caps text-on-surface-variant uppercase">TIMER</span>
<span class="font-hud-timer text-hud-timer text-on-surface">04:12</span>
</div>
</div>
<!-- Foundation & Stock -->
<div class="flex gap-gutter-card justify-between">
<div class="flex gap-gutter-card">
<div class="w-[64px] h-[88px] border border-dashed border-outline rounded-DEFAULT"></div>
<div class="w-[64px] h-[88px] bg-surface border border-outline rounded-DEFAULT relative overflow-hidden">
<div class="absolute inset-0 scanline-overlay"></div>
<div class="absolute top-2 left-2 w-3 h-4 bg-suit-red-cb"></div>
<div class="absolute bottom-2 right-2 font-card-rank text-[12px] text-on-surface">▌RS</div>
</div>
</div>
<div class="flex gap-gutter-card">
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
<div class="w-[64px] h-[88px] border border-outline rounded-DEFAULT bg-surface"></div>
</div>
</div>
<!-- Cascades -->
<div class="grid grid-cols-7 gap-gutter-card">
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
<div class="h-24 border border-dashed border-outline rounded-DEFAULT"></div>
</div>
</main>
<!-- CELEBRATION OVERLAY SCREEM -->
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-surface/95 backdrop-blur-sm">
<!-- Celebration Card -->
<div class="w-[340px] h-[480px] bg-[#202020] border border-highlight-celebration rounded-xl card-glow flex flex-col overflow-hidden relative">
<!-- Title Bar -->
<div class="h-[28px] bg-[#1a1a1a] border-b border-outline flex items-center px-4 shrink-0">
<span class="text-primary mr-1"></span>
<span class="font-headline text-[12px] text-[#a0a0a0]">level-up.tsx</span>
</div>
<!-- Content Area -->
<div class="flex-grow p-6 flex flex-col">
<!-- Hero Band -->
<div class="text-center mb-6">
<div class="font-headline text-[14px] text-highlight-celebration uppercase tracking-[0.08em] mb-2">▲ LEVEL UP</div>
<div class="flex items-baseline justify-center gap-2">
<span class="font-headline font-bold text-[96px] text-suit-black leading-none tracking-tighter">13</span>
<div class="flex flex-col items-start">
<span class="font-label-caps text-[11px] text-outline uppercase">FROM 12</span>
</div>
</div>
<div class="mt-2 font-headline font-medium text-[13px] text-highlight-celebration tracking-[0.08em]">█ NEW PERKS UNLOCKED</div>
</div>
<!-- Perks List -->
<div class="space-y-2 mb-6">
<!-- Item 1 -->
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-3 flex items-center justify-between">
<span class="font-headline text-[13px] text-suit-black">▢ +1 daily challenge slot</span>
<span class="bg-highlight-celebration/10 text-highlight-celebration px-2 py-0.5 rounded-full font-label-caps text-[10px] border border-highlight-celebration/30">NEW</span>
</div>
<!-- Item 2 -->
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-3 flex items-center justify-between">
<span class="font-headline text-[13px] text-suit-black">▢ Background: Forest</span>
<span class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-full font-label-caps text-[10px] border border-highlight-valid/30">UNLOCKED</span>
</div>
<!-- Item 3 -->
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-3 flex items-center justify-between">
<span class="font-headline text-[13px] text-suit-black">▢ Card-back: Stripes</span>
<span class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-full font-label-caps text-[10px] border border-highlight-valid/30">UNLOCKED</span>
</div>
</div>
<!-- XP Recap -->
<div class="h-[48px] bg-[#1a1a1a] rounded-[4px] px-4 flex items-center justify-between mt-auto">
<span class="font-headline text-[12px] text-[#a0a0a0]">XP</span>
<span class="font-headline font-bold text-[14px] text-highlight-valid uppercase">+200 XP THIS LEVEL</span>
<div class="w-[60px] h-[4px] bg-outline-variant rounded-full overflow-hidden">
<div class="w-[0%] h-full bg-suit-red-cb"></div>
</div>
</div>
</div>
<!-- Action Button -->
<button class="h-[56px] w-full bg-suit-red-cb flex items-center justify-center gap-2 hover:opacity-90 active:opacity-75 transition-opacity">
<span class="font-headline font-bold text-[14px] text-background tracking-wider">▶ CONTINUE</span>
</button>
<!-- Scanline layer inside card -->
<div class="absolute inset-0 scanline-overlay opacity-20 pointer-events-none"></div>
</div>
<!-- Caption -->
<div class="absolute bottom-8 w-full text-center">
<span class="font-body-md text-[11px] text-[#a0a0a0] uppercase tracking-widest opacity-60">Tap anywhere to dismiss</span>
</div>
</div>
<!-- Bottom Nav Bar -->
<nav class="fixed bottom-0 w-full z-50 flex justify-between items-center h-action-bar-height bg-surface-container dark:bg-surface-container border-t border-outline dark:border-outline">
<div class="flex flex-col items-center justify-center bg-primary text-background p-2 w-1/5 h-full transition-colors font-label-caps text-label-caps uppercase tracking-widest">
<span class="material-symbols-outlined" data-icon="videogame_asset">videogame_asset</span>
<span>NORMAL</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
<span class="material-symbols-outlined" data-icon="edit">edit</span>
<span>INSERT</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
<span class="material-symbols-outlined" data-icon="visibility">visibility</span>
<span>VISUAL</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
<span class="material-symbols-outlined" data-icon="auto_fix_high">auto_fix_high</span>
<span>SOLVE</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 w-1/5 h-full hover:text-primary transition-colors font-label-caps text-label-caps uppercase tracking-widest">
<span class="material-symbols-outlined" data-icon="power_settings_new">power_settings_new</span>
<span>QUIT</span>
</div>
</nav>
<!-- Persistent Background Overlay (CRT Effect) -->
<div class="fixed inset-0 pointer-events-none z-[200] scanline-overlay opacity-30"></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+206
View File
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary": "#f7c3ff",
"surface": "#151515",
"surface-container-lowest": "#0b0f11",
"surface-variant": "#313538",
"surface-tint": "#7ed0fe",
"primary": "#a1dcff",
"on-tertiary-fixed-variant": "#653173",
"on-error-container": "#ffdad6",
"on-tertiary-container": "#683476",
"secondary": "#bad073",
"surface-dim": "#101417",
"inverse-on-surface": "#2d3134",
"on-tertiary": "#4c195b",
"surface-container-high": "#272a2d",
"on-primary-fixed-variant": "#004c69",
"surface-container": "#202020",
"highlight-celebration": "#e1a3ee",
"background": "#101417",
"suit-red": "#fb9fb1",
"on-secondary-container": "#b2c86d",
"inverse-surface": "#e0e3e6",
"error": "#fb9fb1",
"on-surface": "#d0d0d0",
"info": "#12cfc0",
"secondary-container": "#435401",
"tertiary-fixed-dim": "#f0b0fc",
"surface-bright": "#363a3d",
"outline-variant": "#3f484e",
"on-secondary-fixed-variant": "#3c4d00",
"warning": "#ddb26f",
"surface-container-highest": "#313538",
"secondary-fixed": "#d5ec8c",
"highlight-valid": "#acc267",
"secondary-fixed-dim": "#bad073",
"tertiary-container": "#e1a3ee",
"suit-red-cb": "#6fc2ef",
"on-error": "#690005",
"on-primary-container": "#004f6c",
"suit-black": "#d0d0d0",
"inverse-primary": "#00668a",
"surface-container-low": "#181c1f",
"on-primary-fixed": "#001e2c",
"on-secondary": "#293500",
"primary-fixed": "#c4e7ff",
"on-tertiary-fixed": "#340043",
"outline": "#505050",
"error-container": "#93000a",
"tertiary-fixed": "#fbd7ff",
"primary-container": "#6fc2ef",
"on-background": "#e0e3e6",
"on-primary": "#003549",
"on-surface-variant": "#bfc8cf",
"on-secondary-fixed": "#161e00",
"primary-fixed-dim": "#7ed0fe"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"margin-edge": "1rem",
"gutter-card": "0.375rem",
"touch-target-min": "48dp",
"stack-overlap": "2rem",
"action-bar-height": "64px"
},
"fontFamily": {
"card-rank": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"body-md": ["Inter"],
"headline": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"]
},
"fontSize": {
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
body {
background-color: #151515;
color: #d0d0d0;
-webkit-font-smoothing: antialiased;
}
.card-scanline {
background: linear-gradient(rgba(21, 21, 21, 0) 50%, rgba(26, 26, 26, 1) 50%);
background-size: 100% 4px;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="flex items-center justify-center min-h-screen">
<!-- Mobile Canvas (390x844 simulated) -->
<main class="w-[390px] h-[844px] bg-surface relative overflow-hidden flex flex-col">
<!-- Status Bar -->
<header class="h-8 bg-surface-container flex items-center justify-between px-margin-edge border-b border-outline-variant">
<div class="font-hud-timer text-[11px] text-primary tracking-tight">▌onboard/01-draw.tsx</div>
<div class="font-hud-timer text-[11px] text-on-surface-variant font-bold">STEP 1 OF 3</div>
</header>
<!-- Content Canvas -->
<div class="flex-1 overflow-y-auto pb-action-bar-height">
<!-- Hero Section -->
<section class="h-[140px] flex flex-col items-center justify-center mt-8">
<div class="w-8 h-12 bg-primary animate-pulse mb-2"></div>
<h1 class="font-headline text-headline text-on-surface uppercase tracking-tighter">
WELCOME <span class="text-primary">▌_</span>
</h1>
</section>
<!-- Headline -->
<section class="px-margin-edge mt-4 text-center">
<h2 class="font-headline text-[22px] leading-tight text-on-surface mb-1">CHOOSE A DRAW MODE</h2>
<p class="font-body-md text-[12px] text-on-surface-variant">You can change this any time in Settings.</p>
</section>
<!-- Choice Cards -->
<section class="px-margin-edge mt-8 space-y-4">
<!-- DRAW-3 Card -->
<div class="h-[120px] bg-surface-container border border-primary p-4 relative flex items-start gap-4">
<div class="absolute top-0 right-0 bg-primary px-2 py-0.5">
<span class="font-label-caps text-[10px] text-surface font-bold">RECOMMENDED</span>
</div>
<div class="w-12 h-16 flex items-center justify-center border border-outline-variant bg-surface-dim">
<span class="material-symbols-outlined text-primary" data-icon="filter_3">filter_3</span>
</div>
<div class="flex-1">
<h3 class="font-headline text-[16px] text-primary mb-1">DRAW-3 (CLASSIC)</h3>
<p class="font-hud-timer text-[12px] leading-snug text-on-surface-variant">
Cycle 3 cards at a time. Standard solitaire rules for a tactical challenge.
</p>
</div>
</div>
<!-- DRAW-1 Card -->
<div class="h-[120px] bg-surface-container border border-outline-variant p-4 flex items-start gap-4">
<div class="w-12 h-16 flex items-center justify-center border border-outline-variant bg-surface-dim">
<span class="material-symbols-outlined text-on-surface-variant" data-icon="filter_1">filter_1</span>
</div>
<div class="flex-1">
<h3 class="font-headline text-[16px] text-on-surface mb-1">DRAW-1 (EASY)</h3>
<p class="font-hud-timer text-[12px] leading-snug text-on-surface-variant">
Cycle one card at a time. More winnable, faster pace, perfect for quick sessions.
</p>
</div>
</div>
</section>
<!-- Step Indicator -->
<section class="mt-12 flex flex-col items-center">
<div class="flex gap-2 mb-2">
<div class="w-8 h-1.5 bg-primary"></div>
<div class="w-8 h-1.5 bg-outline-variant"></div>
<div class="w-8 h-1.5 bg-outline-variant"></div>
</div>
<div class="font-hud-timer text-[12px] flex gap-4">
<span class="text-primary font-bold">[1]</span>
<span class="text-outline-variant">[2]</span>
<span class="text-outline-variant">[3]</span>
</div>
</section>
</div>
<!-- Bottom Action Bar -->
<footer class="h-action-bar-height bg-surface-container border-t border-outline flex items-center justify-between px-margin-edge fixed bottom-0 w-[390px] z-50">
<!-- Back Button (Disabled/Muted) -->
<button class="w-[48%] h-12 border border-outline-variant flex items-center justify-center gap-2 opacity-40 cursor-not-allowed">
<span class="material-symbols-outlined text-outline-variant text-[18px]" data-icon="arrow_back">arrow_back</span>
<span class="font-label-caps text-outline-variant">BACK</span>
</button>
<!-- Next Button -->
<button class="w-[48%] h-12 bg-primary flex items-center justify-center gap-2 active:opacity-80 transition-opacity">
<span class="font-headline text-[14px] text-surface font-bold uppercase tracking-widest">NEXT</span>
<span class="material-symbols-outlined text-surface text-[18px]" data-icon="arrow_forward">arrow_forward</span>
</button>
</footer>
<!-- Terminal Overlay (Faint scanlines for atmosphere) -->
<div class="pointer-events-none absolute inset-0 opacity-[0.03] card-scanline z-[100]"></div>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+211
View File
@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"outline-variant": "#3f484e",
"tertiary-fixed-dim": "#f0b0fc",
"surface-bright": "#363a3d",
"secondary-container": "#435401",
"info": "#12cfc0",
"on-secondary-fixed-variant": "#3c4d00",
"highlight-celebration": "#e1a3ee",
"surface-container": "#202020",
"on-primary-fixed-variant": "#004c69",
"on-surface": "#d0d0d0",
"on-secondary-container": "#b2c86d",
"inverse-surface": "#e0e3e6",
"error": "#fb9fb1",
"background": "#101417",
"suit-red": "#fb9fb1",
"surface-dim": "#101417",
"inverse-on-surface": "#2d3134",
"on-tertiary-container": "#683476",
"secondary": "#bad073",
"primary": "#a1dcff",
"on-tertiary-fixed-variant": "#653173",
"on-error-container": "#ffdad6",
"surface-container-high": "#272a2d",
"on-tertiary": "#4c195b",
"tertiary": "#f7c3ff",
"surface": "#151515",
"surface-tint": "#7ed0fe",
"surface-variant": "#313538",
"surface-container-lowest": "#0b0f11",
"on-surface-variant": "#bfc8cf",
"on-primary": "#003549",
"primary-fixed-dim": "#7ed0fe",
"on-secondary-fixed": "#161e00",
"on-tertiary-fixed": "#340043",
"outline": "#505050",
"on-secondary": "#293500",
"primary-fixed": "#c4e7ff",
"primary-container": "#6fc2ef",
"on-background": "#e0e3e6",
"tertiary-fixed": "#fbd7ff",
"error-container": "#93000a",
"suit-red-cb": "#6fc2ef",
"surface-container-low": "#181c1f",
"on-primary-fixed": "#001e2c",
"on-primary-container": "#004f6c",
"suit-black": "#d0d0d0",
"inverse-primary": "#00668a",
"on-error": "#690005",
"secondary-fixed-dim": "#bad073",
"surface-container-highest": "#313538",
"secondary-fixed": "#d5ec8c",
"highlight-valid": "#acc267",
"warning": "#ddb26f",
"tertiary-container": "#e1a3ee"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"margin-edge": "1rem",
"gutter-card": "0.375rem",
"touch-target-min": "48dp",
"stack-overlap": "2rem",
"action-bar-height": "64px"
},
"fontFamily": {
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"body-md": ["Inter"]
},
"fontSize": {
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.scanline-bg {
background: linear-gradient(to bottom, #1a1a1a 1px, transparent 1px);
background-size: 100% 2px;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface font-body-md select-none overflow-hidden h-screen flex flex-col">
<!-- Top Navigation Bar -->
<header class="fixed top-0 w-full bg-background z-50 border-b border-outline flex justify-between items-center px-margin-edge h-action-bar-height">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-primary" data-icon="terminal">terminal</span>
<span class="font-headline text-headline text-primary uppercase tracking-tighter text-sm md:text-base">▌onboard/03-demo.tsx</span>
</div>
<div class="font-label-caps text-label-caps text-on-surface-variant">STEP 3 OF 3</div>
</header>
<main class="flex-1 mt-[64px] mb-[64px] flex flex-col items-center px-margin-edge pt-6 space-y-6 overflow-y-auto">
<!-- Header Text -->
<div class="w-full text-center space-y-2">
<h1 class="font-headline text-headline text-on-surface">TRY IT OUT</h1>
<p class="font-body-md text-on-surface-variant max-w-xs mx-auto">Tap a face-up card to auto-move it to the best legal pile.</p>
</div>
<!-- Demo Panel -->
<div class="w-full max-w-sm bg-surface border border-outline p-6 rounded-lg relative overflow-hidden">
<!-- Subtle scanline background effect for "terminal" pane feel -->
<div class="absolute inset-0 scanline-bg opacity-10 pointer-events-none"></div>
<div class="relative z-10 flex flex-col items-center">
<!-- Foundation Slot -->
<div class="w-20 h-28 border border-dashed border-outline-variant flex items-center justify-center mb-12">
<span class="material-symbols-outlined text-outline-variant opacity-40 text-4xl" data-icon="spades">playing_cards</span>
</div>
<!-- Path Indicator (The Arrow) -->
<div class="absolute top-[84px] left-1/2 -translate-x-1/2 flex flex-col items-center">
<div class="font-label-caps text-secondary text-[10px] mb-1">MOVES HERE</div>
<span class="material-symbols-outlined text-secondary text-3xl font-bold" data-icon="arrow_upward">arrow_upward</span>
</div>
<!-- Mini-Cards Row -->
<div class="flex gap-gutter-card">
<!-- A-Spades (Target) -->
<div class="w-20 h-28 bg-surface border-2 border-primary rounded flex flex-col justify-between p-2 relative ring-1 ring-primary ring-offset-2 ring-offset-surface">
<div class="font-card-rank text-card-rank text-suit-black">A</div>
<span class="material-symbols-outlined self-end text-3xl text-suit-black" data-icon="spades" style="font-variation-settings: 'FILL' 1;">playing_cards</span>
<!-- Pulse Icon -->
<div class="absolute inset-0 flex items-center justify-center">
<span class="material-symbols-outlined text-primary text-4xl opacity-80" data-icon="touch_app">touch_app</span>
</div>
</div>
<!-- K-Hearts -->
<div class="w-20 h-28 bg-surface border border-suit-red rounded flex flex-col justify-between p-2 opacity-50">
<div class="font-card-rank text-card-rank text-suit-red">K</div>
<span class="material-symbols-outlined self-end text-3xl text-suit-red" data-icon="favorite" style="font-variation-settings: 'FILL' 1;">favorite</span>
</div>
<!-- Q-Clubs -->
<div class="w-20 h-28 bg-surface border border-outline rounded flex flex-col justify-between p-2 opacity-50">
<div class="font-card-rank text-card-rank text-on-surface">Q</div>
<span class="material-symbols-outlined self-end text-3xl text-on-surface" data-icon="clubs">groups</span>
</div>
</div>
</div>
</div>
<!-- CLI Prompt -->
<div class="w-full max-w-sm flex items-center gap-2 font-label-caps text-on-surface py-2">
<span class="text-primary"></span>
<span class="tracking-widest">TAP THE A♠ TO CONTINUE</span>
<span class="w-3 h-5 bg-primary animate-pulse"></span>
</div>
<!-- Feature List -->
<div class="w-full max-w-sm space-y-3 pt-2">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check_circle" style="font-variation-settings: 'FILL' 1;">check_circle</span>
<span class="font-label-caps text-label-caps">TAP TO AUTO-MOVE</span>
</div>
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check_circle" style="font-variation-settings: 'FILL' 1;">check_circle</span>
<span class="font-label-caps text-label-caps">DRAG TO TARGET PILE</span>
</div>
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check_circle" style="font-variation-settings: 'FILL' 1;">check_circle</span>
<span class="font-label-caps text-label-caps">DOUBLE-TAP TO FOUNDATION</span>
</div>
</div>
<!-- Step Indicators -->
<div class="flex gap-2 py-4">
<div class="w-8 h-1 bg-primary"></div>
<div class="w-8 h-1 bg-primary"></div>
<div class="w-8 h-1 bg-primary"></div>
</div>
</main>
<!-- Bottom Action Bar -->
<footer class="fixed bottom-0 w-full h-[64px] bg-surface-container border-t border-outline flex items-center px-margin-edge justify-between z-50">
<button class="px-6 py-2 border border-outline text-on-surface-variant font-label-caps text-label-caps transition-colors duration-120 active:bg-surface-bright flex items-center gap-2">
<span class="material-symbols-outlined text-sm" data-icon="arrow_back">arrow_back</span>
BACK
</button>
<button class="px-6 py-2 bg-primary text-on-primary font-label-caps text-label-caps transition-colors duration-120 active:bg-primary-container flex items-center gap-2">
<span class="material-symbols-outlined text-sm" data-icon="play_arrow" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
START PLAYING
</button>
</footer>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=390, height=844, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"highlight-valid": "#acc267",
"outline": "#505050",
"highlight-celebration": "#e1a3ee",
"error-container": "#93000a",
"surface-container": "#202020",
"on-secondary-fixed": "#161e00",
"tertiary": "#f7c3ff",
"inverse-on-surface": "#2d3134",
"tertiary-fixed-dim": "#f0b0fc",
"on-primary-fixed": "#001e2c",
"info": "#12cfc0",
"on-tertiary": "#4c195b",
"secondary-container": "#435401",
"surface": "#151515",
"tertiary-container": "#e1a3ee",
"outline-variant": "#3f484e",
"suit-red": "#fb9fb1",
"secondary-fixed": "#d5ec8c",
"error": "#fb9fb1",
"primary-container": "#6fc2ef",
"surface-container-lowest": "#0b0f11",
"on-surface": "#e0e3e6",
"tertiary-fixed": "#fbd7ff",
"on-secondary-container": "#b2c86d",
"on-primary-fixed-variant": "#004c69",
"on-primary": "#003549",
"on-secondary": "#293500",
"on-primary-container": "#004f6c",
"secondary": "#bad073",
"surface-container-highest": "#313538",
"primary": "#a1dcff",
"surface-container-low": "#181c1f",
"secondary-fixed-dim": "#bad073",
"warning": "#ddb26f",
"suit-black": "#d0d0d0",
"surface-variant": "#313538",
"on-tertiary-container": "#683476",
"on-tertiary-fixed": "#340043",
"on-secondary-fixed-variant": "#3c4d00",
"on-background": "#e0e3e6",
"surface-bright": "#363a3d",
"on-error": "#690005",
"primary-fixed-dim": "#7ed0fe",
"on-tertiary-fixed-variant": "#653173",
"suit-red-cb": "#6fc2ef",
"inverse-surface": "#e0e3e6",
"on-surface-variant": "#bfc8cf",
"background": "#101417",
"primary-fixed": "#c4e7ff",
"on-error-container": "#ffdad6",
"inverse-primary": "#00668a",
"surface-dim": "#101417",
"surface-container-high": "#272a2d",
"surface-tint": "#7ed0fe",
"cyan-terminal": "#6fc2ef"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"touch-target-min": "48dp",
"stack-overlap": "2rem",
"gutter-card": "0.375rem",
"action-bar-height": "64px",
"margin-edge": "1rem"
},
"fontFamily": {
"headline": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"body-md": ["Inter"],
"hud-score": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"]
}
}
}
}
</script>
<style>
.scanline-pattern {
background: repeating-linear-gradient(
0deg,
#151515,
#151515 2px,
#1a1a1a 2px,
#1a1a1a 4px
);
}
.checker-pattern {
background-color: #ffffff;
background-image:
linear-gradient(45deg, #004c69 25%, transparent 25%),
linear-gradient(-45deg, #004c69 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #004c69 75%),
linear-gradient(-45deg, transparent 75%, #004c69 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
}
.stripe-pattern {
background: repeating-linear-gradient(
0deg,
#fb9fb1,
#fb9fb1 4px,
#151515 4px,
#151515 8px
);
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface min-h-screen flex flex-col items-center overflow-hidden selection:bg-cyan-terminal selection:text-surface">
<!-- 1. Status Bar -->
<header class="w-full h-8 bg-surface-container flex items-center justify-between px-4 border-b border-outline-variant">
<span class="font-label-caps text-[12px] text-on-surface uppercase tracking-tight">▌onboard/02-theme.tsx</span>
<span class="font-label-caps text-[12px] text-[#a0a0a0] uppercase tracking-widest">STEP 2 OF 3</span>
</header>
<!-- 2. Hero Illustration Band -->
<section class="w-full flex flex-col items-center pt-8 pb-4">
<div class="h-[100px] flex items-center justify-center relative">
<span class="text-cyan-terminal font-headline text-[48px] mr-4 select-none"></span>
<div class="flex -space-x-4">
<div class="w-[24px] h-[34px] border border-outline bg-surface scanline-pattern transform -rotate-12 translate-y-2"></div>
<div class="w-[24px] h-[34px] border border-outline bg-surface checker-pattern transform rotate-0 z-10"></div>
<div class="w-[24px] h-[34px] border border-outline bg-surface stripe-pattern transform rotate-12 translate-y-2"></div>
</div>
</div>
<h2 class="font-headline text-[28px] font-bold text-suit-black tracking-tight leading-none">PICK YOUR DECK</h2>
</section>
<!-- 3. Headline & Description -->
<section class="w-full px-margin-edge text-center mb-6">
<h3 class="font-headline text-[22px] font-bold text-suit-black mb-1">CHOOSE A CARD-BACK</h3>
<p class="font-body-md text-[12px] text-[#a0a0a0] leading-tight">
You can swap or import more themes from Settings later.
</p>
</section>
<!-- 4. Theme Selection Grid -->
<main class="w-full px-margin-edge grid grid-cols-3 gap-2 flex-grow max-h-[220px]">
<!-- Tile 1: Terminal (Active) -->
<div class="flex flex-col items-center">
<div class="w-full aspect-[110/150] bg-surface-container border-2 border-cyan-terminal rounded-lg p-3 relative flex items-center justify-center overflow-hidden">
<div class="w-full h-full scanline-pattern border border-outline-variant relative">
<div class="absolute top-1 left-1 w-2 h-3 bg-cyan-terminal"></div>
<div class="absolute bottom-1 right-1 font-headline text-[10px] text-on-surface opacity-50">▌RS</div>
</div>
<div class="absolute top-1 right-1 bg-cyan-terminal text-surface w-4 h-4 flex items-center justify-center rounded-full">
<span class="material-symbols-outlined text-[12px] font-bold">check</span>
</div>
</div>
<span class="mt-2 font-label-caps text-[12px] font-bold text-suit-black tracking-widest uppercase">TERMINAL</span>
</div>
<!-- Tile 2: Classic -->
<div class="flex flex-col items-center opacity-70">
<div class="w-full aspect-[110/150] bg-surface-container border border-outline rounded-lg p-3 relative flex items-center justify-center overflow-hidden">
<div class="w-full h-full checker-pattern border border-outline-variant"></div>
</div>
<span class="mt-2 font-label-caps text-[12px] font-bold text-suit-black tracking-widest uppercase">CLASSIC</span>
</div>
<!-- Tile 3: Stripes -->
<div class="flex flex-col items-center opacity-70">
<div class="w-full aspect-[110/150] bg-surface-container border border-outline rounded-lg p-3 relative flex items-center justify-center overflow-hidden">
<div class="w-full h-full stripe-pattern border border-outline-variant"></div>
</div>
<span class="mt-2 font-label-caps text-[12px] font-bold text-suit-black tracking-widest uppercase">STRIPES</span>
</div>
</main>
<!-- 5. More Info -->
<div class="w-full text-center mt-4">
<span class="font-label-caps text-[11px] font-medium text-[#a0a0a0] tracking-widest">
<span class="text-cyan-terminal">+</span> MORE IN SETTINGS
</span>
</div>
<!-- 6. Step Indicator -->
<section class="w-full flex flex-col items-center mt-6">
<div class="flex gap-1 h-2 mb-2">
<div class="w-8 h-1 bg-cyan-terminal rounded-full"></div>
<div class="w-8 h-1 bg-cyan-terminal rounded-full"></div>
<div class="w-8 h-1 bg-outline rounded-full"></div>
</div>
<div class="font-headline text-[12px] font-medium tracking-[0.2em]">
<span class="text-cyan-terminal">[1]</span>
<span class="text-cyan-terminal">[2]</span>
<span class="text-outline">[3]</span>
</div>
</section>
<!-- 7. Bottom Action Bar -->
<footer class="fixed bottom-0 w-full h-[64px] bg-surface-container flex items-center justify-between px-margin-edge z-50">
<button class="w-[48%] h-12 border border-outline bg-transparent text-suit-black font-label-caps text-[13px] font-medium uppercase rounded-lg active:bg-surface-variant transition-colors">
← BACK
</button>
<button class="w-[48%] h-12 bg-cyan-terminal text-surface font-label-caps text-[14px] font-bold uppercase rounded-lg active:opacity-80 transition-opacity">
NEXT →
</button>
</footer>
<!-- Image descriptive data for the model (hidden visually) -->
<div class="hidden" data-alt="A detailed user interface screen for a retro-terminal themed solitaire game called Rusty Solitaire. The background is a deep black with cyan and gray accents. In the center, a card theme selection grid displays three different card back designs: a scanline pattern, a checker pattern, and a striped pattern. The visual style is crisp, technical, and uses monospaced typography to evoke a command-line interface or professional developer environment. The mood is minimalist, efficient, and technologically nostalgic."></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+212
View File
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Rouge Solitaire - Pause</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&amp;family=JetBrains+Mono:wght@400;500;700;800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-secondary-fixed": "#161e00",
"secondary-fixed": "#d5ec8c",
"warning": "#ddb26f",
"surface-container-low": "#181c1f",
"surface-container": "#1c2023",
"on-primary-fixed-variant": "#004c69",
"outline-variant": "#3f484e",
"on-tertiary-container": "#683476",
"surface-container-high": "#272a2d",
"on-primary-fixed": "#001e2c",
"primary-fixed": "#c4e7ff",
"surface-bright": "#363a3d",
"outline": "#505050",
"tertiary-fixed-dim": "#f0b0fc",
"tertiary": "#f7c3ff",
"on-surface": "#e0e3e6",
"secondary": "#bad073",
"tertiary-fixed": "#fbd7ff",
"info": "#12cfc0",
"primary": "#a1dcff",
"secondary-fixed-dim": "#bad073",
"surface-tint": "#7ed0fe",
"background": "#101417",
"surface-container-highest": "#313538",
"on-tertiary-fixed": "#340043",
"highlight-valid": "#acc267",
"inverse-primary": "#00668a",
"surface-dim": "#101417",
"error": "#fb9fb1",
"on-error": "#690005",
"inverse-surface": "#e0e3e6",
"suit-red": "#fb9fb1",
"suit-black": "#d0d0d0",
"inverse-on-surface": "#2d3134",
"highlight-celebration": "#e1a3ee",
"on-error-container": "#ffdad6",
"on-primary": "#003549",
"surface": "#151515",
"surface-container-lowest": "#0b0f11",
"primary-fixed-dim": "#7ed0fe",
"on-secondary": "#293500",
"suit-red-cb": "#6fc2ef",
"on-tertiary": "#4c195b",
"error-container": "#93000a",
"on-secondary-fixed-variant": "#3c4d00",
"on-secondary-container": "#b2c86d",
"on-surface-variant": "#bfc8cf",
"on-primary-container": "#004f6c",
"primary-container": "#6fc2ef",
"on-background": "#e0e3e6",
"surface-variant": "#313538",
"secondary-container": "#435401",
"on-tertiary-fixed-variant": "#653173",
"tertiary-container": "#e1a3ee"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"touch-target-min": "48dp",
"action-bar-height": "64px",
"stack-overlap": "2rem",
"margin-edge": "1rem",
"gutter-card": "0.375rem"
},
"fontFamily": {
"body-md": ["Inter"],
"headline": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"]
},
"fontSize": {
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}]
}
},
},
}
</script>
<style>
.scanline {
background: linear-gradient(
to bottom,
transparent 50%,
rgba(0, 0, 0, 0.05) 50%
);
background-size: 100% 4px;
pointer-events: none;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface font-body-md overflow-hidden antialiased">
<!-- Background Tableau (Simulated by Dimmed Image Overlay) -->
<div class="fixed inset-0 z-0">
<img alt="Game Tableau Background" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDJSHHDQ5Y5qul5C_xabnOSM9aS3uxcWSTk47AOHrS_KIlQi0Ur7YhtL0BomjEWTDc8vRLpytWeG4kf5xgxBzpORahTtsWyXOUPsVRg6_H_qp0QjM6DDo57rOPwjU6TFdfK3Pi7cO9rg-xnUSSu1wu29WyKVwSWDDaA5cZ4QN_9L81YMTCTMKAwDTGsY3eGsj1b1i1X2CdF211aepkhmX8xf4bnV35WSB3QuYxUwlPct0Met7iLFf-AGBeizhK6IAboW5u-Wpg8Ag"/>
<!-- 95% Opacity Scrim -->
<div class="absolute inset-0 bg-surface opacity-95"></div>
<!-- Scanline Overlay for Texture -->
<div class="absolute inset-0 scanline"></div>
</div>
<!-- Modal Container -->
<div class="fixed inset-0 z-10 flex items-center justify-center p-margin-edge">
<!-- Modal Panel -->
<div class="w-[330px] h-[480px] bg-[#202020] border border-outline rounded-xl flex flex-col overflow-hidden">
<!-- Title Bar -->
<div class="h-[28px] bg-[#1a1a1a] border-b border-[#353535] px-3 flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="text-primary-container font-headline text-[12px] leading-none mt-px"></span>
<span class="font-headline text-[12px] text-[#a0a0a0] leading-none">pause.tsx</span>
</div>
<button class="flex items-center justify-center">
<span class="material-symbols-outlined text-[16px] text-[#505050]">close</span>
</button>
</div>
<!-- Content Canvas -->
<div class="flex-1 flex flex-col items-center pt-8 px-4">
<!-- Headline -->
<h1 class="font-headline text-[24px] font-bold text-[#d0d0d0] tracking-tight text-center">
GAME PAUSED
</h1>
<!-- Subline -->
<p class="font-headline text-[12px] text-[#a0a0a0] mt-1 text-center">
12:34 ELAPSED · 87 MOVES · DRAW-3
</p>
<!-- Mini-Stat Chips -->
<div class="flex gap-2 mt-4 justify-center">
<div class="bg-[#1a1a1a] border border-[#353535] rounded-sm px-2 py-1 flex flex-col items-center">
<span class="font-headline text-[11px] text-[#d0d0d0]">SCORE 247</span>
</div>
<div class="bg-[#1a1a1a] border border-[#353535] rounded-sm px-2 py-1 flex flex-col items-center">
<span class="font-headline text-[11px] text-[#d0d0d0]">STOCK 18</span>
</div>
<div class="bg-[#1a1a1a] border border-[#353535] rounded-sm px-2 py-1 flex flex-col items-center">
<span class="font-headline text-[11px] text-[#d0d0d0]">MOVES 87</span>
</div>
</div>
<!-- Action Buttons Cluster -->
<div class="w-full mt-6 space-y-3">
<!-- Primary CTA -->
<button class="w-full h-[48px] bg-primary-container text-surface flex items-center justify-center rounded-lg active:scale-95 transition-transform duration-75">
<span class="font-headline text-[14px] font-bold tracking-[0.08em] uppercase">▶ RESUME GAME</span>
</button>
<!-- Secondary Buttons -->
<button class="w-full h-[48px] bg-transparent border border-outline text-[#d0d0d0] flex items-center justify-center rounded-lg hover:border-primary-container hover:text-primary-container transition-colors duration-120">
<span class="font-headline text-[13px] font-medium tracking-[0.08em] uppercase">↻ RESTART</span>
</button>
<button class="w-full h-[48px] bg-transparent border border-outline text-[#d0d0d0] flex items-center justify-center rounded-lg hover:border-primary-container hover:text-primary-container transition-colors duration-120">
<span class="font-headline text-[13px] font-medium tracking-[0.08em] uppercase">✕ FORFEIT</span>
</button>
<button class="w-full h-[48px] bg-transparent border border-outline text-[#d0d0d0] flex items-center justify-center rounded-lg hover:border-primary-container hover:text-primary-container transition-colors duration-120">
<span class="font-headline text-[13px] font-medium tracking-[0.08em] uppercase">⌂ QUIT TO MENU</span>
</button>
</div>
</div>
<!-- Footer Status Line -->
<div class="h-[24px] border-t border-[#353535] px-3 flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-primary-container font-headline text-[11px]"></span>
<span class="font-headline text-[11px] text-[#a0a0a0]">NORMAL</span>
<span class="text-[#505050] text-[11px]"></span>
<span class="font-headline text-[11px] text-[#a0a0a0]">pause</span>
</div>
<div class="flex items-center gap-1 font-headline text-[11px]">
<span class="text-[#a0a0a0]">[ESC]</span>
<span class="text-[#505050]">resume</span>
</div>
</div>
</div>
</div>
<!-- Hidden Navigation Shell (Suppressed due to Task-Focused Modal Context) -->
<!-- But included visually as per the brand anchor hierarchy for TopAppBar identity if it were visible -->
<header class="hidden fixed top-0 w-full h-action-bar-height flex items-center justify-between px-margin-edge w-full bg-background border-b border-outline-variant">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-primary">terminal</span>
<span class="font-headline text-headline text-primary uppercase tracking-tighter">▌ROUGE_SOLITAIRE</span>
</div>
</header>
<!-- Bottom Nav Suppression Logic: Not rendered to prioritize the focus canvas -->
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+274
View File
@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500;700&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* CRT Scanline Overlay Effect */
.crt-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
background-size: 100% 3px, 3px 100%;
pointer-events: none;
z-index: 100;
}
.custom-scrollbar::-webkit-scrollbar {
height: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #151515;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #505050;
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-container-low": "#181c1f",
"on-primary-fixed": "#001e2c",
"on-error": "#690005",
"suit-black": "#d0d0d0",
"inverse-primary": "#00668a",
"on-primary-container": "#004f6c",
"suit-red-cb": "#6fc2ef",
"tertiary-container": "#e1a3ee",
"secondary-fixed": "#d5ec8c",
"highlight-valid": "#acc267",
"surface-container-highest": "#313538",
"secondary-fixed-dim": "#bad073",
"warning": "#ddb26f",
"on-secondary-fixed": "#161e00",
"primary-fixed-dim": "#7ed0fe",
"on-surface-variant": "#bfc8cf",
"on-primary": "#003549",
"tertiary-fixed": "#fbd7ff",
"primary-container": "#6fc2ef",
"on-background": "#e0e3e6",
"error-container": "#93000a",
"outline": "#505050",
"on-tertiary-fixed": "#340043",
"primary-fixed": "#c4e7ff",
"on-surface": "#e0e3e6",
"on-secondary": "#293500",
"surface-container-high": "#272a2d",
"on-tertiary": "#4c195b",
"secondary": "#bad073",
"on-tertiary-container": "#683476",
"inverse-on-surface": "#2d3134",
"surface-dim": "#101417",
"primary": "#a1dcff",
"on-tertiary-fixed-variant": "#653173",
"on-error-container": "#ffdad6",
"surface-variant": "#313538",
"surface-tint": "#7ed0fe",
"surface-container-lowest": "#0b0f11",
"surface": "#151515",
"tertiary": "#f7c3ff",
"on-secondary-fixed-variant": "#3c4d00",
"surface-bright": "#363a3d",
"tertiary-fixed-dim": "#f0b0fc",
"outline-variant": "#3f484e",
"info": "#12cfc0",
"secondary-container": "#435401",
"on-secondary-container": "#b2c86d",
"inverse-surface": "#e0e3e6",
"error": "#fb9fb1",
"suit-red": "#fb9fb1",
"background": "#101417",
"highlight-celebration": "#e1a3ee",
"on-primary-fixed-variant": "#004c69",
"surface-container": "#202020"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"stack-overlap": "2rem",
"action-bar-height": "64px",
"touch-target-min": "48dp",
"gutter-card": "0.375rem",
"margin-edge": "1rem"
},
"fontFamily": {
"headline": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"body-md": ["Inter"],
"card-rank": ["JetBrains Mono"]
},
"fontSize": {
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-background font-body-md selection:bg-primary selection:text-background overflow-x-hidden">
<div class="crt-overlay"></div>
<!-- Status Bar -->
<header class="bg-surface-container h-[32px] w-full flex items-center justify-between px-margin-edge z-50">
<div class="font-headline text-[12px] text-on-surface-variant tracking-wider">
▌profile.tsx
</div>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-info"></span>
<span class="font-label-caps text-[10px] text-on-surface">● SYNCED</span>
</div>
</header>
<!-- Main Canvas -->
<main class="flex-1 overflow-y-auto pb-24">
<!-- Profile Header -->
<section class="h-[120px] bg-surface-container border-b border-outline-variant flex items-center px-margin-edge gap-4">
<div class="w-[64px] h-[64px] bg-[#1a1a1a] border border-outline flex items-center justify-center shrink-0">
<span class="font-headline text-[28px] text-primary-container">RS</span>
</div>
<div class="flex flex-col gap-1 overflow-hidden">
<h1 class="font-headline text-[18px] text-on-surface truncate">anonymous@local</h1>
<p class="font-label-caps text-on-surface-variant text-[10px]">MEMBER SINCE 2026-04-22</p>
<div class="flex gap-2 mt-1">
<span class="px-2 py-0.5 bg-[#1a1a1a] font-label-caps text-[10px] text-suit-black">247 GAMES</span>
<span class="px-2 py-0.5 bg-[#1a1a1a] font-label-caps text-[10px] text-suit-black">61% WR</span>
<span class="px-2 py-0.5 bg-[#1a1a1a] font-label-caps text-[10px] text-suit-black">12 STREAK</span>
</div>
</div>
</section>
<!-- Level/XP Section -->
<section class="p-margin-edge bg-surface-container border-b border-outline-variant">
<div class="flex justify-between items-baseline mb-2">
<span class="font-headline text-[24px] text-on-surface">LEVEL 12</span>
<span class="font-hud-timer text-on-surface-variant">320/500 XP</span>
</div>
<div class="h-3 w-full bg-[#353535] relative overflow-hidden">
<div class="h-full bg-primary-container" style="width: 64%;"></div>
</div>
<div class="flex items-center gap-2 mt-3">
<span class="w-1.5 h-1.5 rounded-full bg-highlight-celebration"></span>
<span class="font-label-caps text-[10px] text-on-surface-variant">180 XP TO LEVEL 13</span>
</div>
</section>
<!-- Unlocked Cards -->
<section class="mt-6">
<h2 class="px-margin-edge font-headline text-[14px] text-on-surface mb-4">▌ unlocked.cards</h2>
<div class="flex overflow-x-auto gap-4 px-margin-edge pb-4 custom-scrollbar">
<!-- Terminal (Active) -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[60px] h-[84px] bg-surface-container-low border-2 border-primary-container relative flex items-center justify-center p-1">
<div class="w-full h-full bg-[#151515] overflow-hidden flex flex-col p-1">
<div class="w-2 h-2.5 bg-primary-container mb-auto"></div>
<div class="self-end text-[8px] font-headline text-on-surface-variant opacity-50">▌RS</div>
</div>
</div>
<span class="font-label-caps text-[9px] text-primary-container text-center">ACTIVE</span>
</div>
<!-- Classic -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[60px] h-[84px] bg-white border border-outline relative p-1">
<div class="w-full h-full border border-red-200 bg-red-50 opacity-20"></div>
</div>
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">TAP TO USE</span>
</div>
<!-- Stripes -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[60px] h-[84px] bg-surface-container border border-outline p-1">
<div class="w-full h-full bg-gradient-to-br from-secondary-container via-surface to-secondary-container opacity-40"></div>
</div>
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">TAP TO USE</span>
</div>
<!-- Polka -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[60px] h-[84px] bg-surface-container border border-outline p-1 overflow-hidden relative">
<div class="w-full h-full opacity-30" style="background-image: radial-gradient(#505050 1px, transparent 0); background-size: 6px 6px;"></div>
</div>
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">TAP TO USE</span>
</div>
</div>
</section>
<!-- Unlocked Backgrounds -->
<section class="mt-6">
<h2 class="px-margin-edge font-headline text-[14px] text-on-surface mb-4">▌ unlocked.backgrounds</h2>
<div class="flex overflow-x-auto gap-4 px-margin-edge pb-4 custom-scrollbar">
<!-- Default (Active) -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[80px] h-[56px] bg-[#151515] border-2 border-primary-container"></div>
<span class="font-label-caps text-[9px] text-primary-container text-center">ACTIVE</span>
</div>
<!-- Forest -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[80px] h-[56px] bg-[#0d160d] border border-outline"></div>
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">FOREST</span>
</div>
<!-- Slate -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[80px] h-[56px] bg-[#1c2128] border border-outline"></div>
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">SLATE</span>
</div>
<!-- Midnight -->
<div class="shrink-0 flex flex-col gap-2">
<div class="w-[80px] h-[56px] bg-[#09090b] border border-outline"></div>
<span class="font-label-caps text-[9px] text-on-surface-variant text-center opacity-50">MIDNIGHT</span>
</div>
</div>
</section>
<!-- Sign-in Card -->
<section class="mt-8 px-margin-edge">
<button class="w-full h-[64px] bg-surface-container border border-dashed border-outline flex items-center justify-between px-6 hover:bg-surface-variant transition-colors group">
<span class="font-label-caps text-on-surface-variant tracking-widest">+ SIGN IN TO SYNC PROGRESS</span>
<span class="material-symbols-outlined text-primary-container group-hover:translate-x-1 transition-transform">arrow_forward</span>
</button>
</section>
</main>
<!-- TopAppBar (from Shared Components - as Terminal Header) -->
<div class="fixed top-[32px] left-0 w-full z-40 bg-background border-b border-outline-variant flex items-center justify-between px-margin-edge h-action-bar-height">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-primary">terminal</span>
<span class="font-headline text-headline text-primary tracking-tighter">~/root/usr/settings</span>
</div>
<div class="flex items-center">
<button class="p-2 hover:bg-surface-variant text-primary transition-colors">
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
<!-- BottomNavBar (from Shared Components - as Terminal Footer) -->
<nav class="fixed bottom-0 left-0 w-full z-50 h-[24px] bg-surface-container-lowest border-t border-outline-variant flex justify-between items-center px-4">
<div class="flex items-center gap-4">
<span class="font-label-caps text-[10px] text-on-surface-variant">▌ NORMAL │ profile</span>
</div>
<div class="flex items-center gap-4">
<button class="flex items-center gap-1 group">
<span class="font-label-caps text-[10px] text-on-surface-variant group-hover:text-primary">[ESC] back</span>
</button>
</div>
</nav>
<!-- Decorative CRT Scanline overlay line -->
<div class="fixed top-0 left-0 w-full h-[1px] bg-primary opacity-20 pointer-events-none animate-pulse"></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+271
View File
@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-variant": "#313538",
"surface-dim": "#101417",
"secondary-fixed-dim": "#bad073",
"surface-bright": "#363a3d",
"secondary-fixed": "#d5ec8c",
"secondary-container": "#435401",
"on-tertiary-fixed-variant": "#653173",
"surface-container-highest": "#313538",
"outline-variant": "#3f484e",
"error": "#fb9fb1",
"surface-container": "#202020",
"inverse-on-surface": "#2d3134",
"on-primary-fixed-variant": "#004c69",
"outline": "#505050",
"on-secondary": "#293500",
"suit-red": "#fb9fb1",
"inverse-primary": "#00668a",
"on-secondary-container": "#b2c86d",
"highlight-celebration": "#e1a3ee",
"warning": "#ddb26f",
"primary-fixed-dim": "#7ed0fe",
"info": "#12cfc0",
"primary-fixed": "#c4e7ff",
"highlight-valid": "#acc267",
"on-surface-variant": "#bfc8cf",
"on-tertiary": "#4c195b",
"background": "#101417",
"tertiary-container": "#e1a3ee",
"suit-black": "#d0d0d0",
"on-error-container": "#ffdad6",
"on-surface": "#d0d0d0",
"primary": "#a1dcff",
"error-container": "#93000a",
"secondary": "#bad073",
"surface": "#151515",
"primary-container": "#6fc2ef",
"suit-red-cb": "#6fc2ef",
"on-primary": "#003549",
"surface-container-low": "#181c1f",
"tertiary-fixed-dim": "#f0b0fc",
"surface-tint": "#7ed0fe",
"on-tertiary-container": "#683476",
"on-secondary-fixed": "#161e00",
"surface-container-lowest": "#0b0f11",
"on-tertiary-fixed": "#340043",
"surface-container-high": "#272a2d",
"on-error": "#690005",
"tertiary-fixed": "#fbd7ff",
"tertiary": "#f7c3ff",
"on-background": "#e0e3e6",
"on-secondary-fixed-variant": "#3c4d00",
"on-primary-fixed": "#001e2c",
"on-primary-container": "#004f6c",
"inverse-surface": "#e0e3e6"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"action-bar-height": "64px",
"stack-overlap": "2rem",
"gutter-card": "0.375rem",
"touch-target-min": "48dp",
"margin-edge": "1rem"
},
"fontFamily": {
"label-caps": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"body-md": ["Inter"],
"headline": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"]
},
"fontSize": {
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
.radial-segment {
clip-path: polygon(50% 50%, 100% 0, 100% 100%);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
display: inline-block;
vertical-align: middle;
}
.scanline-bg {
background: repeating-linear-gradient(
0deg,
rgba(26, 26, 26, 1) 0px,
rgba(26, 26, 26, 1) 2px,
rgba(21, 21, 21, 1) 2px,
rgba(21, 21, 21, 1) 4px
);
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface font-body-md text-on-surface select-none overflow-hidden h-screen w-screen flex flex-col">
<!-- Underlying Game Tableau (Dimmed Background) -->
<main class="relative flex-grow opacity-30 grid grid-cols-7 gap-2 p-margin-edge pointer-events-none">
<!-- Top row: Foundation/Stock -->
<div class="col-span-1 aspect-[2/3] border border-dashed border-outline-variant bg-surface-container flex items-center justify-center">
<span class="material-symbols-outlined text-outline-variant">terminal</span>
</div>
<div class="col-span-1 aspect-[2/3] border border-dashed border-outline-variant"></div>
<div class="col-span-1"></div>
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-red-cb flex items-center justify-center">
<span class="material-symbols-outlined text-suit-red-cb">favorite</span>
</div>
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-black flex items-center justify-center">
<span class="material-symbols-outlined text-suit-black">backspace</span>
</div>
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-red-cb flex items-center justify-center">
<span class="material-symbols-outlined text-suit-red-cb">diamond</span>
</div>
<div class="col-span-1 aspect-[2/3] border border-outline border-suit-black flex items-center justify-center">
<span class="material-symbols-outlined text-suit-black">spa</span>
</div>
<!-- Tableau piles -->
<div class="col-span-7 grid grid-cols-7 gap-2 mt-4">
<div class="space-y-[-120%]">
<div class="aspect-[2/3] bg-surface-container-high border border-outline p-1 flex flex-col justify-between">
<span class="font-card-rank text-card-rank text-suit-red-cb">K</span>
<span class="material-symbols-outlined self-end text-3xl rotate-180 text-suit-red-cb">diamond</span>
</div>
</div>
<div class="space-y-[-120%]">
<div class="aspect-[2/3] scanline-bg border border-outline relative">
<div class="absolute top-1 left-1 w-3 h-4 bg-primary"></div>
<span class="absolute bottom-1 right-1 font-label-caps text-[10px] text-primary">▌RS</span>
</div>
<div class="aspect-[2/3] bg-surface-container-high border border-outline p-1 flex flex-col justify-between">
<span class="font-card-rank text-card-rank text-suit-black">Q</span>
<span class="material-symbols-outlined self-end text-3xl rotate-180 text-suit-black" style="font-variation-settings: 'FILL' 1;">spa</span>
</div>
</div>
<div class="space-y-[-120%]">
<div class="aspect-[2/3] scanline-bg border border-outline"></div>
<div class="aspect-[2/3] scanline-bg border border-outline"></div>
<div class="aspect-[2/3] bg-surface-container-high border border-outline p-1 flex flex-col justify-between">
<span class="font-card-rank text-card-rank text-suit-red-cb">J</span>
<span class="material-symbols-outlined self-end text-3xl rotate-180 text-suit-red-cb" style="font-variation-settings: 'FILL' 1;">favorite</span>
</div>
</div>
<!-- More stacks... omitted for brevity as background -->
</div>
</main>
<!-- Radial Menu Overlay -->
<div class="fixed inset-0 z-50 bg-[#151515]/70 flex items-center justify-center overflow-hidden">
<div class="relative w-[280px] h-[280px] flex items-center justify-center">
<!-- Outer Circular Ring Shell -->
<div class="absolute inset-0 rounded-full border border-outline bg-surface-container overflow-hidden">
<!-- SVG Segments Construction -->
<svg class="w-full h-full transform -rotate-22.5" viewbox="0 0 100 100">
<!-- Slice 1 (UNDO) - Top / 12:00 -->
<!-- Active state: bg-primary-container/15, stroke-primary -->
<path d="M 50 50 L 50 0 A 50 50 0 0 1 85.35 14.65 Z" fill="#6fc2ef26" stroke="#6fc2ef" stroke-width="0.5" transform="rotate(-22.5, 50, 50)"></path>
<!-- Slice 2 (REDO) -->
<path d="M 50 50 L 85.35 14.65 A 50 50 0 0 1 100 50 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
<!-- Slice 3 (HINT) -->
<path d="M 50 50 L 100 50 A 50 50 0 0 1 85.35 85.35 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
<!-- Slice 4 (AUTO) -->
<path d="M 50 50 L 85.35 85.35 A 50 50 0 0 1 50 100 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
<!-- Slice 5 (NEW) -->
<path d="M 50 50 L 50 100 A 50 50 0 0 1 14.65 85.35 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
<!-- Slice 6 (PAUSE) -->
<path d="M 50 50 L 14.65 85.35 A 50 50 0 0 1 0 50 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
<!-- Slice 7 (STATS) -->
<path d="M 50 50 L 0 50 A 50 50 0 0 1 14.65 14.65 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
<!-- Slice 8 (SETTINGS) -->
<path d="M 50 50 L 14.65 14.65 A 50 50 0 0 1 50 0 Z" fill="transparent" stroke="#353535" stroke-width="0.25" transform="rotate(-22.5, 50, 50)"></path>
</svg>
</div>
<!-- Labels and Icons Overlay -->
<div class="absolute inset-0 pointer-events-none">
<!-- 12:00 UNDO (ACTIVE) -->
<div class="absolute top-[12%] left-1/2 -translate-x-1/2 flex flex-col items-center text-primary">
<span class="material-symbols-outlined text-[24px]" data-icon="undo">undo</span>
<span class="font-label-caps text-[11px] mt-1">UNDO</span>
</div>
<!-- 1:30 REDO -->
<div class="absolute top-[22%] right-[12%] flex flex-col items-center text-on-surface">
<span class="material-symbols-outlined text-[24px]" data-icon="redo">redo</span>
<span class="font-label-caps text-[11px] mt-1">REDO</span>
</div>
<!-- 3:00 HINT -->
<div class="absolute top-1/2 right-[8%] -translate-y-1/2 flex flex-col items-center text-on-surface">
<span class="material-symbols-outlined text-[24px]" data-icon="lightbulb">lightbulb</span>
<span class="font-label-caps text-[11px] mt-1">HINT</span>
</div>
<!-- 4:30 AUTO -->
<div class="absolute bottom-[22%] right-[12%] flex flex-col items-center text-on-surface">
<span class="material-symbols-outlined text-[24px]" data-icon="double_arrow">double_arrow</span>
<span class="font-label-caps text-[11px] mt-1">AUTO</span>
</div>
<!-- 6:00 NEW -->
<div class="absolute bottom-[12%] left-1/2 -translate-x-1/2 flex flex-col items-center text-on-surface">
<span class="material-symbols-outlined text-[24px]" data-icon="add">add</span>
<span class="font-label-caps text-[11px] mt-1">NEW</span>
</div>
<!-- 7:30 PAUSE -->
<div class="absolute bottom-[22%] left-[12%] flex flex-col items-center text-on-surface">
<span class="material-symbols-outlined text-[24px]" data-icon="pause">pause</span>
<span class="font-label-caps text-[11px] mt-1">PAUSE</span>
</div>
<!-- 9:00 STATS -->
<div class="absolute top-1/2 left-[8%] -translate-y-1/2 flex flex-col items-center text-on-surface">
<span class="material-symbols-outlined text-[24px]" data-icon="bar_chart">bar_chart</span>
<span class="font-label-caps text-[11px] mt-1">STATS</span>
</div>
<!-- 10:30 SETTINGS -->
<div class="absolute top-[22%] left-[12%] flex flex-col items-center text-on-surface">
<span class="material-symbols-outlined text-[24px]" data-icon="settings">settings</span>
<span class="font-label-caps text-[11px] mt-1">SETTINGS</span>
</div>
</div>
<!-- Inner Hole -->
<div class="absolute w-20 h-20 rounded-full bg-surface-container border border-outline-variant flex flex-col items-center justify-center z-10">
<div class="font-headline text-[32px] text-primary leading-none"></div>
<div class="font-label-caps text-[10px] text-on-surface-variant tracking-widest mt-1">RADIAL</div>
</div>
</div>
<!-- Instructions (Bottom Floating) -->
<div class="absolute bottom-12 left-0 w-full flex flex-col items-center gap-4">
<div class="font-label-caps text-[12px] text-on-surface-variant tracking-wider">
DRAG TO SELECT · RELEASE TO ACTIVATE
</div>
<!-- Status Line (Vim style) -->
<div class="w-full h-8 bg-surface-container border-t border-outline-variant flex items-center justify-center">
<span class="font-label-caps text-[11px] text-on-surface-variant">
<span class="text-primary"></span> NORMAL │ radial · 1/8 selected
</span>
</div>
</div>
</div>
<!-- Hidden image for standard requirement compliance, though not visually used in this specific overlay task -->
<div class="hidden">
<img data-alt="A macro shot of a vintage terminal screen displaying green computer code and technical data. The lighting is low-key, with a soft glow emanating from the screen, highlighting the CRT scanlines and subtle reflections. The aesthetic is purely technical and retro-futuristic, focusing on precision and high-contrast digital artifacts. Deep blacks and vibrant green neon tones dominate the color palette, evoking a high-performance system environment." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAQuJUCOQev_BN72KyX0c-ylmW3DMZD-gOUlylYo3w1SrSpGnvorMvSUwe5oGPAgBgc050cCowC8f1QaxHEDN-DUkyCynOLhzrZHXyCJh2ebCWd6x1quLQwp0ffwbHsZW1-J2zAMuUydMNpEVmpHFQDij0yjVg6lxc6JdsC0etMoAWMhb61S3HUoDffSl-Q23N8Oc77r3dSf6kLFKAMAJCbXFz4nTaJKCKAwtMs62pLr6fd1jzMZrItH43RaO28uzMzvnGGZj3Miw"/>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+284
View File
@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Solitaire Replay Overlay</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-background": "#e0e3e6",
"suit-red-cb": "#6fc2ef",
"surface-container": "#202020",
"primary": "#a1dcff",
"tertiary-fixed-dim": "#f0b0fc",
"on-tertiary-fixed-variant": "#653173",
"surface-tint": "#7ed0fe",
"outline": "#505050",
"suit-black": "#d0d0d0",
"secondary": "#bad073",
"on-surface": "#d0d0d0",
"on-primary": "#003549",
"on-error-container": "#ffdad6",
"on-secondary-fixed-variant": "#3c4d00",
"surface-bright": "#363a3d",
"surface-variant": "#313538",
"secondary-container": "#435401",
"surface-container-highest": "#313538",
"surface-container-low": "#181c1f",
"primary-container": "#6fc2ef",
"on-error": "#690005",
"primary-fixed-dim": "#7ed0fe",
"tertiary-container": "#e1a3ee",
"on-tertiary-container": "#683476",
"suit-red": "#fb9fb1",
"highlight-celebration": "#e1a3ee",
"on-primary-fixed-variant": "#004c69",
"secondary-fixed": "#d5ec8c",
"primary-fixed": "#c4e7ff",
"on-primary-container": "#004f6c",
"secondary-fixed-dim": "#bad073",
"warning": "#ddb26f",
"on-secondary": "#293500",
"info": "#12cfc0",
"on-tertiary-fixed": "#340043",
"background": "#101417",
"surface-container-high": "#272a2d",
"surface-dim": "#101417",
"surface": "#151515",
"inverse-surface": "#e0e3e6",
"on-surface-variant": "#bfc8cf",
"error-container": "#93000a",
"tertiary": "#f7c3ff",
"inverse-primary": "#00668a",
"surface-container-lowest": "#0b0f11",
"inverse-on-surface": "#2d3134",
"on-primary-fixed": "#001e2c",
"highlight-valid": "#acc267",
"outline-variant": "#3f484e",
"tertiary-fixed": "#fbd7ff",
"on-secondary-fixed": "#161e00",
"error": "#fb9fb1",
"on-tertiary": "#4c195b",
"on-secondary-container": "#b2c86d"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"touch-target-min": "48dp",
"stack-overlap": "2rem",
"margin-edge": "1rem",
"action-bar-height": "64px",
"gutter-card": "0.375rem"
},
"fontFamily": {
"hud-timer": ["JetBrains Mono"],
"body-md": ["Inter"],
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"]
},
"fontSize": {
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.card-back-pattern {
background-color: #151515;
background-image: repeating-linear-gradient(0deg, transparent, transparent 2px, #1a1a1a 2px, #1a1a1a 4px);
}
/* Custom mechanical transition style */
.mechanical-transition {
transition: all 120ms cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface font-body-md select-none overflow-hidden flex flex-col h-[844px] w-[390px] mx-auto border-x border-outline-variant">
<!-- Status Bar -->
<header class="h-8 bg-surface-container flex items-center justify-between px-4 border-b border-outline-variant flex-shrink-0">
<div class="flex items-center gap-2">
<span class="text-primary font-headline text-[14px]">▌replay.tsx</span>
</div>
<div class="flex items-center text-on-surface-variant font-label-caps text-[10px] tracking-wider uppercase">
GAME #2024-127 · 87 MOVES
</div>
</header>
<!-- Game Peek Band (Tableau) -->
<main class="h-[240px] relative bg-background overflow-hidden border-b border-outline-variant">
<!-- 7-Column Tableau (Dimmed 50%) -->
<div class="absolute inset-0 opacity-50 flex justify-around p-2 gap-1">
<!-- Tableau Columns 1-7 -->
<div class="flex-1 flex flex-col items-center">
<div class="w-full aspect-[2/3] border border-dashed border-outline-variant mb-1"></div>
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline"></div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full aspect-[2/3] bg-surface-container-low border border-outline relative">
<!-- Central Focused Card (Move 47) -->
<div class="absolute inset-0 z-20 opacity-100">
<!-- Shadow-less highlight using glow outline -->
<div class="w-full h-full bg-[#1a1a1a] border border-suit-red-cb ring-2 ring-suit-red-cb/40 flex flex-col justify-between p-1">
<div class="flex flex-col">
<span class="font-card-rank text-card-rank text-suit-red-cb">4</span>
<span class="material-symbols-outlined text-[14px] text-suit-red-cb" data-icon="diamond">diamond</span>
</div>
<div class="self-end rotate-180 flex flex-col">
<span class="font-card-rank text-card-rank text-suit-red-cb">4</span>
<span class="material-symbols-outlined text-[14px] text-suit-red-cb" data-icon="diamond">diamond</span>
</div>
</div>
<!-- Move Chip -->
<div class="absolute -top-6 left-1/2 -translate-x-1/2 bg-suit-red-cb px-2 py-0.5 rounded-sm">
<span class="text-surface font-label-caps text-[9px] font-bold">MOVE 47/87</span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Playback Toolbar -->
<div class="h-16 bg-surface-container flex items-center justify-between px-4 border-b border-outline-variant">
<!-- Left: Timer -->
<div class="flex flex-col">
<span class="text-on-surface font-hud-timer text-[18px] font-bold leading-none">00:42</span>
<span class="text-[11px] text-[#a0a0a0] font-label-caps tracking-tighter">/ 02:18</span>
</div>
<!-- Center: Controls -->
<div class="flex items-center gap-4">
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="skip_previous">skip_previous</button>
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="arrow_left">arrow_left</button>
<button class="material-symbols-outlined text-suit-red-cb text-[32px] mechanical-transition" data-icon="play_arrow">play_arrow</button>
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="arrow_right">arrow_right</button>
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary mechanical-transition" data-icon="skip_next">skip_next</button>
</div>
<!-- Right: Speed -->
<div class="flex items-center bg-surface-variant border border-outline px-2 py-1 gap-1">
<span class="font-label-caps text-[14px] font-bold text-on-surface">1.0x</span>
<span class="material-symbols-outlined text-[16px] text-on-surface-variant" data-icon="unfold_more">unfold_more</span>
</div>
</div>
<!-- Scrub Bar Area -->
<div class="px-margin-edge pt-6 pb-8 bg-surface-container-low border-b border-outline-variant">
<div class="relative w-full h-1 bg-outline rounded-full">
<!-- Cyan Progress Track -->
<div class="absolute left-0 top-0 h-full bg-suit-red-cb" style="width: 54%;"></div>
<!-- Notches & Labels -->
<div class="absolute inset-0 flex justify-between">
<div class="relative">
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">0%</span>
</div>
<div class="relative">
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">25%</span>
</div>
<div class="relative">
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">50%</span>
</div>
<div class="relative">
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">75%</span>
</div>
<div class="relative">
<div class="w-[1px] h-3 bg-outline -mt-1"></div>
<span class="absolute -bottom-5 left-1/2 -translate-x-1/2 text-[9px] font-label-caps text-outline">100%</span>
</div>
</div>
<!-- Current Marker (54%) -->
<div class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-suit-red-cb border border-surface" style="left: 54%;"></div>
<div class="absolute -top-4 left-[54%] -translate-x-1/2 text-[10px] text-suit-red-cb font-label-caps font-bold">47/87</div>
<!-- Win Marker (72%) -->
<div class="absolute top-0 w-[2px] h-3 bg-highlight-valid -translate-y-1" style="left: 72%;"></div>
<div class="absolute -bottom-5 left-[72%] -translate-x-1/2 text-[8px] text-highlight-valid font-label-caps font-bold whitespace-nowrap">WIN MOVE</div>
</div>
</div>
<!-- Move Log Card -->
<section class="flex-1 bg-surface-container p-4 overflow-y-auto">
<h3 class="font-label-caps text-label-caps text-on-surface-variant mb-4 flex items-center gap-2">
<span class="w-1.5 h-3 bg-primary block"></span>
MOVE LOG · 47/87
</h3>
<div class="flex flex-col font-label-caps text-[12px]">
<!-- Log Rows -->
<div class="flex items-center h-6 px-2 text-[#a0a0a0] border-b border-outline-variant/30">
<span class="w-8">44 |</span>
<span>5♥ → tableau col 3</span>
</div>
<div class="flex items-center h-6 px-2 text-[#a0a0a0] border-b border-outline-variant/30">
<span class="w-8">45 |</span>
<span>8♣ → tableau col 1</span>
</div>
<div class="flex items-center h-6 px-2 text-[#a0a0a0] border-b border-outline-variant/30">
<span class="w-8">46 |</span>
<span>stock cycle</span>
</div>
<!-- Highlighted Active Move -->
<div class="flex items-center h-6 px-2 bg-suit-red-cb text-surface-container font-bold">
<span class="w-8">▶ 47 |</span>
<span>4♦ → 5♣ on col 7</span>
</div>
<div class="flex items-center h-6 px-2 text-on-surface border-b border-outline-variant/30">
<span class="w-8">48 |</span>
<span class="material-symbols-outlined text-[14px] align-middle mr-1" data-icon="foundation">foundation</span> A♠ → foundation
</div>
<div class="flex items-center h-6 px-2 text-on-surface border-b border-outline-variant/30">
<span class="w-8">49 |</span>
<span class="material-symbols-outlined text-[14px] align-middle mr-1" data-icon="foundation">foundation</span> 2♠ → foundation
</div>
</div>
</section>
<!-- Footer -->
<footer class="h-6 bg-surface-container flex items-center justify-between px-4 border-t border-outline-variant flex-shrink-0 text-[10px] font-label-caps tracking-wider">
<div class="text-on-surface-variant">
<span class="text-primary"></span> NORMAL │ replay
</div>
<div class="text-on-surface-variant opacity-70">
[SPACE] play · [← →] scrub · [ESC]
</div>
</footer>
<!-- Overlay Background (For visualization of depth) -->
<div class="fixed inset-0 pointer-events-none border-[16px] border-surface-dim/40 z-50"></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+258
View File
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-secondary": "#293500",
"surface-container": "#1c2023",
"on-primary-fixed": "#001e2c",
"surface-tint": "#7ed0fe",
"suit-black": "#d0d0d0",
"on-background": "#e0e3e6",
"tertiary-fixed": "#fbd7ff",
"on-tertiary": "#4c195b",
"on-error": "#690005",
"secondary-fixed-dim": "#bad073",
"tertiary-fixed-dim": "#f0b0fc",
"suit-red-cb": "#6fc2ef",
"on-primary-container": "#004f6c",
"on-error-container": "#ffdad6",
"surface-container-lowest": "#0b0f11",
"on-surface": "#e0e3e6",
"secondary-container": "#435401",
"inverse-on-surface": "#2d3134",
"suit-red": "#fb9fb1",
"tertiary": "#f7c3ff",
"surface-dim": "#101417",
"background": "#101417",
"error": "#fb9fb1",
"on-secondary-container": "#b2c86d",
"on-primary": "#003549",
"on-tertiary-container": "#683476",
"surface-container-low": "#181c1f",
"warning": "#ddb26f",
"surface-variant": "#313538",
"primary-fixed": "#c4e7ff",
"primary": "#a1dcff",
"on-secondary-fixed-variant": "#3c4d00",
"surface": "#151515",
"secondary": "#bad073",
"outline-variant": "#3f484e",
"on-tertiary-fixed-variant": "#653173",
"highlight-valid": "#acc267",
"primary-fixed-dim": "#7ed0fe",
"surface-bright": "#363a3d",
"secondary-fixed": "#d5ec8c",
"primary-container": "#6fc2ef",
"surface-container-highest": "#313538",
"on-secondary-fixed": "#161e00",
"surface-container-high": "#272a2d",
"inverse-primary": "#00668a",
"on-tertiary-fixed": "#340043",
"tertiary-container": "#e1a3ee",
"on-surface-variant": "#bfc8cf",
"highlight-celebration": "#e1a3ee",
"inverse-surface": "#e0e3e6",
"outline": "#505050",
"info": "#12cfc0",
"on-primary-fixed-variant": "#004c69",
"error-container": "#93000a"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"gutter-card": "0.375rem",
"stack-overlap": "2rem",
"margin-edge": "1rem",
"touch-target-min": "48dp",
"action-bar-height": "64px",
"top-action-bar-height": "32px",
"bottom-action-bar-height": "24px"
},
"fontFamily": {
"body-md": ["Inter"],
"label-caps": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"headline": ["JetBrains Mono"]
},
"fontSize": {
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
}
.card-back-pattern {
background: repeating-linear-gradient(
0deg,
#151515,
#151515 2px,
#1a1a1a 2px,
#1a1a1a 4px
);
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-background font-body-md selection:bg-primary selection:text-on-primary-container overflow-hidden flex flex-col h-[844px] w-[390px] mx-auto border-x border-outline-variant">
<!-- Status Bar / TopAppBar Logic -->
<header class="bg-[#202020] h-8 flex items-center justify-between px-4 w-full z-50">
<div class="flex items-center gap-1">
<span class="text-primary font-headline text-[12px]"></span>
<span class="text-on-surface font-label-caps text-[12px]">settings.toml</span>
</div>
<div class="text-outline font-label-caps text-[12px]">v0.20.0</div>
</header>
<!-- NavigationDrawer (Tab Strip Mapping) -->
<nav class="bg-[#1a1a1a] h-10 border-b border-[#353535] flex items-center w-full">
<!-- COSMETIC Tab (Active) -->
<div class="h-full flex-1 flex flex-col items-center justify-center relative">
<span class="text-primary font-label-caps text-[12px] px-2">[ COSMETIC ]</span>
<div class="absolute bottom-0 left-0 right-0 h-[2px] bg-primary"></div>
</div>
<!-- GAMEPLAY Tab -->
<div class="h-full flex-1 flex items-center justify-center">
<span class="text-[#a0a0a0] font-label-caps text-[12px]">GAMEPLAY</span>
</div>
<!-- SYNC Tab -->
<div class="h-full flex-1 flex items-center justify-center">
<span class="text-[#a0a0a0] font-label-caps text-[12px]">SYNC</span>
</div>
<!-- AUDIO Tab -->
<div class="h-full flex-1 flex items-center justify-center">
<span class="text-[#a0a0a0] font-label-caps text-[12px]">AUDIO</span>
</div>
</nav>
<!-- Content Area (Canvas) -->
<main class="flex-1 bg-[#151515] p-margin-edge overflow-y-auto space-y-2">
<!-- row: card_theme -->
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[#a0a0a0] font-label-caps text-[11px]">card_theme</span>
<span class="text-suit-black font-label-caps text-[14px]">Terminal</span>
</div>
<span class="text-primary material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
</div>
<!-- row: background -->
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[#a0a0a0] font-label-caps text-[11px]">background</span>
<span class="text-suit-black font-label-caps text-[14px]">Solid #151515</span>
</div>
<span class="text-primary material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
</div>
<!-- row: card_back -->
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[#a0a0a0] font-label-caps text-[11px]">card_back</span>
<span class="text-suit-black font-label-caps text-[14px]">Terminal</span>
</div>
<div class="flex items-center gap-3">
<div class="w-4 h-6 border border-outline card-back-pattern relative overflow-hidden">
<div class="absolute top-0.5 left-0.5 w-1.5 h-2 bg-primary"></div>
</div>
<span class="text-primary material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
</div>
</div>
<!-- row: color_blind_mode -->
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[#a0a0a0] font-label-caps text-[11px]">color_blind_mode</span>
<span class="text-suit-black font-label-caps text-[14px]">false</span>
</div>
<!-- Toggle OFF -->
<div class="w-10 h-5 bg-[#202020] border border-outline rounded-full relative">
<div class="absolute left-0.5 top-0.5 w-3.5 h-3.5 bg-outline rounded-full"></div>
</div>
</div>
<!-- row: high_contrast -->
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[#a0a0a0] font-label-caps text-[11px]">high_contrast</span>
<span class="text-suit-black font-label-caps text-[14px]">false</span>
</div>
<!-- Toggle OFF -->
<div class="w-10 h-5 bg-[#202020] border border-outline rounded-full relative">
<div class="absolute left-0.5 top-0.5 w-3.5 h-3.5 bg-outline rounded-full"></div>
</div>
</div>
<!-- row: reduce_motion -->
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[#a0a0a0] font-label-caps text-[11px]">reduce_motion</span>
<span class="text-suit-black font-label-caps text-[14px]">true</span>
</div>
<!-- Toggle ON -->
<div class="w-10 h-5 bg-[#1f3a4a] border border-primary/50 rounded-full relative">
<div class="absolute right-0.5 top-0.5 w-3.5 h-3.5 bg-primary-container rounded-full"></div>
</div>
</div>
<!-- row: crt_scanline_effect -->
<div class="bg-[#202020] h-14 rounded-[4px] px-3 flex items-center justify-between">
<div class="flex flex-col">
<span class="text-[#a0a0a0] font-label-caps text-[11px]">crt_scanline_effect</span>
<span class="text-suit-black font-label-caps text-[14px]">false</span>
</div>
<!-- Toggle OFF -->
<div class="w-10 h-5 bg-[#202020] border border-outline rounded-full relative">
<div class="absolute left-0.5 top-0.5 w-3.5 h-3.5 bg-outline rounded-full"></div>
</div>
</div>
</main>
<!-- BottomNavBar (Footer Logic) -->
<footer class="h-6 bg-[#202020] border-t border-[#353535] flex items-center justify-between px-4 w-full z-50">
<div class="flex items-center gap-1 font-label-caps text-[11px]">
<span class="text-primary"></span>
<span class="text-suit-black">NORMAL</span>
<span class="text-outline"></span>
<span class="text-on-surface">settings</span>
</div>
<div class="flex items-center gap-2 font-label-caps text-[11px]">
<div class="flex items-center">
<span class="text-[#a0a0a0]">[1-4]</span>
<span class="text-outline ml-1">tab</span>
</div>
<span class="text-outline">·</span>
<div class="flex items-center">
<span class="text-[#a0a0a0]">[ESC]</span>
<span class="text-outline ml-1">back</span>
</div>
</div>
</footer>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Rusty Solitaire - Terminal Edition</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500;700&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"primary-fixed": "#c4e7ff",
"on-tertiary-fixed": "#340043",
"surface-dim": "#101417",
"on-error": "#690005",
"inverse-primary": "#00668a",
"highlight-celebration": "#e1a3ee",
"surface-variant": "#313538",
"on-background": "#e0e3e6",
"secondary-fixed": "#d5ec8c",
"surface-bright": "#363a3d",
"on-primary-container": "#004f6c",
"on-surface": "#d0d0d0",
"surface-container-lowest": "#0b0f11",
"on-tertiary-container": "#683476",
"surface-container-high": "#272a2d",
"error": "#fb9fb1",
"highlight-valid": "#acc267",
"primary-container": "#6fc2ef",
"on-secondary": "#293500",
"tertiary-fixed-dim": "#f0b0fc",
"on-primary-fixed": "#001e2c",
"outline-variant": "#3f484e",
"secondary-container": "#435401",
"background": "#101417",
"on-primary-fixed-variant": "#004c69",
"suit-black": "#d0d0d0",
"surface-container": "#1c2023",
"on-primary": "#003549",
"primary": "#a1dcff",
"error-container": "#93000a",
"on-tertiary-fixed-variant": "#653173",
"info": "#12cfc0",
"warning": "#ddb26f",
"tertiary-container": "#e1a3ee",
"tertiary-fixed": "#fbd7ff",
"on-tertiary": "#4c195b",
"surface": "#151515",
"secondary-fixed-dim": "#bad073",
"on-secondary-fixed-variant": "#3c4d00",
"surface-container-highest": "#313538",
"inverse-on-surface": "#2d3134",
"suit-red": "#fb9fb1",
"surface-container-low": "#181c1f",
"on-secondary-container": "#b2c86d",
"on-error-container": "#ffdad6",
"tertiary": "#f7c3ff",
"suit-red-cb": "#6fc2ef",
"inverse-surface": "#e0e3e6",
"surface-tint": "#7ed0fe",
"primary-fixed-dim": "#7ed0fe",
"outline": "#505050",
"on-surface-variant": "#bfc8cf",
"secondary": "#bad073",
"on-secondary-fixed": "#161e00",
"boot-cyan": "#a1dcff",
"boot-lime": "#acc267"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"stack-overlap": "2rem",
"action-bar-height": "64px",
"margin-edge": "1rem",
"gutter-card": "0.375rem"
},
"fontFamily": {
"hud-timer": ["JetBrains Mono"],
"body-md": ["Inter"],
"card-rank": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"]
},
"fontSize": {
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
body {
background-color: #151515;
color: #d0d0d0;
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.cyan-halo {
box-shadow: 0 0 40px 4px rgba(161, 220, 255, 0.15);
}
.scanlines {
background: linear-gradient(to bottom, rgba(21, 21, 21, 0) 50%, rgba(0, 0, 0, 0.2) 50%);
background-size: 100% 4px;
pointer-events: none;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="flex flex-col items-center justify-between font-body-md w-[390px] h-[844px] mx-auto relative overflow-hidden">
<div class="absolute inset-0 scanlines opacity-30 z-10"></div>
<!-- Top Safe Area (Blank) -->
<div class="h-10 w-full"></div>
<!-- Main Content Stack -->
<main class="flex-1 w-full flex flex-col items-center z-20">
<!-- Header Section (~30% Top) -->
<div class="mt-[10%] flex flex-col items-center">
<div class="w-24 h-24 flex items-center justify-center relative mb-4">
<div class="text-[96px] text-boot-cyan font-headline cyan-halo leading-none"></div>
</div>
<div class="flex flex-col items-center">
<h1 class="font-headline text-[32px] font-bold text-on-surface tracking-tight mb-1">RUSTY SOLITAIRE</h1>
<div class="w-48 h-[1px] bg-outline-variant"></div>
<span class="font-label-caps text-[12px] text-outline mt-2 tracking-[0.1em] uppercase">TERMINAL EDITION</span>
</div>
</div>
<!-- Terminal Boot Log (~60% Top Position) -->
<div class="mt-48 w-[70%] flex flex-col items-start font-hud-timer text-[11px] space-y-1">
<div class="flex items-center gap-2">
<span class="text-boot-lime"></span>
<span class="text-outline">assets loaded</span>
</div>
<div class="flex items-center gap-2">
<span class="text-boot-lime"></span>
<span class="text-outline">theme: terminal</span>
</div>
<div class="flex items-center gap-2">
<span class="text-boot-lime"></span>
<span class="text-outline">progress restored</span>
</div>
<div class="flex items-center gap-1 mt-1">
<span class="text-on-surface-variant">▌ ready_</span>
<span class="w-[6px] h-3 bg-boot-cyan animate-pulse"></span>
</div>
</div>
<!-- Progress Bar (~75% Top Position) -->
<div class="mt-20 w-full px-8">
<div class="w-full h-[1px] bg-surface-container-highest relative">
<div class="absolute top-0 left-0 h-full bg-boot-cyan w-full"></div>
</div>
<div class="w-full text-right mt-2">
<span class="font-label-caps text-[10px] text-outline tracking-wider uppercase">DONE · 247 ASSETS</span>
</div>
</div>
</main>
<!-- Bottom Strip & Footer -->
<footer class="w-full flex flex-col items-center z-20 pb-10">
<div class="flex flex-col items-center mb-8">
<span class="font-label-caps text-[9px] text-outline mb-3 tracking-widest uppercase">BASE16-EIGHTIES</span>
<div class="flex gap-1.5">
<div class="w-3 h-3 bg-[#fb9fb1]"></div>
<div class="w-3 h-3 bg-[#ddb26f]"></div>
<div class="w-3 h-3 bg-[#acc267]"></div>
<div class="w-3 h-3 bg-[#12cfc0]"></div>
<div class="w-3 h-3 bg-[#6fc2ef]"></div>
<div class="w-3 h-3 bg-[#e1a3ee]"></div>
<div class="w-3 h-3 bg-[#d0d0d0]"></div>
<div class="w-3 h-3 bg-[#505050]"></div>
</div>
</div>
<div class="font-hud-timer text-[11px] text-outline">
v0.20.0
</div>
</footer>
<!-- Top App Bar Content (Hidden as per requirement, but following Shared Components Logic) -->
<div class="hidden">
<header class="fixed top-0 w-full z-50 flex justify-between items-center px-margin-edge h-action-bar-height border-b border-outline-variant bg-background">
<div class="font-headline text-headline text-primary uppercase tracking-tighter">ROOT@SOLITAIRE:~</div>
<div class="flex gap-4 text-primary">
<span class="material-symbols-outlined">memory</span>
<span class="material-symbols-outlined">settings_ethernet</span>
<span class="material-symbols-outlined">wifi_tethering</span>
</div>
</header>
</div>
<!-- Placeholder for data-alt requirements even if visual images are minimal -->
<div class="hidden">
<img data-alt="A cinematic, low-angle digital render of a retro-terminal interface appearing on a high-end mobile display in a dark room. The screen glows with a soft cyan light, illuminating a minimalist layout with sharp, 1px white lines and a large block cursor symbol. The atmosphere is quiet, technical, and high-performance, reflecting a dark-mode synthwave aesthetic with muted grays and vibrant cyan accents. The focus is on the crisp, high-density typography and the mechanical precision of the digital environment." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBeZymuyGd_-VJr-zgC8p08qBLD4pk0WyRtxuIhVT5kY6cc3y_qSkZ-P_EYYwKIliGysN5rDgqbCsXLxksfslVnB4nj4BYktu4d5EAKi1zEQ8t8MId17UzIgKujbGqebDo0FWO51Snqxt9AvrjX_afEsvACaaeAyIfTKgoAB8MBOUnanIre26Y1tNTftn1y9jxKfrXgi9eCYiJn6zoiaRmNmdLwo_s7RenmSlloPdIURVb3KKHKaBZHldPaStbWcyMYNR877R6O_A"/>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+279
View File
@@ -0,0 +1,279 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500;600&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* CRT Scanline effect overlay */
.crt-overlay::before {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.02));
z-index: 100;
background-size: 100% 2px, 3px 100%;
pointer-events: none;
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary": "#f7c3ff",
"surface-container": "#202020",
"primary-container": "#6fc2ef",
"surface": "#151515",
"tertiary-fixed": "#fbd7ff",
"info": "#12cfc0",
"inverse-on-surface": "#2d3134",
"suit-red": "#fb9fb1",
"secondary-fixed-dim": "#bad073",
"on-background": "#e0e3e6",
"tertiary-container": "#e1a3ee",
"suit-black": "#d0d0d0",
"on-primary": "#003549",
"secondary-fixed": "#d5ec8c",
"tertiary-fixed-dim": "#f0b0fc",
"suit-red-cb": "#6fc2ef",
"primary": "#a1dcff",
"on-primary-container": "#004f6c",
"primary-fixed": "#c4e7ff",
"on-surface-variant": "#bfc8cf",
"surface-tint": "#7ed0fe",
"surface-dim": "#101417",
"on-tertiary-container": "#683476",
"inverse-primary": "#00668a",
"background": "#101417",
"on-surface": "#d0d0d0",
"primary-fixed-dim": "#7ed0fe",
"on-error": "#690005",
"surface-container-highest": "#313538",
"outline-variant": "#3f484e",
"inverse-surface": "#e0e3e6",
"warning": "#ddb26f",
"surface-variant": "#313538",
"on-tertiary-fixed": "#340043",
"outline": "#505050",
"surface-container-lowest": "#0b0f11",
"on-secondary-container": "#b2c86d",
"surface-container-high": "#272a2d",
"secondary": "#bad073",
"error": "#fb9fb1",
"highlight-valid": "#acc267",
"surface-container-low": "#181c1f",
"on-secondary": "#293500",
"surface-bright": "#363a3d",
"on-secondary-fixed": "#161e00",
"on-error-container": "#ffdad6",
"highlight-celebration": "#e1a3ee",
"on-primary-fixed-variant": "#004c69",
"on-tertiary": "#4c195b",
"on-primary-fixed": "#001e2c",
"secondary-container": "#435401",
"error-container": "#93000a",
"on-tertiary-fixed-variant": "#653173",
"on-secondary-fixed-variant": "#3c4d00"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"touch-target-min": "48dp",
"stack-overlap": "2rem",
"margin-edge": "1rem",
"action-bar-height": "64px",
"gutter-card": "0.375rem"
},
"fontFamily": {
"hud-timer": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"body-md": ["Inter"],
"hud-score": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"]
},
"fontSize": {
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}]
}
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface font-body-md min-h-screen selection:bg-primary-container selection:text-on-primary-container">
<!-- Top Navigation Shell -->
<header class="fixed top-0 w-full bg-background z-50 border-b border-outline flex justify-between items-center px-margin-edge h-action-bar-height">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-primary" data-icon="terminal">terminal</span>
<h1 class="font-headline text-headline text-primary uppercase tracking-tighter">STATISTICS.LOG</h1>
</div>
<div class="flex items-center">
<button class="material-symbols-outlined text-on-surface-variant hover:bg-surface-container-highest transition-colors duration-120 p-2 rounded" data-icon="settings_input_component">settings_input_component</button>
</div>
</header>
<main class="pt-action-bar-height pb-[88px] crt-overlay">
<!-- Status Bar (Emulated Retro Style) -->
<div class="h-[32px] bg-surface-container flex items-center justify-between px-margin-edge text-[11px] font-hud-timer">
<span class="text-on-surface">▌stats.log</span>
<span class="text-on-surface-variant">247 GAMES TRACKED</span>
</div>
<!-- Sub-tab Strip -->
<nav class="h-[40px] bg-[#1a1a1a] border-b border-outline flex items-center px-margin-edge overflow-x-auto whitespace-nowrap scrollbar-hide">
<div class="flex items-center h-full gap-4">
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-primary-container border-b-2 border-primary-container">
[ OVERVIEW ]
</button>
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-on-surface-variant hover:text-primary transition-colors">
DRAW-1
</button>
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-on-surface-variant hover:text-primary transition-colors">
DRAW-3
</button>
<button class="h-full px-1 flex flex-col justify-center font-label-caps text-[11px] text-on-surface-variant hover:text-primary transition-colors">
DAILY
</button>
</div>
</nav>
<!-- Main Content Grid -->
<section class="p-4 flex flex-col gap-4">
<!-- Hero Stat Card -->
<div class="h-24 bg-surface-container border border-outline rounded-lg p-4 flex justify-between items-center">
<div>
<span class="font-headline text-[48px] text-on-surface">61%</span>
</div>
<div class="text-right">
<p class="font-label-caps text-[11px] text-on-surface-variant">WIN RATE</p>
<p class="font-hud-timer text-[13px] text-highlight-valid">▲ +3% vs last 30</p>
</div>
</div>
<!-- 2x2 Grid -->
<div class="grid grid-cols-2 gap-2">
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
<p class="font-label-caps text-[11px] text-on-surface-variant">GAMES</p>
<p class="font-hud-score text-hud-score">247</p>
</div>
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
<p class="font-label-caps text-[11px] text-on-surface-variant">WINS</p>
<p class="font-hud-score text-hud-score">151</p>
</div>
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
<p class="font-label-caps text-[11px] text-on-surface-variant">BEST TIME</p>
<p class="font-hud-score text-hud-score text-primary">01:54</p>
</div>
<div class="h-[88px] bg-surface-container border border-outline rounded-lg p-3 flex flex-col justify-between">
<p class="font-label-caps text-[11px] text-on-surface-variant">STREAK</p>
<p class="font-hud-score text-hud-score">12</p>
</div>
</div>
<!-- Sparkline Card -->
<div class="bg-surface-container border border-outline rounded-lg p-4 h-[200px] flex flex-col">
<p class="font-label-caps text-[11px] text-on-surface-variant mb-4">WIN RATE · LAST 30 DAYS</p>
<div class="relative flex-1 flex items-end justify-between border-b border-outline pb-1">
<!-- Y-Axis Labels -->
<span class="absolute top-0 left-0 text-[9px] font-hud-timer text-outline">100%</span>
<span class="absolute bottom-1 left-0 text-[9px] font-hud-timer text-outline">0%</span>
<!-- Pixel Bar Chart -->
<div class="flex items-end justify-between w-full h-[80%] gap-[2px]">
<!-- Generated bars with upward trend -->
<div class="bg-primary-container w-full" style="height: 35%"></div>
<div class="bg-primary-container w-full" style="height: 30%"></div>
<div class="bg-primary-container w-full" style="height: 40%"></div>
<div class="bg-primary-container w-full" style="height: 38%"></div>
<div class="bg-primary-container w-full" style="height: 45%"></div>
<div class="bg-primary-container w-full" style="height: 42%"></div>
<div class="bg-primary-container w-full" style="height: 50%"></div>
<div class="bg-primary-container w-full" style="height: 48%"></div>
<div class="bg-primary-container w-full" style="height: 55%"></div>
<div class="bg-primary-container w-full" style="height: 52%"></div>
<div class="bg-primary-container w-full" style="height: 58%"></div>
<div class="bg-primary-container w-full" style="height: 60%"></div>
<div class="bg-primary-container w-full" style="height: 55%"></div>
<div class="bg-primary-container w-full" style="height: 62%"></div>
<div class="bg-primary-container w-full" style="height: 65%"></div>
<div class="bg-primary-container w-full" style="height: 63%"></div>
<div class="bg-primary-container w-full" style="height: 68%"></div>
<div class="bg-primary-container w-full" style="height: 70%"></div>
<div class="bg-primary-container w-full" style="height: 65%"></div>
<div class="bg-primary-container w-full" style="height: 72%"></div>
<div class="bg-primary-container w-full" style="height: 75%"></div>
<div class="bg-primary-container w-full" style="height: 78%"></div>
<div class="bg-primary-container w-full" style="height: 74%"></div>
<div class="bg-primary-container w-full" style="height: 80%"></div>
<div class="bg-primary-container w-full" style="height: 82%"></div>
<div class="bg-primary-container w-full" style="height: 85%"></div>
<div class="bg-primary-container w-full" style="height: 88%"></div>
<div class="bg-primary-container w-full" style="height: 86%"></div>
<div class="bg-primary-container w-full" style="height: 90%"></div>
<div class="bg-primary-container w-full" style="height: 95%"></div>
</div>
</div>
<div class="flex justify-between mt-1">
<span class="text-[9px] font-hud-timer text-outline uppercase">30d ago</span>
<span class="text-[9px] font-hud-timer text-outline uppercase">today</span>
</div>
</div>
<!-- Draw-Mode Split Card -->
<div class="h-20 bg-surface-container border border-outline rounded-lg p-4 flex flex-col justify-between">
<p class="font-label-caps text-[11px] text-on-surface-variant">DRAW MODE SPLIT</p>
<div class="h-3 w-full flex rounded-sm overflow-hidden bg-outline">
<div class="h-full bg-highlight-valid" style="width: 60%"></div>
<div class="h-full bg-primary-container" style="width: 40%"></div>
</div>
<div class="flex justify-between">
<span class="font-hud-timer text-[11px] text-highlight-valid">DRAW-1 · 60%</span>
<span class="font-hud-timer text-[11px] text-primary-container">DRAW-3 · 40%</span>
</div>
</div>
</section>
<!-- Retro Footer Strip -->
<footer class="h-[24px] px-margin-edge flex items-center justify-between font-hud-timer text-[10px] text-outline mt-2">
<div>▌ NORMAL │ stats</div>
<div>[1-4] view</div>
</footer>
</main>
<!-- Bottom Navigation Shell -->
<nav class="fixed bottom-0 w-full z-50 h-[64px] bg-surface-container border-t border-outline flex justify-around items-center">
<div class="flex flex-col items-center justify-center text-on-surface-variant pt-1 hover:text-primary transition-colors duration-120">
<span class="material-symbols-outlined" data-icon="playing_cards">playing_cards</span>
<span class="font-label-caps text-label-caps">DECK</span>
</div>
<!-- ACTIVE TAB: STATS -->
<div class="flex flex-col items-center justify-center text-primary border-t-2 border-primary pt-1 hover:text-primary transition-colors duration-120">
<span class="material-symbols-outlined" data-icon="query_stats" style="font-variation-settings: 'FILL' 1;">query_stats</span>
<span class="font-label-caps text-label-caps">STATS</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant pt-1 hover:text-primary transition-colors duration-120">
<span class="material-symbols-outlined" data-icon="history_edu">history_edu</span>
<span class="font-label-caps text-label-caps">LOGS</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant pt-1 hover:text-primary transition-colors duration-120">
<span class="material-symbols-outlined" data-icon="terminal">terminal</span>
<span class="font-label-caps text-label-caps">SYS</span>
</div>
</nav>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+262
View File
@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Rusty Solitaire - Sync Progress</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-container-low": "#181c1f",
"highlight-valid": "#acc267",
"on-secondary-container": "#b2c86d",
"on-tertiary": "#4c195b",
"suit-black": "#d0d0d0",
"surface-container": "#1c2023",
"warning": "#ddb26f",
"outline": "#505050",
"error-container": "#93000a",
"on-secondary": "#293500",
"surface-dim": "#101417",
"on-primary-fixed": "#001e2c",
"secondary": "#bad073",
"on-surface": "#e0e3e6",
"primary": "#a1dcff",
"tertiary-fixed": "#fbd7ff",
"on-tertiary-container": "#683476",
"inverse-primary": "#00668a",
"on-surface-variant": "#bfc8cf",
"tertiary-fixed-dim": "#f0b0fc",
"surface-tint": "#7ed0fe",
"primary-container": "#6fc2ef",
"on-primary-container": "#004f6c",
"secondary-container": "#435401",
"suit-red-cb": "#6fc2ef",
"surface-container-high": "#272a2d",
"on-background": "#e0e3e6",
"on-primary": "#003549",
"highlight-celebration": "#e1a3ee",
"surface": "#151515",
"secondary-fixed": "#d5ec8c",
"on-primary-fixed-variant": "#004c69",
"surface-container-lowest": "#0b0f11",
"suit-red": "#fb9fb1",
"inverse-on-surface": "#2d3134",
"surface-variant": "#313538",
"on-tertiary-fixed-variant": "#653173",
"tertiary-container": "#e1a3ee",
"outline-variant": "#3f484e",
"inverse-surface": "#e0e3e6",
"primary-fixed-dim": "#7ed0fe",
"on-secondary-fixed": "#161e00",
"info": "#12cfc0",
"primary-fixed": "#c4e7ff",
"surface-container-highest": "#313538",
"on-tertiary-fixed": "#340043",
"on-error": "#690005",
"background": "#101417",
"secondary-fixed-dim": "#bad073",
"error": "#fb9fb1",
"tertiary": "#f7c3ff",
"on-error-container": "#ffdad6",
"on-secondary-fixed-variant": "#3c4d00",
"surface-bright": "#363a3d"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"touch-target-min": "48dp",
"gutter-card": "0.375rem",
"action-bar-height": "64px",
"stack-overlap": "2rem",
"margin-edge": "1rem"
},
"fontFamily": {
"hud-timer": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"body-md": ["Inter"]
},
"fontSize": {
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
display: inline-block;
vertical-align: middle;
}
.scanline {
background: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.1) 50%);
background-size: 100% 2px;
pointer-events: none;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface font-body-md min-h-screen flex flex-col overflow-x-hidden selection:bg-primary-container selection:text-on-primary-container">
<!-- 1. Status Bar (Top) -->
<nav class="h-8 bg-[#202020] flex items-center justify-between px-4 border-b border-outline-variant z-50">
<div class="flex items-center gap-2">
<span class="font-headline text-[13px] tracking-tight text-on-surface-variant">▌sync.config</span>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-warning animate-pulse"></span>
<span class="font-label-caps text-[11px] text-warning">PENDING</span>
</div>
<span class="font-label-caps text-[11px] text-outline">v0.20.0</span>
</div>
</nav>
<!-- Shared TopAppBar Component -->
<header class="bg-surface flex justify-between items-center w-full px-margin-edge h-action-bar-height max-w-full border-b border-outline-variant">
<div class="font-headline text-headline text-primary tracking-tighter uppercase">▌RS_TERMINAL_OS</div>
<div class="hidden md:flex gap-6 items-center">
<a class="font-label-caps text-label-caps uppercase tracking-widest text-on-surface-variant hover:text-primary transition-colors duration-120" href="#">PLAY</a>
<a class="font-label-caps text-label-caps uppercase tracking-widest text-on-surface-variant hover:text-primary transition-colors duration-120" href="#">DAILY</a>
<a class="font-label-caps text-label-caps uppercase tracking-widest text-primary border-b-2 border-primary pb-1" href="#">STATS</a>
</div>
<div class="flex gap-4">
<span class="material-symbols-outlined text-primary cursor-pointer">account_circle</span>
<span class="material-symbols-outlined text-primary cursor-pointer">sync</span>
<span class="material-symbols-outlined text-primary cursor-pointer">settings</span>
</div>
</header>
<main class="flex-grow p-4 md:p-8 max-w-3xl mx-auto w-full space-y-6">
<!-- 2. Header Area -->
<div class="h-20 flex flex-col justify-center border-l-2 border-outline-variant pl-4">
<h1 class="font-headline text-[24px] font-bold text-suit-black leading-tight">SYNC PROGRESS</h1>
<p class="font-body-md text-[12px] text-[#a0a0a0]">Connect to a server to sync games across devices.</p>
</div>
<!-- 3. Status Card -->
<section class="bg-[#202020] rounded-[4px] p-4 border-l-[4px] border-warning flex flex-col gap-1">
<h2 class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">STATUS</h2>
<div class="flex flex-col">
<span class="font-headline text-[16px] font-bold text-[#a0a0a0]">○ NOT SIGNED IN</span>
<span class="text-[11px] text-outline font-medium">Local progress only · Last attempt: never</span>
</div>
</section>
<!-- 4. Login Form Card -->
<section class="bg-[#202020] border border-outline-variant rounded-[4px] p-4 flex flex-col gap-5">
<div class="flex items-center gap-2 border-b border-outline-variant pb-2 mb-1">
<span class="font-headline text-[12px] text-[#a0a0a0] flex items-center">
▌ AUTH.toml <span class="ml-1 w-1.5 h-3 bg-primary-container animate-pulse"></span>
</span>
</div>
<!-- Server URL -->
<div class="flex flex-col gap-1.5">
<label class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">server_url</label>
<div class="h-12 bg-[#1a1a1a] border border-outline-variant rounded-[2px] flex items-center px-4">
<span class="font-headline text-[13px] text-suit-black">https://sync.rusty-solitaire.app</span>
</div>
</div>
<!-- Email -->
<div class="flex flex-col gap-1.5">
<label class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">email</label>
<div class="h-12 bg-[#1a1a1a] border border-outline-variant rounded-[2px] flex items-center px-4">
<span class="font-headline text-[13px] text-outline">/ user@example.com</span>
</div>
</div>
<!-- Passcode -->
<div class="flex flex-col gap-1.5">
<label class="font-label-caps text-[10px] text-[#a0a0a0] uppercase tracking-widest">passcode</label>
<div class="h-12 bg-[#1a1a1a] border border-outline-variant rounded-[2px] flex items-center justify-between px-4">
<span class="font-headline text-[13px] text-outline">•••••••• (12 chars)</span>
<span class="material-symbols-outlined text-outline cursor-pointer text-[18px]">visibility</span>
</div>
</div>
</section>
<!-- 5. Form Actions -->
<div class="grid grid-cols-2 gap-4">
<button class="h-12 bg-primary-container text-surface flex items-center justify-center rounded-[2px] font-headline text-[14px] font-bold active:scale-95 transition-transform">
▶ SIGN IN
</button>
<button class="h-12 border border-outline text-suit-black flex items-center justify-center rounded-[2px] font-headline text-[13px] font-medium hover:border-primary-container hover:text-primary-container transition-all">
+ CREATE ACCOUNT
</button>
</div>
<!-- 6. Recent History Panel -->
<section class="bg-[#202020] rounded-[4px] p-4 flex flex-col gap-3">
<h2 class="font-label-caps text-[12px] text-[#a0a0a0] uppercase tracking-widest">RECENT</h2>
<div class="space-y-2 border-l border-outline-variant pl-4">
<div class="font-headline text-[11px] text-[#a0a0a0] flex items-center gap-2">
<span class="text-outline">2026-05-07 17:38</span>
<span class="text-outline">·</span>
<span>○ no auth</span>
<span class="text-outline">·</span>
<span>skip</span>
</div>
<div class="font-headline text-[11px] text-[#a0a0a0] flex items-center gap-2">
<span class="text-outline">2026-05-07 14:12</span>
<span class="text-outline">·</span>
<span>○ no auth</span>
<span class="text-outline">·</span>
<span>skip</span>
</div>
<div class="font-headline text-[11px] text-[#a0a0a0] flex items-center gap-2">
<span class="text-outline">2026-05-06 09:01</span>
<span class="text-outline">·</span>
<span class="text-highlight-valid">✓ synced 12 games</span>
</div>
</div>
</section>
</main>
<!-- 7. Footer -->
<footer class="h-10 bg-surface border-t border-outline-variant flex items-center justify-between px-4 fixed bottom-0 w-full z-50">
<div class="flex items-center gap-2 font-headline text-[11px]">
<span class="text-primary-container">▌ NORMAL</span>
<span class="text-outline"></span>
<span class="text-on-surface-variant">sync</span>
</div>
<div class="flex items-center gap-4 font-headline text-[11px] text-outline uppercase tracking-wider">
<span>[ENTER] <span class="text-on-surface-variant">sign in</span></span>
<span>[ESC] <span class="text-on-surface-variant">cancel</span></span>
</div>
</footer>
<!-- Shared BottomNavBar Component (Mobile Only) -->
<nav class="md:hidden fixed bottom-10 w-full z-50 flex justify-around items-center h-action-bar-height px-margin-edge bg-surface-container border-t border-outline-variant">
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
<span class="material-symbols-outlined" data-icon="videogame_asset">videogame_asset</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">F1_NEW_GAME</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
<span class="material-symbols-outlined" data-icon="event_upcoming">event_upcoming</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">F2_CHALLENGE</span>
</div>
<div class="flex flex-col items-center justify-center text-primary dark:text-primary-fixed-dim bg-surface-container-highest rounded-none p-2 transition-all duration-120 scale-95">
<span class="material-symbols-outlined" data-icon="query_stats">query_stats</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">F5_STATS</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
<span class="material-symbols-outlined" data-icon="power_settings_new">power_settings_new</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest mt-1">ESC_EXIT</span>
</div>
</nav>
<!-- Decorative Screen Texture -->
<div class="fixed inset-0 pointer-events-none opacity-[0.03] scanline z-[100]"></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500;600&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>.material-symbols-outlined {
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24
}
.scanline-pattern {
background: repeating-linear-gradient(0deg, #1a1a1a, #1a1a1a 2px, #151515 2px, #151515 4px)
}
.checker-pattern {
background-color: #001e2c;
background-image: radial-gradient(#a1dcff 10%, transparent 10%), radial-gradient(#a1dcff 10%, transparent 10%);
background-size: 8px 8px;
background-position: 0 0, 4px 4px
}
.stripe-pattern {
background: repeating-linear-gradient(0deg, #fb9fb1, #fb9fb1 4px, #202020 4px, #202020 8px)
}
.polka-pattern {
background-color: #001e2c;
background-image: radial-gradient(#e0e3e6 15%, transparent 15%);
background-size: 12px 12px
}
.vintage-pattern {
background-color: #d5ec8c;
background-image: url(https://lh3.googleusercontent.com/aida-public/AB6AXuD8S9vQTpEh-DjYtjB5CUHqi2CO326ZEjVVLJoOqG1AA6b92NZ6ctGoD4yZHKV7oHJnSFdvp3z3Wei9zfTI2EGAdrQfHxFYJ-h1DaeiZQY3vTa5khIQ83Sf-bjz2xiudHsjs3RyhSKC5bHv2c8_9t6YjepJdQnJa4GelCetFEs_agpN6u2IfMS1M9RrGxGKLl4K18fj0Pg3BW8IptX_ladhVFR5Hk8F0Reu5WHY8eQt1Nr-p9NNXl-w3C9Jz0uGSxi_Wb7R771lgQ);
opacity: 0.8
}</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-variant": "#313538",
"on-error-container": "#ffdad6",
"warning": "#ddb26f",
"on-surface": "#e0e3e6",
"inverse-on-surface": "#2d3134",
"on-secondary-fixed": "#161e00",
"on-error": "#690005",
"on-primary-fixed": "#001e2c",
"primary-container": "#6fc2ef",
"secondary-fixed-dim": "#bad073",
"tertiary": "#f7c3ff",
"surface-dim": "#101417",
"surface-container-low": "#181c1f",
"on-surface-variant": "#bfc8cf",
"surface-container": "#1c2023",
"secondary-container": "#435401",
"error": "#fb9fb1",
"on-tertiary": "#4c195b",
"surface-tint": "#7ed0fe",
"error-container": "#93000a",
"tertiary-fixed": "#fbd7ff",
"info": "#12cfc0",
"tertiary-fixed-dim": "#f0b0fc",
"highlight-valid": "#acc267",
"on-primary": "#003549",
"inverse-surface": "#e0e3e6",
"primary": "#a1dcff",
"surface-container-high": "#272a2d",
"background": "#101417",
"surface-container-lowest": "#0b0f11",
"suit-red": "#fb9fb1",
"suit-red-cb": "#6fc2ef",
"surface": "#151515",
"primary-fixed": "#c4e7ff",
"outline": "#505050",
"on-secondary": "#293500",
"surface-container-highest": "#313538",
"secondary-fixed": "#d5ec8c",
"on-primary-container": "#004f6c",
"suit-black": "#d0d0d0",
"on-primary-fixed-variant": "#004c69",
"on-tertiary-container": "#683476",
"secondary": "#bad073",
"highlight-celebration": "#e1a3ee",
"on-tertiary-fixed": "#340043",
"on-tertiary-fixed-variant": "#653173",
"on-secondary-container": "#b2c86d",
"surface-bright": "#363a3d",
"primary-fixed-dim": "#7ed0fe",
"on-secondary-fixed-variant": "#3c4d00",
"inverse-primary": "#00668a",
"outline-variant": "#3f484e",
"on-background": "#e0e3e6",
"tertiary-container": "#e1a3ee"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"stack-overlap": "2rem",
"gutter-card": "0.375rem",
"touch-target-min": "48dp",
"action-bar-height": "64px",
"margin-edge": "1rem"
},
"fontFamily": {
"hud-score": ["JetBrains Mono"],
"label-caps": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"body-md": ["Inter"]
},
"fontSize": {
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
}
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface selection:bg-primary-container selection:text-on-primary-container min-h-screen flex flex-col font-body-md overflow-x-hidden">
<!-- TopAppBar Semantic Shell -->
<header class="fixed top-0 w-full z-50 flex justify-between items-center h-[32px] px-margin-edge bg-surface-container border-b border-outline-variant">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[16px] text-primary" data-icon="terminal">terminal</span>
<span class="font-headline text-[14px] font-bold text-on-surface">▌theme.picker</span>
</div>
<div class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity">
<span class="font-label-caps text-[12px] text-on-surface-variant uppercase">× CLOSE</span>
</div>
</header>
<main class="mt-[32px] mb-[64px] flex-grow flex flex-col px-margin-edge py-6">
<!-- Header Section -->
<div class="flex justify-between items-start mb-8">
<div class="flex-1">
<h1 class="font-headline text-[24px] font-bold text-on-surface mb-1">CARD THEMES</h1>
<p class="font-body-md text-[13px] text-on-surface-variant max-w-[280px]">Choose a card-face theme. Imported themes appear at the bottom.</p>
</div>
<div class="bg-surface-container-high px-2 py-1 border border-outline-variant">
<span class="font-label-caps text-[11px] text-primary">5 INSTALLED</span>
</div>
</div>
<!-- Theme Grid -->
<div class="grid grid-cols-2 gap-4">
<!-- Active Theme: Terminal -->
<div class="relative flex flex-col bg-surface border-2 border-primary-container">
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
<!-- Card Preview -->
<div class="w-24 h-36 bg-surface border border-primary-container scanline-pattern relative">
<div class="absolute top-1 left-1 w-3 h-4 bg-primary-container"></div>
<div class="absolute bottom-1 right-1 font-headline text-[10px] text-on-surface">▌RS</div>
</div>
</div>
<div class="p-3 bg-surface-container-high border-t border-primary-container">
<div class="flex justify-between items-center mb-1">
<span class="font-headline text-[14px] font-bold truncate">Terminal</span>
<span class="font-label-caps text-[10px] text-primary-container">✓ ACTIVE</span>
</div>
<span class="font-body-md text-[11px] text-on-surface-variant">by Rusty Solitaire</span>
</div>
</div>
<!-- Theme: Classic -->
<div class="relative flex flex-col bg-surface border border-outline">
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
<div class="w-24 h-36 bg-surface border border-outline checker-pattern"></div>
</div>
<div class="p-3 bg-surface-container-low border-t border-outline">
<span class="font-headline text-[14px] font-bold block truncate">Classic</span>
<span class="font-body-md text-[11px] text-on-surface-variant">by Rusty Solitaire</span>
</div>
</div>
<!-- Theme: Stripes -->
<div class="relative flex flex-col bg-surface border border-outline">
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
<div class="w-24 h-36 bg-surface border border-outline stripe-pattern"></div>
</div>
<div class="p-3 bg-surface-container-low border-t border-outline">
<span class="font-headline text-[14px] font-bold block truncate">Stripes</span>
<span class="font-body-md text-[11px] text-on-surface-variant">by hayeah</span>
</div>
</div>
<!-- Theme: Polka -->
<div class="relative flex flex-col bg-surface border border-outline">
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
<div class="w-24 h-36 bg-surface border border-outline polka-pattern"></div>
</div>
<div class="p-3 bg-surface-container-low border-t border-outline">
<span class="font-headline text-[14px] font-bold block truncate">Polka</span>
<span class="font-body-md text-[11px] text-on-surface-variant">by hayeah</span>
</div>
</div>
<!-- Theme: Vintage -->
<div class="relative flex flex-col bg-surface border border-outline">
<div class="aspect-[2.5/3.5] w-full p-2 overflow-hidden flex items-center justify-center bg-background">
<div class="w-24 h-36 bg-surface border border-outline vintage-pattern"></div>
</div>
<div class="p-3 bg-surface-container-low border-t border-outline">
<span class="font-headline text-[14px] font-bold block truncate">Vintage</span>
<span class="font-body-md text-[11px] text-on-surface-variant">by hayeah</span>
</div>
</div>
<!-- Import Theme -->
<div class="relative flex flex-col bg-surface border-2 border-dashed border-outline-variant hover:border-primary-container transition-colors cursor-pointer">
<div class="aspect-[2.5/3.5] w-full flex flex-col items-center justify-center gap-3">
<span class="material-symbols-outlined text-[32px] text-primary-container" data-icon="add">add</span>
<span class="font-label-caps text-[10px] text-on-surface-variant tracking-widest text-center px-4">IMPORT FROM .ZIP</span>
</div>
<div class="p-3 bg-surface-container-low border-t border-outline-variant">
<span class="font-headline text-[14px] font-bold block truncate">+ IMPORT THEME</span>
<span class="font-body-md text-[11px] opacity-0">spacer</span>
</div>
</div>
</div>
</main>
<!-- BottomNavBar Semantic Shell -->
<footer class="fixed bottom-0 left-0 w-full h-[64px] z-50 bg-surface-container border-t border-outline-variant flex justify-between items-center px-margin-edge pb-safe">
<div class="flex items-center gap-2">
<span class="font-headline text-[14px] font-bold text-on-surface">▌ NORMAL</span>
<span class="text-on-surface-variant"></span>
<span class="font-label-caps text-[12px] text-on-surface-variant">theme</span>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center">
<span class="text-on-surface-variant font-label-caps text-[11px]">[ENTER]</span>
<span class="text-on-surface-variant font-body-md text-[11px] ml-1">activate</span>
</div>
<div class="flex items-center">
<span class="text-on-surface-variant font-label-caps text-[11px]">[I]</span>
<span class="text-on-surface-variant font-body-md text-[11px] ml-1">import</span>
</div>
<div class="flex items-center">
<span class="text-on-surface-variant font-label-caps text-[11px]">[ESC]</span>
<span class="text-on-surface-variant font-body-md text-[11px] ml-1">back</span>
</div>
</div>
</footer>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Rusty Solitaire - Time Attack Configuration</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&amp;family=Inter:wght@400;500&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-tint": "#7ed0fe",
"on-secondary-fixed-variant": "#3c4d00",
"primary-container": "#6fc2ef",
"surface-container-high": "#272a2d",
"tertiary": "#f7c3ff",
"suit-black": "#d0d0d0",
"secondary-container": "#435401",
"on-error-container": "#ffdad6",
"on-surface-variant": "#bfc8cf",
"surface-dim": "#101417",
"on-primary-container": "#004f6c",
"info": "#12cfc0",
"outline-variant": "#3f484e",
"surface": "#151515",
"inverse-on-surface": "#2d3134",
"surface-container-low": "#181c1f",
"on-secondary-container": "#b2c86d",
"on-primary-fixed-variant": "#004c69",
"inverse-primary": "#00668a",
"suit-red": "#fb9fb1",
"tertiary-fixed": "#fbd7ff",
"surface-container-lowest": "#0b0f11",
"on-surface": "#d0d0d0",
"on-secondary": "#293500",
"error-container": "#93000a",
"highlight-valid": "#acc267",
"surface-container-highest": "#313538",
"primary": "#a1dcff",
"secondary-fixed": "#d5ec8c",
"error": "#fb9fb1",
"outline": "#505050",
"tertiary-container": "#e1a3ee",
"tertiary-fixed-dim": "#f0b0fc",
"highlight-celebration": "#e1a3ee",
"surface-container": "#202020",
"on-primary": "#003549",
"on-error": "#690005",
"warning": "#ddb26f",
"suit-red-cb": "#6fc2ef",
"on-primary-fixed": "#001e2c",
"on-secondary-fixed": "#161e00",
"on-tertiary-fixed": "#340043",
"on-background": "#e0e3e6",
"on-tertiary": "#4c195b",
"inverse-surface": "#e0e3e6",
"secondary-fixed-dim": "#bad073",
"on-tertiary-fixed-variant": "#653173",
"background": "#101417",
"surface-variant": "#313538",
"secondary": "#bad073",
"on-tertiary-container": "#683476",
"surface-bright": "#363a3d",
"primary-fixed-dim": "#7ed0fe",
"primary-fixed": "#c4e7ff"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"margin-edge": "1rem",
"touch-target-min": "48px",
"stack-overlap": "2rem",
"gutter-card": "0.375rem",
"action-bar-height": "64px"
},
"fontFamily": {
"label-caps": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"body-md": ["Inter"],
"hud-timer": ["JetBrains Mono"]
},
"fontSize": {
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}]
}
},
},
}
</script>
<style>
body { background-color: #151515; color: #d0d0d0; }
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.scanline {
background: linear-gradient(to bottom, rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%);
background-size: 100% 4px;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="flex items-center justify-center min-h-screen">
<!-- Mobile Container (390x844) -->
<div class="w-[390px] h-[844px] bg-surface relative flex flex-col overflow-hidden border border-outline-variant">
<!-- STATUS BAR -->
<header class="h-8 bg-surface-container flex items-center justify-between px-4 z-10">
<span class="font-headline text-[12px] tracking-tight text-primary">▌time-attack.tsx</span>
<span class="font-label-caps text-[10px] text-on-surface-variant">MODE · TIMED</span>
</header>
<!-- TOP APP BAR (from JSON) -->
<nav class="flex justify-between items-center w-full px-margin-edge h-action-bar-height max-w-full bg-surface text-primary font-headline text-headline font-bold border-b border-outline-variant">
<div class="font-headline text-headline text-primary tracking-tighter uppercase">▌RS_TERMINAL_OS</div>
<div class="flex gap-4">
<span class="material-symbols-outlined cursor-pointer hover:text-primary-fixed transition-colors duration-120">account_circle</span>
<span class="material-symbols-outlined cursor-pointer hover:text-primary-fixed transition-colors duration-120">sync</span>
<span class="material-symbols-outlined cursor-pointer hover:text-primary-fixed transition-colors duration-120">settings</span>
</div>
</nav>
<!-- MAIN CONTENT -->
<main class="flex-1 px-4 py-4 flex flex-col gap-4 overflow-y-auto">
<!-- HERO BAND -->
<section class="flex flex-col items-center justify-center h-[100px] text-center">
<h1 class="font-headline text-[32px] font-bold tracking-tighter text-on-surface uppercase">TIME ATTACK</h1>
<p class="font-body-md text-[12px] text-on-surface-variant max-w-[280px]">Race the clock. The faster you finish, the higher your score.</p>
</section>
<!-- TIMER DISPLAY -->
<section class="w-full h-[120px] bg-surface-dim border border-outline-variant flex flex-col items-center justify-center relative overflow-hidden">
<div class="absolute inset-0 scanline opacity-10 pointer-events-none"></div>
<div class="font-headline text-[64px] font-bold tracking-tight text-primary tabular-nums leading-none">05:00</div>
<div class="font-label-caps text-[11px] uppercase tracking-[0.2em] text-on-surface-variant mt-2">MINUTES</div>
</section>
<!-- DURATION PICKER -->
<section class="grid grid-cols-4 gap-px bg-outline-variant border border-outline-variant">
<button class="h-12 bg-surface-container font-label-caps text-[12px] text-on-surface hover:bg-surface-bright transition-all">1 MIN</button>
<button class="h-12 bg-surface-container font-label-caps text-[12px] text-on-surface hover:bg-surface-bright transition-all">3 MIN</button>
<button class="h-12 bg-primary text-on-primary font-bold font-label-caps text-[12px]">5 MIN</button>
<button class="h-12 bg-surface-container font-label-caps text-[12px] text-on-surface hover:bg-surface-bright transition-all">10 MIN</button>
</section>
<!-- RULES CARD -->
<section class="bg-surface-container h-20 p-3 border border-outline-variant flex flex-col justify-between">
<div class="font-label-caps text-[10px] text-on-surface-variant uppercase tracking-widest">RULES</div>
<div class="font-headline text-[12px] text-on-surface flex items-center gap-2">
<span class="w-1 h-1 bg-primary"></span> DRAW-3
<span class="w-1 h-1 bg-primary ml-2"></span> NO HINT PENALTY
<span class="w-1 h-1 bg-primary ml-2"></span> +50 XP / WIN
</div>
</section>
<!-- BEST RUN CARD -->
<section class="bg-surface-container h-16 p-3 border border-outline-variant flex items-center justify-between">
<div class="flex flex-col">
<div class="font-label-caps text-[10px] text-on-surface-variant uppercase tracking-widest">PERSONAL BEST · 5 MIN</div>
<div class="font-headline text-[24px] font-bold text-on-surface leading-tight">02:47 <span class="text-[12px] font-normal text-on-surface-variant">WIN</span></div>
</div>
<div class="flex flex-col items-end">
<div class="font-label-caps text-[10px] text-on-surface-variant">GLOBAL RANK 142</div>
<div class="bg-warning text-on-surface text-[9px] px-1.5 py-0.5 font-bold mt-1">TOP 5%</div>
</div>
</section>
<!-- PRIMARY CTA -->
<section class="mt-auto pt-4">
<button class="w-full h-20 bg-primary text-on-primary flex flex-col items-center justify-center transition-all active:scale-[0.98] duration-80">
<div class="font-headline text-[18px] font-extrabold uppercase tracking-[0.2em] flex items-center gap-2">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span> BEGIN COUNTDOWN
</div>
</button>
<p class="font-body-md text-[11px] text-on-surface-variant text-center mt-3">Game starts after a 3-second countdown.</p>
</section>
</main>
<!-- FOOTER -->
<footer class="h-6 bg-surface-container-lowest flex items-center justify-between px-4 border-t border-outline-variant">
<span class="font-headline text-[10px] text-on-surface-variant">▌ NORMAL │ time-attack</span>
<span class="font-label-caps text-[9px] text-on-surface-variant uppercase">[ENTER] begin · [ESC] back</span>
</footer>
<!-- BOTTOM NAV BAR (from JSON) -->
<nav class="fixed bottom-0 w-[390px] z-50 flex justify-around items-center h-action-bar-height px-margin-edge bg-surface-container border-t border-outline-variant">
<div class="flex flex-col items-center justify-center text-primary bg-surface-container-highest rounded-none p-2 transition-transform duration-80 active:scale-95">
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">videogame_asset</span>
<span class="font-label-caps text-label-caps uppercase tracking-widest">F1_NEW_GAME</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
<span class="material-symbols-outlined">event_upcoming</span>
<span class="font-label-caps text-label-caps uppercase tracking-widest">F2_CHALLENGE</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
<span class="material-symbols-outlined">query_stats</span>
<span class="font-label-caps text-label-caps uppercase tracking-widest">F5_STATS</span>
</div>
<div class="flex flex-col items-center justify-center text-on-surface-variant p-2 hover:bg-surface-bright transition-all duration-120">
<span class="material-symbols-outlined">power_settings_new</span>
<span class="font-label-caps text-label-caps uppercase tracking-widest">ESC_EXIT</span>
</div>
</nav>
<!-- PADDING FOR FIXED NAV -->
<div class="h-action-bar-height"></div>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+265
View File
@@ -0,0 +1,265 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Weekly Goals - Rusty Solitaire</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-container-high": "#272a2d",
"on-primary": "#003549",
"highlight-valid": "#acc267",
"primary-container": "#6fc2ef",
"on-error-container": "#ffdad6",
"surface-tint": "#7ed0fe",
"outline": "#505050",
"on-primary-container": "#004f6c",
"on-background": "#e0e3e6",
"on-secondary-fixed-variant": "#3c4d00",
"error": "#fb9fb1",
"suit-black": "#d0d0d0",
"secondary": "#bad073",
"on-secondary-fixed": "#161e00",
"surface-container": "#202020",
"primary": "#a1dcff",
"error-container": "#93000a",
"secondary-fixed": "#d5ec8c",
"surface-container-highest": "#313538",
"surface-dim": "#101417",
"suit-red": "#fb9fb1",
"warning": "#ddb26f",
"secondary-fixed-dim": "#bad073",
"highlight-celebration": "#e1a3ee",
"on-tertiary-container": "#683476",
"on-surface": "#e0e3e6",
"on-surface-variant": "#bfc8cf",
"inverse-on-surface": "#2d3134",
"primary-fixed-dim": "#7ed0fe",
"tertiary-fixed": "#fbd7ff",
"info": "#12cfc0",
"tertiary-fixed-dim": "#f0b0fc",
"surface-variant": "#313538",
"inverse-primary": "#00668a",
"suit-red-cb": "#6fc2ef",
"tertiary-container": "#e1a3ee",
"secondary-container": "#435401",
"primary-fixed": "#c4e7ff",
"surface-container-low": "#181c1f",
"background": "#101417",
"inverse-surface": "#e0e3e6",
"surface-bright": "#363a3d",
"on-primary-fixed": "#001e2c",
"surface-container-lowest": "#0b0f11",
"on-secondary": "#293500",
"on-secondary-container": "#b2c86d",
"on-tertiary": "#4c195b",
"tertiary": "#f7c3ff",
"outline-variant": "#3f484e",
"on-tertiary-fixed-variant": "#653173",
"on-tertiary-fixed": "#340043",
"on-primary-fixed-variant": "#004c69",
"surface": "#151515",
"on-error": "#690005"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"margin-edge": "1rem",
"touch-target-min": "48dp",
"gutter-card": "0.375rem",
"stack-overlap": "2rem",
"action-bar-height": "64px"
},
"fontFamily": {
"headline": ["JetBrains Mono"],
"body-md": ["Inter"],
"label-caps": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"]
},
"fontSize": {
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
}
}
}
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
.tabular-nums { font-variant-numeric: tabular-nums; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: #151515; }
::-webkit-scrollbar-thumb { background: #353535; border-radius: 2px; }
/* Scanline Overlay Effect */
.crt-overlay {
pointer-events: none;
position: fixed;
top: 0; left: 0; bottom: 0; right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.03));
background-size: 100% 3px, 3px 100%;
z-index: 100;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface font-body-md selection:bg-primary-container selection:text-on-primary-container overflow-hidden h-screen flex flex-col">
<!-- Top Status Bar -->
<div class="h-[32px] bg-surface flex items-center justify-between px-margin-edge z-50 shrink-0">
<div class="font-label-caps text-[12px] text-[#a0a0a0] flex items-center">
<span class="mr-1"></span>weekly-goals.json
</div>
<div class="bg-warning/10 text-warning px-2 py-0.5 rounded-sm flex items-center gap-1.5">
<span class="material-symbols-outlined text-[14px]">timer</span>
<span class="font-headline text-[10px] font-bold tabular-nums tracking-wider uppercase">RESETS IN 2D 14H</span>
</div>
</div>
<!-- Main Header Band -->
<header class="h-[80px] px-margin-edge flex flex-col justify-center border-b border-outline/20 shrink-0">
<h1 class="font-headline text-[24px] font-bold text-suit-black tracking-tight leading-none">WEEKLY GOALS</h1>
<p class="font-body-md text-[12px] text-[#a0a0a0] mt-1">Complete goals before reset to claim XP and rewards.</p>
</header>
<!-- Main Content Canvas -->
<main class="flex-1 overflow-y-auto px-margin-edge py-4 space-y-4">
<!-- Overall Progress Card -->
<section class="h-[80px] bg-surface-container border border-outline/30 rounded-[4px] p-4 flex flex-col justify-between">
<div class="flex justify-between items-center">
<span class="font-label-caps text-[10px] text-[#a0a0a0] tracking-widest uppercase">OVERALL · 3/5</span>
<span class="font-headline text-[12px] text-highlight-celebration tabular-nums">(60%)</span>
</div>
<div class="w-full h-[6px] bg-[#353535] rounded-full overflow-hidden">
<div class="h-full bg-highlight-celebration w-[60%]"></div>
</div>
<div class="flex justify-end">
<div class="font-label-caps text-[11px] text-highlight-valid flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">stars</span>
+220 XP CLAIMED
</div>
</div>
</section>
<!-- Goal List -->
<div class="space-y-2 pb-16">
<!-- Goal 1: COMPLETED -->
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between">
<div class="flex justify-between items-start">
<h3 class="font-headline text-[14px] font-bold text-suit-black">PLAY 10 GAMES</h3>
<div class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+50 XP</div>
</div>
<div class="w-full h-[4px] bg-[#353535] rounded-full">
<div class="h-full bg-highlight-valid w-full"></div>
</div>
<div class="flex justify-between items-center font-label-caps text-[10px]">
<span class="text-[#a0a0a0]">10/10 GAMES</span>
<span class="text-highlight-valid flex items-center gap-1">✓ CLAIMED</span>
</div>
</div>
<!-- Goal 2: COMPLETED -->
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between">
<div class="flex justify-between items-start">
<h3 class="font-headline text-[14px] font-bold text-suit-black">WIN 5 DAILY SEEDS</h3>
<div class="bg-highlight-celebration/10 text-highlight-celebration px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+100 XP</div>
</div>
<div class="w-full h-[4px] bg-[#353535] rounded-full">
<div class="h-full bg-highlight-valid w-full"></div>
</div>
<div class="flex justify-between items-center font-label-caps text-[10px]">
<span class="text-[#a0a0a0]">5/5 DONE</span>
<span class="text-highlight-valid flex items-center gap-1">✓ CLAIMED</span>
</div>
</div>
<!-- Goal 3: IN PROGRESS -->
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between border-l-2 border-l-primary-container">
<div class="flex justify-between items-start">
<h3 class="font-headline text-[14px] font-bold text-suit-black">WIN UNDER 4:00 (3 TIMES)</h3>
<div class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+75 XP</div>
</div>
<div class="w-full h-[4px] bg-[#353535] rounded-full">
<div class="h-full bg-primary-container w-[66%]"></div>
</div>
<div class="flex justify-between items-center font-label-caps text-[10px]">
<span class="text-[#a0a0a0]">2/3</span>
<span class="text-primary-container flex items-center gap-1">▶ IN PROGRESS</span>
</div>
</div>
<!-- Goal 4: NOT STARTED -->
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between opacity-70">
<div class="flex justify-between items-start">
<h3 class="font-headline text-[14px] font-bold text-suit-black">PERFECT GAME (NO UNDO)</h3>
<div class="bg-warning/10 text-warning px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+150 XP</div>
</div>
<div class="w-full h-[4px] bg-[#353535] rounded-full">
<div class="h-full bg-outline w-0"></div>
</div>
<div class="flex justify-between items-center font-label-caps text-[10px]">
<span class="text-[#a0a0a0]">0/1</span>
<span class="text-outline flex items-center gap-1">○ NOT STARTED</span>
</div>
</div>
<!-- Goal 5: IN PROGRESS -->
<div class="h-[88px] bg-surface-container border border-outline/30 rounded-[4px] p-3 flex flex-col justify-between border-l-2 border-l-primary-container">
<div class="flex justify-between items-start">
<h3 class="font-headline text-[14px] font-bold text-suit-black">STREAK OF 5 WINS</h3>
<div class="bg-highlight-valid/10 text-highlight-valid px-2 py-0.5 rounded-sm font-label-caps text-[11px]">+50 XP</div>
</div>
<div class="w-full h-[4px] bg-[#353535] rounded-full">
<div class="h-full bg-primary-container w-[60%]"></div>
</div>
<div class="flex justify-between items-center font-label-caps text-[10px]">
<span class="text-[#a0a0a0]">3/5</span>
<span class="text-primary-container flex items-center gap-1">▶ IN PROGRESS</span>
</div>
</div>
</div>
</main>
<!-- Navigation Shell from JSON -->
<nav class="fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-margin-edge bg-surface-container border-t border-outline-variant h-action-bar-height">
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-120 active:scale-95 transition-transform duration-80">
<span class="material-symbols-outlined">refresh</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-120 active:scale-95 transition-transform duration-80">
<span class="material-symbols-outlined">undo</span>
</button>
<button class="flex flex-col items-center justify-center text-primary active:scale-95 transition-transform duration-80">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">style</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-120 active:scale-95 transition-transform duration-80">
<span class="material-symbols-outlined">help_outline</span>
</button>
</nav>
<!-- Bottom Terminal Status Footer -->
<footer class="fixed bottom-[64px] left-0 w-full h-[24px] bg-surface flex items-center justify-between px-margin-edge z-50 border-t border-outline/10">
<div class="font-label-caps text-[10px] text-[#a0a0a0]">
<span class="text-primary"></span> NORMAL │ weekly
</div>
<div class="font-label-caps text-[10px] flex gap-3">
<span class="text-[#505050]"><span class="text-[#a0a0a0]">[C]</span> claim all</span>
<span class="text-[#505050]"><span class="text-[#a0a0a0]">[ESC]</span> back</span>
</div>
</footer>
<!-- CRT Overlay -->
<div class="crt-overlay"></div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+200
View File
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>ROOT@SOLITAIRE:~ | Win Summary</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;family=Inter:wght@400;500;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"primary-fixed": "#c4e7ff",
"inverse-surface": "#e0e3e6",
"on-tertiary": "#4c195b",
"suit-red-cb": "#6fc2ef",
"secondary-fixed-dim": "#bad073",
"on-secondary-fixed-variant": "#3c4d00",
"on-secondary-fixed": "#161e00",
"surface-container-highest": "#313538",
"primary-fixed-dim": "#7ed0fe",
"on-secondary": "#293500",
"on-background": "#e0e3e6",
"highlight-celebration": "#e1a3ee",
"warning": "#ddb26f",
"on-tertiary-fixed-variant": "#653173",
"background": "#101417",
"surface-container-lowest": "#0b0f11",
"info": "#12cfc0",
"tertiary-container": "#e1a3ee",
"surface-container": "#202020",
"secondary": "#bad073",
"outline": "#505050",
"tertiary-fixed-dim": "#f0b0fc",
"secondary-container": "#435401",
"inverse-primary": "#00668a",
"surface": "#151515",
"on-error-container": "#ffdad6",
"error-container": "#93000a",
"surface-bright": "#363a3d",
"surface-dim": "#101417",
"on-primary-fixed-variant": "#004c69",
"on-tertiary-fixed": "#340043",
"inverse-on-surface": "#2d3134",
"surface-container-high": "#272a2d",
"secondary-fixed": "#d5ec8c",
"on-tertiary-container": "#683476",
"on-secondary-container": "#b2c86d",
"surface-tint": "#7ed0fe",
"on-primary-container": "#004f6c",
"on-error": "#690005",
"on-surface": "#e0e3e6",
"surface-variant": "#313538",
"highlight-valid": "#acc267",
"primary": "#a1dcff"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"margin-edge": "1rem",
"action-bar-height": "64px",
"gutter-card": "0.375rem",
"touch-target-min": "48dp",
"stack-overlap": "2rem"
},
"fontFamily": {
"body-md": ["Inter"],
"label-caps": ["JetBrains Mono"],
"card-rank": ["JetBrains Mono"],
"hud-timer": ["JetBrains Mono"],
"headline": ["JetBrains Mono"],
"hud-score": ["JetBrains Mono"]
},
"fontSize": {
"body-md": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"label-caps": ["12px", {"lineHeight": "16px", "letterSpacing": "0.08em", "fontWeight": "500"}],
"card-rank": ["18px", {"lineHeight": "18px", "fontWeight": "700"}],
"hud-timer": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
"headline": ["28px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"hud-score": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.02em", "fontWeight": "700"}]
}
},
},
}
</script>
<style>
.crt-scanlines {
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.1) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.02), rgba(0, 255, 0, 0.01), rgba(0, 0, 255, 0.02));
background-size: 100% 2px, 3px 100%;
pointer-events: none;
}
.lavender-glow {
text-shadow: 0 0 16px rgba(225, 163, 238, 0.3);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-background font-body-md selection:bg-primary-container selection:text-on-primary-container min-h-screen flex flex-col relative overflow-hidden">
<!-- CRT Overlay -->
<div class="fixed inset-0 crt-scanlines z-[100] opacity-30"></div>
<!-- Status Bar (Emulating TopAppBar context) -->
<header class="h-[32px] bg-surface-container flex items-center justify-between px-margin-edge z-50 border-b border-outline-variant">
<div class="flex items-center gap-2">
<span class="font-label-caps text-[11px] text-on-surface">▌win.tsx</span>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-info"></span>
<span class="font-label-caps text-[11px] text-info uppercase">Synced</span>
</div>
<span class="font-label-caps text-[11px] text-outline uppercase tracking-tight">v0.20.0</span>
</div>
</header>
<!-- Content Canvas -->
<main class="flex-1 flex flex-col px-margin-edge pt-12 pb-8 gap-8 relative z-10">
<!-- Hero Band -->
<section class="flex flex-col items-center text-center space-y-2">
<h1 class="font-headline text-[48px] leading-tight text-highlight-celebration uppercase lavender-glow tracking-tighter">
█ COMPLETE
</h1>
<p class="font-label-caps text-[12px] text-outline-variant tracking-[0.2em]">
GAME #2024-127 · DRAW-3
</p>
</section>
<!-- Stats Card -->
<section class="bg-surface-container border border-outline-variant rounded-lg overflow-hidden p-6 grid grid-cols-2 gap-y-8 gap-x-4">
<div class="space-y-1">
<span class="block font-label-caps text-outline uppercase text-[10px]">Final Score</span>
<span class="block font-hud-score text-on-background">1,024</span>
</div>
<div class="space-y-1">
<span class="block font-label-caps text-outline uppercase text-[10px]">Time</span>
<span class="block font-hud-timer text-on-background">12:34</span>
</div>
<div class="space-y-1">
<span class="block font-label-caps text-outline uppercase text-[10px]">Moves</span>
<span class="block font-hud-timer text-on-background">87</span>
</div>
<div class="space-y-1">
<span class="block font-label-caps text-outline uppercase text-[10px]">Par Delta</span>
<span class="block font-hud-timer text-highlight-valid">13</span>
</div>
</section>
<!-- Achievement Card -->
<section class="bg-surface-container border-l-2 border-highlight-celebration rounded-r-lg p-4 flex items-center justify-between">
<div class="space-y-1">
<span class="font-label-caps text-highlight-celebration text-[10px] flex items-center gap-1">
<span class="text-[8px]"></span> ACHIEVEMENT UNLOCKED
</span>
<p class="font-label-caps text-suit-black uppercase tracking-wider text-[14px]">FIRST DAILY WIN</p>
</div>
<div class="w-12 h-12 rounded-full border border-outline-variant flex items-center justify-center bg-surface-container-low">
<span class="material-symbols-outlined text-highlight-celebration text-2xl" data-icon="military_tech">military_tech</span>
</div>
</section>
<!-- Action Buttons -->
<div class="mt-auto space-y-3">
<button class="w-full h-[56px] bg-info text-surface font-label-caps text-[14px] font-bold tracking-widest flex items-center justify-center gap-2 hover:opacity-90 active:opacity-80 transition-all uppercase">
<span class="text-lg"></span> New Game
</button>
<div class="grid grid-cols-2 gap-3">
<button class="h-[48px] border border-outline text-on-surface-variant font-label-caps text-[12px] flex items-center justify-center gap-2 hover:border-info hover:text-info transition-colors uppercase">
<span class="text-sm"></span> Replay Seed
</button>
<button class="h-[48px] border border-outline text-on-surface-variant font-label-caps text-[12px] flex items-center justify-center gap-2 hover:border-info hover:text-info transition-colors uppercase">
<span class="text-sm"></span> Home
</button>
</div>
</div>
</main>
<!-- Footer Keys -->
<footer class="h-action-bar-height flex flex-col items-center justify-center px-margin-edge border-t border-outline-variant bg-surface-container-low">
<div class="flex gap-4">
<div class="flex items-center gap-1.5">
<span class="font-label-caps text-outline-variant">[ S ]</span>
<span class="font-label-caps text-outline uppercase text-[10px]">share screenshot</span>
</div>
<div class="flex items-center gap-1.5">
<span class="font-label-caps text-outline-variant">[ X ]</span>
<span class="font-label-caps text-outline uppercase text-[10px]">copy seed</span>
</div>
</div>
</footer>
<!-- Bottom Nav Suppression Logic: This is a task-focused confirmation screen, so the global BottomNavBar from JSON is suppressed to focus on primary actions. -->
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+60
View File
@@ -8,8 +8,68 @@ edition.workspace = true
name = "solitaire_app"
path = "src/main.rs"
# `cdylib` is what cargo-apk packages into `libsolitaire_app.so` for
# Android — the activity dlopens the shared object and calls into it.
# `rlib` lets the bin target above link the library normally on
# desktop. Both produce the same code; only the linkage form differs.
[lib]
name = "solitaire_app"
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[dependencies]
bevy = { workspace = true }
solitaire_engine = { workspace = true }
solitaire_data = { workspace = true }
# `keyring`'s default-store init only matters on platforms with a
# real keychain backend (Linux Secret Service, macOS Keychain,
# Windows Credential Store). The crate also pulls `rpassword`
# transitively, which uses `libc::__errno_location` — a symbol
# Android's bionic doesn't expose. Target-gating keeps
# `cargo apk build` viable; the call site in `lib.rs` has its own
# `cfg(not(target_os = "android"))` guard so the desktop init path
# is unchanged.
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring = { workspace = true }
# --- Android packaging metadata (read by `cargo-apk`) -------------------
#
# Pinning these values inside the repo means a contributor running
# `cargo apk build -p solitaire_app --target x86_64-linux-android`
# does not need to install whatever SDK version cargo-apk happens to
# default to today. The numbers track the SDK we install in the dev
# setup script: target SDK 34 (Android 14, current Play Store target),
# min SDK 26 (Android 8, the lowest Bevy 0.18 supports cleanly with
# the wgpu / GLES path).
#
# Asset path is `../assets` so the same directory the desktop build
# already uses ships into the APK without copy-tree gymnastics.
# `apk_name` keeps the output filename predictable across machines.
[package.metadata.android]
package = "com.solitairequest.app"
apk_name = "solitaire-quest"
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
assets = "../assets"
# No `runtime_libs` — we don't ship any precompiled .so files,
# the entire app is pure Rust + Bevy. cargo-apk would try to
# resolve `runtime_libs/<arch>/` if set, and fail on a non-existent
# arch directory under our package.
strip = "strip"
[package.metadata.android.sdk]
target_sdk_version = 34
min_sdk_version = 26
[[package.metadata.android.uses_feature]]
name = "android.hardware.touchscreen"
required = true
[[package.metadata.android.uses_permission]]
name = "android.permission.INTERNET"
[package.metadata.android.application]
label = "Solitaire Quest"
# `debuggable` defaults to false on release builds; cargo-apk flips it
# automatically for debug profiles. Leaving the field unset keeps the
# default behaviour.
+281
View File
@@ -0,0 +1,281 @@
//! Library entry point for `solitaire_app`.
//!
//! The app is a `cdylib + bin` hybrid: desktop builds run through the
//! `bin` target's [`main`](crate::main_desktop) shim; Android builds
//! load this `cdylib` via NativeActivity / GameActivity, which calls
//! into the platform's own `main` glue. Both paths converge on
//! [`run`], so the ECS bootstrap is single-sourced.
//!
//! Why split this out: cargo-apk requires the package to expose a
//! `cdylib` library target — the Android activity dlopens
//! `libsolitaire_app.so` and calls into it. A bin-only crate panics
//! at build time with `Bin is not compatible with Cdylib`. The split
//! keeps the desktop `cargo run -p solitaire_app` flow unchanged
//! while making `cargo apk build -p solitaire_app` viable.
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, FeedbackAnimPlugin, FontPlugin,
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
/// App entry point — builds and runs the Bevy app.
///
/// Called from both the desktop `bin` target's `main` shim and (on
/// Android) the platform's NativeActivity / GameActivity glue.
pub fn run() {
// Install a panic hook that writes a crash log next to the save files
// before re-running the default hook (so stderr still gets the message
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
// If the platform has no OS keyring (e.g. a headless CI box), keyring
// operations will fail gracefully with TokenError::KeychainUnavailable.
//
// Android: `keyring` isn't compiled in (its `rpassword` transitive
// pulls a libc symbol Android's bionic doesn't expose). `auth_tokens`
// ships an Android stub that returns KeychainUnavailable for every
// call — the runtime behaviour is "session login required each launch"
// until we wire Android Keystore via JNI in the Phase-Android round.
#[cfg(not(target_os = "android"))]
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
"warn: could not initialise OS keyring ({e}); \
server sync login will be unavailable"
);
}
// Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path()
.map(|p| load_settings_from(&p))
.unwrap_or_default();
let sync_provider = provider_for_backend(&settings.sync_backend);
// Restore the previous window geometry if the player has one saved.
// Otherwise open at the platform default (1280×800, centred on the
// primary monitor) — `apply_smart_default_window_size` will resize
// up to a monitor-relative target on the first frame so HiDPI / 4K
// sessions don't end up with a comparatively tiny window.
let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
(geom.width, geom.height).into(),
WindowPosition::At(IVec2::new(geom.x, geom.y)),
),
None => (
(1280u32, 800u32).into(),
WindowPosition::Centered(MonitorSelection::Primary),
),
};
let mut app = App::new();
// The card-theme system's `themes://` asset source must be
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
// because that plugin freezes the asset-source list at build
// time. The matching `AssetSourcesPlugin` (added below) finishes
// the wiring after `DefaultPlugins` by populating the embedded
// default theme into Bevy's `EmbeddedAssetRegistry`.
register_theme_asset_sources(&mut app);
app
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
resolution: window_resolution,
position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and
// falls back to Immediate, eliminating the vsync stall
// that AutoVsync produces during continuous window
// resize on X11 / Wayland. The game's frame budget is
// small enough that a few stray dropped frames from
// disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync,
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
.set(bevy::asset::AssetPlugin {
file_path: "../assets".to_string(),
..default()
}),
)
.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(ReplayPlaybackPlugin)
.add_plugins(ReplayOverlayPlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin);
// Smart default window sizing: when no saved geometry was loaded,
// resize the freshly-opened 1280×800 window to ~70 % of the primary
// monitor's logical size on the first frame. Without this, a 4K
// monitor opens the same 1280×800 window that a 1080p monitor
// does — visually tiny relative to screen. Skipped entirely when
// saved geometry was applied; the player's preference always wins.
//
// Players who specifically want the literal 1280×800 baseline on
// every fresh launch can flip `disable_smart_default_size` in
// Settings to opt out. The flag is checked once at startup; a
// mid-session change applies on the next launch.
if !had_saved_geometry && !settings.disable_smart_default_size {
app.add_systems(Update, apply_smart_default_window_size);
}
app.run();
}
/// One-shot Update system that runs only on launches without saved
/// window geometry. Resizes the primary window to a fraction of the
/// primary monitor's *logical* size — bigger monitors get bigger
/// windows automatically. Logical size already accounts for the OS's
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
/// target window — same physical inches as a 1920×1080 monitor with
/// scale_factor 1.0 yielding 1344×756.
///
/// Uses `Local<bool>` to make itself one-shot rather than introducing
/// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass.
fn apply_smart_default_window_size(
mut applied: Local<bool>,
monitors: Query<&Monitor, With<PrimaryMonitor>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
if *applied {
return;
}
let Ok(monitor) = monitors.single() else {
// Primary monitor not yet spawned by bevy_winit. Try again
// next frame; the cost is one early-exit per tick until
// monitors arrive (typically frame 1 or 2).
return;
};
let Ok(mut window) = windows.single_mut() else {
return;
};
let scale = monitor.scale_factor as f32;
if scale <= 0.0 {
// Defensive: a zero or negative scale factor would NaN the
// arithmetic below. Bail and accept the default size.
*applied = true;
return;
}
let logical_w = monitor.physical_width as f32 / scale;
let logical_h = monitor.physical_height as f32 / scale;
// Target 70 % of monitor in each dimension, clamped to the
// existing 800×600 minimum and the monitor's own logical size
// (so we never request a window larger than the screen).
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
// Resize only when the change is meaningful — at exactly 1280×800
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
// wider), worth the resize; at the same default on an 800×600
// monitor the clamp pins us at 800×600 and we shouldn't resize.
let curr_w = window.resolution.width();
let curr_h = window.resolution.height();
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
window.resolution.set(target_w, target_h);
}
*applied = true;
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
/// unchanged. If the data directory is unavailable, the wrapper silently
/// falls through — the default hook handles output either way.
fn install_crash_log_hook() {
let crash_log_path = settings_file_path().and_then(|p| {
p.parent()
.map(|parent| parent.join("crash.log"))
});
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(path) = crash_log_path.as_ref()
&& let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
// Plain unix-seconds timestamp keeps the format trivially
// parseable and avoids pulling in chrono just for this.
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
}
default_hook(info);
}));
}
+6 -169
View File
@@ -1,172 +1,9 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin,
StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
};
//! Desktop entry point for `solitaire_app`.
//!
//! The body of the app lives in `lib.rs` so cargo-apk can package the
//! same code into an Android `cdylib`. This shim is the desktop /
//! `cargo run` path — it just delegates to [`solitaire_app::run`].
fn main() {
// Install a panic hook that writes a crash log next to the save files
// before re-running the default hook (so stderr still gets the message
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
// If the platform has no OS keyring (e.g. a headless CI box), keyring
// operations will fail gracefully with TokenError::KeychainUnavailable.
if let Err(e) = keyring::use_native_store(true) {
eprintln!(
"warn: could not initialise OS keyring ({e}); \
server sync login will be unavailable"
);
}
// Load settings before building the app so we can construct the right
// sync provider. Falls back to defaults if no settings file exists yet.
let settings: Settings = settings_file_path()
.map(|p| load_settings_from(&p))
.unwrap_or_default();
let sync_provider = provider_for_backend(&settings.sync_backend);
// Restore the previous window geometry if the player has one saved.
// Otherwise open at the platform default (1280×800, centred on the
// primary monitor). The window_geometry field is None on first run
// and after upgrading from a build that didn't persist geometry.
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
(geom.width, geom.height).into(),
WindowPosition::At(IVec2::new(geom.x, geom.y)),
),
None => (
(1280u32, 800u32).into(),
WindowPosition::Centered(MonitorSelection::Primary),
),
};
let mut app = App::new();
// The card-theme system's `themes://` asset source must be
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
// because that plugin freezes the asset-source list at build
// time. The matching `AssetSourcesPlugin` (added below) finishes
// the wiring after `DefaultPlugins` by populating the embedded
// default theme into Bevy's `EmbeddedAssetRegistry`.
register_theme_asset_sources(&mut app);
app
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
title: "Solitaire Quest".into(),
// X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly.
name: Some("solitaire-quest".into()),
resolution: window_resolution,
position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and
// falls back to Immediate, eliminating the vsync stall
// that AutoVsync produces during continuous window
// resize on X11 / Wayland. The game's frame budget is
// small enough that a few stray dropped frames from
// disabling vsync are imperceptible.
present_mode: PresentMode::AutoNoVsync,
resize_constraints: bevy::window::WindowResizeConstraints {
min_width: 800.0,
min_height: 600.0,
..default()
},
..default()
}),
..default()
})
// The `assets/` directory lives at the workspace root, but
// Bevy resolves `AssetPlugin::file_path` relative to the
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
// Point one level up so `cargo run -p solitaire_app` finds
// card faces, backs, backgrounds, and the UI font.
.set(bevy::asset::AssetPlugin {
file_path: "../assets".to_string(),
..default()
}),
)
.add_plugins(AssetSourcesPlugin)
.add_plugins(ThemePlugin)
.add_plugins(ThemeRegistryPlugin)
.add_plugins(FontPlugin)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(CardPlugin)
.add_plugins(CursorPlugin)
.add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin)
.add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin)
.add_plugins(AutoCompletePlugin)
.add_plugins(StatsPlugin::default())
.add_plugins(ProgressPlugin::default())
.add_plugins(AchievementPlugin::default())
.add_plugins(DailyChallengePlugin)
.add_plugins(WeeklyGoalsPlugin)
.add_plugins(ChallengePlugin)
.add_plugins(TimeAttackPlugin)
.add_plugins(HudPlugin)
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin)
.add_plugins(ProfilePlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(AudioPlugin)
.add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.run();
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
/// unchanged. If the data directory is unavailable, the wrapper silently
/// falls through — the default hook handles output either way.
fn install_crash_log_hook() {
let crash_log_path = settings_file_path().and_then(|p| {
p.parent()
.map(|parent| parent.join("crash.log"))
});
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(path) = crash_log_path.as_ref()
&& let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
// Plain unix-seconds timestamp keeps the format trivially
// parseable and avoids pulling in chrono just for this.
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
}
default_hook(info);
}));
solitaire_app::run();
}
+47
View File
@@ -140,6 +140,16 @@ fn comeback(c: &AchievementContext) -> bool {
fn zen_winner(c: &AchievementContext) -> bool {
c.last_win_is_zen
}
/// Cinephile is event-driven: it unlocks when the engine observes a
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
/// any field of [`AchievementContext`]. The condition predicate therefore
/// always returns false so [`check_achievements`] never unlocks it from a
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
/// `AchievementUnlockedEvent` written directly from the engine's
/// replay-playback observer.
fn cinephile_never(_c: &AchievementContext) -> bool {
false
}
/// All currently-evaluable achievements. Order is stable so persistence files
/// remain readable across versions (new achievements append).
@@ -288,6 +298,18 @@ pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
reward: Some(Reward::Badge),
condition: zen_winner,
},
AchievementDef {
id: "cinephile",
name: "Cinephile",
description: "Watch a saved replay all the way through",
secret: false,
reward: None,
// Event-driven unlock: the engine's replay-playback observer fires
// `AchievementUnlockedEvent("cinephile")` directly on a Playing →
// Completed transition. `cinephile_never` keeps the condition path
// a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
condition: cinephile_never,
},
];
/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
@@ -721,6 +743,31 @@ mod tests {
assert!(ids.contains(&"no_undo"), "no_undo must also unlock when perfectionist does");
}
#[test]
fn cinephile_achievement_in_canonical_list() {
let def = achievement_by_id("cinephile").expect("cinephile must be registered");
assert_eq!(def.id, "cinephile");
assert_eq!(def.name, "Cinephile");
assert!(!def.secret, "cinephile is not a secret achievement");
// Event-driven: the predicate is a sentinel that always returns
// false. `check_achievements` must never unlock cinephile from a
// GameWonEvent context, even one that satisfies every other gate.
let mut c = ctx();
c.games_won = 1;
c.win_streak_current = 999;
c.last_win_time_seconds = 1;
c.last_win_used_undo = false;
c.best_single_score = 99_999;
c.lifetime_score = u64::MAX;
c.last_win_is_zen = true;
c.last_win_recycle_count = 99;
let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
assert!(
!ids.contains(&"cinephile"),
"cinephile must never unlock via condition evaluation; got {ids:?}",
);
}
#[test]
fn perfectionist_score_well_above_threshold_still_passes() {
let mut c = ctx();
+6 -31
View File
@@ -77,16 +77,6 @@ pub struct Card {
mod tests {
use super::*;
#[test]
fn rank_value_ace_is_one() {
assert_eq!(Rank::Ace.value(), 1);
}
#[test]
fn rank_value_king_is_thirteen() {
assert_eq!(Rank::King.value(), 13);
}
#[test]
fn rank_values_are_sequential() {
let ranks = [
@@ -100,26 +90,11 @@ mod tests {
}
#[test]
fn suit_red_is_diamonds_and_hearts() {
assert!(Suit::Diamonds.is_red());
assert!(Suit::Hearts.is_red());
assert!(!Suit::Clubs.is_red());
assert!(!Suit::Spades.is_red());
}
#[test]
fn suit_black_is_clubs_and_spades() {
assert!(Suit::Clubs.is_black());
assert!(Suit::Spades.is_black());
assert!(!Suit::Diamonds.is_black());
assert!(!Suit::Hearts.is_black());
}
#[test]
fn card_face_up_field_reflects_construction() {
let card = Card { id: 0, suit: Suit::Hearts, rank: Rank::Ace, face_up: false };
assert!(!card.face_up);
let card2 = Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true };
assert!(card2.face_up);
fn suit_red_and_black_are_complementary() {
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
assert_ne!(suit.is_red(), suit.is_black(), "{suit:?} must be exactly one of red/black");
}
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
}
-16
View File
@@ -815,11 +815,6 @@ mod tests {
assert!(g.undo_stack_len() <= 64);
}
#[test]
fn undo_count_starts_at_zero() {
assert_eq!(new_game().undo_count, 0);
}
#[test]
fn undo_count_increments_on_each_undo() {
let mut g = new_game();
@@ -900,11 +895,6 @@ mod tests {
assert_eq!(g.score, 0);
}
#[test]
fn zen_mode_default_is_classic_via_default_trait() {
assert_eq!(GameMode::default(), GameMode::Classic);
}
#[test]
fn zen_mode_field_persists_through_construction() {
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
@@ -956,12 +946,6 @@ mod tests {
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
}
#[test]
fn time_attack_score_starts_at_zero() {
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
assert_eq!(g.score, 0);
}
#[test]
fn time_attack_draw_three_combination() {
// TimeAttack + DrawThree is a valid combination; verify construction.
+1
View File
@@ -6,3 +6,4 @@ pub mod game_state;
pub mod pile;
pub mod rules;
pub mod scoring;
pub mod solver;
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -13,10 +13,19 @@ chrono = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
dirs = { workspace = true }
keyring-core = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
# `rpassword` which uses `libc::__errno_location` — a symbol the
# Android NDK doesn't expose (`__errno` lives at a different path
# on bionic). On Android `auth_tokens` falls back to a stub
# implementation that always returns `KeychainUnavailable`; the
# real backend lands when we wire Android Keystore via JNI.
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring-core = { workspace = true }
[dev-dependencies]
solitaire_server = { path = "../solitaire_server" }
solitaire_sync = { workspace = true }
+1 -1
View File
@@ -15,7 +15,7 @@ const FILE_NAME: &str = "achievements.json";
/// Platform-specific default path for `achievements.json`.
pub fn achievements_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
}
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
+51
View File
@@ -14,8 +14,19 @@
//! the Bevy `App`). If no default store is set, all operations in this module
//! will return [`TokenError::KeychainUnavailable`].
//!
//! # Android stub
//!
//! `keyring-core` cannot compile for the android target (its `rpassword`
//! transitive dep uses `libc::__errno_location`, which Android's bionic
//! doesn't expose). On Android every function in this module returns
//! [`TokenError::KeychainUnavailable`] so callers can detect the fallback
//! the same way they handle a Linux box without Secret Service. The
//! real Android backend will arrive in the Phase-Android round when we
//! wire Android Keystore via JNI.
//!
//! # Note: no unit tests — requires live OS keychain.
#[cfg(not(target_os = "android"))]
use keyring_core::Entry;
use thiserror::Error;
@@ -34,9 +45,11 @@ pub enum TokenError {
}
/// Service name used to namespace all keychain entries for this application.
#[cfg(not(target_os = "android"))]
const SERVICE: &str = "solitaire_quest_server";
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
#[cfg(not(target_os = "android"))]
fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
let msg = err.to_string();
match err {
@@ -51,6 +64,7 @@ fn map_keyring_err(err: keyring_core::Error, username: &str) -> TokenError {
/// Store the access and refresh tokens for `username` in the OS keychain.
///
/// Any previously stored tokens for that username are overwritten.
#[cfg(not(target_os = "android"))]
pub fn store_tokens(
username: &str,
access_token: &str,
@@ -72,6 +86,7 @@ pub fn store_tokens(
/// Load the stored access token for `username` from the OS keychain.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
#[cfg(not(target_os = "android"))]
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
@@ -82,6 +97,7 @@ pub fn load_access_token(username: &str) -> Result<String, TokenError> {
/// Load the stored refresh token for `username` from the OS keychain.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
#[cfg(not(target_os = "android"))]
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
Entry::new(SERVICE, &format!("{username}_refresh"))
.map_err(|e| map_keyring_err(e, username))?
@@ -93,6 +109,7 @@ pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
///
/// Intended to be called on logout or account deletion. Missing entries are
/// silently ignored (the tokens are already gone, which is the desired state).
#[cfg(not(target_os = "android"))]
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
match Entry::new(SERVICE, &format!("{username}_access"))
.map_err(|e| map_keyring_err(e, username))?
@@ -112,3 +129,37 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
Ok(())
}
// -------------------------------------------------------------------
// Android stub — same public API, always returns KeychainUnavailable.
// Lets `sync_client::*` compile unchanged on Android; the runtime
// effect is "session login required every launch", same as a Linux
// box without Secret Service.
// -------------------------------------------------------------------
#[cfg(target_os = "android")]
const ANDROID_STUB_MSG: &str = "android stub: keychain not yet wired (Phase-Android task)";
#[cfg(target_os = "android")]
pub fn store_tokens(
_username: &str,
_access_token: &str,
_refresh_token: &str,
) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}
#[cfg(target_os = "android")]
pub fn load_access_token(_username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}
#[cfg(target_os = "android")]
pub fn load_refresh_token(_username: &str) -> Result<String, TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}
#[cfg(target_os = "android")]
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
Err(TokenError::KeychainUnavailable(ANDROID_STUB_MSG.to_string()))
}
-5
View File
@@ -90,9 +90,4 @@ mod tests {
seeds.dedup();
assert_eq!(seeds.len(), len_before);
}
#[test]
fn challenge_count_matches_seed_list_length() {
assert_eq!(challenge_count() as usize, CHALLENGE_SEEDS.len());
}
}
+20 -13
View File
@@ -56,13 +56,13 @@ pub trait SyncProvider: Send + Sync {
async fn delete_account(&self) -> Result<(), SyncError> {
Ok(())
}
/// Upload a winning replay to the backend so it's available for web
/// playback at `<server>/replays/<id>`. Default returns
/// `UnsupportedPlatform` so backends without a server (e.g.
/// `LocalOnlyProvider`) are silently no-op'd by the engine's
/// push-on-win system, matching the same pattern `pull` / `push`
/// follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<(), SyncError> {
/// Upload a winning replay to the backend. On success, returns the
/// shareable web URL the player can copy to their clipboard
/// (`<server>/replays/<id>`). Default returns `UnsupportedPlatform`
/// so backends without a server (e.g. `LocalOnlyProvider`) are
/// silently no-op'd by the engine's push-on-win system, matching
/// the same pattern `pull` / `push` follow.
async fn push_replay(&self, _replay: &crate::replay::Replay) -> Result<String, SyncError> {
Err(SyncError::UnsupportedPlatform)
}
}
@@ -101,7 +101,7 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn delete_account(&self) -> Result<(), SyncError> {
(**self).delete_account().await
}
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<(), SyncError> {
async fn push_replay(&self, replay: &crate::replay::Replay) -> Result<String, SyncError> {
(**self).push_replay(replay).await
}
}
@@ -141,9 +141,10 @@ pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub mod settings;
pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme, WindowGeometry, TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN,
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS,
TOOLTIP_DELAY_STEP_SECS,
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
};
pub mod auth_tokens;
@@ -155,7 +156,13 @@ pub mod sync_client;
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
pub mod replay;
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::{
latest_replay_path, load_latest_replay_from, save_latest_replay_to, Replay, ReplayMove,
REPLAY_SCHEMA_VERSION,
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
};
pub mod platform;
pub use platform::data_dir;
+92
View File
@@ -0,0 +1,92 @@
//! Per-platform resolution of the per-user data directory.
//!
//! The rest of `solitaire_data` (settings, stats, achievements,
//! replays, progress, game state) and the engine's user-themes
//! discovery all need a base path under which to nest
//! `solitaire_quest/<file>`. On desktop the right answer is
//! `dirs::data_dir()` (which resolves to platform-appropriate
//! locations: `~/.local/share` on Linux, `~/Library/Application
//! Support` on macOS, `%APPDATA%` on Windows). On Android the
//! `dirs` crate returns `None`, which would silently disable
//! every persistence path — settings, stats, replays, the lot.
//!
//! [`data_dir`] is a thin shim that returns the right base path
//! per target. Callers continue to append
//! `solitaire_quest/<file>` themselves, so the on-disk layout is
//! identical across platforms (the per-app Android sandbox makes
//! the extra `solitaire_quest/` segment harmless, and a `tar`
//! export from one platform deserialises cleanly on another).
//!
//! # Why hardcode on Android?
//!
//! The "proper" Android answer is JNI: call back into Java to
//! invoke `Activity.getFilesDir()`. That requires plumbing an
//! `AndroidApp` context through Bevy's startup hooks and a
//! per-call JNI bridge — meaningfully more code than the
//! sandbox-guaranteed `/data/data/<package>/files` path. The
//! package name `com.solitairequest.app` is fixed at compile
//! time in `solitaire_app/Cargo.toml`'s
//! `[package.metadata.android]` block, so a hardcoded path is
//! safe until that ever changes (at which point this constant
//! moves with it).
use std::path::PathBuf;
/// Hardcoded per-app private files directory on Android.
///
/// Matches `[package.metadata.android]` in `solitaire_app/Cargo.toml`.
/// The Android sandbox guarantees this path exists, is writable,
/// and is private to the app — no JNI needed. Update both this
/// constant and the Cargo metadata together if the package id
/// ever changes.
#[cfg(target_os = "android")]
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.solitairequest.app/files";
/// Returns the per-user data directory for the current target,
/// or `None` if the platform doesn't expose one (rare; usually
/// indicates a broken `$HOME` or `$XDG_*` configuration on a
/// minimal Linux container).
///
/// Callers append `solitaire_quest/<file>` themselves. See the
/// module-level doc comment for the per-platform behaviour and
/// why Android uses a hardcoded path.
pub fn data_dir() -> Option<PathBuf> {
#[cfg(target_os = "android")]
{
Some(PathBuf::from(ANDROID_APP_FILES_DIR))
}
#[cfg(not(target_os = "android"))]
{
dirs::data_dir()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// On every supported desktop target the OS reports a usable
/// data directory. This test only runs on desktop because the
/// Android branch returns a fixed string regardless of host
/// state, and asserting on a fixed string is a tautology.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
#[test]
fn data_dir_returns_some_on_desktop_targets() {
let dir = data_dir().expect("desktop targets must report a data dir");
assert!(
dir.is_absolute(),
"data_dir() must return an absolute path on desktop, got {dir:?}",
);
}
/// On Android the hardcoded path matches the package id pinned
/// in `solitaire_app/Cargo.toml`'s `[package.metadata.android]`.
/// If a future change rotates that id, this test fails loudly
/// so the path constant moves with it.
#[cfg(target_os = "android")]
#[test]
fn data_dir_returns_sandbox_path_on_android() {
let dir = data_dir().expect("android must report a data dir");
assert_eq!(dir, PathBuf::from("/data/data/com.solitairequest.app/files"));
}
}
+1 -16
View File
@@ -46,7 +46,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
/// Platform-specific default path for `progress.json`.
pub fn progress_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
}
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
@@ -162,21 +162,6 @@ mod tests {
// --- Persistence ---
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let mut p = PlayerProgress::default();
p.add_xp(1234);
p.unlocked_card_backs.push(2);
save_progress_to(&path, &p).expect("save");
let loaded = load_progress_from(&path);
assert_eq!(loaded.total_xp, 1234);
assert_eq!(loaded.level, p.level);
assert!(loaded.unlocked_card_backs.contains(&2));
}
#[test]
fn load_from_missing_file_returns_default() {
let path = tmp_path("missing_xyz");
+445 -2
View File
@@ -31,6 +31,34 @@ use solitaire_core::pile::PileType;
const APP_DIR_NAME: &str = "solitaire_quest";
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
/// Maximum number of recent winning replays the rolling history retains.
///
/// When [`append_replay_to_history`] pushes a fresh entry past this cap,
/// the oldest entry is dropped so the file never grows unbounded. The
/// player can revisit any of the last [`REPLAY_HISTORY_CAP`] wins from
/// the Stats overlay's replay selector — older wins age out silently.
pub const REPLAY_HISTORY_CAP: usize = 8;
/// Save-file schema version for [`ReplayHistory`]. Bump when the on-disk
/// shape of the wrapper changes incompatibly so [`load_replay_history_from`]
/// returns `None` for older files (the player simply sees an empty
/// history rather than a half-loaded broken one). Bumping
/// [`REPLAY_SCHEMA_VERSION`] independently invalidates individual
/// [`Replay`] payloads inside an otherwise-current history.
///
/// History:
/// - v1 (current): initial release of the rolling history wrapper.
pub const REPLAY_HISTORY_SCHEMA_VERSION: u32 = 1;
/// Default value for [`ReplayHistory::schema_version`] when deserialising
/// files that pre-date the field. Any value other than
/// [`REPLAY_HISTORY_SCHEMA_VERSION`] causes [`load_replay_history_from`]
/// to return `None`.
fn history_schema_v0() -> u32 {
0
}
/// Save-file schema version for [`Replay`]. Increment when the on-disk
/// representation changes incompatibly so [`load_latest_replay_from`] can
@@ -110,6 +138,15 @@ pub struct Replay {
/// Ordered move list. Each entry is what the player did, replayable
/// against a fresh `GameState` constructed from the seed.
pub moves: Vec<ReplayMove>,
/// Public share URL for this replay on the active sync backend, set
/// by `sync_plugin::poll_replay_upload_result` when the upload
/// task resolves. `None` when the player won on a local-only
/// backend, the upload failed, or the replay pre-dates v0.19.0
/// share-link persistence. `#[serde(default)]` keeps older
/// `replays.json` files loadable without bumping
/// [`REPLAY_SCHEMA_VERSION`].
#[serde(default)]
pub share_url: Option<String>,
}
impl Replay {
@@ -134,14 +171,80 @@ impl Replay {
final_score,
recorded_at,
moves,
share_url: None,
}
}
}
/// Rolling history of the player's most recent winning replays.
///
/// Stored as a single JSON file at
/// `<data_dir>/solitaire_quest/replays.json` (see
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
/// when [`append_replay_to_history`] pushes past the cap, the oldest
/// entry is dropped so the file never grows unbounded.
///
/// `replays[0]` is always the most recent win; the Stats overlay's
/// replay selector defaults to that entry and surfaces the older
/// entries behind a small chooser so the player can revisit a memorable
/// game even after a more recent win.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplayHistory {
/// Schema version. See [`REPLAY_HISTORY_SCHEMA_VERSION`].
#[serde(default = "history_schema_v0")]
pub schema_version: u32,
/// Most recent first. Capped at [`REPLAY_HISTORY_CAP`] entries —
/// older entries drop off when the cap is hit.
pub replays: Vec<Replay>,
}
impl Default for ReplayHistory {
/// An empty history at the current schema version. Used by callers
/// that need a starting point before the first winning replay has
/// ever been recorded.
fn default() -> Self {
Self {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: Vec::new(),
}
}
}
impl ReplayHistory {
/// Returns the most recent replay (`replays[0]`), or `None` when the
/// history is empty. Convenience used by the Stats overlay's default
/// selector position.
pub fn most_recent(&self) -> Option<&Replay> {
self.replays.first()
}
/// Returns the number of replays currently retained.
pub fn len(&self) -> usize {
self.replays.len()
}
/// Returns `true` when no replays have been recorded yet.
pub fn is_empty(&self) -> bool {
self.replays.is_empty()
}
}
/// Returns the platform-specific path to `latest_replay.json`, or `None`
/// if `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
/// if `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
#[deprecated(
note = "single-slot replay storage replaced by the rolling history at \
replay_history_path(); kept for the one-shot legacy migration \
in migrate_legacy_latest_replay"
)]
pub fn latest_replay_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
}
/// Returns the platform-specific path to `replays.json`, the rolling
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
/// minimal Linux containers).
pub fn replay_history_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
}
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
@@ -149,6 +252,11 @@ pub fn latest_replay_path() -> Option<PathBuf> {
///
/// Overwrites any existing replay — only the most recent winning replay
/// is retained on disk.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use append_replay_to_history instead. Kept for the one-shot \
legacy migration."
)]
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
@@ -168,6 +276,11 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
/// "No replay recorded yet" caption rather than a half-loaded broken
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
/// older save without further migration code.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
use load_replay_history_from instead. Kept for the one-shot \
legacy migration."
)]
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
let data = fs::read(path).ok()?;
let replay: Replay = serde_json::from_slice(&data).ok()?;
@@ -177,7 +290,124 @@ pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
Some(replay)
}
/// Save a [`ReplayHistory`] atomically to `path` using the standard
/// `.tmp` → rename contract.
///
/// The on-disk encoding is pretty-printed JSON; the file is intended to
/// be small (≤ [`REPLAY_HISTORY_CAP`] entries, each carrying a few
/// hundred move records at most) so the readability tradeoff is fine.
pub fn save_replay_history_to(path: &Path, history: &ReplayHistory) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(history).map_err(io::Error::other)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Load a [`ReplayHistory`] from `path`, returning `None` when the file
/// is missing, corrupt, or carries a [`schema_version`](ReplayHistory::schema_version)
/// other than [`REPLAY_HISTORY_SCHEMA_VERSION`].
///
/// Individual [`Replay`] entries inside an otherwise-current history are
/// filtered to only those carrying [`REPLAY_SCHEMA_VERSION`] — older
/// entries are silently dropped so a future bump of the inner replay
/// schema does not corrupt the wrapper.
pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
let data = fs::read(path).ok()?;
let history: ReplayHistory = serde_json::from_slice(&data).ok()?;
if history.schema_version != REPLAY_HISTORY_SCHEMA_VERSION {
return None;
}
let filtered: Vec<Replay> = history
.replays
.into_iter()
.filter(|r| r.schema_version == REPLAY_SCHEMA_VERSION)
.collect();
Some(ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: filtered,
})
}
/// Append `replay` to the front of the rolling history at `path`,
/// dropping the oldest entry once [`REPLAY_HISTORY_CAP`] is exceeded,
/// and persist the updated history atomically.
///
/// If `path` has no existing history (missing file, corrupt, or
/// schema-mismatched) a fresh [`ReplayHistory::default`] is used as the
/// starting point so the new replay is always saved. The returned
/// [`ReplayHistory`] is the exact value written to disk so callers can
/// update an in-memory mirror (e.g. the Stats overlay's
/// `ReplayHistoryResource`) without a follow-up `load`.
pub fn append_replay_to_history(
path: &Path,
replay: Replay,
) -> io::Result<ReplayHistory> {
let mut history = load_replay_history_from(path).unwrap_or_default();
// Most recent first. Reserve the front slot; pop the oldest if we
// exceed the cap so the file never grows unbounded.
history.replays.insert(0, replay);
if history.replays.len() > REPLAY_HISTORY_CAP {
history.replays.truncate(REPLAY_HISTORY_CAP);
}
save_replay_history_to(path, &history)?;
Ok(history)
}
/// One-shot migration from the legacy single-slot
/// `latest_replay.json` file to the rolling [`ReplayHistory`] stored at
/// `history_path`.
///
/// Behaviour matrix:
/// - `history_path` already exists → no-op (the rolling history wins).
/// - `history_path` is absent and `latest_path` is absent → no-op.
/// - `history_path` is absent and `latest_path` exists with a valid
/// replay → seed a fresh history with that one replay and write it.
/// - `history_path` is absent and `latest_path` exists but is corrupt /
/// schema-mismatched → write an empty history (we know the player is
/// on the new build and shouldn't keep being prompted to migrate).
///
/// The legacy `latest_replay.json` file is intentionally NOT deleted by
/// this helper — keep it for one release as a safety net so a player
/// rolling back to the previous build doesn't lose their last winning
/// replay. The deletion is planned for the release after this one.
pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
if history_path.exists() {
// Rolling history is authoritative once it exists.
return;
}
if !latest_path.exists() {
return;
}
// Use the deprecated loader directly — the migration is the one
// place we still consult the legacy file shape on purpose.
#[allow(deprecated)]
let legacy = load_latest_replay_from(latest_path);
let history = match legacy {
Some(replay) => ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay],
},
None => ReplayHistory::default(),
};
if let Err(e) = save_replay_history_to(history_path, &history) {
// Migration failure is non-fatal: on the next launch we'll just
// try again. We log to stderr rather than panic so headless
// tests stay quiet.
eprintln!(
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
);
}
}
#[cfg(test)]
// The legacy single-slot tests still exercise `save_latest_replay_to` /
// `load_latest_replay_from` on purpose — they're the round-trip
// guardrails for the migration source format.
#[allow(deprecated)]
mod tests {
use super::*;
use std::env;
@@ -261,6 +491,34 @@ mod tests {
let _ = fs::remove_file(&path);
}
/// Backwards-compat: a `Replay` record persisted before v0.19.0
/// share-link persistence carries no `share_url` field on disk.
/// `#[serde(default)]` must let it deserialise cleanly with
/// `share_url == None`, so existing players don't see their
/// rolling history wiped on the v0.19.0 update.
#[test]
fn replay_loads_when_share_url_field_is_absent() {
let pre_v019_json = format!(
r#"{{
"schema_version": {schema},
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2025-01-01",
"moves": []
}}"#,
schema = REPLAY_SCHEMA_VERSION,
);
let parsed: Replay = serde_json::from_str(&pre_v019_json)
.expect("pre-v0.19.0 replay JSON must still deserialise");
assert!(
parsed.share_url.is_none(),
"missing share_url field must default to None",
);
}
/// Atomic-write contract — `.tmp` must not be left behind after
/// `save_latest_replay_to` returns. Mirrors the same check that
/// guards `save_game_state_to` in `storage.rs`.
@@ -294,4 +552,189 @@ mod tests {
assert!(load_latest_replay_from(&path).is_none());
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// ReplayHistory — rolling list of recent wins
// -----------------------------------------------------------------------
/// Build a [`Replay`] whose `final_score` carries `id` so tests can
/// assert ordering / identity without writing a deep equality match.
fn replay_with_id(id: i32) -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
id as u64,
DrawMode::DrawOne,
GameMode::Classic,
60,
id,
date,
vec![ReplayMove::StockClick],
)
}
/// Pushing past [`REPLAY_HISTORY_CAP`] must drop the oldest entries —
/// the on-disk file (and the in-memory mirror returned by the helper)
/// stays bounded so the user's data dir never grows unbounded.
#[test]
fn append_replay_to_history_caps_at_eight() {
let path = tmp_path("history_cap");
let _ = fs::remove_file(&path);
let mut last_returned = ReplayHistory::default();
for i in 0..10 {
last_returned = append_replay_to_history(&path, replay_with_id(i))
.expect("append must succeed");
}
assert_eq!(
last_returned.replays.len(),
REPLAY_HISTORY_CAP,
"history must be capped at REPLAY_HISTORY_CAP entries",
);
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
// survive (newest first), ids 0 and 1 aged out.
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![9, 8, 7, 6, 5, 4, 3, 2],
"newest entries must survive, oldest must age out",
);
// The on-disk file must agree with the returned in-memory copy.
let loaded = load_replay_history_from(&path).expect("load must succeed");
assert_eq!(loaded, last_returned, "disk must mirror returned history");
let _ = fs::remove_file(&path);
}
/// `append_replay_to_history` must place new entries at index 0 so
/// the Stats overlay's default selector (most recent) lands on the
/// just-saved replay.
#[test]
fn append_replay_inserts_at_front() {
let path = tmp_path("history_front");
let _ = fs::remove_file(&path);
append_replay_to_history(&path, replay_with_id(1)).expect("append 1");
append_replay_to_history(&path, replay_with_id(2)).expect("append 2");
let history = append_replay_to_history(&path, replay_with_id(3)).expect("append 3");
let ids: Vec<i32> = history.replays.iter().map(|r| r.final_score).collect();
assert_eq!(
ids,
vec![3, 2, 1],
"history must be reverse-chronological (newest first)",
);
let _ = fs::remove_file(&path);
}
/// On first launch with the new code, a pre-existing
/// `latest_replay.json` must seed the new rolling history so the
/// player doesn't lose their last winning replay across the upgrade.
#[test]
fn legacy_latest_replay_migrates_to_history_on_first_launch() {
let latest = tmp_path("legacy_migrate_latest");
let history = tmp_path("legacy_migrate_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
// Seed the legacy file with a real replay.
let legacy_replay = sample_replay();
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
assert!(!history.exists(), "history file must not exist pre-migration");
migrate_legacy_latest_replay(&latest, &history);
assert!(history.exists(), "migration must create the history file");
let loaded = load_replay_history_from(&history)
.expect("post-migration history must load");
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
// Legacy file is intentionally retained for one release as a
// safety net — see `migrate_legacy_latest_replay` doc comment.
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// When the rolling history file already exists, the migration must
/// be a no-op — we never want to overwrite the player's accumulated
/// history with a stale single-slot legacy entry.
#[test]
fn migrate_is_noop_when_history_already_exists() {
let latest = tmp_path("legacy_noop_latest");
let history = tmp_path("legacy_noop_history");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
save_latest_replay_to(&latest, &sample_replay()).expect("seed legacy");
let pre_existing = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(42)],
};
save_replay_history_to(&history, &pre_existing).expect("seed history");
migrate_legacy_latest_replay(&latest, &history);
let loaded = load_replay_history_from(&history).expect("load");
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
}
/// A populated [`ReplayHistory`] must round-trip byte-identically
/// through `save_replay_history_to` / `load_replay_history_from`.
#[test]
fn replay_history_round_trips_through_save_and_load() {
let path = tmp_path("history_round_trip");
let _ = fs::remove_file(&path);
let history = ReplayHistory {
schema_version: REPLAY_HISTORY_SCHEMA_VERSION,
replays: vec![replay_with_id(7), replay_with_id(3), sample_replay()],
};
save_replay_history_to(&path, &history).expect("save");
let loaded = load_replay_history_from(&path).expect("load");
assert_eq!(loaded, history, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older history schema must be rejected so the
/// player sees a clean empty history rather than a half-loaded one.
#[test]
fn replay_history_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("history_legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key → defaults to 0 via `history_schema_v0()`.
let v0_json = r#"{
"replays": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_replay_history_from(&path).is_none(),
"v0 history must be rejected (schema gate)",
);
let _ = fs::remove_file(&path);
}
/// Atomic-write contract for the rolling history — `.tmp` must not be
/// left behind after `save_replay_history_to` returns.
#[test]
fn replay_history_save_is_atomic() {
let path = tmp_path("history_atomic");
let _ = fs::remove_file(&path);
save_replay_history_to(&path, &ReplayHistory::default()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
}
+132 -415
View File
@@ -166,6 +166,46 @@ pub struct Settings {
/// `#[serde(default = "default_time_bonus_multiplier")]`.
#[serde(default = "default_time_bonus_multiplier")]
pub time_bonus_multiplier: f32,
/// When `true`, the engine rejects new-game deals the
/// [`solitaire_core::solver`] cannot prove winnable, retrying
/// fresh seeds up to [`SOLVER_DEAL_RETRY_CAP`] attempts before
/// giving up and using the last tried seed. Off by default —
/// the solver adds a few hundred milliseconds of latency on the
/// pathological deals that hit the budget cap, and not every
/// player wants to wait. Older `settings.json` files written
/// before this field existed deserialize cleanly to `false` via
/// `#[serde(default)]`.
///
/// Scope: only random-seed Classic-mode deals are filtered.
/// Daily challenges, replays, and explicit-seed requests skip the
/// solver retry loop — see `solitaire_engine::handle_new_game`.
#[serde(default)]
pub winnable_deals_only: bool,
/// When `true`, suppresses the launch-time
/// `apply_smart_default_window_size` system: the window opens at
/// the literal `(1280, 800)` default instead of resizing to ~70 %
/// of the primary monitor's logical size on the first frame. For
/// players who specifically prefer the 1280×800 baseline on every
/// fresh launch (i.e. installs without saved geometry).
///
/// Older `settings.json` files written before this field existed
/// deserialize cleanly to `false` via `#[serde(default)]`, which
/// preserves the smart-default behaviour shipped in v0.19.0.
/// Saved-geometry launches are unaffected by this flag — the
/// player's last window size always wins.
#[serde(default)]
pub disable_smart_default_size: bool,
/// Per-move duration during replay playback, in seconds. Range
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`;
/// default mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
/// (0.45 s/move) so existing playback behaviour is unchanged for
/// players who never touch the slider. Smaller values scrub
/// faster through the recorded move list. Older `settings.json`
/// files written before this field existed deserialize cleanly to
/// the default via
/// `#[serde(default = "default_replay_move_interval_secs")]`.
#[serde(default = "default_replay_move_interval_secs")]
pub replay_move_interval_secs: f32,
}
fn default_draw_mode() -> DrawMode {
@@ -223,6 +263,44 @@ fn default_time_bonus_multiplier() -> f32 {
1.0
}
/// Default per-move duration during replay playback, in seconds.
/// Mirrors `solitaire_engine::replay_playback::REPLAY_MOVE_INTERVAL_SECS`
/// so legacy `settings.json` files load to the existing baseline and
/// playback feels identical for players who never touch the slider.
/// The constant is duplicated across the data and engine crates
/// because `solitaire_data` cannot depend on the engine crate — keep
/// the two values in sync when adjusting either.
fn default_replay_move_interval_secs() -> f32 {
0.45
}
/// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible.
pub const REPLAY_MOVE_INTERVAL_MIN_SECS: f32 = 0.10;
/// Upper bound of the player-tunable replay-playback per-move interval,
/// in seconds. One second per move is a comfortable upper limit for
/// players who want to study a recorded game frame by frame.
pub const REPLAY_MOVE_INTERVAL_MAX_SECS: f32 = 1.00;
/// Increment applied by the replay-playback decrement / increment
/// buttons. 0.05 s gives 19 stops between MIN and MAX — fine-grained
/// enough to land on any "round" speed (0.10 s, 0.25 s, 0.45 s, etc.)
/// without making the slider feel stuck on the same value.
pub const REPLAY_MOVE_INTERVAL_STEP_SECS: f32 = 0.05;
/// Maximum number of seed retries [`solitaire_engine::handle_new_game`]
/// is willing to attempt before giving up and accepting the latest
/// candidate seed when [`Settings::winnable_deals_only`] is on. If
/// every retry comes back [`SolverResult::Unwinnable`] (which would
/// be very unusual) we'd rather hand the player a possibly-unwinnable
/// deal than spin forever on the main thread.
///
/// 50 attempts × ~50 ms median per solve = ~2.5 s worst-case stall —
/// the upper bound on UI freeze when the toggle is on.
pub const SOLVER_DEAL_RETRY_CAP: u32 = 50;
impl Default for Settings {
fn default() -> Self {
Self {
@@ -241,14 +319,18 @@ impl Default for Settings {
shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(),
winnable_deals_only: false,
disable_smart_default_size: false,
replay_move_interval_secs: default_replay_move_interval_secs(),
}
}
}
impl Settings {
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`, and
/// `time_bonus_multiplier` into their respective ranges after
/// deserialization or hand-editing of `settings.json`.
/// Clamps `sfx_volume`, `music_volume`, `tooltip_delay_secs`,
/// `time_bonus_multiplier`, and `replay_move_interval_secs` into
/// their respective ranges after deserialization or hand-editing of
/// `settings.json`.
pub fn sanitized(self) -> Self {
Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
@@ -259,6 +341,9 @@ impl Settings {
time_bonus_multiplier: self
.time_bonus_multiplier
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX),
replay_move_interval_secs: self
.replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
..self
}
}
@@ -297,12 +382,27 @@ impl Settings {
self.time_bonus_multiplier = (raw * 10.0).round() / 10.0;
self.time_bonus_multiplier
}
/// Adjust the replay-playback per-move interval by `delta`
/// seconds, clamped to
/// `[REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS]`.
/// The result is rounded to two decimal places so the readout
/// stays clean across repeated `±` clicks at the 0.05 s step
/// (avoids float drift like `0.45000003`). Returns the new value.
pub fn adjust_replay_move_interval(&mut self, delta: f32) -> f32 {
let raw = (self.replay_move_interval_secs + delta)
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS);
// Round to 2 decimal places — the slider step is 0.05, so this
// collapses any FP drift introduced by repeated additions.
self.replay_move_interval_secs = (raw * 100.0).round() / 100.0;
self.replay_move_interval_secs
}
}
/// Returns the platform-specific path to `settings.json`, or `None` if
/// `dirs::data_dir()` is unavailable.
/// the platform's data directory is unavailable.
pub fn settings_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
}
/// Load settings from an explicit path. Returns `Settings::default()` if the
@@ -337,19 +437,6 @@ mod tests {
env::temp_dir().join(format!("solitaire_settings_test_{name}.json"))
}
#[test]
fn defaults_are_reasonable() {
let s = Settings::default();
assert!((s.sfx_volume - 0.8).abs() < 1e-6);
assert!((s.music_volume - 0.5).abs() < 1e-6);
assert!(!s.first_run_complete);
assert_eq!(s.draw_mode, DrawMode::DrawOne);
assert_eq!(s.animation_speed, AnimSpeed::Normal);
assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local);
assert!((s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6);
}
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
@@ -382,75 +469,6 @@ mod tests {
assert!(s.first_run_complete);
}
#[test]
fn sanitized_clamps_music_volume() {
let s = Settings { music_volume: 2.0, ..Default::default() }.sanitized();
assert_eq!(s.music_volume, 1.0);
let s2 = Settings { music_volume: -0.5, ..Default::default() }.sanitized();
assert_eq!(s2.music_volume, 0.0);
}
#[test]
fn round_trip_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
sfx_volume: 0.42,
first_run_complete: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded, s);
}
#[test]
fn round_trip_save_and_load_full_settings() {
let path = tmp_path("round_trip_full");
let _ = fs::remove_file(&path);
let s = Settings {
draw_mode: DrawMode::DrawThree,
sfx_volume: 0.3,
music_volume: 0.7,
animation_speed: AnimSpeed::Fast,
theme: Theme::Dark,
sync_backend: SyncBackend::SolitaireServer {
url: "https://example.com".to_string(),
username: "testuser".to_string(),
},
selected_card_back: 0,
selected_background: 0,
first_run_complete: true,
color_blind_mode: false,
window_geometry: None,
selected_theme_id: "default".to_string(),
shown_achievement_onboarding: false,
tooltip_delay_secs: default_tooltip_delay(),
time_bonus_multiplier: default_time_bonus_multiplier(),
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded, s);
}
#[test]
fn round_trip_preserves_non_default_cosmetic_selections() {
// selected_card_back and selected_background must survive save→load with
// non-zero values — zero is the default and not a meaningful regression check.
let path = tmp_path("cosmetic_selections");
let _ = fs::remove_file(&path);
let s = Settings {
selected_card_back: 3,
selected_background: 2,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded.selected_card_back, 3);
assert_eq!(loaded.selected_background, 2);
}
#[test]
fn load_from_missing_file_returns_default() {
let path = tmp_path("missing_xyz");
@@ -467,250 +485,6 @@ mod tests {
assert_eq!(s, Settings::default());
}
#[test]
fn load_from_old_format_uses_defaults_for_new_fields() {
// Simulate a settings.json written by an older version that only had
// sfx_volume and first_run_complete.
let path = tmp_path("old_format");
fs::write(
&path,
br#"{ "sfx_volume": 0.6, "first_run_complete": true }"#,
)
.expect("write");
let s = load_settings_from(&path);
assert!((s.sfx_volume - 0.6).abs() < 1e-6);
assert!(s.first_run_complete);
// New fields should fall back to their defaults.
assert!((s.music_volume - 0.5).abs() < 1e-6);
assert_eq!(s.animation_speed, AnimSpeed::Normal);
assert_eq!(s.theme, Theme::Green);
assert_eq!(s.sync_backend, SyncBackend::Local);
assert_eq!(s.draw_mode, DrawMode::DrawOne);
assert_eq!(s.selected_card_back, 0, "cosmetic card-back must default to 0 on old format");
assert_eq!(s.selected_background, 0, "cosmetic background must default to 0 on old format");
assert!(!s.color_blind_mode, "color_blind_mode must default to false on old format");
}
#[test]
fn color_blind_mode_defaults_to_false_when_field_absent() {
// Simulate a JSON file that has no color_blind_mode field.
let json = br#"{ "sfx_volume": 0.7 }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(!s.color_blind_mode, "color_blind_mode must be false when absent from JSON");
}
#[test]
fn color_blind_mode_round_trips() {
let path = tmp_path("color_blind");
let _ = std::fs::remove_file(&path);
let s = Settings {
color_blind_mode: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(loaded.color_blind_mode, "color_blind_mode must survive a save/load round-trip");
let _ = std::fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// Task #62 — selected_card_back
// -----------------------------------------------------------------------
#[test]
fn settings_card_back_default_is_zero() {
assert_eq!(Settings::default().selected_card_back, 0);
}
#[test]
fn settings_card_back_serializes_round_trip() {
let path = tmp_path("card_back_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
selected_card_back: 2,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded.selected_card_back, 2, "selected_card_back must survive serde round-trip");
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// Task #63 — selected_background
// -----------------------------------------------------------------------
#[test]
fn settings_background_default_is_zero() {
assert_eq!(Settings::default().selected_background, 0);
}
#[test]
fn settings_background_serializes_round_trip() {
let path = tmp_path("background_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
selected_background: 3,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(loaded.selected_background, 3, "selected_background must survive serde round-trip");
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// window_geometry — persisted window size/position
// -----------------------------------------------------------------------
#[test]
fn settings_window_geometry_default_is_none() {
assert!(
Settings::default().window_geometry.is_none(),
"default window_geometry must be None so first launch uses platform defaults"
);
}
#[test]
fn settings_with_window_geometry_round_trip() {
let path = tmp_path("window_geometry_round_trip");
let _ = fs::remove_file(&path);
let geom = WindowGeometry {
width: 1440,
height: 900,
x: 120,
y: 80,
};
let s = Settings {
window_geometry: Some(geom),
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert_eq!(
loaded.window_geometry,
Some(geom),
"window_geometry must survive serde round-trip"
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_window_geometry_deserializes_to_none() {
// A settings.json written by an older version of the game will be
// missing this field entirely. `#[serde(default)]` on the field
// must yield `None` rather than failing the whole deserialise.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
s.window_geometry.is_none(),
"legacy settings.json missing window_geometry must deserialize to None"
);
}
#[test]
fn window_geometry_explicit_null_deserializes_to_none() {
// An explicit `"window_geometry": null` is also valid input that
// must yield None — keeps tooling that hand-edits the file safe.
let json = br#"{ "window_geometry": null }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(s.window_geometry.is_none());
}
// -----------------------------------------------------------------------
// shown_achievement_onboarding — first-win cue one-shot guard
// -----------------------------------------------------------------------
#[test]
fn settings_shown_achievement_onboarding_default_is_false() {
assert!(
!Settings::default().shown_achievement_onboarding,
"default shown_achievement_onboarding must be false so the cue fires once"
);
}
#[test]
fn settings_shown_achievement_onboarding_round_trip() {
let path = tmp_path("achievement_onboarding_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
shown_achievement_onboarding: true,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
loaded.shown_achievement_onboarding,
"shown_achievement_onboarding must survive serde round-trip"
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_shown_achievement_onboarding_deserializes_to_false() {
// A settings.json written by an older version of the game will be
// missing this field entirely. `#[serde(default)]` on the field
// must yield `false` — the cue then fires on the next win, but
// only when stats.games_won == 1, so existing players who have
// already won past their first game won't see the toast either.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
!s.shown_achievement_onboarding,
"legacy settings.json missing shown_achievement_onboarding must deserialize to false"
);
}
// -----------------------------------------------------------------------
// tooltip_delay_secs — player-tunable tooltip hover delay
// -----------------------------------------------------------------------
#[test]
fn settings_tooltip_delay_default_is_existing_baseline() {
// The existing baseline pre-slider is 0.5 s, matching the
// `MOTION_TOOLTIP_DELAY_SECS` constant in
// `solitaire_engine::ui_theme`. The default must not regress.
let s = Settings::default();
assert!(
(s.tooltip_delay_secs - 0.5).abs() < 1e-6,
"tooltip_delay_secs default must be 0.5 (the pre-slider baseline), got {}",
s.tooltip_delay_secs
);
}
#[test]
fn settings_tooltip_delay_round_trip() {
let path = tmp_path("tooltip_delay_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
tooltip_delay_secs: 1.2,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
(loaded.tooltip_delay_secs - 1.2).abs() < 1e-6,
"tooltip_delay_secs must survive serde round-trip; got {}",
loaded.tooltip_delay_secs
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_tooltip_delay_deserializes_to_default() {
// A settings.json written before this field existed must
// deserialize cleanly to the existing 0.5 s baseline rather
// than failing the whole load or yielding a zero value.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
(s.tooltip_delay_secs - default_tooltip_delay()).abs() < 1e-6,
"legacy settings.json missing tooltip_delay_secs must deserialize to default ({}), got {}",
default_tooltip_delay(),
s.tooltip_delay_secs
);
}
#[test]
fn adjust_tooltip_delay_clamps_to_range() {
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
@@ -724,90 +498,6 @@ mod tests {
assert_eq!(s.tooltip_delay_secs, 0.0);
}
#[test]
fn sanitized_clamps_out_of_range_tooltip_delay() {
// Negative or oversized values from a hand-edited file must be
// clamped on load.
let s = Settings {
tooltip_delay_secs: -0.4,
..Settings::default()
}
.sanitized();
assert_eq!(s.tooltip_delay_secs, TOOLTIP_DELAY_MIN_SECS);
let s2 = Settings {
tooltip_delay_secs: 99.0,
..Settings::default()
}
.sanitized();
assert_eq!(s2.tooltip_delay_secs, TOOLTIP_DELAY_MAX_SECS);
}
// -----------------------------------------------------------------------
// time_bonus_multiplier — cosmetic win-modal time-bonus weight
// -----------------------------------------------------------------------
#[test]
fn settings_time_bonus_multiplier_default_is_one() {
let s = Settings::default();
assert!(
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
"default time_bonus_multiplier must be 1.0 (no change to displayed bonus), got {}",
s.time_bonus_multiplier
);
}
#[test]
fn settings_time_bonus_multiplier_round_trip() {
let path = tmp_path("time_bonus_multiplier_round_trip");
let _ = fs::remove_file(&path);
let s = Settings {
time_bonus_multiplier: 1.5,
..Settings::default()
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
assert!(
(loaded.time_bonus_multiplier - 1.5).abs() < 1e-6,
"time_bonus_multiplier must survive serde round-trip; got {}",
loaded.time_bonus_multiplier
);
let _ = fs::remove_file(&path);
}
#[test]
fn legacy_settings_without_time_bonus_multiplier_deserializes_to_one() {
// A settings.json written before this field existed must
// deserialize cleanly to the existing 1.0 baseline so old
// players see no change to their win-modal bonuses.
let json = br#"{ "sfx_volume": 0.7, "first_run_complete": true }"#;
let s: Settings = serde_json::from_slice(json).unwrap_or_default();
assert!(
(s.time_bonus_multiplier - 1.0).abs() < 1e-6,
"legacy settings.json missing time_bonus_multiplier must deserialize to 1.0, got {}",
s.time_bonus_multiplier
);
}
#[test]
fn settings_time_bonus_multiplier_clamps_to_range() {
// Negative or oversized values from a hand-edited file must be
// clamped on load.
let s = Settings {
time_bonus_multiplier: -0.5,
..Settings::default()
}
.sanitized();
assert_eq!(s.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MIN);
let s2 = Settings {
time_bonus_multiplier: 99.0,
..Settings::default()
}
.sanitized();
assert_eq!(s2.time_bonus_multiplier, TIME_BONUS_MULTIPLIER_MAX);
}
#[test]
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
@@ -835,4 +525,31 @@ mod tests {
s2.time_bonus_multiplier
);
}
#[test]
fn adjust_replay_move_interval_clamps_and_rounds() {
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
// Step down to 0.40.
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
// Big positive jump clamps to MAX.
assert!(
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
);
// Big negative jump clamps to MIN.
assert!(
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
);
// Repeated 0.05 steps must not drift past the 0.05 grid.
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
for _ in 0..6 {
s2.adjust_replay_move_interval(0.05);
}
// After six +0.05 steps from 0.10, value should be exactly 0.40 (2 decimals).
assert!(
(s2.replay_move_interval_secs - 0.40).abs() < 1e-6,
"rounding should pin repeated 0.05 steps to the decimal grid, got {}",
s2.replay_move_interval_secs
);
}
}
+7 -7
View File
@@ -19,9 +19,9 @@ const GAME_STATE_FILE_NAME: &str = "game_state.json";
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
/// Returns the platform-specific path to `stats.json`, or `None` if
/// `dirs::data_dir()` is unavailable (e.g. minimal Linux containers).
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
pub fn stats_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
}
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
@@ -69,9 +69,9 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
// ---------------------------------------------------------------------------
/// Returns the platform-specific path to `game_state.json`, or `None` if
/// `dirs::data_dir()` is unavailable.
/// `crate::data_dir()` is unavailable.
pub fn game_state_file_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
}
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
@@ -129,7 +129,7 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
/// in an atomic save. Safe to call on startup; missing or unreadable entries
/// are silently skipped.
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
let dir = match dirs::data_dir() {
let dir = match crate::data_dir() {
Some(d) => d.join(APP_DIR_NAME),
None => return Ok(()),
};
@@ -179,9 +179,9 @@ pub struct TimeAttackSession {
}
/// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `dirs::data_dir()` is unavailable.
/// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
}
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
+33 -18
View File
@@ -358,13 +358,12 @@ impl SyncProvider for SolitaireServerClient {
extract_leaderboard_body(resp).await
}
/// Upload a winning replay to `POST /api/replays`. Mirrors the
/// `push` auth flow: 401 triggers a token refresh and one retry.
/// Non-success statuses are surfaced as the relevant `SyncError`
/// variant so the engine's push-on-win system can downgrade
/// network/auth failures into a quiet log without aborting the
/// game flow.
async fn push_replay(&self, replay: &Replay) -> Result<(), SyncError> {
/// Upload a winning replay to `POST /api/replays`. On success the
/// server returns `{ "id": "<uuid>" }`; this method composes that
/// id with the configured base URL into the player-shareable
/// `<base>/replays/<id>` link and returns it. Mirrors the `push`
/// auth flow: 401 triggers a token refresh and one retry.
async fn push_replay(&self, replay: &Replay) -> Result<String, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/replays", self.base_url);
@@ -388,22 +387,38 @@ impl SyncProvider for SolitaireServerClient {
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return check_replay_status(resp.status());
return self.share_url_from_response(resp).await;
}
check_replay_status(resp.status())
self.share_url_from_response(resp).await
}
}
fn check_replay_status(status: reqwest::StatusCode) -> Result<(), SyncError> {
if status.is_success() {
Ok(())
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
Err(SyncError::Auth(format!("server returned {status}")))
} else {
Err(SyncError::Network(format!("server returned {status}")))
impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path.
async fn share_url_from_response(
&self,
resp: reqwest::Response,
) -> Result<String, SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let id = body["id"].as_str().ok_or_else(|| {
SyncError::Serialization("upload response missing `id`".into())
})?;
Ok(format!("{}/replays/{}", self.base_url, id))
}
}
+10
View File
@@ -22,6 +22,16 @@ ron = { workspace = true }
dirs = { workspace = true }
zip = { workspace = true }
# `arboard` provides clipboard access for the Stats overlay's
# "Copy share link" button. The crate has no Android backend
# (its `platform::Clipboard` module is unimplemented for the
# android target — `cargo apk build` fails with E0433 if this is
# left unconditional). On Android the same button surfaces an
# informational toast instead; see
# `stats_plugin::handle_copy_share_link_button`.
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
tempfile = { workspace = true }
+495 -69
View File
@@ -7,6 +7,7 @@
use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{
@@ -25,11 +26,13 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
@@ -47,6 +50,19 @@ pub struct AchievementsScreen;
#[derive(Component, Debug)]
pub struct AchievementRow;
/// Marker on the scrollable body Node inside the Achievements modal.
///
/// The Achievements list can grow to ~19 rows which overflows the modal at
/// the 800x600 minimum window. This marker tags the inner container that
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
/// content scrolls instead of clipping. Mirrors the
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
///
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
/// events into the body's `ScrollPosition`.
#[derive(Component, Debug)]
pub struct AchievementsScrollable;
/// All per-player achievement records (one per known achievement).
#[derive(Resource, Debug, Clone)]
pub struct AchievementsResource(pub Vec<AchievementRecord>);
@@ -95,6 +111,11 @@ impl Plugin for AchievementPlugin {
.add_message::<XpAwardedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee).
@@ -116,7 +137,13 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate),
)
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button);
.add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
// omit `ReplayPlaybackPlugin` still build.
.add_systems(Update, evaluate_cinephile_on_replay_completion);
}
}
@@ -222,6 +249,66 @@ fn evaluate_on_win(
}
}
/// Cinephile unlock observer.
///
/// Watches [`ReplayPlaybackState`] and unlocks the `cinephile` achievement
/// the first time the resource transitions from `Playing` to `Completed` —
/// i.e. the player watched a saved replay all the way through. The Stop
/// button transitions `Playing` → `Inactive` directly (never via
/// `Completed`), so manual aborts do not trigger the unlock.
///
/// Idempotent: once the record is unlocked, subsequent Playing → Completed
/// transitions are a no-op (no extra `AchievementUnlockedEvent`, no extra
/// disk write). The transition itself is debounced by tracking the
/// previous frame's `is_playing()` state in a `Local<bool>` — without
/// this, a freshly-spawned `Completed` state would re-fire each frame
/// during the linger window.
///
/// Reads `ReplayPlaybackState` via `Option<Res<_>>` so achievement tests
/// that omit `ReplayPlaybackPlugin` still build cleanly.
fn evaluate_cinephile_on_replay_completion(
state: Option<Res<ReplayPlaybackState>>,
// `Local` collides with `chrono::Local` imported at the top of this
// module — fully qualify so the Bevy system parameter resolves
// correctly.
mut last_was_playing: bevy::prelude::Local<bool>,
mut achievements: ResMut<AchievementsResource>,
mut unlocks: MessageWriter<AchievementUnlockedEvent>,
path: Res<AchievementsStoragePath>,
) {
let Some(state) = state else {
return;
};
// Detect the Playing → Completed transition: was playing last frame,
// is now completed. Direct Playing → Inactive (Stop button) does not
// satisfy this guard because it never enters `Completed`.
let now_playing = state.is_playing();
let now_completed = state.is_completed();
let just_completed = *last_was_playing && now_completed;
*last_was_playing = now_playing;
if !just_completed {
return;
}
let Some(record) = achievements.0.iter_mut().find(|r| r.id == "cinephile") else {
return;
};
if record.unlocked {
return;
}
record.unlock(Utc::now());
record.reward_granted = true;
unlocks.write(AchievementUnlockedEvent(record.clone()));
if let Some(target) = &path.0
&& let Err(e) = save_achievements_to(target, &achievements.0)
{
warn!("failed to save achievements after cinephile unlock: {e}");
}
}
/// Achievement-onboarding cue.
///
/// On the player's very first win — and only their first — fires a single
@@ -329,6 +416,38 @@ fn handle_achievements_close_button(
}
}
/// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open.
///
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
/// is in the world (modal closed) so this is a no-op outside the open
/// state without an explicit gate resource.
fn scroll_achievements_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
fn spawn_achievements_screen(
commands: &mut Commands,
records: &[AchievementRecord],
@@ -355,79 +474,119 @@ fn spawn_achievements_screen(
..default()
};
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
let any_unlocked = records.iter().any(|r| r.unlocked);
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, header, font_res);
// Achievement rows — unlocked first, then locked alphabetical.
let mut sorted: Vec<_> = records.iter().collect();
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
for record in &sorted {
let def = achievement_by_id(&record.id);
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
// Hide secret locked achievements so they remain a surprise.
let is_secret = def.is_some_and(|d| d.secret);
if is_secret && !record.unlocked {
continue;
}
let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
} else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
};
let tooltip_text = tooltip_for_row(record.unlocked, def);
// First-time hint — shown until the player has unlocked anything.
// The list itself describes individual rewards, but a top-level
// explanation gives newer players context for the otherwise dense
// greyed-out grid.
if !any_unlocked {
card.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
Text::new(
"Complete games and try new modes to unlock achievements and rewards.",
),
TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
},
AchievementRow,
Tooltip::new(tooltip_text),
))
.with_children(|row| {
row.spawn((
Text::new(format!("{prefix}{name}")),
font_name.clone(),
TextColor(name_color),
));
if !description.is_empty() {
row.spawn((
Text::new(format!(" {description}")),
font_desc.clone(),
TextColor(desc_color),
));
}
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
row.spawn((
Text::new(format!(" Reward: {reward_str}")),
font_meta.clone(),
TextColor(STATE_SUCCESS),
));
}
if let Some(date) = record.unlock_date {
row.spawn((
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
font_meta.clone(),
TextColor(TEXT_SECONDARY),
));
}
});
// Subtle row separator — keeps the long list scannable.
card.spawn((
Node {
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
TextColor(TEXT_SECONDARY),
));
}
// Scrollable body — the achievements list grows to ~19 rows which
// overflows the modal on the 800x600 minimum window. Wrapping the
// row list in an `Overflow::scroll_y()` Node with a constrained
// `max_height` keeps every row reachable. The Done button below
// sits outside the scroll so it's always one click away. Mirrors
// the `SettingsPanelScrollable` pattern.
card.spawn((
AchievementsScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// Achievement rows — unlocked first, then locked alphabetical.
let mut sorted: Vec<_> = records.iter().collect();
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
for record in &sorted {
let def = achievement_by_id(&record.id);
let (name, description) =
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
// Hide secret locked achievements so they remain a surprise.
let is_secret = def.is_some_and(|d| d.secret);
if is_secret && !record.unlocked {
continue;
}
let (name_color, desc_color, prefix) = if record.unlocked {
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
} else {
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
};
let tooltip_text = tooltip_for_row(record.unlocked, def);
body.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
..default()
},
AchievementRow,
Tooltip::new(tooltip_text),
))
.with_children(|row| {
row.spawn((
Text::new(format!("{prefix}{name}")),
font_name.clone(),
TextColor(name_color),
));
if !description.is_empty() {
row.spawn((
Text::new(format!(" {description}")),
font_desc.clone(),
TextColor(desc_color),
));
}
if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) {
row.spawn((
Text::new(format!(" Reward: {reward_str}")),
font_meta.clone(),
TextColor(STATE_SUCCESS),
));
}
if let Some(date) = record.unlock_date {
row.spawn((
Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))),
font_meta.clone(),
TextColor(TEXT_SECONDARY),
));
}
});
// Subtle row separator — keeps the long list scannable.
body.spawn((
Node {
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
@@ -439,6 +598,9 @@ fn spawn_achievements_screen(
);
});
});
// Achievements is a read-only list — clicking the scrim outside
// the card dismisses alongside the existing A / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
}
fn format_reward(reward: Reward) -> String {
@@ -829,6 +991,64 @@ mod tests {
assert_eq!(count, 0);
}
// -----------------------------------------------------------------------
// Scrollable body
// -----------------------------------------------------------------------
/// Spawning the modal must place exactly one `AchievementsScrollable`
/// marker in the world so the row list scrolls instead of clipping at
/// the 800x600 minimum window.
#[test]
fn achievements_modal_body_is_scrollable() {
let mut app = headless_app();
press(&mut app, KeyCode::KeyA);
app.update();
let count = app
.world_mut()
.query::<&AchievementsScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Achievements modal must spawn exactly one AchievementsScrollable body"
);
}
/// The scrollable body must constrain its `max_height` so the modal
/// actually engages scrolling on tall content. Without this the inner
/// flex column would expand to fit every row and `Overflow::scroll_y`
/// would have nothing to clip.
#[test]
fn achievements_modal_body_has_max_height() {
let mut app = headless_app();
press(&mut app, KeyCode::KeyA);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&Node, With<AchievementsScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_eq!(nodes.len(), 1, "expected exactly one scrollable body");
let node = nodes[0];
// `Val::Auto` is the default; assert the body's `max_height` was
// explicitly set to something else so scroll engages.
assert_ne!(
node.max_height,
Val::Auto,
"scrollable body must set a non-default max_height; got {:?}",
node.max_height
);
// And the overflow axis must be y-scroll.
assert_eq!(
node.overflow,
Overflow::scroll_y(),
"scrollable body must use Overflow::scroll_y(); got {:?}",
node.overflow
);
}
// -----------------------------------------------------------------------
// format_reward
// -----------------------------------------------------------------------
@@ -1149,9 +1369,215 @@ mod tests {
);
}
/// Without any `GameWonEvent` arriving the system must be a no-op:
/// no toast, no flag flip — even on update ticks where stats happen
/// to read `games_won == 1`.
// -----------------------------------------------------------------------
// Cinephile (event-driven via ReplayPlaybackState)
// -----------------------------------------------------------------------
use crate::replay_playback::ReplayPlaybackState;
use solitaire_data::{Replay, ReplayMove};
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
/// Headless app variant that injects a default `ReplayPlaybackState`
/// directly (no `ReplayPlaybackPlugin`) so we can drive the resource
/// by hand. The achievement plugin's cinephile observer reads it via
/// `Option<Res<_>>` so the absence of the playback plugin is safe.
fn cinephile_app() -> App {
let mut app = headless_app();
app.init_resource::<ReplayPlaybackState>();
app
}
fn dummy_replay() -> Replay {
Replay::new(
1,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
)
}
fn cinephile_unlocked(app: &App) -> bool {
app.world()
.resource::<AchievementsResource>()
.0
.iter()
.find(|r| r.id == "cinephile")
.map(|r| r.unlocked)
.unwrap_or(false)
}
fn cinephile_unlocks_emitted(app: &App) -> usize {
let events = app.world().resource::<Messages<AchievementUnlockedEvent>>();
let mut cursor = events.get_cursor();
cursor
.read(events)
.filter(|e| e.0.id == "cinephile")
.count()
}
/// The cinephile record must be seeded on plugin init like every other
/// achievement, so the observer can find and mutate it later.
#[test]
fn cinephile_record_seeded_by_plugin() {
let app = cinephile_app();
let records = &app.world().resource::<AchievementsResource>().0;
assert!(
records.iter().any(|r| r.id == "cinephile" && !r.unlocked),
"cinephile record must be seeded as locked",
);
}
/// Drive Inactive → Playing → Completed and assert the cinephile
/// achievement unlocks and exactly one `AchievementUnlockedEvent` is
/// emitted.
#[test]
fn cinephile_unlocks_on_replay_completion() {
let mut app = cinephile_app();
// Frame 1: enter Playing. The observer's first sample sees
// `last_was_playing = false` and `now_playing = true`.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
assert!(
!cinephile_unlocked(&app),
"Playing alone must not unlock cinephile",
);
// Frame 2: transition to Completed. The observer must detect
// `last_was_playing = true && now_completed = true` and unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert!(
cinephile_unlocked(&app),
"cinephile must unlock on Playing → Completed transition",
);
assert_eq!(
cinephile_unlocks_emitted(&app),
1,
"exactly one AchievementUnlockedEvent must fire for cinephile",
);
}
/// Stop button transitions Playing → Inactive directly (not via
/// Completed). Drive that path and assert no cinephile unlock.
#[test]
fn cinephile_does_not_unlock_on_stop_button_abort() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
// Direct Playing → Inactive — the path the Stop button takes via
// `stop_replay_playback`. Must not unlock cinephile.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
app.update();
assert!(
!cinephile_unlocked(&app),
"Stop button (Playing → Inactive) must not unlock cinephile",
);
assert_eq!(
cinephile_unlocks_emitted(&app),
0,
"no AchievementUnlockedEvent for cinephile on a Stop transition",
);
}
/// A second Playing → Completed cycle on an already-unlocked record
/// must be idempotent: no additional `AchievementUnlockedEvent`.
#[test]
fn cinephile_does_not_double_fire() {
let mut app = cinephile_app();
// First completion cycle to unlock.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert!(cinephile_unlocked(&app), "precondition: first cycle must unlock");
// Drain the event queue so the next assertion doesn't double-count
// the legitimate first-time unlock event.
app.world_mut()
.resource_mut::<Messages<AchievementUnlockedEvent>>()
.clear();
// Second cycle: Inactive → Playing → Completed once more.
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Inactive;
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
assert_eq!(
cinephile_unlocks_emitted(&app),
0,
"cinephile must not re-fire on a second Playing → Completed cycle",
);
}
/// `Completed` lingers across multiple frames before the auto-clear
/// transitions back to `Inactive`. The observer must fire exactly
/// once during that linger window — not once per frame.
#[test]
fn cinephile_fires_once_across_completed_linger() {
let mut app = cinephile_app();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Playing {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
ReplayPlaybackState::Completed;
app.update();
// Stay in Completed for a few more frames as the real auto-clear
// does. Each subsequent frame the resource is still `Completed`
// but the observer has already counted this transition.
app.update();
app.update();
app.update();
assert_eq!(
cinephile_unlocks_emitted(&app),
1,
"cinephile must fire exactly once across the Completed linger window",
);
}
#[test]
fn no_win_event_means_no_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
+204 -63
View File
@@ -21,7 +21,7 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{InfoToastEvent, NewGameConfirmEvent, XpAwardedEvent};
use crate::events::{InfoToastEvent, XpAwardedEvent};
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
@@ -30,8 +30,9 @@ use crate::progress_plugin::LevelUpEvent;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, MOTION_CASCADE_SLIDE_SECS, MOTION_CASCADE_STAGGER_SECS,
MOTION_SLIDE_SECS, TEXT_PRIMARY, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
scaled_duration, ACCENT_SECONDARY, BG_ELEVATED, MOTION_CASCADE_SLIDE_SECS,
MOTION_CASCADE_STAGGER_SECS, MOTION_SLIDE_SECS, RADIUS_MD, STATE_DANGER, STATE_INFO,
STATE_WARNING, TEXT_PRIMARY, TYPE_BODY_LG, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_TOAST,
};
use crate::weekly_goals_plugin::WeeklyGoalCompletedEvent;
@@ -61,7 +62,6 @@ fn anim_speed_to_secs(speed: &AnimSpeed) -> f32 {
scaled_duration(MOTION_SLIDE_SECS, *speed)
}
const WIN_TOAST_SECS: f32 = 4.0;
const ACHIEVEMENT_TOAST_SECS: f32 = 3.0;
const LEVELUP_TOAST_SECS: f32 = 3.0;
const DAILY_TOAST_SECS: f32 = 3.0;
@@ -161,7 +161,6 @@ impl Plugin for AnimationPlugin {
.add_message::<TimeAttackEndedEvent>()
.add_message::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<NewGameConfirmEvent>()
.add_message::<InfoToastEvent>()
.add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>()
@@ -183,7 +182,6 @@ impl Plugin for AnimationPlugin {
handle_challenge_toast,
handle_settings_toast,
handle_auto_complete_toast,
handle_new_game_confirm_toast,
handle_xp_awarded_toast,
tick_toasts,
(enqueue_toasts, drive_toast_display).chain(),
@@ -268,9 +266,15 @@ fn handle_win_cascade(
layout: Option<Res<LayoutResource>>,
settings: Option<Res<SettingsResource>>,
) {
let Some(ev) = events.read().next() else {
// Drain the event reader; the cascade visual is the only thing
// this system contributes — the post-win "You Won!" modal
// (`win_summary_plugin`) consumes the same `GameWonEvent` and
// carries score / time / achievements / XP itself, so a duplicate
// toast saying "You Win! Score X Time Y" rendered behind the modal
// in earlier builds. Removed.
if events.read().next().is_none() {
return;
};
}
let margin = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
@@ -286,11 +290,6 @@ fn handle_win_cascade(
Vec3::new(-margin, 0.0, 300.0),
];
let m = ev.time_seconds / 60;
let s = ev.time_seconds % 60;
let win_msg = format!("You Win! Score: {} Time: {m}:{s:02}", ev.score);
spawn_toast(&mut commands, win_msg, WIN_TOAST_SECS);
let step = settings
.as_ref()
.map_or(CASCADE_STAGGER_NORMAL, |s| cascade_step_secs(s.0.animation_speed));
@@ -341,6 +340,7 @@ fn handle_achievement_toast(
&mut commands,
format!("Achievement: {}", display_name_for(&ev.0.id)),
ACHIEVEMENT_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
@@ -351,6 +351,7 @@ fn handle_levelup_toast(mut commands: Commands, mut events: MessageReader<LevelU
&mut commands,
format!("Level Up! → {}", ev.new_level),
LEVELUP_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
@@ -360,7 +361,12 @@ fn handle_daily_goal_announcement_toast(
mut events: MessageReader<DailyGoalAnnouncementEvent>,
) {
for ev in events.read() {
spawn_toast(&mut commands, format!("Goal: {}", ev.0), DAILY_TOAST_SECS);
spawn_toast(
&mut commands,
format!("Goal: {}", ev.0),
DAILY_TOAST_SECS,
ToastVariant::Info,
);
}
}
@@ -373,6 +379,7 @@ fn handle_daily_toast(
&mut commands,
format!("Daily Challenge Complete! (Streak: {})", ev.streak),
DAILY_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
@@ -386,6 +393,7 @@ fn handle_weekly_toast(
&mut commands,
format!("Weekly Goal: {}", ev.description),
WEEKLY_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
@@ -399,6 +407,7 @@ fn handle_time_attack_toast(
&mut commands,
format!("Time Attack: {} win{}", ev.wins, if ev.wins == 1 { "" } else { "s" }),
TIME_ATTACK_TOAST_SECS,
ToastVariant::Info,
);
}
}
@@ -412,6 +421,7 @@ fn handle_challenge_toast(
&mut commands,
format!("Challenge {} cleared!", ev.previous_index.saturating_add(1)),
CHALLENGE_TOAST_SECS,
ToastVariant::Celebration,
);
}
}
@@ -431,11 +441,21 @@ fn handle_settings_toast(
*last_music = Some(music);
if sfx_changed {
let pct = (sfx * 100.0).round() as i32;
spawn_toast(&mut commands, format!("SFX: {pct}%"), VOLUME_TOAST_SECS);
spawn_toast(
&mut commands,
format!("SFX: {pct}%"),
VOLUME_TOAST_SECS,
ToastVariant::Info,
);
}
if music_changed {
let pct = (music * 100.0).round() as i32;
spawn_toast(&mut commands, format!("Music: {pct}%"), VOLUME_TOAST_SECS);
spawn_toast(
&mut commands,
format!("Music: {pct}%"),
VOLUME_TOAST_SECS,
ToastVariant::Info,
);
}
}
}
@@ -451,7 +471,12 @@ fn handle_auto_complete_toast(
if s.active {
if !*shown {
*shown = true;
spawn_toast(&mut commands, "Auto-completing…".to_string(), 2.0);
spawn_toast(
&mut commands,
"Auto-completing…".to_string(),
2.0,
ToastVariant::Info,
);
}
} else {
*shown = false;
@@ -459,15 +484,6 @@ fn handle_auto_complete_toast(
}
}
fn handle_new_game_confirm_toast(
mut commands: Commands,
mut events: MessageReader<NewGameConfirmEvent>,
) {
for _ in events.read() {
spawn_toast(&mut commands, "Press N again to start a new game".to_string(), 3.0);
}
}
/// Reads every incoming `InfoToastEvent` and appends its text to `ToastQueue`.
///
/// This is the first half of the two-system toast queue (Task #67). The queue
@@ -524,37 +540,72 @@ fn drive_toast_display(
}
}
/// Spawns a centered top-of-screen `ToastEntity` for the queued toast system.
/// Visual variant of a toast — drives the 1px border accent per the
/// design-system toast spec
/// (`docs/ui-mockups/design-system.md` → "Toasts").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastVariant {
/// Neutral system message — teal border. Default for `InfoToastEvent`,
/// settings volume notifications, and the auto-complete announcement.
Info,
/// Caution / penalty — gold border. Currently unused by an in-engine
/// event; kept so future warning-flavoured toasts have a slot.
#[allow(dead_code)]
Warning,
/// Failure / rejected action — pink border. Currently unused; kept so
/// future error-flavoured toasts have a slot.
#[allow(dead_code)]
Error,
/// Reward / milestone — lavender border. Used for XP awards,
/// achievement unlocks, level-ups, daily/weekly/challenge completions.
Celebration,
}
impl ToastVariant {
/// Returns the 1px border accent for this variant per the design
/// system. Single source of truth — `spawn_toast` and
/// `spawn_queued_toast` both consume it so a future palette swap
/// only has to touch the token, never every call site.
fn border_color(self) -> Color {
match self {
ToastVariant::Info => STATE_INFO,
ToastVariant::Warning => STATE_WARNING,
ToastVariant::Error => STATE_DANGER,
ToastVariant::Celebration => ACCENT_SECONDARY,
}
}
}
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
///
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
/// by [`InfoToastEvent`] which is by definition neutral system info.
/// Variants other than `Info` belong on the immediate-fire path
/// ([`spawn_toast`]) where the call site knows the semantic intent.
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
commands
.spawn((
ToastEntity,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(15.0),
top: Val::Percent(8.0),
width: Val::Percent(70.0),
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.60)),
ZIndex(Z_TOAST),
))
.with_children(|b| {
b.spawn((
Text::new(message),
TextFont { font_size: 22.0, ..default() },
TextColor(TEXT_PRIMARY),
));
})
.id()
spawn_toast_node(
commands,
ToastEntity,
message,
ToastVariant::Info,
// Slightly taller anchor than the immediate-fire path so a
// queued info banner doesn't collide with a celebration toast
// fired in the same frame.
Val::Percent(6.0),
Val::Percent(15.0),
Val::Percent(70.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
)
}
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
for ev in events.read() {
spawn_toast(&mut commands, format!("+{} XP", ev.amount), 3.0);
spawn_toast(
&mut commands,
format!("+{} XP", ev.amount),
3.0,
ToastVariant::Celebration,
);
}
}
@@ -580,33 +631,88 @@ fn tick_toasts(
}
}
fn spawn_toast(commands: &mut Commands, message: String, duration_secs: f32) {
/// Spawns a bottom-anchored fire-and-forget toast that despawns after
/// `duration_secs`. The `variant` selects the 1px accent border color
/// per the design-system toast spec.
fn spawn_toast(
commands: &mut Commands,
message: String,
duration_secs: f32,
variant: ToastVariant,
) {
spawn_toast_node(
commands,
(ToastOverlay, ToastTimer(duration_secs)),
message,
variant,
// Sits above the queued banner so a celebration toast spawned
// alongside a queued info message remains readable.
Val::Percent(14.0),
Val::Percent(25.0),
Val::Percent(50.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
);
}
/// Common toast-spawn primitive used by both the queued and the
/// fire-and-forget paths. Centralizes the design-system contract so a
/// future spec change (e.g. a different border thickness) is a
/// one-line edit.
///
/// The Terminal toast spec from `design-system.md`:
/// - Opaque [`BG_ELEVATED`] fill (no translucent dim).
/// - 1px border in the variant's accent color.
/// - [`TYPE_BODY_LG`] (18px) `TEXT_PRIMARY` caption — the spec calls
/// for 16px, but the engine type scale only carries 14/18/26/40/...
/// rungs; 18 is the closest rung that preserves the scale invariants
/// tested in `ui_theme::tests`.
/// - [`RADIUS_MD`] corners.
/// - Bottom-anchored absolute position; `bottom_pct` differs between
/// queued and immediate paths so they layer instead of overlap.
// The 8-argument signature is intentional — these are the per-toast
// layout values that genuinely differ between the queued and fire-and-
// forget call sites. A struct wrapper would just rename the same data.
#[allow(clippy::too_many_arguments)]
fn spawn_toast_node<B: Bundle>(
commands: &mut Commands,
bundle: B,
message: String,
variant: ToastVariant,
bottom_pct: Val,
left_pct: Val,
width_pct: Val,
padding: UiRect,
) -> Entity {
commands
.spawn((
ToastOverlay,
ToastTimer(duration_secs),
bundle,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(25.0),
top: Val::Percent(42.0),
width: Val::Percent(50.0),
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
left: left_pct,
bottom: bottom_pct,
width: width_pct,
padding,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.72)),
BackgroundColor(BG_ELEVATED),
BorderColor::all(variant.border_color()),
ZIndex(Z_TOAST),
))
.with_children(|b| {
b.spawn((
Text::new(message),
TextFont {
font_size: 32.0,
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
TextColor(TEXT_PRIMARY),
));
});
})
.id()
}
#[cfg(test)]
@@ -714,6 +820,41 @@ mod tests {
assert!(anim_speed_to_secs(&AnimSpeed::Fast) < anim_speed_to_secs(&AnimSpeed::Normal));
}
/// Pin every `ToastVariant` to its design-system border colour.
/// A future palette swap that touches `STATE_INFO`, `STATE_WARNING`,
/// `STATE_DANGER`, or `ACCENT_SECONDARY` flows through this mapping
/// automatically; this test guards against accidental remappings.
#[test]
fn toast_variant_border_colors_match_design_tokens() {
assert_eq!(ToastVariant::Info.border_color(), STATE_INFO);
assert_eq!(ToastVariant::Warning.border_color(), STATE_WARNING);
assert_eq!(ToastVariant::Error.border_color(), STATE_DANGER);
assert_eq!(ToastVariant::Celebration.border_color(), ACCENT_SECONDARY);
}
/// Every `ToastVariant` resolves to a unique border colour so a
/// careless rebinding (e.g. accidentally setting `Warning` to the
/// same hue as `Info`) fails loudly. Pure check — does not run a
/// Bevy app.
#[test]
fn toast_variant_border_colors_are_distinct() {
let colors = [
ToastVariant::Info.border_color(),
ToastVariant::Warning.border_color(),
ToastVariant::Error.border_color(),
ToastVariant::Celebration.border_color(),
];
for i in 0..colors.len() {
for j in (i + 1)..colors.len() {
assert_ne!(
format!("{:?}", colors[i]),
format!("{:?}", colors[j]),
"variants {i} and {j} resolved to the same border colour",
);
}
}
}
#[test]
fn anim_speed_instant_is_zero() {
assert_eq!(anim_speed_to_secs(&AnimSpeed::Instant), 0.0);
+38 -64
View File
@@ -1,13 +1,12 @@
//! Per-platform resolution of the user-themes directory.
//!
//! The path is determined exactly once and exposed via
//! [`user_theme_dir`]. On desktop platforms it is derived from
//! `dirs::data_dir()` (matching the rest of the project's
//! per-app-storage convention); on mobile it must be supplied by the
//! platform entry point via [`set_user_theme_dir`] before any code
//! that needs the path executes — there is deliberately no silent
//! fallback because mobile sandboxing makes any guess we'd hard-code
//! wrong.
//! [`user_theme_dir`]. The base directory comes from
//! [`solitaire_data::data_dir`] (desktop: `dirs::data_dir()`;
//! Android: the hardcoded `/data/data/<package>/files` sandbox
//! path). Mobile entry points may still override the path via
//! [`set_user_theme_dir`] when they need to point at a non-default
//! location (e.g. tests, custom AssetManager wiring).
//!
//! # Why panic instead of returning Result?
//!
@@ -35,17 +34,18 @@ const APP_DIR_NAME: &str = "solitaire_quest";
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
const THEME_DIR_NAME: &str = "themes";
/// Sets the user-themes directory at runtime — mobile-only API.
/// Sets the user-themes directory at runtime — escape hatch for
/// embedders or tests that need to override the platform default.
///
/// Returns `Err` containing the rejected path if the override has
/// already been set. The first caller wins and subsequent calls are
/// silently a no-op-with-feedback so a mis-configured embedder can't
/// flip the path mid-session.
///
/// On desktop platforms this is functional but unnecessary —
/// [`user_theme_dir`] derives the path from `dirs::data_dir` directly
/// and ignores the override. Setting it on desktop is harmless but
/// nearly always a sign of confusion.
/// Mostly unnecessary now that [`solitaire_data::data_dir`] handles
/// every supported target — the override is kept for tests and for
/// embedders that want a non-default location (e.g. a sandboxed
/// AssetManager root on a future iOS port).
pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
USER_THEME_DIR_OVERRIDE.set(path)
}
@@ -55,16 +55,10 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
///
/// # Panics
///
/// Panics on:
///
/// - Desktop, if `dirs::data_dir()` returns `None` (rare; usually
/// indicates a broken `$HOME` or `$XDG_*` configuration).
/// - Mobile, if no entry point has called [`set_user_theme_dir`] yet.
/// - Any other target, where the embedder is required to supply the
/// path manually.
///
/// The panic message names the missing piece so the failure is
/// immediately actionable.
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
/// Android always returns `Some`. The panic message names the
/// supported workaround ([`set_user_theme_dir`]).
pub fn user_theme_dir() -> PathBuf {
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
return p.clone();
@@ -79,45 +73,23 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
data_dir.join(APP_DIR_NAME).join(THEME_DIR_NAME)
}
/// Per-target-os resolution of the platform's data dir. Split out so
/// mobile branches can grow without disturbing desktop behaviour.
/// Per-target-os resolution of the platform's data dir. Delegates
/// to [`solitaire_data::data_dir`] which encapsulates the
/// per-target shape (desktop: `dirs::data_dir()`; android: the
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
/// only when the underlying resolver returns `None`, which on
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
/// the panic message names the supported workaround.
fn detected_platform_data_dir() -> PathBuf {
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
{
dirs::data_dir().unwrap_or_else(|| {
panic!(
"user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
the OS reported no Application Support / AppData path. \
As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()."
)
})
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
solitaire_data::data_dir().unwrap_or_else(|| {
panic!(
"user_theme_dir(): mobile entry point must call \
solitaire_engine::assets::user_dir::set_user_theme_dir() \
before App::run() there is no platform default."
"user_theme_dir(): platform data directory is unavailable. \
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
the OS reported no Application Support / AppData path. \
As a workaround call solitaire_engine::assets::user_dir::\
set_user_theme_dir() before App::run()."
)
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "android",
target_os = "ios"
)))]
{
panic!(
"user_theme_dir(): unsupported platform; call \
solitaire_engine::assets::user_dir::set_user_theme_dir() \
from your entry point before App::run()."
)
}
})
}
#[cfg(test)]
@@ -140,14 +112,16 @@ mod tests {
assert_eq!(dir, PathBuf::from("solitaire_quest/themes"));
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
#[test]
fn detected_data_dir_yields_a_path_with_a_parent() {
// On every supported desktop platform the OS reports a
// user-writable data directory; the test machine already has
// one for `dirs::data_dir()` to discover. We don't pin the
// exact value because it depends on the user's $HOME, but it
// must at least be a non-empty path with a parent component.
// On every supported target the platform resolver
// (`solitaire_data::data_dir`) returns a usable directory:
// desktop targets via `dirs::data_dir()` (the test machine
// already has a `$HOME` for it to discover), Android via
// the hardcoded `/data/data/<package>/files` sandbox path.
// We don't pin the exact value because it depends on the
// user's `$HOME` on desktop, but it must at least be a
// non-empty path with a parent component.
let dir = detected_platform_data_dir();
assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute");
}
+50 -15
View File
@@ -29,12 +29,13 @@ use crate::layout::{Layout, LayoutResource, LayoutSystem};
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::table_plugin::PileMarker;
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
use crate::font_plugin::FontResource;
use crate::ui_theme::{
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TYPE_CAPTION, Z_STOCK_BADGE,
CARD_SHADOW_PADDING_IDLE, STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_CAPTION,
Z_STOCK_BADGE,
};
/// Fraction of card height used as vertical offset between face-up tableau cards.
@@ -925,12 +926,17 @@ fn update_drag_shadow(
commands.entity(e).insert(Transform::from_translation(shadow_pos));
}
None => {
// Spawn a new shadow sprite.
// Spawn a new shadow sprite. Alpha tracks the per-card
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
// "no box-shadow" policy disables this stack shadow in
// lockstep with the per-card shadows. Re-enabling shadows
// is then a one-line change in `ui_theme`, not a hunt
// through plugin code.
let e = commands
.spawn((
ShadowEntity,
Sprite {
color: Color::srgba(0.0, 0.0, 0.0, 0.35),
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
..default()
},
@@ -1024,11 +1030,13 @@ fn tick_hint_highlight(
// Task #46 — Right-click legal destination highlights
// ---------------------------------------------------------------------------
/// Color applied to a `PileMarker` sprite when it is a legal destination for
/// the right-clicked card.
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.2, 0.8, 0.2, 0.6);
/// Restored color for `PileMarker` sprites when the highlight is cleared.
const PILE_MARKER_DEFAULT_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Lime tint applied to a `PileMarker` sprite when it is a legal
/// destination for the right-clicked card. Same RGB as the design-
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
/// because `Alpha::with_alpha` is not yet a `const` trait method on
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
/// so a palette swap can't drift the two apart silently.
const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
/// when the timer expires.
@@ -1238,11 +1246,16 @@ fn find_top_card_at(
// ---------------------------------------------------------------------------
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
/// to signal to the player that there are no more cards to draw.
/// to signal to the player that there are no more cards to draw. Pure white
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
/// the "empty" state is more visible, not less. Not derived from a palette
/// token: this is a sprite tint, not chrome colour.
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
/// Sprite colour applied to the stock `PileMarker` when cards remain in stock.
const STOCK_NORMAL_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Sprite colour applied to the stock `PileMarker` when cards remain in
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
/// of the engine's idle pile-marker tint automatically.
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
///
@@ -1283,7 +1296,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
StockEmptyLabel,
Text2d::new(""),
TextFont { font_size, ..default() },
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.7)),
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
Transform::from_xyz(0.0, 0.0, 0.1),
));
});
@@ -2329,9 +2342,15 @@ mod tests {
assert_ne!(idle_offset, drag_offset, "drag offset must differ from idle");
assert_ne!(idle_padding, drag_padding, "drag padding must differ from idle");
// Under the Terminal design system both alphas are pinned to 0
// (depth comes from 1px borders + tonal layering, no `box-shadow`).
// The invariant we still enforce is "drag never weaker than idle"
// — so an accidental swap of the two constants fails loudly,
// and a future palette that re-enables shadows still has to keep
// the lift cue stronger than the rest state.
assert!(
drag_alpha > idle_alpha,
"drag alpha must be stronger than idle (got drag={drag_alpha}, idle={idle_alpha})"
drag_alpha >= idle_alpha,
"drag alpha must not be weaker than idle (got drag={drag_alpha}, idle={idle_alpha})"
);
// Drag offset magnitude should be larger than idle so the parallax
// reads as "lifted".
@@ -2700,4 +2719,20 @@ mod tests {
"after a theme apply the theme_back slot must hold the theme's back handle",
);
}
/// `RIGHT_CLICK_HIGHLIGHT_COLOUR` is spelled as a literal because
/// `Alpha::with_alpha` is not a `const` trait method on stable.
/// This test pins its RGB to the design-system `STATE_SUCCESS`
/// token so a future palette swap that updates the token but
/// forgets the right-click highlight fails loudly here.
#[test]
fn right_click_highlight_rgb_tracks_state_success_token() {
use crate::ui_theme::STATE_SUCCESS;
let highlight = RIGHT_CLICK_HIGHLIGHT_COLOUR.to_srgba();
let success = STATE_SUCCESS.to_srgba();
assert!((highlight.red - success.red).abs() < 1e-6);
assert!((highlight.green - success.green).abs() < 1e-6);
assert!((highlight.blue - success.blue).abs() < 1e-6);
assert!((highlight.alpha - 0.6).abs() < 1e-6);
}
}
+141 -25
View File
@@ -2,9 +2,19 @@
//!
//! **Cursor icons** (`update_cursor_icon`)
//! - Cards are being dragged → `Grabbing` (closed hand)
//! - A UI `Button` entity is hovered (and no drag in progress) → `Pointer`
//! (the hand-with-extended-index-finger icon). This telegraphs
//! clickability for every modal button, HUD action, mode-launcher
//! card, settings toggle, etc.
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
//! - Otherwise → `Default` (arrow)
//!
//! Priority order: dragging > button-hover > card-hover > default. A
//! button-overlapping-a-card edge case favours `Pointer` because UI
//! elements take precedence over world-space cards; in practice
//! buttons are always on UI nodes and cards are sprites, so they
//! cannot occupy the same hit region simultaneously.
//!
//! **Drop-target highlights** (`update_drop_highlights`)
//! While a drag is in progress every `PileMarker` sprite is tinted:
//! - **Green** if the dragged stack can legally land there.
@@ -31,17 +41,28 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
use crate::layout::{Layout, LayoutResource};
use crate::resources::{DragState, GameStateResource};
use crate::table_plugin::PileMarker;
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
use crate::ui_theme::{
DROP_TARGET_FILL, DROP_TARGET_OUTLINE, DROP_TARGET_OUTLINE_PX, Z_DROP_OVERLAY,
};
/// Semi-transparent white that `table_plugin` uses for idle pile markers.
/// Kept in sync with the `marker_colour` constant there.
const MARKER_DEFAULT: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Idle pile-marker tint — re-exported from `table_plugin` so the
/// "valid drop" toggle in this plugin and the marker spawn in
/// `table_plugin` cannot drift apart. Was previously a duplicated
/// literal kept in sync via doc comment.
const MARKER_DEFAULT: Color = PILE_MARKER_DEFAULT_COLOUR;
/// Green tint applied to pile markers that are valid drop targets during drag.
const MARKER_VALID: Color = Color::srgba(0.15, 0.85, 0.25, 0.55);
/// Lime tint applied to pile markers that are valid drop targets during
/// a drag. Same RGB as the design-system [`STATE_SUCCESS`] token at 55%
/// alpha, so the in-game "this is a legal target" colour stays
/// consistent with foundation-completion flourishes and other
/// valid-move signals. Spelled as a literal because `Alpha::with_alpha`
/// is not yet a `const` trait method on stable; the tracking test
/// below pins the RGB to `STATE_SUCCESS` so a palette swap can't drift
/// the two apart silently. Distinct from [`DROP_TARGET_FILL`] (10%
/// alpha) because the marker sprite is thin and would otherwise wash
/// out at a similar opacity.
const MARKER_VALID: Color = Color::srgba(0.675, 0.761, 0.404, 0.55);
/// Marker component on a parent entity that owns one drop-target overlay
/// (a translucent fill plus four outline edges as children). The wrapped
@@ -70,6 +91,31 @@ impl Plugin for CursorPlugin {
// #31 — Cursor icon
// ---------------------------------------------------------------------------
/// Pure decision function for the cursor icon, separated from the Bevy
/// system so it can be unit-tested without `PrimaryWindow` /
/// `Camera` / `Time` plumbing.
///
/// Priority order (highest first):
/// 1. `is_dragging` → `Grabbing`
/// 2. `any_button_hovered` → `Pointer`
/// 3. `any_card_hovered` → `Grab`
/// 4. otherwise → `Default`
fn pick_cursor_icon(
is_dragging: bool,
any_button_hovered: bool,
any_card_hovered: bool,
) -> SystemCursorIcon {
if is_dragging {
SystemCursorIcon::Grabbing
} else if any_button_hovered {
SystemCursorIcon::Pointer
} else if any_card_hovered {
SystemCursorIcon::Grab
} else {
SystemCursorIcon::Default
}
}
/// Updates the primary-window cursor icon based on drag state and hover.
fn update_cursor_icon(
drag: Res<DragState>,
@@ -77,32 +123,39 @@ fn update_cursor_icon(
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>,
button_q: Query<&Interaction, With<Button>>,
mut commands: Commands,
) {
let Ok((win_entity, window)) = windows.single() else { return };
if !drag.is_idle() {
commands
.entity(win_entity)
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
return;
}
let is_dragging = !drag.is_idle();
let hovering = (|| {
let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
let layout = layout.as_ref()?.0.clone();
let game = game.as_ref()?;
Some(cursor_over_draggable(world, &game.0, &layout))
})()
.unwrap_or(false);
// A UI button is "hovered" if any `Button` entity has its
// `Interaction` set to `Hovered` or `Pressed`. We include
// `Pressed` so the pointer icon stays visible while a click is
// being held, matching browser behaviour.
let any_button_hovered = button_q
.iter()
.any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
SystemCursorIcon::Grab
let any_card_hovered = if is_dragging || any_button_hovered {
// No need to do the world-space hit test when a higher
// priority branch already wins.
false
} else {
SystemCursorIcon::Default
}));
(|| {
let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
let layout = layout.as_ref()?.0.clone();
let game = game.as_ref()?;
Some(cursor_over_draggable(world, &game.0, &layout))
})()
.unwrap_or(false)
};
let icon = pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered);
commands.entity(win_entity).insert(CursorIcon::from(icon));
}
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
@@ -482,6 +535,69 @@ mod tests {
);
}
#[test]
fn marker_valid_rgb_tracks_state_success_token() {
// `MARKER_VALID` is spelled as a literal because
// `Alpha::with_alpha` is not a `const` trait method on stable.
// This test pins its RGB to `STATE_SUCCESS` so a future
// palette swap that updates the token but forgets the marker
// fails loudly here.
use crate::ui_theme::STATE_SUCCESS;
let valid = MARKER_VALID.to_srgba();
let success = STATE_SUCCESS.to_srgba();
assert!((valid.red - success.red).abs() < 1e-6);
assert!((valid.green - success.green).abs() < 1e-6);
assert!((valid.blue - success.blue).abs() < 1e-6);
assert!((valid.alpha - 0.55).abs() < 1e-6);
}
// -----------------------------------------------------------------------
// pick_cursor_icon priority-order tests
// -----------------------------------------------------------------------
#[test]
fn cursor_picks_grabbing_when_dragging_overrides_button_hover() {
// Dragging always wins regardless of button or card hover state.
assert!(matches!(
pick_cursor_icon(true, true, true),
SystemCursorIcon::Grabbing
));
assert!(matches!(
pick_cursor_icon(true, false, false),
SystemCursorIcon::Grabbing
));
}
#[test]
fn cursor_picks_pointer_when_button_hovered_and_no_drag() {
// Button hover beats card hover when not dragging.
assert!(matches!(
pick_cursor_icon(false, true, false),
SystemCursorIcon::Pointer
));
assert!(matches!(
pick_cursor_icon(false, true, true),
SystemCursorIcon::Pointer
));
}
#[test]
fn cursor_picks_grab_when_card_hovered_and_no_button() {
// Card hover wins only when no drag and no button-hover.
assert!(matches!(
pick_cursor_icon(false, false, true),
SystemCursorIcon::Grab
));
}
#[test]
fn cursor_picks_default_when_nothing_hovered() {
assert!(matches!(
pick_cursor_icon(false, false, false),
SystemCursorIcon::Default
));
}
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use solitaire_core::game_state::{DrawMode, GameState};
+164
View File
@@ -0,0 +1,164 @@
//! Optional on-screen FPS / frame-time overlay.
//!
//! Wraps Bevy's [`FrameTimeDiagnosticsPlugin`] and renders a tiny
//! corner readout that the developer (or a curious player) can toggle
//! with `F3`. Hidden by default — production builds ship the plugin
//! but the overlay starts invisible, so the production HUD is never
//! cluttered unless explicitly summoned.
//!
//! Why this exists: with an Android port on the roadmap, "feels
//! slow" became a real risk to plan around. A togglable FPS / frame-
//! time pair gives us a numeric baseline we can quote across desktop
//! and mobile, instead of optimising on vibes.
//!
//! ## Display contract
//!
//! When visible, the overlay reads `"FPS NN \u{2022} M.MM ms"` in a
//! small monospaced cell, anchored top-right. Both numbers are the
//! `smoothed()` value (Bevy's exponential moving average) — peak
//! and worst-case readings would jitter the text every frame, which
//! is harder to glance at than a smoothed reading.
//!
//! ## Hotkey scope
//!
//! `F3` is a global, gameplay-blockable toggle: the system reads
//! `ButtonInput<KeyCode>` directly and ignores the rest of the modal
//! / pause stack. The overlay is informational and shouldn't depend
//! on game state.
use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::ui_theme::Z_SPLASH;
/// Z-index for the diagnostics HUD — above every modal / toast /
/// splash layer so a developer can always see the readout, no matter
/// what overlay is up.
const Z_DIAGNOSTICS_HUD: i32 = Z_SPLASH + 100;
/// Width-stable font size for the readout. Hand-tuned literal — the
/// HUD is a developer affordance and uses its own sizing rather than
/// borrowing a typography token whose meaning may drift.
const DIAGNOSTICS_FONT_SIZE: f32 = 12.0;
/// Background alpha for the readout cell. Translucent so the HUD
/// doesn't fully obscure whatever's behind it but stays legible.
const DIAGNOSTICS_BG_ALPHA: f32 = 0.7;
/// Wires the FPS / frame-time HUD overlay.
///
/// Adds [`FrameTimeDiagnosticsPlugin`] (no-op if already added — the
/// plugin's `Plugin::build` is idempotent on duplicate registration
/// in our codebase since no other site adds it). Spawns the HUD
/// hidden, registers the toggle handler, and wires the per-frame
/// text refresh.
pub struct DiagnosticsHudPlugin;
impl Plugin for DiagnosticsHudPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(FrameTimeDiagnosticsPlugin::default())
.init_resource::<DiagnosticsHudVisible>()
.add_systems(Startup, spawn_diagnostics_hud)
.add_systems(
Update,
(toggle_diagnostics_hud, update_diagnostics_hud).chain(),
);
}
}
/// Tracks whether the overlay is currently visible. Flipped by the
/// `F3` toggle; defaults to hidden so production launches start clean.
#[derive(Resource, Debug, Default)]
struct DiagnosticsHudVisible(bool);
/// Marker on the overlay's root Node — used to flip `Visibility`.
#[derive(Component, Debug)]
struct DiagnosticsHudRoot;
/// Marker on the readout `Text` node — used by the per-frame refresh
/// system to find the right text to overwrite.
#[derive(Component, Debug)]
struct DiagnosticsHudText;
/// Spawns the (initially-hidden) overlay at startup. Anchored
/// top-right with absolute positioning so it never participates in
/// the rest of the UI flex tree.
fn spawn_diagnostics_hud(mut commands: Commands, font_res: Option<Res<FontResource>>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let bg = Color::srgba(0.0, 0.0, 0.0, DIAGNOSTICS_BG_ALPHA);
commands
.spawn((
DiagnosticsHudRoot,
Node {
position_type: PositionType::Absolute,
top: Val::Px(8.0),
right: Val::Px(8.0),
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
..default()
},
BackgroundColor(bg),
Visibility::Hidden,
GlobalZIndex(Z_DIAGNOSTICS_HUD),
))
.with_children(|parent| {
parent.spawn((
DiagnosticsHudText,
Text::new("FPS \u{2014}"),
TextFont {
font: font_handle,
font_size: DIAGNOSTICS_FONT_SIZE,
..default()
},
TextColor(Color::WHITE),
));
});
}
/// `F3` flips the visible flag and the overlay's `Visibility`. Reads
/// the keyboard input directly so it isn't gated by pause / modal
/// state — diagnostics should always be reachable.
fn toggle_diagnostics_hud(
keys: Res<ButtonInput<KeyCode>>,
mut visible: ResMut<DiagnosticsHudVisible>,
mut roots: Query<&mut Visibility, With<DiagnosticsHudRoot>>,
) {
if !keys.just_pressed(KeyCode::F3) {
return;
}
visible.0 = !visible.0;
let target = if visible.0 {
Visibility::Visible
} else {
Visibility::Hidden
};
for mut v in &mut roots {
*v = target;
}
}
/// Reads the smoothed FPS + frame-time diagnostics each frame and
/// rewrites the readout text. Skipped while the overlay is hidden so
/// we don't pay the diagnostic-store lookup or the text mutation
/// when nobody's looking.
fn update_diagnostics_hud(
diagnostics: Res<DiagnosticsStore>,
visible: Res<DiagnosticsHudVisible>,
mut texts: Query<&mut Text, With<DiagnosticsHudText>>,
) {
if !visible.0 {
return;
}
let fps = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FPS)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
let frame_time_ms = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FRAME_TIME)
.and_then(|d| d.smoothed())
.unwrap_or(0.0);
for mut text in &mut texts {
**text = format!("FPS {fps:.0} \u{2022} {frame_time_ms:.2} ms");
}
}
-7
View File
@@ -207,13 +207,6 @@ pub struct ToggleLeaderboardRequestEvent;
#[derive(Message, Debug, Clone)]
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
/// Fired by `InputPlugin` when N is pressed while a game is in progress
/// but confirmation has not yet been received. The animation plugin shows
/// a "Press N again to confirm" toast. A second N press within the
/// confirmation window sends `NewGameRequestEvent`.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct NewGameConfirmEvent;
/// Generic informational toast message. Any system can fire this to display
/// a short string to the player, e.g. "Locked — reach level 5".
#[derive(Message, Debug, Clone)]
+2 -2
View File
@@ -21,8 +21,8 @@
//!
//! # Task #69 — Animated card deal on new game start
//!
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`) or
//! `NewGameConfirmEvent` fires, `start_deal_anim` reads `LayoutResource` and
//! When `NewGameRequestEvent` fires (on a fresh game, `move_count == 0`),
//! `start_deal_anim` reads `LayoutResource` and
//! inserts a `CardAnim` on every card entity, sliding each card from the stock
//! pile's position to its current (final) position with a per-card stagger
//! derived from the current `AnimSpeed` setting plus a deterministic ±10 %
+745 -36
View File
@@ -10,11 +10,18 @@ use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use chrono::Utc;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::pile::PileType;
use solitaire_data::{delete_game_state_at, game_state_file_path, latest_replay_path,
load_game_state_from, save_game_state_to, save_latest_replay_to, Replay, ReplayMove};
use solitaire_core::solver::{try_solve, SolverConfig, SolverResult};
use solitaire_data::{
append_replay_to_history, delete_game_state_at, game_state_file_path, load_game_state_from,
migrate_legacy_latest_replay, replay_history_path, save_game_state_to, Replay, ReplayMove,
SOLVER_DEAL_RETRY_CAP,
};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
use crate::events::{
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
@@ -54,10 +61,44 @@ pub struct GameMutation;
#[derive(Resource, Debug, Clone)]
pub struct GameStatePath(pub Option<PathBuf>);
/// Persistence path for the most recent winning replay. `None` disables I/O.
/// Persistence path for the rolling [`solitaire_data::ReplayHistory`]
/// file (`replays.json`). `None` disables I/O — used by tests and on
/// minimal Linux containers without `dirs::data_dir()`.
///
/// Each `GameWonEvent` appends the freshly-frozen [`Replay`] to the
/// history at this path via
/// [`solitaire_data::append_replay_to_history`], capping at
/// [`solitaire_data::REPLAY_HISTORY_CAP`] so the file never grows
/// unbounded.
#[derive(Resource, Debug, Clone)]
pub struct ReplayPath(pub Option<PathBuf>);
/// Holds the saved-on-disk in-progress game between plugin build and
/// the player's answer to the "Continue or start a new game?" prompt.
///
/// Some(game) at startup means a previously-saved game existed and had
/// real moves on it. The restore-prompt modal swaps it into
/// `GameStateResource` if the player picks Continue, or drops it (and
/// lets `handle_new_game` clean up the disk file) on New Game. None for
/// first-launch installs and for save files that contain a fresh deal
/// with no moves yet — there's nothing meaningful to "continue" there.
#[derive(Resource, Debug, Default)]
pub struct PendingRestoredGame(pub Option<GameState>);
/// Marker on the "Welcome back — Continue or start a new game?" modal
/// scrim. Despawning the scrim cascades to the card and children, so a
/// single `commands.entity(scrim).despawn()` tears the modal down.
#[derive(Component, Debug)]
pub struct RestorePromptScreen;
/// Marker on the modal's primary "Continue" button.
#[derive(Component, Debug)]
pub struct RestoreContinueButton;
/// Marker on the modal's secondary "New game" button.
#[derive(Component, Debug)]
pub struct RestoreNewGameButton;
/// In-memory accumulator for [`ReplayMove`] entries during the current
/// game. Cleared on every new-game start; frozen into a [`Replay`] and
/// flushed to disk by [`record_replay_on_win`] when the player wins.
@@ -95,16 +136,57 @@ impl GamePlugin {
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
let path = game_state_file_path();
// Restore any saved in-progress game, falling back to a fresh deal.
let initial_state = path
.as_deref()
.and_then(load_game_state_from)
.unwrap_or_else(|| GameState::new(seed_from_system_time(), DrawMode::DrawOne));
// Try to load any saved in-progress game. We don't want to
// silently restore a half-played game on launch — the player
// should get to decide between continuing and starting fresh.
// So: if there IS a saved game with progress and it isn't
// already won, hold it in `PendingRestoredGame` and let the
// restore-prompt modal swap it into `GameStateResource` if
// the player picks Continue. Otherwise put it directly into
// `GameStateResource` (existing behaviour for un-played /
// won deals which there's nothing to ask about).
let saved = path.as_deref().and_then(load_game_state_from);
let prompt_worthy = saved
.as_ref()
.is_some_and(|g| g.move_count > 0 && !g.is_won);
let (initial_state, pending_restore) = if prompt_worthy {
(
GameState::new(seed_from_system_time(), DrawMode::DrawOne),
saved,
)
} else {
(
saved.unwrap_or_else(|| {
GameState::new(seed_from_system_time(), DrawMode::DrawOne)
}),
None,
)
};
// One-shot migration from the legacy single-slot
// `latest_replay.json` to the rolling history at `replays.json`.
// Runs at plugin construction so the player's last winning
// replay from a pre-history build is the first entry of the
// new history file. The legacy file is intentionally left in
// place for one release as a safety net (see
// `migrate_legacy_latest_replay` doc comment).
let history_path = replay_history_path();
if let (Some(legacy), Some(history)) =
(
#[allow(deprecated)]
latest_replay_path(),
history_path.as_ref(),
)
{
migrate_legacy_latest_replay(&legacy, history);
}
app.insert_resource(GameStateResource(initial_state))
.insert_resource(GameStatePath(path))
.insert_resource(ReplayPath(latest_replay_path()))
.insert_resource(ReplayPath(history_path))
.insert_resource(PendingRestoredGame(pending_restore))
.init_resource::<RecordingReplay>()
.init_resource::<PendingNewGameSeed>()
.init_resource::<DragState>()
.init_resource::<SyncStatusResource>()
.add_message::<MoveRequestEvent>()
@@ -118,6 +200,10 @@ impl Plugin for GamePlugin {
.add_message::<crate::events::AchievementUnlockedEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<InfoToastEvent>()
.add_systems(
Update,
poll_pending_new_game_seed.before(GameMutation),
)
.add_systems(
Update,
(
@@ -135,6 +221,11 @@ impl Plugin for GamePlugin {
.add_systems(Update, handle_confirm_button_input.after(GameMutation))
.add_systems(Update, handle_game_over_input.after(GameMutation))
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
// Restore prompt: spawn the modal once the splash is gone,
// route Continue / New Game intents back into the existing
// GameMutation flow.
.add_systems(Update, spawn_restore_prompt_if_pending)
.add_systems(Update, handle_restore_prompt.before(GameMutation))
.init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time)
.add_systems(Update, auto_save_game_state)
@@ -161,16 +252,20 @@ pub fn advance_elapsed(
}
/// Increment `GameState.elapsed_seconds` once per real-world second while
/// the game is in progress (not won) and not paused. Stops counting on
/// win so the final time reflects how long the player took to solve the
/// deal; stops while the pause overlay is open.
/// the game is in progress (not won), not paused, and the launch /
/// mode-picker Home modal isn't covering the board. Stops counting on
/// win so the final time reflects how long the player took to solve
/// the deal; stops while the pause overlay is open; stops while Home
/// is up so the timer doesn't tick under the picker before the player
/// has actually committed to a deal.
fn tick_elapsed_time(
time: Res<Time>,
mut game: ResMut<GameStateResource>,
mut accumulator: Local<f32>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
) {
if paused.is_some_and(|p| p.0) {
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
return;
}
let is_won = game.0.is_won;
@@ -188,6 +283,95 @@ fn seed_from_system_time() -> u64 {
.map_or(0, |d| d.as_nanos() as u64)
}
/// Walks forward from `initial_seed` (incrementing by 1 with wrapping
/// arithmetic) until the [`solitaire_core::solver`] returns a verdict
/// the engine accepts as winnable, or until [`SOLVER_DEAL_RETRY_CAP`]
/// attempts have elapsed.
///
/// The solver classifies each deal as one of three verdicts:
/// - [`SolverResult::Winnable`] — provably solvable; accept.
/// - [`SolverResult::Inconclusive`] — budget exceeded, no proof
/// either way; accept (we treat "we don't know" as winnable so
/// the toggle never silently drops a player into the retry cap).
/// - [`SolverResult::Unwinnable`] — provably dead; try the next seed.
///
/// If every seed in the retry window is `Unwinnable` (extremely
/// unlikely on real inputs), the function returns the *last* tried
/// seed so the player still gets a deal — better a possibly-unwinnable
/// hand than an infinite loop.
///
/// In-flight async work for "Winnable deals only" seed selection.
///
/// `handle_new_game` writes here when it needs the solver to vet a deal;
/// `poll_pending_new_game_seed` reads from here, polls the task, and
/// re-emits a `NewGameRequestEvent` with the chosen seed once the task
/// completes. The desktop client's UI never blocks on the worst-case
/// 50 × ~120 ms solver runs that can pile up on pathological deals.
///
/// At most one task is ever in flight: a fresh new-game request while
/// a previous task is still running drops the previous task (Bevy's
/// `Task` `Drop` cancels it cooperatively at the next await point) and
/// queues the new one.
#[derive(Resource, Default)]
pub struct PendingNewGameSeed {
/// `Some` while a solver-vetted seed is being computed.
inner: Option<PendingSeedTask>,
}
/// One in-flight winnable-seed search plus the request fields that
/// would have flowed through `handle_new_game` synchronously. The
/// poll system replays them on a synthetic `NewGameRequestEvent` once
/// the task completes — `seed: Some(...)` skips the solver branch on
/// the second pass so we don't loop.
struct PendingSeedTask {
handle: Task<u64>,
mode: Option<GameMode>,
confirmed: bool,
}
/// Update system: poll the in-flight winnable-seed search. When the
/// task resolves, emit a synthetic `NewGameRequestEvent` carrying the
/// chosen seed. Ordered `.before(GameMutation)` so `handle_new_game`
/// picks up the synthetic event on the same frame, completing the
/// new-game flow without a one-frame visual lag.
fn poll_pending_new_game_seed(
mut pending: ResMut<PendingNewGameSeed>,
mut new_game_writer: MessageWriter<NewGameRequestEvent>,
) {
let Some(p) = pending.inner.as_mut() else {
return;
};
let Some(seed) = future::block_on(future::poll_once(&mut p.handle)) else {
return;
};
let mode = p.mode;
let confirmed = p.confirmed;
pending.inner = None;
new_game_writer.write(NewGameRequestEvent {
seed: Some(seed),
mode,
confirmed,
});
}
/// Pure helper extracted for testability — `new_game_with_solver_*`
/// engine tests in the same file exercise this path.
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
let cfg = SolverConfig::default();
let mut seed = initial_seed;
for _ in 0..SOLVER_DEAL_RETRY_CAP {
match try_solve(seed, draw_mode.clone(), &cfg) {
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
SolverResult::Unwinnable => {
seed = seed.wrapping_add(1);
}
}
}
// Retry cap exhausted — accept the latest tried seed rather than
// recurring forever.
seed
}
#[allow(clippy::too_many_arguments)]
fn handle_new_game(
mut commands: Commands,
@@ -195,6 +379,7 @@ fn handle_new_game(
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut recording: ResMut<RecordingReplay>,
mut pending_seed: ResMut<PendingNewGameSeed>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>,
font_res: Option<Res<FontResource>>,
@@ -229,7 +414,14 @@ fn handle_new_game(
commands.entity(entity).despawn();
}
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
// Drop any in-flight winnable-seed search now that we've
// committed to acting on a new request. Its result was for
// the previous user intent — the new request supersedes it
// regardless of which branch we take below (synchronous
// explicit-seed deal vs. another async solver search).
pending_seed.inner = None;
let initial_seed = ev.seed.unwrap_or_else(seed_from_system_time);
// Prefer the draw mode from Settings when starting a fresh game.
// Fall back to the current game's draw mode in headless/test contexts
// where SettingsPlugin is not installed.
@@ -237,7 +429,43 @@ fn handle_new_game(
.as_ref()
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
let mode = ev.mode.unwrap_or(game.0.mode);
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
// Solver-backed retry: when the player has opted in to
// "Winnable deals only" AND this is a random Classic deal
// (no caller-supplied seed), reject deals the solver can
// prove unwinnable and try the next seed. Capped at
// [`SOLVER_DEAL_RETRY_CAP`] so a pathological run can't
// hang the main thread — if every attempt is rejected we
// fall through to the latest tried seed.
//
// **Scope** — the retry deliberately skips:
// - Daily challenges and challenge-mode seeds (caller passes
// `ev.seed = Some(...)` so the player gets the same deal as
// everyone else).
// - Replays (the replay's own seed is authoritative).
// - Any other explicit seed request — the player asked for
// that seed; honour it.
let winnable_only = settings
.as_ref()
.is_some_and(|s| s.0.winnable_deals_only);
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
let dm = draw_mode.clone();
let task = AsyncComputeTaskPool::get()
.spawn(async move { choose_winnable_seed(initial_seed, &dm) });
pending_seed.inner = Some(PendingSeedTask {
handle: task,
mode: ev.mode,
confirmed: ev.confirmed,
});
// Skip the rest of the new-game flow; the polling system
// will re-emit a synthetic event with a chosen seed once
// the task resolves.
continue;
}
let chosen_seed = initial_seed;
game.0 = GameState::new_with_mode(chosen_seed, draw_mode, mode);
// Reset the in-flight replay buffer — a fresh deal starts with
// an empty move list. The previously saved replay on disk
// (latest_replay.json) is preserved until the player wins again.
@@ -291,6 +519,132 @@ pub struct ConfirmNoButton;
/// and "No (N)" — those were not real Button entities, so the player
/// had no hover / press feedback and the modal felt like a debug panel
/// (the user's smoke-test "#2 complaint").
/// Update-schedule system: once the splash overlay is gone and there's
/// a pending restored game waiting for the player's answer, spawn the
/// "Welcome back — Continue or start a new game?" modal. Idempotent —
/// the existing `RestorePromptScreen` query gates against duplicate
/// spawns if Update fires before the player clicks.
fn spawn_restore_prompt_if_pending(
mut commands: Commands,
pending: Res<PendingRestoredGame>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
existing: Query<(), With<RestorePromptScreen>>,
font_res: Option<Res<FontResource>>,
) {
if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
return;
}
spawn_modal(
&mut commands,
RestorePromptScreen,
ui_theme::Z_MODAL_PANEL,
|card| {
spawn_modal_header(card, "Welcome back", font_res.as_deref());
spawn_modal_body_text(
card,
"You have an in-progress game. Continue where you left off, or start a new one?",
ui_theme::TEXT_SECONDARY,
font_res.as_deref(),
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
RestoreNewGameButton,
"New game",
Some("N"),
ButtonVariant::Secondary,
font_res.as_deref(),
);
spawn_modal_button(
actions,
RestoreContinueButton,
"Continue",
Some("Enter"),
ButtonVariant::Primary,
font_res.as_deref(),
);
});
},
);
}
/// Click handlers + keyboard shortcuts for the restore prompt.
///
/// Continue (Enter / C) — swaps the saved game into `GameStateResource`
/// and writes a `StateChangedEvent` so card sprites resync to the
/// restored layout.
/// New game (N) — drops the saved game and writes
/// `NewGameRequestEvent { confirmed: true }`. The existing
/// `handle_new_game` flow takes over: deletes `game_state.json`, deals
/// a fresh game, fires `StateChangedEvent`. `confirmed: true` skips
/// the abandon-current-game confirm dialog (the player has already
/// confirmed by clicking New game here).
#[allow(clippy::too_many_arguments)]
fn handle_restore_prompt(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
screens: Query<Entity, With<RestorePromptScreen>>,
continue_buttons: Query<&Interaction, (With<RestoreContinueButton>, Changed<Interaction>)>,
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
mut pending: ResMut<PendingRestoredGame>,
mut game: ResMut<GameStateResource>,
mut changed: MessageWriter<StateChangedEvent>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
) {
if screens.is_empty() {
return;
}
// Esc maps to Continue rather than New Game so a stray dismiss
// press preserves the saved game — the data-preserving default is
// the safer fallback when a player hits Esc reflexively to "close
// this dialog" without reading it.
let key_continue = keys.as_ref().is_some_and(|k| {
k.just_pressed(KeyCode::Enter)
|| k.just_pressed(KeyCode::KeyC)
|| k.just_pressed(KeyCode::Escape)
});
let key_new = keys.as_ref().is_some_and(|k| k.just_pressed(KeyCode::KeyN));
let click_continue = continue_buttons
.iter()
.any(|i| *i == Interaction::Pressed);
let click_new = new_game_buttons.iter().any(|i| *i == Interaction::Pressed);
let resolved = if key_continue || click_continue {
if let Some(restored) = pending.0.take() {
game.0 = restored;
changed.write(StateChangedEvent);
}
for entity in &screens {
commands.entity(entity).despawn();
}
true
} else if key_new || click_new {
pending.0 = None;
for entity in &screens {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: true,
});
true
} else {
false
};
// The player has just made an explicit launch-time choice (continue
// saved game, or start a fresh deal). Suppress the launch-time Home
// auto-show so it doesn't pop on top of the resolution they picked.
// `M` still re-opens the picker on demand.
if resolved
&& let Some(ref mut shown) = launch_home_shown
{
shown.0 = true;
}
}
fn spawn_confirm_dialog(
commands: &mut Commands,
original_request: NewGameRequestEvent,
@@ -557,14 +911,15 @@ fn handle_undo(
/// On every `GameWonEvent`, freeze the in-flight [`RecordingReplay`] into
/// a [`Replay`] tagged with the deal seed/mode, the win's score and
/// elapsed time, and today's date — then persist it atomically to
/// `<data_dir>/solitaire_quest/latest_replay.json` (or to whichever path
/// `ReplayPath` carries; tests inject a temp path).
/// elapsed time, and today's date — then append it to the rolling
/// [`solitaire_data::ReplayHistory`] at the path `ReplayPath` carries
/// (tests inject a temp path).
///
/// Only the most recent winning replay is retained — the existing file is
/// overwritten. The recording buffer is left intact after the win so a
/// subsequent state-change does not erase the move list before the save
/// completes; it gets cleared on the next `NewGameRequestEvent`.
/// The history is capped at [`solitaire_data::REPLAY_HISTORY_CAP`]
/// entries; older wins age out automatically when the cap is hit. The
/// recording buffer is left intact after the win so a subsequent
/// state-change does not erase the move list before the save completes;
/// it gets cleared on the next `NewGameRequestEvent`.
pub fn record_replay_on_win(
mut wins: MessageReader<GameWonEvent>,
game: Res<GameStateResource>,
@@ -597,8 +952,8 @@ pub fn record_replay_on_win(
// to inspect it without going through the disk.
continue;
};
if let Err(e) = save_latest_replay_to(p, &replay) {
warn!("replay: failed to save winning replay: {e}");
if let Err(e) = append_replay_to_history(p, replay) {
warn!("replay: failed to append winning replay to history: {e}");
}
}
}
@@ -843,9 +1198,17 @@ fn auto_save_game_state(
path: Option<Res<GameStatePath>>,
mut timer: ResMut<AutoSaveTimer>,
paused: Option<Res<crate::pause_plugin::PausedResource>>,
pending: Res<PendingRestoredGame>,
) {
// Don't save if paused, game is won, or no moves have been made yet.
if paused.is_some_and(|p| p.0) || game.0.is_won || game.0.move_count == 0 {
// Don't save if paused, game is won, no moves have been made yet,
// or there's a pending restore the player hasn't answered — saving
// the fresh-deal placeholder we seeded GameStateResource with at
// startup would clobber the real saved game on disk.
if paused.is_some_and(|p| p.0)
|| game.0.is_won
|| game.0.move_count == 0
|| pending.0.is_some()
{
return;
}
timer.0 += time.delta_secs();
@@ -862,17 +1225,25 @@ fn auto_save_game_state(
/// player can resume where they left off. Won games are not saved (the
/// `save_game_state_to` helper skips them). Blocking on exit is acceptable
/// because the game loop is already shutting down.
///
/// Special case: when `PendingRestoredGame` still holds a saved game the
/// player never answered the restore prompt for, write THAT to disk
/// instead of the live `GameStateResource`. Otherwise we'd clobber a
/// real saved game with the fresh-deal placeholder we seeded
/// `GameStateResource` with at startup.
fn save_game_state_on_exit(
mut exit_events: MessageReader<AppExit>,
game: Res<GameStateResource>,
path: Res<GameStatePath>,
pending: Res<PendingRestoredGame>,
) {
if exit_events.is_empty() {
return;
}
exit_events.clear();
let Some(p) = path.0.as_deref() else { return };
if let Err(e) = save_game_state_to(p, &game.0) {
let to_save = pending.0.as_ref().unwrap_or(&game.0);
if let Err(e) = save_game_state_to(p, to_save) {
warn!("game_state: failed to save on exit: {e}");
}
}
@@ -893,6 +1264,14 @@ mod tests {
// plugin's build path; clearing them keeps tests self-contained.
app.insert_resource(GameStatePath(None));
app.insert_resource(ReplayPath(None));
// Force `PendingRestoredGame` empty so production saved-game
// state on the dev machine's disk (loaded by `GamePlugin::build`)
// can't leak into per-test world state and trip the
// `pending.0.is_some()` guard in `auto_save_game_state` /
// `save_game_state_on_exit`. Without this clear, an
// unrelated `~/.local/share/solitaire_quest/game_state.json`
// would silently disable the auto-save path under test.
app.insert_resource(PendingRestoredGame(None));
// Override the system-time seed with a known value.
app.world_mut()
.resource_mut::<GameStateResource>()
@@ -1145,6 +1524,16 @@ mod tests {
}
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
///
/// The timer is pre-seeded just past the threshold and the test
/// re-arms it before each `app.update()` in a small bounded loop:
/// under `MinimalPlugins` the first frame's `Time::delta_secs()`
/// can be 0.0 (or, under heavy parallel cargo-test load, large
/// enough that the pre-seeded margin is consumed by it), so a
/// single-frame check is fragile. Looping until the file appears
/// (or hitting the bound) makes the test robust against
/// first-frame Time variance without changing the underlying
/// behaviour contract.
#[test]
fn auto_save_writes_after_30_seconds() {
use solitaire_data::load_game_state_from;
@@ -1160,10 +1549,18 @@ mod tests {
.0
.move_count = 1;
// Pre-seed the timer just past the threshold. The system will trigger
// on the very next update() without needing to control Time::delta_secs().
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
app.update();
// Re-arm the timer past the threshold every frame and pump
// updates until the save fires. Caps at 16 iterations — a
// healthy run hits it on the first or second frame; the cap
// prevents an infinite loop if a future regression skips
// the save unconditionally.
for _ in 0..16 {
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 1.0));
app.update();
if path.exists() {
break;
}
}
assert!(path.exists(), "auto-save file must exist after timer crosses threshold");
let loaded = load_game_state_from(&path).expect("file must be loadable");
@@ -1946,11 +2343,13 @@ mod tests {
}
/// On `GameWonEvent`, the recording is frozen into a `Replay` and
/// persisted. We point `ReplayPath` at a temp file, fake a win, and
/// load the file back to assert the metadata + move list match.
/// appended to the rolling [`solitaire_data::ReplayHistory`]. We
/// point `ReplayPath` at a temp file, fake a win, and load the
/// history back to assert the just-saved entry sits at the front
/// with the metadata + move list intact.
#[test]
fn replay_recording_freezes_into_replay_on_game_won() {
use solitaire_data::load_latest_replay_from;
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
let _ = std::fs::remove_file(&path);
@@ -1978,8 +2377,14 @@ mod tests {
});
app.update();
let loaded = load_latest_replay_from(&path)
let history = load_replay_history_from(&path)
.expect("a winning replay must be persisted to ReplayPath");
assert_eq!(
history.replays.len(),
1,
"fresh history must contain exactly the just-recorded win",
);
let loaded = &history.replays[0];
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
assert_eq!(loaded.draw_mode, DrawMode::DrawOne, "draw_mode must be captured");
assert_eq!(loaded.final_score, 4321, "final_score must come from the win event");
@@ -1998,6 +2403,53 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
/// Successive `GameWonEvent`s must accumulate in the rolling
/// history rather than overwriting one another. Pre-cap, every win
/// joins the front of `history.replays`.
#[test]
fn replay_recording_appends_to_history_across_wins() {
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
let _ = std::fs::remove_file(&path);
let mut app = test_app(11);
app.insert_resource(ReplayPath(Some(path.clone())));
// First win.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 60,
});
app.update();
// Second win — different score so we can distinguish.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(ReplayMove::StockClick);
recording.moves.push(ReplayMove::StockClick);
}
app.world_mut().write_message(GameWonEvent {
score: 200,
time_seconds: 120,
});
app.update();
let history = load_replay_history_from(&path).expect("history must exist");
assert_eq!(history.replays.len(), 2, "both wins must be retained");
// Newest first — second win lands at index 0.
assert_eq!(history.replays[0].final_score, 200);
assert_eq!(history.replays[1].final_score, 100);
let _ = std::fs::remove_file(&path);
}
/// `GameWonEvent` with an empty recording must NOT touch disk.
/// Without this guard, parallel-plugin tests that synthesise
/// win events for XP / streak / weekly-goal logic (without
@@ -2022,4 +2474,261 @@ mod tests {
"no replay must be written when recording is empty",
);
}
// -----------------------------------------------------------------------
// Solver-backed "Winnable deals only" toggle
//
// Exercises [`choose_winnable_seed`] and the wiring inside
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
// -----------------------------------------------------------------------
/// Inject a `SettingsResource` with the given `winnable_deals_only`
/// flag. The handle_new_game system already reads this resource via
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
let settings = solitaire_data::Settings {
winnable_deals_only,
..solitaire_data::Settings::default()
};
app.insert_resource(crate::settings_plugin::SettingsResource(settings));
}
#[test]
fn new_game_with_solver_toggle_off_uses_requested_seed() {
// Toggle off — the engine must use the seed it was handed and
// never invoke the solver. Seed 999 is just an arbitrary
// deterministic seed; the test asserts the resulting deal
// matches `GameState::new(999, DrawOne)`.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
mode: None,
confirmed: false,
});
app.update();
let actual_seed = app.world().resource::<GameStateResource>().0.seed;
assert_eq!(
actual_seed, 999,
"with solver toggle off, the requested seed must be honoured exactly"
);
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
let expected = GameState::new(999, DrawMode::DrawOne);
for i in 0..7 {
assert_eq!(
app.world().resource::<GameStateResource>().0.piles[&PileType::Tableau(i)].cards,
expected.piles[&PileType::Tableau(i)].cards,
"tableau column {i} must match the unfiltered seed",
);
}
}
#[test]
fn new_game_with_solver_toggle_off_random_seed_path() {
// When seed is None and toggle is off, the engine uses a
// system-time seed and skips the solver. We can't pin the
// exact seed, but we can assert the seed is *not* the
// sentinel zero (which would only happen if SystemTime is
// before the epoch — practically impossible), AND that no
// resource has been mutated to suggest the solver ran.
// The strongest assertion is "the move runs to completion
// without panicking", which the .update() call covers.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
}
#[test]
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
// Even with the toggle on, an *explicit* seed must be honoured:
// daily challenges, replay seeding, and challenge-mode all
// pass `Some(seed)` and must never be retried.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(123),
mode: None,
confirmed: false,
});
app.update();
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
123,
"explicit-seed requests must skip the solver retry loop",
);
}
#[test]
fn choose_winnable_seed_skips_unwinnable_seed() {
// Seed 394 was identified by the offline scan
// (`solver::tests::find_unwinnable`) as the only Unwinnable
// seed in 0..500 under the default solver budget. Seed 395
// resolves as Inconclusive — the engine treats Inconclusive
// as winnable (see `choose_winnable_seed` doc), so the
// helper must return 395 when started at 394.
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
assert_eq!(
chosen, 395,
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
);
}
#[test]
fn new_game_with_solver_toggle_on_retries_until_winnable() {
// End-to-end: with the toggle on, fire a NewGameRequestEvent
// with seed=None and *manually pre-seed* the system-time
// path by clearing the GameStateResource so handle_new_game
// takes the random branch. We can't easily inject the
// system-time seed here, so we exercise the helper via a
// separate call and assert the *resource* receives the
// post-retry seed when the helper would have rejected.
//
// We test the integration by setting up an alternative
// scenario: pass `seed: Some(394)` with toggle on. Our
// implementation already documents that explicit seeds skip
// the retry, so this *won't* trigger retry. The cleaner
// integration is captured in `choose_winnable_seed_skips_*`.
// Here we verify the default-seed path doesn't crash when
// toggle is on — exercising the live solver call inside
// handle_new_game without depending on the solver picking
// a specific seed.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(app.world().resource::<GameStateResource>().0.move_count, 0);
assert_eq!(
app.world().resource::<GameStateResource>().0.undo_stack_len(),
0
);
}
/// Async-solver flow: a winnable-only request with no explicit
/// seed must populate `PendingNewGameSeed` on the same frame the
/// request fires (no main-thread stall waiting on the solver),
/// and subsequent updates must clear the pending state and
/// produce a new GameState.
///
/// Drives multiple `app.update()` calls because the polling
/// system needs at least one tick after spawn to observe the
/// task as ready and re-emit the synthetic event.
#[test]
fn winnable_seed_search_runs_async_and_completes_eventually() {
let mut app = test_app(394);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
// First update: handle_new_game spawns the solver task and
// returns. The GameStateResource is unchanged on this tick —
// the player's previous game is still on screen, so the UI
// doesn't visually stall.
app.update();
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
"first frame should have an in-flight solver task",
);
// Pump frames until the polling system observes the task as
// ready and re-emits the synthetic event. AsyncComputeTaskPool
// is a shared pool across the whole `cargo test` run — when
// dozens of tests execute in parallel the pool can take a
// while to actually schedule our future. The yield_now() lets
// the pool's worker threads make progress between our polls
// without burning wall-clock time.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingNewGameSeed>().inner.is_some() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
"solver task should have completed within 15 s wall-clock",
);
// New game completed: a fresh deal carries 0 moves.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count,
0,
"completed new game must be in fresh-deal state",
);
}
/// Cancel-on-replace: a winnable-only request that arrives while
/// a previous solver task is in flight must drop the previous
/// task and queue the new one. The most recently-fired request
/// is the one whose seed wins, regardless of which task started
/// first.
#[test]
fn winnable_seed_search_drops_in_flight_task_on_new_request() {
let mut app = test_app(394);
insert_settings(&mut app, true);
// Fire the first request; first update spawns the task.
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
"first request should be in flight",
);
// Fire a SECOND request with an explicit seed before the
// first task can complete. handle_new_game's `pending.inner =
// None` line must drop the in-flight task; the explicit-seed
// branch then bypasses the solver entirely. After this tick
// the GameStateResource carries seed 12345, not whatever the
// solver would have picked for the first request.
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(12345),
mode: None,
confirmed: true,
});
app.update();
// Drive a few more ticks to drain any stragglers.
for _ in 0..5 {
app.update();
}
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
"explicit-seed request must have cancelled the in-flight task",
);
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
12345,
"explicit-seed request takes precedence over the dropped solver task",
);
}
}
+148 -49
View File
@@ -4,12 +4,14 @@
//! is an optional accelerator. Listed shortcuts are grouped by intent —
//! gameplay, modes, and overlays.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
@@ -24,6 +26,16 @@ pub struct HelpScreen;
#[derive(Component, Debug)]
pub struct HelpCloseButton;
/// Marker on the scrollable body Node inside the Help modal.
///
/// The controls reference is six sections totalling ~28 rows, which
/// overflows the modal on the 800x600 minimum window. This marker tags
/// the inner container that carries `Overflow::scroll_y()` plus a
/// `max_height` constraint so every row stays reachable. Mirrors the
/// `SettingsPanelScrollable` pattern.
#[derive(Component, Debug)]
pub struct HelpScrollable;
/// Spawns and despawns the help / controls overlay shown when the player
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
/// guides live here.
@@ -32,7 +44,14 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>()
.add_systems(Update, (toggle_help_screen, handle_help_close_button));
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the help-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
);
}
}
@@ -71,6 +90,32 @@ fn handle_help_close_button(
}
}
/// Routes mouse-wheel events into the Help modal's scrollable body while
/// the panel is open. No-op when no `HelpScrollable` exists in the world
/// (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_help_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
/// Each entry in the controls reference table.
struct ControlRow {
keys: &'static str,
@@ -139,6 +184,8 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlSection {
title: "Overlays",
rows: &[
ControlRow { keys: "M", description: "Mode launcher (Home)" },
ControlRow { keys: "P", description: "Profile" },
ControlRow { keys: "S", description: "Stats & progression" },
ControlRow { keys: "A", description: "Achievements" },
ControlRow { keys: "L", description: "Leaderboard" },
@@ -147,6 +194,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlRow { keys: "F11", description: "Toggle fullscreen" },
ControlRow { keys: "Esc", description: "Pause / resume" },
ControlRow { keys: "[ / ]", description: "SFX volume down / up" },
ControlRow { keys: "Enter", description: "Play Again (on the Win Summary)" },
],
},
];
@@ -165,62 +213,80 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
};
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Controls", font_res);
for section in CONTROL_SECTIONS {
// Section title in muted text — distinguishes from row content.
card.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
// Scrollable body — the controls reference is six sections totalling
// ~28 rows, which overflows the modal on the 800x600 minimum
// window. Wrapping in an `Overflow::scroll_y()` Node with a
// constrained `max_height` keeps every row reachable; the Done
// button below stays fixed outside the scroll.
card.spawn((
HelpScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
for section in CONTROL_SECTIONS {
// Section title in muted text — distinguishes from row content.
body.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
// Each row is a flex-row: kbd-style chip + description.
for row in section.rows {
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
// The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of
// the description text.
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(row.keys),
font_kbd.clone(),
// Each row is a flex-row: kbd-style chip + description.
for row in section.rows {
body.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
// The hotkey rendered as a small chip with a border —
// visual cue that it's a key reference, not part of
// the description text.
line.spawn((
Node {
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
min_width: Val::Px(64.0),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
Text::new(row.keys),
font_kbd.clone(),
TextColor(TEXT_PRIMARY),
));
});
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
});
line.spawn((
Text::new(row.description),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
// Section spacer — small empty box. Keeps each section
// visually grouped.
body.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
// Section spacer — small empty box. Keeps each section
// visually grouped.
card.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -233,6 +299,9 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
);
});
});
// Help is read-only — clicking the scrim outside the card dismisses
// alongside the existing F1 / Esc / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
}
#[cfg(test)]
@@ -264,6 +333,36 @@ mod tests {
);
}
#[test]
fn help_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
let count = app
.world_mut()
.query::<&HelpScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Help modal must spawn exactly one HelpScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<HelpScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn pressing_f1_twice_closes_help_screen() {
let mut app = headless_app();
+710 -33
View File
@@ -13,24 +13,34 @@
//! [`InfoToastEvent`] explaining the gate but does not launch the mode
//! or close the overlay.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::game_state::DrawMode;
use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
ToggleProfileRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::settings_plugin::{
SettingsChangedEvent, SettingsResource, SettingsStoragePath,
};
use crate::stats_plugin::StatsResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO,
TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD,
STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
@@ -46,6 +56,31 @@ pub struct HomeScreen;
#[derive(Component, Debug)]
pub struct HomeCancelButton;
/// Marker on the player-stats chip strip at the top of the Home modal.
/// Clicking the strip opens the Profile overlay so the player can drill
/// into level / XP / cosmetics without first dismissing Home.
#[derive(Component, Debug)]
struct HomeProfileChip;
/// Marker on the "Draw 1" toggle button inside the Home modal's
/// draw-mode row. Clicking flips `Settings.draw_mode` to `DrawOne` and
/// fires `SettingsChangedEvent` so audio / UI dependents react.
#[derive(Component, Debug)]
struct HomeDrawOneButton;
/// Marker on the "Draw 3" toggle button inside the Home modal's
/// draw-mode row. Mirror of [`HomeDrawOneButton`] for `DrawThree`.
#[derive(Component, Debug)]
struct HomeDrawThreeButton;
/// Marker on the scrollable inner Node containing the player chips,
/// draw-mode row, and tile grid. Wrapping these in a scrollable
/// container keeps the modal usable on small viewports — without it,
/// the 3-row tile stack pushes the Cancel button off the bottom of
/// the screen on 800x600 hardware. Mirrors `SettingsPanelScrollable`.
#[derive(Component, Debug)]
struct HomeScrollable;
// ---------------------------------------------------------------------------
// Private mode-card data shape
// ---------------------------------------------------------------------------
@@ -86,6 +121,38 @@ impl HomeMode {
}
}
/// Unicode glyph rendered as the picture-tile centrepiece. Stand-in
/// for real per-mode artwork — chosen for one-glyph-tells-the-mode
/// readability rather than visual fidelity. Swap to `Image` nodes
/// when art lands; the rest of the tile layout doesn't change.
///
/// Picks are constrained to **card suits** (U+2660-2666) and basic
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled
/// FiraMono-Medium face actually covers. Earlier choices in
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as
/// missing-glyph rectangles because FiraMono's coverage there is
/// minimal.
fn glyph(self) -> &'static str {
match self {
// Black club — card suit, the obvious solitaire mark.
HomeMode::Classic => "\u{2663}",
// Black diamond — Geometric Shapes; reads as the day's gem.
HomeMode::Daily => "\u{25C6}",
// White circle — Geometric Shapes; reads as the Zen enso.
HomeMode::Zen => "\u{25CB}",
// Black up-pointing triangle — Geometric Shapes; reads as
// a mountain / a step up in difficulty.
HomeMode::Challenge => "\u{25B2}",
// Rightwards arrow — Arrows block (U+2190-21FF), a core
// range every dev-oriented monospace font (FiraMono
// included) ships. Reads as "go / fast-forward" for the
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
// ships ▲ (up triangle) but evidently not the sideways
// siblings.
HomeMode::TimeAttack => "\u{2192}",
}
}
/// The keyboard accelerator that dispatches the same launch event,
/// shown in a small chip on the card.
fn hotkey(self) -> &'static str {
@@ -114,27 +181,69 @@ impl HomeMode {
#[derive(Component, Debug)]
struct HomeModeCard(HomeMode);
/// Tracks whether the launch-time Home modal has already been auto-shown
/// for this app session. Flipped to `true` by [`spawn_home_on_launch`]
/// the first time it spawns the modal, so the auto-show is one-shot per
/// process — subsequent dismissals (Cancel / mode pick) don't trigger
/// a respawn, but the player can still re-open the picker with `M`.
///
/// Other plugins (e.g. `game_plugin`'s restore-prompt handler) can flip
/// the flag manually to suppress the launch auto-show when the player
/// has already made a launch-time choice through a different surface.
#[derive(Resource, Debug, Default)]
pub struct LaunchHomeShown(pub bool);
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers the M-key toggle, the mode-card click handler, and the
/// Cancel-button handler.
pub struct HomePlugin;
///
/// `auto_show_on_launch` (default true) controls whether the picker
/// auto-spawns once the splash clears at app start. Headless tests use
/// [`HomePlugin::headless`] to opt out so each test starts with no
/// modal in the world.
pub struct HomePlugin {
auto_show_on_launch: bool,
}
impl Default for HomePlugin {
fn default() -> Self {
Self {
auto_show_on_launch: true,
}
}
}
impl HomePlugin {
/// Test-only constructor that disables the launch-time auto-show.
/// `MinimalPlugins` test setups don't include a splash, so the
/// gating system would otherwise fire on the first tick and
/// pre-spawn the modal that every test asserts is absent.
pub fn headless() -> Self {
Self {
auto_show_on_launch: false,
}
}
}
impl Plugin for HomePlugin {
fn build(&self, app: &mut App) {
// Be defensive about message registration so HomePlugin works
// standalone in tests (the actual handlers live in
// input_plugin / challenge_plugin / time_attack_plugin /
// daily_challenge_plugin, but those plugins might not be
// installed in a tightly-scoped headless app).
app.add_message::<NewGameRequestEvent>()
// Pre-mark the auto-show as already done in headless mode so the
// gating system is a permanent no-op for tests.
app.insert_resource(LaunchHomeShown(!self.auto_show_on_launch))
.add_message::<NewGameRequestEvent>()
.add_message::<StartZenRequestEvent>()
.add_message::<StartChallengeRequestEvent>()
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>()
// Defensively register MouseWheel so `scroll_home_panel`
// runs cleanly under MinimalPlugins headless tests too.
.add_message::<MouseWheel>()
// `.chain()` because several systems (M-toggle, card click,
// cancel button, digit-key shortcut) all read the
// `HomeScreen` entity and may queue a despawn on it in the
@@ -146,25 +255,92 @@ impl Plugin for HomePlugin {
.add_systems(
Update,
(
spawn_home_on_launch,
toggle_home_screen,
attach_focusable_to_home_mode_cards,
handle_home_card_click,
handle_home_cancel_button,
handle_home_profile_chip,
handle_home_draw_mode_buttons,
handle_home_digit_keys,
)
.chain(),
);
)
.add_systems(Update, scroll_home_panel);
}
}
// ---------------------------------------------------------------------------
// Auto-show on launch
// ---------------------------------------------------------------------------
/// Auto-spawns the Home / mode-picker modal once per app session, so
/// the player lands on a deliberate "what mode do I want to play"
/// screen instead of the default Classic deal.
///
/// Gated on the launch-time UI being clear:
///
/// * `SplashRoot` must be gone — the splash owns the foreground during
/// the brand beat and the home modal appearing under it would feel
/// like a flash of half-rendered UI.
/// * `RestorePromptScreen` must not be open and `PendingRestoredGame`
/// must be empty — when the player has a saved in-progress game the
/// restore prompt takes precedence; the home picker would compete
/// with it for attention.
/// * `HomeScreen` must not already exist (defensive — e.g. the player
/// pressed `M` between ticks).
/// * `LaunchHomeShown` flips to `true` after the first spawn so this
/// system becomes a no-op for the rest of the session. Cancelling
/// the modal therefore goes to the underlying default deal rather
/// than respawning the picker.
#[allow(clippy::too_many_arguments)]
fn spawn_home_on_launch(
mut commands: Commands,
mut shown: ResMut<LaunchHomeShown>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
existing: Query<(), With<HomeScreen>>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
) {
if shown.0
|| !splash.is_empty()
|| !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|| !existing.is_empty()
{
return;
}
spawn_home_screen(
&mut commands,
build_home_context(
progress.as_deref(),
stats.as_deref(),
settings.as_deref(),
daily.as_deref(),
font_res.as_deref(),
),
);
shown.0 = true;
}
// ---------------------------------------------------------------------------
// M-key toggle
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
fn toggle_home_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>,
) {
@@ -174,8 +350,54 @@ fn toggle_home_screen(
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
let level = progress.as_ref().map_or(0, |p| p.0.level);
spawn_home_screen(&mut commands, level, font_res.as_deref());
spawn_home_screen(
&mut commands,
build_home_context(
progress.as_deref(),
stats.as_deref(),
settings.as_deref(),
daily.as_deref(),
font_res.as_deref(),
),
);
}
}
/// Builds a [`HomeContext`] from the live resources the Home modal
/// reads. Falls back to safe defaults when a resource is missing
/// (typical for `MinimalPlugins` headless tests that don't install
/// every contributor plugin).
fn build_home_context<'a>(
progress: Option<&ProgressResource>,
stats: Option<&StatsResource>,
settings: Option<&SettingsResource>,
daily: Option<&DailyChallengeResource>,
font_res: Option<&'a FontResource>,
) -> HomeContext<'a> {
let daily_today = daily.map(|d| {
let completed_today = progress
.and_then(|p| p.0.daily_challenge_last_completed)
.is_some_and(|d_last| d_last == d.date);
DailyToday {
date_label: d.date.format("%b %-d").to_string(),
goal: d.goal_description.clone(),
completed_today,
}
});
HomeContext {
level: progress.map_or(0, |p| p.0.level),
total_xp: progress.map_or(0, |p| p.0.total_xp),
daily_streak: progress.map_or(0, |p| p.0.daily_challenge_streak),
lifetime_score: stats.map_or(0, |s| s.0.lifetime_score),
classic_best: stats.map_or(0, |s| s.0.classic_best_score),
zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings
.map(|s| s.0.draw_mode.clone())
.unwrap_or(DrawMode::DrawOne),
font_res,
}
}
@@ -250,10 +472,22 @@ fn handle_home_card_click(
fn handle_home_cancel_button(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
cancel_buttons: Query<&Interaction, (With<HomeCancelButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
) {
if !cancel_buttons.iter().any(|i| *i == Interaction::Pressed) {
if screens.is_empty() {
return;
}
let click = cancel_buttons.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
// Esc only closes Home when it is the *topmost* modal. With Profile
// (or any other ModalScrim) layered on top, the topmost owns the
// dismissal — without this gate a single Esc closed the back
// modal (Home) and left the front modal orphaned.
let esc_targets_home = esc && other_modal_scrims.is_empty();
if !click && !esc_targets_home {
return;
}
for entity in &screens {
@@ -261,6 +495,115 @@ fn handle_home_cancel_button(
}
}
// ---------------------------------------------------------------------------
// Header chip + draw-mode button handlers
// ---------------------------------------------------------------------------
/// Routes mouse-wheel events into the Home modal's scrollable body
/// while the modal is open. No-op when no `HomeScrollable` exists in
/// the world (modal closed). Mirrors `scroll_settings_panel` and
/// `scroll_leaderboard_panel`.
fn scroll_home_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<HomeScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
/// Click on the player-stats header chip → fire
/// [`ToggleProfileRequestEvent`] so the Profile overlay opens on top
/// of Home. Closing Profile (`P` / `Esc`) returns the player to the
/// Home picker without losing their context.
fn handle_home_profile_chip(
chips: Query<&Interaction, (With<HomeProfileChip>, Changed<Interaction>)>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
) {
if chips.iter().any(|i| *i == Interaction::Pressed) {
profile.write(ToggleProfileRequestEvent);
}
}
/// Click on a draw-mode chip — flip `Settings.draw_mode`, persist,
/// fire `SettingsChangedEvent`, and respawn the Home modal so the
/// active-chip styling reflects the new state. Repaint by full
/// rebuild keeps the helper code small (no per-entity colour
/// surgery) and the modal is light enough to respawn cleanly.
#[allow(clippy::too_many_arguments)]
fn handle_home_draw_mode_buttons(
mut commands: Commands,
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HomeScreen>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut changed: MessageWriter<SettingsChangedEvent>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
) {
if screens.is_empty() {
return;
}
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
if !want_one && !want_three {
return;
}
let Some(settings) = settings.as_mut() else {
return;
};
let target = if want_one {
DrawMode::DrawOne
} else {
DrawMode::DrawThree
};
if settings.0.draw_mode == target {
return; // already in this mode — avoid a redundant respawn.
}
settings.0.draw_mode = target;
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("home: failed to persist draw-mode change: {e}");
}
changed.write(SettingsChangedEvent(settings.0.clone()));
// Repaint by despawn + respawn so the chip styling and any
// dependent labels (none today, but Phase B may surface a
// "Standard (Draw 1)" caption like MSSC) reflect the new state.
for entity in &screens {
commands.entity(entity).despawn();
}
spawn_home_screen(
&mut commands,
build_home_context(
progress.as_deref(),
stats.as_deref(),
Some(settings),
daily.as_deref(),
font_res.as_deref(),
),
);
}
// ---------------------------------------------------------------------------
// Digit-key shortcuts (1-5) — modal-scoped
// ---------------------------------------------------------------------------
@@ -357,20 +700,95 @@ fn handle_home_digit_keys(
// Spawn helpers
// ---------------------------------------------------------------------------
/// Spawns the Home modal with five mode cards plus a Cancel button.
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
/// Bundles the data the Home modal needs to render the new
/// MSSC-inspired header chips, per-mode score chips, and draw-mode
/// row. Built fresh by the two call sites (`spawn_home_on_launch`
/// and `toggle_home_screen`) from the live progress / stats /
/// settings resources, with sensible defaults when a resource is
/// missing under `MinimalPlugins` headless tests.
struct HomeContext<'a> {
level: u32,
total_xp: u64,
lifetime_score: u64,
classic_best: u32,
zen_best: u32,
challenge_best: u32,
daily_streak: u32,
daily_today: Option<DailyToday>,
draw_mode: DrawMode,
font_res: Option<&'a FontResource>,
}
/// Today's daily-challenge metadata as the Home picker needs it. Only
/// populated when both [`DailyChallengeResource`] is present (the
/// plugin is wired) and we have something useful to show — otherwise
/// the Daily card falls back to its baseline description without a
/// dated callout.
struct DailyToday {
/// Short calendar label, e.g. `"May 6"`. Always populated.
date_label: String,
/// Server-supplied goal copy ("Win in under 5 minutes"). `None`
/// when no server backend is wired or the fetch hasn't returned.
goal: Option<String>,
/// `true` when the player has already recorded today's daily.
/// Surfaces a "Done" badge so the picker reads as reward-state
/// rather than "you still owe today's run".
completed_today: bool,
}
/// Spawns the Home modal with the player-stats header strip, draw-mode
/// row, five mode cards, and a Cancel button.
fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
let HomeContext { font_res, .. } = ctx;
let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Choose a Mode", font_res);
for mode in [
HomeMode::Classic,
HomeMode::Daily,
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
] {
spawn_mode_card(card, mode, level, font_res);
}
// Scrollable middle — chips + draw row + tile grid. Constrained
// to 70vh so the modal fits on small viewports (the 5-tile
// grid alone is ~540 px). Cancel button sits outside this
// node so it's always one click away.
card.spawn((
HomeScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
spawn_home_header_chips(body, &ctx);
spawn_draw_mode_row(body, &ctx);
// Mode tiles in a wrapping 2-column grid. Each tile takes 48%
// of the row so column_gap fits comfortably; the 5 modes wrap
// to a third row of one tile, which we leave left-aligned —
// the asymmetry matches MSSC's "Daily Challenges / Today's
// Event" half-cell on the right of their grid and keeps the
// visual rhythm.
body.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_3,
column_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
..default()
})
.with_children(|grid| {
for mode in [
HomeMode::Classic,
HomeMode::Daily,
HomeMode::Zen,
HomeMode::Challenge,
HomeMode::TimeAttack,
] {
spawn_mode_card(grid, mode, &ctx);
}
});
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -383,6 +801,190 @@ fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&Font
);
});
});
// Home is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
/// Player-stats chip strip — Level, XP, Lifetime Score. Clickable as a
/// whole to open the Profile overlay (mirrors the MSSC top-right
/// avatar+rewards corner that surfaces level + premium status). Falls
/// back to plain Text in headless contexts where `Button` interaction
/// isn't driven by the input pipeline anyway.
fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_value = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
parent
.spawn((
HomeProfileChip,
Button,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
column_gap: VAL_SPACE_2,
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
width: Val::Percent(100.0),
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|row| {
for (label, value) in [
("Level".to_string(), format_compact(ctx.level as u64)),
("XP".to_string(), format_compact(ctx.total_xp)),
("Score".to_string(), format_compact(ctx.lifetime_score)),
] {
row.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_1,
..default()
})
.with_children(|col| {
col.spawn((
Text::new(label),
font_label.clone(),
TextColor(TEXT_SECONDARY),
));
col.spawn((
Text::new(value),
font_value.clone(),
TextColor(ACCENT_PRIMARY),
));
});
}
});
}
/// Draw-mode row — "Draw 1" / "Draw 3" toggle. Affects the next Classic
/// deal (the Settings value the new-game flow reads). Surfacing it on
/// the Home modal keeps the per-game choice one tap away rather than
/// buried in Settings, mirroring the dropdown MSSC puts on its
/// difficulty picker.
fn spawn_draw_mode_row(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<'_>) {
let font_handle = ctx.font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_label = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_btn = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
let active_one = matches!(ctx.draw_mode, DrawMode::DrawOne);
parent
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
row.spawn((
Text::new("Draw mode"),
font_label.clone(),
TextColor(TEXT_SECONDARY),
));
spawn_draw_mode_chip::<HomeDrawOneButton>(
row,
HomeDrawOneButton,
"Draw 1",
active_one,
&font_btn,
);
spawn_draw_mode_chip::<HomeDrawThreeButton>(
row,
HomeDrawThreeButton,
"Draw 3",
!active_one,
&font_btn,
);
});
}
fn spawn_draw_mode_chip<M: Component>(
parent: &mut ChildSpawnerCommands,
marker: M,
label: &str,
active: bool,
font: &TextFont,
) {
let (bg, fg) = if active {
(ACCENT_PRIMARY, BG_ELEVATED)
} else {
(BG_ELEVATED_HI, TEXT_PRIMARY)
};
parent
.spawn((
marker,
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_1),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|c| {
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
});
}
/// Compact decimal formatter: `1234567` → `"1.2M"`, `12345` → `"12.3K"`,
/// otherwise the raw number with thousands separators. Keeps chip text
/// short enough to fit a 3-up header strip without wrapping.
fn format_compact(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 10_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else if n >= 1_000 {
let (high, low) = (n / 1_000, n % 1_000);
format!("{high},{low:03}")
} else {
n.to_string()
}
}
/// Per-mode score / streak chip text. `None` for modes where no
/// per-mode best exists yet (Time Attack uses session scoring; modes
/// with `0` recorded mean "no win yet" and we hide the chip rather
/// than show a 0).
fn score_chip_text_for(mode: HomeMode, ctx: &HomeContext<'_>) -> Option<String> {
match mode {
HomeMode::Classic if ctx.classic_best > 0 => {
Some(format!("Best {}", format_compact(ctx.classic_best as u64)))
}
HomeMode::Zen if ctx.zen_best > 0 => {
Some(format!("Best {}", format_compact(ctx.zen_best as u64)))
}
HomeMode::Challenge if ctx.challenge_best > 0 => {
Some(format!("Best {}", format_compact(ctx.challenge_best as u64)))
}
HomeMode::Daily if ctx.daily_streak > 0 => {
Some(format!("Streak {}", ctx.daily_streak))
}
_ => None,
}
}
/// Tab-walk order for each mode card, matching the visual top-to-bottom
@@ -456,9 +1058,11 @@ fn attach_focusable_to_home_mode_cards(
fn spawn_mode_card(
parent: &mut ChildSpawnerCommands,
mode: HomeMode,
level: u32,
font_res: Option<&FontResource>,
ctx: &HomeContext<'_>,
) {
let level = ctx.level;
let font_res = ctx.font_res;
let score_chip = score_chip_text_for(mode, ctx);
let unlocked = mode.is_unlocked(level);
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_title = TextFont {
@@ -472,10 +1076,17 @@ fn spawn_mode_card(
..default()
};
let font_chip = TextFont {
font: font_handle,
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
// Glyph rendered at display size — Unicode emoji standing in for
// the per-mode artwork. Centred at the top of the tile.
let font_glyph = TextFont {
font: font_handle,
font_size: TYPE_DISPLAY,
..default()
};
// Locked cards mute their text to communicate the disabled state at
// a glance; the explicit "Unlocks at level N" caption underneath
@@ -483,6 +1094,7 @@ fn spawn_mode_card(
let title_color = if unlocked { TEXT_PRIMARY } else { TEXT_DISABLED };
let desc_color = if unlocked { TEXT_SECONDARY } else { TEXT_DISABLED };
let border_color = if unlocked { BORDER_SUBTLE } else { BORDER_STRONG };
let glyph_color = if unlocked { ACCENT_PRIMARY } else { TEXT_DISABLED };
parent
.spawn((
@@ -493,9 +1105,13 @@ fn spawn_mode_card(
Button,
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
row_gap: VAL_SPACE_2,
padding: UiRect::all(VAL_SPACE_3),
width: Val::Percent(100.0),
// 48% per tile + the row's column_gap = a clean 2-up
// grid that wraps to a single tile on the third row.
width: Val::Percent(48.0),
min_height: Val::Px(180.0),
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
@@ -504,12 +1120,20 @@ fn spawn_mode_card(
BorderColor::all(border_color),
))
.with_children(|c| {
// Centerpiece glyph — placeholder for real per-mode art.
c.spawn((
Text::new(mode.glyph().to_string()),
font_glyph.clone(),
TextColor(glyph_color),
));
// Title row — title text on the left, hotkey chip on the right.
c.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
column_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
..default()
})
.with_children(|row| {
@@ -559,6 +1183,59 @@ fn spawn_mode_card(
TextColor(desc_color),
));
// Per-mode score / streak chip — populated only when the
// player has data for this mode. Hidden on a 0 best so a
// fresh profile doesn't show "Best 0" everywhere.
if let Some(text) = score_chip.clone()
&& unlocked
{
c.spawn((
Text::new(text),
font_chip.clone(),
TextColor(ACCENT_PRIMARY),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
}
// Daily-only "Today's Event" caption — date, optional
// server goal, and a "Done" badge once the player has
// already recorded today's completion. Only renders for
// the Daily card when DailyChallengeResource is present.
if matches!(mode, HomeMode::Daily)
&& unlocked
&& let Some(today) = ctx.daily_today.as_ref()
{
let date_text = if today.completed_today {
format!("Today, {} \u{2022} Done", today.date_label)
} else {
format!("Today, {}", today.date_label)
};
let date_color = if today.completed_today {
ACCENT_PRIMARY
} else {
STATE_INFO
};
c.spawn((
Text::new(date_text),
font_chip.clone(),
TextColor(date_color),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
if let Some(goal) = today.goal.as_ref() {
c.spawn((
Text::new(format!("Goal: {goal}")),
font_chip.clone(),
TextColor(TEXT_SECONDARY),
));
}
}
// Locked footnote — explicit copy so the gate is unambiguous.
if !unlocked {
c.spawn((
@@ -599,7 +1276,7 @@ mod tests {
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(HomePlugin);
.add_plugins(HomePlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
@@ -889,7 +1566,7 @@ mod tests {
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(ProgressPlugin::headless())
.add_plugins(HomePlugin);
.add_plugins(HomePlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
+82 -1
View File
@@ -62,6 +62,18 @@ pub struct HudMode;
#[derive(Component, Debug)]
pub struct HudChallenge;
/// Marker on the "won this deal before" indicator text node.
///
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
/// mode triple matches one of the entries in `ReplayHistoryResource`.
/// Empty string otherwise (including won games — the score readout
/// already conveys the win on the active deal). Only meaningful for
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
/// are filtered out implicitly because their replay entries always
/// carry a different mode tag.
#[derive(Component, Debug)]
pub struct HudWonPreviously;
/// Marker on the undo-count text node.
///
/// Shows how many undos have been used this game. Displayed in amber when
@@ -194,6 +206,16 @@ pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
#[derive(Component, Debug)]
pub struct ActionButton;
/// Marker on rows inside a popover panel ([`ModesPopover`] or
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
/// hover/press paint path applies to them, but the auto-fade applied
/// to the top-level action bar must NOT also fade these rows — the
/// popover only renders when the player has explicitly opened it, so
/// its content should always be at full opacity. `apply_action_fade`
/// excludes entities with this marker via `Without<PopoverRow>`.
#[derive(Component, Debug)]
pub struct PopoverRow;
/// Marker on the "New Game" action button anchored top-right of the play
/// area. Click fires [`NewGameRequestEvent`]; the existing
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
@@ -302,6 +324,7 @@ impl Plugin for HudPlugin {
.init_resource::<HudActionFade>()
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
.add_systems(Update, update_hud.after(GameMutation))
.add_systems(Update, update_won_previously.after(GameMutation))
.add_systems(Update, announce_auto_complete.after(GameMutation))
.add_systems(Update, update_selection_hud)
.add_systems(
@@ -481,6 +504,15 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
font_body.clone(),
TextColor(STATE_INFO),
));
t2.spawn((
HudWonPreviously,
Tooltip::new(
"You've won this deal before. Same seed in your replay history.",
),
Text::new(""),
font_body.clone(),
TextColor(STATE_SUCCESS),
));
});
// Tier 3 — penalty / bonus. Undos and Recycles share the
@@ -834,6 +866,7 @@ fn spawn_modes_popover(
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
@@ -987,6 +1020,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
@@ -1117,9 +1151,20 @@ fn update_action_fade(
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
/// same frame doesn't override the fade with an opaque idle / hover
/// colour.
#[allow(clippy::type_complexity)]
fn apply_action_fade(
fade: Res<HudActionFade>,
mut buttons: Query<(&Children, &mut BackgroundColor), With<ActionButton>>,
// Excludes `PopoverRow` so the auto-fade only applies to the
// top-level action bar buttons. Popover rows live inside an
// explicitly-opened dropdown panel and need to stay visible
// regardless of the bar's fade state — without the exclusion
// the rows fade to invisible while the popover container stays
// visible, leaving a solid background block with no readable
// content.
mut buttons: Query<
(&Children, &mut BackgroundColor),
(With<ActionButton>, Without<PopoverRow>),
>,
mut text_q: Query<&mut TextColor>,
) {
for (children, mut bg) in &mut buttons {
@@ -1480,6 +1525,42 @@ fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
)
}
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
/// current deal's seed + draw_mode + mode triple matches an entry in
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
/// (the on-screen "Game won!" cue already conveys victory) and on
/// fresh deals the player hasn't won before.
///
/// Lives in its own system rather than `update_hud` to keep this
/// orthogonal: `update_hud`'s query disambiguation is already busy
/// enough; threading another marker through every Without filter
/// would touch ~10 unrelated queries for no benefit.
fn update_won_previously(
game: Res<GameStateResource>,
// Optional because the HUD plugin's headless tests run without
// `StatsPlugin` and therefore without this resource. With the
// resource absent there's no history to compare against; the
// indicator just stays empty.
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut q: Query<&mut Text, With<HudWonPreviously>>,
) {
let Ok(mut text) = q.single_mut() else {
return;
};
let won_before = !game.0.is_won
&& history.as_ref().is_some_and(|h| {
h.0.replays.iter().any(|r| {
r.seed == game.0.seed
&& r.draw_mode == game.0.draw_mode
&& r.mode == game.0.mode
})
});
let next = if won_before { "\u{2713} Won before" } else { "" };
if text.0 != next {
text.0 = next.to_string();
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn update_hud(
game: Res<GameStateResource>,
+203 -113
View File
@@ -35,15 +35,15 @@ use crate::card_plugin::{
CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, TABLEAU_FACEDOWN_FAN_FRAC,
TABLEAU_FAN_FRAC,
};
use crate::ui_theme::MOTION_DRAG_REJECT_SECS;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_WARNING};
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::events::{
DrawRequestEvent, ForfeitRequestEvent, HintVisualEvent, InfoToastEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameConfirmEvent, NewGameRequestEvent, StartZenRequestEvent,
StateChangedEvent, UndoRequestEvent,
MoveRequestEvent, NewGameRequestEvent, StartZenRequestEvent, StateChangedEvent,
UndoRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen};
use crate::pause_plugin::PausedResource;
use crate::progress_plugin::ProgressResource;
use crate::layout::{Layout, LayoutResource};
@@ -54,21 +54,15 @@ use crate::time_attack_plugin::TimeAttackResource;
/// Z-depth used for cards while being dragged — above all resting cards.
const DRAG_Z: f32 = 500.0;
/// Shared countdown state for the new-game double-press confirmation
/// flow.
/// Solver budgets used by the H-key hint system.
///
/// Using a resource (instead of `Local`) lets the keyboard sub-systems
/// share the same countdown state without needing to pass values
/// between them. Forfeit no longer has a keyboard countdown — `G` now
/// fires `ForfeitRequestEvent` and `PausePlugin` shows a real
/// `ForfeitConfirmScreen` modal.
#[derive(Resource, Debug, Default)]
struct KeyboardConfirmState {
/// Seconds remaining in the new-game confirmation window (> 0 while open).
new_game_countdown: f32,
/// True while we are waiting for the second N press to confirm a new game.
new_game_pending: bool,
}
/// Wraps `solitaire_core::solver::SolverConfig` as a Bevy resource so
/// tests can inject tighter budgets to exercise the heuristic-fallback
/// path. Production initialises this to `SolverConfig::default()` (100k
/// move / 200k state budgets, the same numbers the new-game retry loop
/// uses).
#[derive(Resource, Debug, Clone, Default)]
pub struct HintSolverConfig(pub solitaire_core::solver::SolverConfig);
/// Registers keyboard, mouse, and touch input systems.
///
@@ -89,8 +83,8 @@ pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<HintCycleIndex>()
.init_resource::<KeyboardConfirmState>()
.add_message::<NewGameConfirmEvent>()
.init_resource::<HintSolverConfig>()
.init_resource::<crate::pending_hint::PendingHintTask>()
.add_message::<StartZenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ForfeitRequestEvent>()
@@ -116,13 +110,21 @@ impl Plugin for InputPlugin {
.chain(),
)
.add_systems(Update, handle_fullscreen)
.add_systems(Update, reset_hint_cycle_on_state_change);
.add_systems(Update, reset_hint_cycle_on_state_change)
// Async hint pipeline: state-change drop runs before the
// poll system so a move applied this frame cancels any
// in-flight task before its result can be surfaced.
.add_systems(
Update,
(
crate::pending_hint::drop_pending_hint_on_state_change,
crate::pending_hint::poll_pending_hint_task,
)
.chain(),
);
}
}
/// Seconds after the first N press during which a second N confirms new game.
const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
/// Bundles the event writers needed by the core keyboard handler.
///
/// Keeping these in a [`SystemParam`] avoids hitting Bevy's 16-parameter limit.
@@ -130,43 +132,39 @@ const NEW_GAME_CONFIRM_WINDOW: f32 = 3.0;
struct CoreKeyboardMessages<'w> {
undo: MessageWriter<'w, UndoRequestEvent>,
new_game: MessageWriter<'w, NewGameRequestEvent>,
confirm_event: MessageWriter<'w, NewGameConfirmEvent>,
info_toast: MessageWriter<'w, InfoToastEvent>,
draw: MessageWriter<'w, DrawRequestEvent>,
}
/// Handles the core keyboard shortcuts: U (undo), N (new game + confirmation
/// window), Z (zen mode), D / Space (draw), and ticks down the new-game
/// confirmation countdown each frame.
/// Handles the core keyboard shortcuts: U (undo), N (new game), Z (zen mode),
/// D / Space (draw).
///
/// `N` fires `NewGameRequestEvent` straight through; the existing
/// `handle_new_game` flow shows the `ConfirmNewGameScreen` modal when
/// the current game is in progress, so a single press surfaces a real
/// Confirm / Cancel UI instead of a "press N again" toast. `Shift+N`
/// keeps the keyboard power-user bypass by setting `confirmed: true`.
///
/// While the confirm modal or the restore prompt is already open, the
/// system skips the N branch so those modals' own input handlers can
/// process N (cancel / start-new-game) without us re-firing a request
/// the same frame.
#[allow(clippy::too_many_arguments)]
fn handle_keyboard_core(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
progress: Option<Res<ProgressResource>>,
game: Option<Res<GameStateResource>>,
time: Res<Time>,
mut confirm: ResMut<KeyboardConfirmState>,
mut ev: CoreKeyboardMessages<'_>,
mut time_attack: Option<ResMut<TimeAttackResource>>,
selection: Option<Res<SelectionState>>,
mut zen_requests: MessageReader<StartZenRequestEvent>,
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
restore_prompts: Query<(), With<RestorePromptScreen>>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Tick down the new-game confirmation window each frame.
if confirm.new_game_countdown > 0.0 {
confirm.new_game_countdown -= time.delta_secs();
if confirm.new_game_countdown <= 0.0 {
confirm.new_game_countdown = 0.0;
if confirm.new_game_pending {
confirm.new_game_pending = false;
ev.info_toast.write(InfoToastEvent("New game cancelled".to_string()));
}
}
}
if keys.just_pressed(KeyCode::KeyU) {
ev.undo.write(UndoRequestEvent);
}
@@ -183,27 +181,24 @@ fn handle_keyboard_core(
mode: Some(solitaire_core::game_state::GameMode::Classic),
confirmed: false,
});
confirm.new_game_countdown = 0.0;
return;
}
let active_game = game.as_ref().is_some_and(|g| g.0.move_count > 0 && !g.0.is_won);
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
if shift_held || !active_game {
// Shift+N or no active game — start immediately, no confirmation.
ev.new_game.write(NewGameRequestEvent::default());
confirm.new_game_countdown = 0.0;
confirm.new_game_pending = false;
} else if confirm.new_game_countdown > 0.0 {
// Second press within the window — confirmed.
ev.new_game.write(NewGameRequestEvent::default());
confirm.new_game_countdown = 0.0;
confirm.new_game_pending = false;
// The confirm modal and restore prompt own N while they're up —
// they cancel / accept respectively. Skipping here prevents us
// from firing a fresh request the same frame those modals close.
if !confirm_screens.is_empty() || !restore_prompts.is_empty() {
// intentional: defer to those modals' input handlers.
} else {
// First press on an active game — require confirmation.
confirm.new_game_countdown = NEW_GAME_CONFIRM_WINDOW;
confirm.new_game_pending = true;
ev.confirm_event.write(NewGameConfirmEvent);
let shift_held = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
ev.new_game.write(NewGameRequestEvent {
seed: None,
mode: None,
// Shift+N skips the confirm modal for keyboard power-users;
// bare N falls through `handle_new_game`'s active-game check
// and shows the modal when a game is in progress.
confirmed: shift_held,
});
}
}
@@ -236,22 +231,29 @@ fn handle_keyboard_core(
// Esc is handled by `PausePlugin` (overlay toggle + paused flag).
}
/// Handles the H key: cycles through all available hints, highlighting the
/// source card yellow for 2 s and showing a descriptive toast.
/// Handles the H key: spawn an async solver task on
/// `AsyncComputeTaskPool` whose result `pending_hint::poll_pending_hint_task`
/// turns into hint visuals one frame later.
///
/// The hint index wraps around once all hints have been cycled through. When no
/// moves are available a "No hints available" toast is shown instead.
#[allow(clippy::too_many_arguments)]
/// Median solve time is ~2 ms but pathological positions can hit the
/// `SolverConfig::default()` cap at ~120 ms; running synchronously
/// (the v0.17.0 behaviour) blocked the main thread on the same frame
/// the player pressed H. Cancel-on-replace lives in
/// `PendingHintTask::spawn` — a fresh H press while a previous task
/// is in flight drops the previous task's handle.
///
/// Special-cases: when the game is already won, surface a "Game won!"
/// toast instead of asking the solver. The poll system handles the
/// "no legal moves" toast on the heuristic fallback path so the
/// handler here only needs to dispatch.
fn handle_keyboard_hint(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
mut hint_cycle: ResMut<HintCycleIndex>,
mut commands: Commands,
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
solver_config: Res<HintSolverConfig>,
mut pending_hint: ResMut<crate::pending_hint::PendingHintTask>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut hint_visual: MessageWriter<HintVisualEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
@@ -269,23 +271,51 @@ fn handle_keyboard_hint(
let Some(_layout_res) = layout else { return };
let hints = all_hints(&g.0);
if hints.is_empty() {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
pending_hint.spawn(g.0.clone(), solver_config.0);
}
// Pick the hint at the current cycle index (wrapping) and advance.
/// Heuristic hint helper used by `pending_hint::poll_pending_hint_task`
/// when the solver returns `Inconclusive` or `Unwinnable`.
///
/// Picks the hint at `HintCycleIndex % hints.len()` (wrapping) and
/// advances the index so successive H presses on a stuck position
/// cycle through every legal move. Returns `None` when no legal move
/// exists at all — the caller surfaces a "No hints available" toast.
pub fn find_heuristic_hint(
game: &GameState,
hint_cycle: &mut HintCycleIndex,
) -> Option<(PileType, PileType)> {
let hints = all_hints(game);
if hints.is_empty() {
return None;
}
let idx = hint_cycle.0 % hints.len();
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
let (from, to, _count) = &hints[idx];
let (from, to, _count) = hints[idx].clone();
Some((from, to))
}
/// Apply the visual + toast effects for a single chosen hint move.
///
/// Shared between the solver-driven and heuristic-driven hint paths so
/// both produce identical player-facing feedback. Called from
/// `pending_hint::poll_pending_hint_task` once the async solver task
/// resolves.
pub fn emit_hint_visuals(
game: &GameState,
from: &PileType,
to: &PileType,
commands: &mut Commands,
mut card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
info_toast: &mut MessageWriter<InfoToastEvent>,
hint_visual: &mut MessageWriter<HintVisualEvent>,
) {
// When the hint points at the stock (draw suggestion) there is no
// face-up card to highlight — show a toast instead.
// If the stock is empty, pressing D will recycle the waste rather
// than draw a card, so the toast text must reflect that.
if *from == PileType::Stock {
let stock_empty = g.0.piles
let stock_empty = game.piles
.get(&PileType::Stock)
.is_some_and(|p| p.cards.is_empty());
let msg = if stock_empty {
@@ -298,15 +328,18 @@ fn handle_keyboard_hint(
}
// Find the top face-up card in the source pile and highlight it.
let top_card_id = g.0.piles.get(from)
let top_card_id = game.piles.get(from)
.and_then(|p| p.cards.last().filter(|c| c.face_up))
.map(|c| c.id);
if let Some(card_id) = top_card_id {
for (entity, card_entity, mut sprite) in card_entities.iter_mut() {
if card_entity.card_id == card_id {
// Tint the card gold without replacing the Sprite (which would
// discard the image handle set by CardImageSet).
sprite.color = Color::srgba(1.0, 1.0, 0.4, 1.0);
// discard the image handle set by CardImageSet). Uses the
// design-system `STATE_WARNING` token so the source-card
// tint matches the destination pile highlight, both of
// which signal "look here" for the hint.
sprite.color = STATE_WARNING;
commands.entity(entity)
.insert(HintHighlight { remaining: 2.0 })
.insert(HintHighlightTimer(2.0));
@@ -327,7 +360,7 @@ fn handle_keyboard_hint(
// player keeps thinking in suit terms; otherwise fall back to "foundation".
let msg = match to {
PileType::Foundation(_) => {
let claimed = g.0.piles.get(to).and_then(|p| p.claimed_suit());
let claimed = game.piles.get(to).and_then(|p| p.claimed_suit());
if let Some(suit) = claimed {
let suit_name = match suit {
Suit::Clubs => "Clubs",
@@ -614,10 +647,23 @@ fn end_drag(
}
// If the drag was never committed (user tapped without moving far enough),
// treat it as a click: just cancel the pending drag and resync card positions.
// treat it as a click: cancel the pending drag and exit. We deliberately
// do NOT fire `StateChangedEvent` here — `start_drag` only mutates the
// `DragState` resource on press, never card transforms, so an uncommitted
// drag has no visual side effect to undo.
//
// Firing one would race a CardAnim that's already in flight on the same
// card. Specifically: on a successful double-click, `handle_double_click`
// fires `MoveRequestEvent`, `start_drag` picks the card up the same
// frame (uncommitted), and `handle_move` queues a `StateChangedEvent` →
// `sync_cards_on_change` starts a slide animation. When the player
// releases the button mid-slide, `end_drag` would fire a second
// `StateChangedEvent`, `sync_cards_on_change` would see the card mid-
// animation (`cur != target`), and replace the in-flight CardAnim with
// a fresh one — restarting the slide and reading on screen as the move
// animation playing twice.
if !drag.committed {
drag.clear();
changed.write(StateChangedEvent);
return;
}
let Some(layout) = layout else {
@@ -1280,32 +1326,37 @@ fn handle_double_click(
// Priority 2: if the player clicked the base of a multi-card face-up
// stack (card_ids.len() > 1), try moving the whole stack to another
// tableau column.
if card_ids.len() > 1 {
let Some(bottom_card) = game.0.piles.get(&pile)
.and_then(|p| p.cards.get(stack_index)) else { return };
if let Some((dest, count)) = best_tableau_destination_for_stack(
if card_ids.len() > 1
&& let Some(bottom_card) = game.0.piles.get(&pile)
.and_then(|p| p.cards.get(stack_index))
&& let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
&pile,
&game.0,
card_ids.len(),
) {
moves.write(MoveRequestEvent {
from: pile,
to: dest,
count,
});
} else {
// No legal destination for the stack — play the invalid-move
// sound and shake the source pile cards as feedback.
// `MoveRejectedEvent` with `from == to` routes the shake to
// the source pile (which `start_shake_anim` reads from `ev.to`).
rejected.write(MoveRejectedEvent {
from: pile.clone(),
to: pile,
count: card_ids.len(),
});
}
)
{
moves.write(MoveRequestEvent {
from: pile,
to: dest,
count,
});
return;
}
// Both priorities failed — play the invalid-move sound and shake
// the source pile as feedback. `MoveRejectedEvent` with
// `from == to` routes the shake to the source pile (which
// `start_shake_anim` reads from `ev.to`). Pre-fix, this branch
// only fired for multi-card stacks, so a double-click on a
// single card with no legal destination did nothing — no
// sound, no shake. Now both single-card and stack misses get
// the same feedback.
rejected.write(MoveRejectedEvent {
from: pile.clone(),
to: pile,
count: card_ids.len(),
});
} else {
// Single click — record the time.
last_click.insert(top_card_id, now);
@@ -1960,15 +2011,6 @@ mod tests {
assert!(hints.is_empty(), "no hint should exist when the game is truly stuck");
}
/// Const-assert that `NEW_GAME_CONFIRM_WINDOW` is positive so the
/// confirmation countdown actually opens on the first N press.
///
/// Mirrors the existing `forfeit_confirm_window_is_positive` test.
#[test]
fn new_game_confirm_window_is_positive() {
const { assert!(NEW_GAME_CONFIRM_WINDOW > 0.0, "NEW_GAME_CONFIRM_WINDOW must be > 0"); }
}
// -----------------------------------------------------------------------
// Drag-rejection return tween — `CardAnimation` replaces the legacy
// `ShakeAnim` on the dragged cards. The audio cue
@@ -2125,5 +2167,53 @@ mod tests {
anim.end_z
);
}
// -----------------------------------------------------------------------
// Hint system — async port (v0.18.0+)
//
// `handle_keyboard_hint` no longer runs the solver inline; it
// spawns an `AsyncComputeTaskPool` task whose result the polling
// system in `pending_hint` turns into hint visuals one frame
// later. The behaviour contract this section pins is "pressing H
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
// covered end-to-end in `pending_hint::tests`.
// -----------------------------------------------------------------------
/// Pressing H on a non-paused, non-won game with a live
/// `GameStateResource` + `LayoutResource` must populate
/// `PendingHintTask`. The polling system, exercised in
/// `pending_hint::tests`, drives the result to a visual event.
#[test]
fn pressing_h_spawns_pending_hint_task() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<InfoToastEvent>();
app.add_message::<HintVisualEvent>();
app.init_resource::<HintCycleIndex>();
app.init_resource::<HintSolverConfig>();
app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)),
));
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.add_systems(Update, handle_keyboard_hint);
// Simulate the H key being pressed this frame.
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::KeyH);
input.clear();
input.press(KeyCode::KeyH);
}
app.update();
assert!(
app.world()
.resource::<crate::pending_hint::PendingHintTask>()
.is_pending(),
"pressing H must spawn an async hint task",
);
}
}
+165 -65
View File
@@ -9,6 +9,7 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend;
@@ -20,10 +21,11 @@ use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
@@ -66,6 +68,18 @@ struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>)
#[derive(Component, Debug)]
pub struct LeaderboardScreen;
/// Marker on the scrollable body Node inside the Leaderboard modal.
///
/// The leaderboard caps at the top 10 entries today, but rendering the
/// caption + opt-in/opt-out row + 10 data rows on the 800x600 minimum
/// window is right at the edge of overflowing — long display names or
/// future row-count expansion would cut off entries below the fold.
/// Wrapping the data section in an `Overflow::scroll_y()` Node with a
/// constrained `max_height` keeps every row reachable. Mirrors the
/// `SettingsPanelScrollable` pattern.
#[derive(Component, Debug)]
pub struct LeaderboardScrollable;
/// Marker on the "Opt In" button inside the leaderboard panel.
#[derive(Component, Debug)]
struct LeaderboardOptInButton;
@@ -98,6 +112,11 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<OptInTask>()
.init_resource::<OptOutTask>()
.add_message::<ToggleLeaderboardRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// leaderboard-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(
@@ -112,7 +131,8 @@ impl Plugin for LeaderboardPlugin {
poll_opt_out_task,
)
.chain(),
);
)
.add_systems(Update, scroll_leaderboard_panel);
}
}
@@ -222,6 +242,33 @@ fn update_leaderboard_panel(
}
/// Click handler for the modal's "Done" button — despawns the overlay.
/// Routes mouse-wheel events into the Leaderboard modal's scrollable
/// data body while the panel is open. No-op when no
/// `LeaderboardScrollable` exists in the world (modal closed). Mirrors
/// `scroll_settings_panel`.
fn scroll_leaderboard_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<LeaderboardScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
fn handle_leaderboard_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
@@ -346,7 +393,7 @@ fn spawn_leaderboard_screen(
remote_available: bool,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Leaderboard", font_res);
// Subhead — what the screen does + what the buttons control.
@@ -420,76 +467,99 @@ fn spawn_leaderboard_screen(
BackgroundColor(BORDER_SUBTLE),
));
match data {
LeaderboardResource::Idle => {
card.spawn((
Text::new("Fetching\u{2026}"),
font_status.clone(),
TextColor(STATE_INFO),
));
}
LeaderboardResource::Error(_) => {
card.spawn((
Text::new("Couldn't reach the leaderboard. Try again later."),
font_status.clone(),
TextColor(TEXT_SECONDARY),
));
}
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
card.spawn((
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
LeaderboardResource::Loaded(rows) => {
// Column headers
card.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
header_cell(row, "#", 30.0, &font_header);
header_cell(row, "Player", 160.0, &font_header);
header_cell(row, "Best Score", 100.0, &font_header);
header_cell(row, "Fastest Win", 110.0, &font_header);
});
let mut sorted = rows.to_vec();
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
for (i, entry) in sorted.iter().take(10).enumerate() {
// Top three get accent treatments to highlight the
// podium without leaning on hand-picked metallic
// colours that sit outside the token system.
let rank_color = match i {
0 => ACCENT_PRIMARY, // Balatro yellow for #1
1 | 2 => TEXT_PRIMARY,
_ => TEXT_SECONDARY,
};
let time_str = entry
.best_time_secs
.map_or_else(|| "-".to_string(), format_secs);
let score_str = entry
.best_score
.map_or_else(|| "-".to_string(), |s| s.to_string());
card.spawn(Node {
// Scrollable data section — caps at top 10 rows today, but on the
// 800x600 minimum window the header + caption + opt-in row + 10
// entries crowds the modal. Wrapping in `Overflow::scroll_y()`
// with a `max_height` keeps every entry reachable and survives
// any future expansion of the row cap.
card.spawn((
LeaderboardScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
max_height: Val::Vh(50.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
match data {
LeaderboardResource::Idle => {
body.spawn((
Text::new("Fetching\u{2026}"),
font_status.clone(),
TextColor(STATE_INFO),
));
}
LeaderboardResource::Error(_) => {
body.spawn((
Text::new("Couldn't reach the leaderboard. Try again later."),
font_status.clone(),
TextColor(TEXT_SECONDARY),
));
}
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
body.spawn((
Text::new("Be the first on the leaderboard."),
font_status.clone(),
TextColor(TEXT_PRIMARY),
));
body.spawn((
Text::new("Win a game and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
LeaderboardResource::Loaded(rows) => {
// Column headers
body.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
header_cell(row, "#", 30.0, &font_header);
header_cell(row, "Player", 160.0, &font_header);
header_cell(row, "Best Score", 100.0, &font_header);
header_cell(row, "Fastest Win", 110.0, &font_header);
});
let mut sorted = rows.to_vec();
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
for (i, entry) in sorted.iter().take(10).enumerate() {
// Top three get accent treatments to highlight the
// podium without leaning on hand-picked metallic
// colours that sit outside the token system.
let rank_color = match i {
0 => ACCENT_PRIMARY, // Balatro yellow for #1
1 | 2 => TEXT_PRIMARY,
_ => TEXT_SECONDARY,
};
let time_str = entry
.best_time_secs
.map_or_else(|| "-".to_string(), format_secs);
let score_str = entry
.best_score
.map_or_else(|| "-".to_string(), |s| s.to_string());
body.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
});
}
}
}
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -502,6 +572,8 @@ fn spawn_leaderboard_screen(
);
});
});
// Leaderboard is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
@@ -646,6 +718,34 @@ mod tests {
assert_eq!(count, 1);
}
#[test]
fn leaderboard_modal_body_is_scrollable() {
let mut app = headless_app();
press(&mut app, KeyCode::KeyL);
app.update();
let count = app
.world_mut()
.query::<&LeaderboardScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Leaderboard modal must spawn exactly one LeaderboardScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<LeaderboardScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn pressing_l_twice_dismisses_screen() {
let mut app = headless_app();
+16 -2
View File
@@ -12,6 +12,7 @@ pub mod feedback_anim_plugin;
pub mod challenge_plugin;
pub mod cursor_plugin;
pub mod daily_challenge_plugin;
pub mod diagnostics_hud;
pub mod events;
pub mod game_plugin;
pub mod help_plugin;
@@ -22,8 +23,11 @@ pub mod input_plugin;
pub mod layout;
pub mod onboarding_plugin;
pub mod pause_plugin;
pub mod pending_hint;
pub mod profile_plugin;
pub mod radial_menu;
pub mod replay_overlay;
pub mod replay_playback;
pub mod settings_plugin;
pub mod progress_plugin;
pub mod resources;
@@ -82,11 +86,12 @@ pub use card_plugin::{
};
pub use font_plugin::{FontPlugin, FontResource};
pub use cursor_plugin::CursorPlugin;
pub use diagnostics_hud::DiagnosticsHudPlugin;
pub use events::{
AchievementUnlockedEvent, CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent,
ForfeitEvent, ForfeitRequestEvent, FoundationCompletedEvent, GameWonEvent, HelpRequestEvent,
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameConfirmEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
StateChangedEvent, SyncCompleteEvent, ToggleAchievementsRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
@@ -112,6 +117,14 @@ pub use radial_menu::{
legal_destinations_for_card, radial_anchor_for_index, radial_hovered_index, RadialIcon,
RadialMenuPlugin, RightClickRadialState, Z_RADIAL_MENU,
};
pub use replay_overlay::{
ReplayOverlayBannerText, ReplayOverlayPlugin, ReplayOverlayProgressText, ReplayOverlayRoot,
ReplayStopButton, Z_REPLAY_OVERLAY,
};
pub use replay_playback::{
start_replay_playback, stop_replay_playback, ReplayPlaybackPlugin, ReplayPlaybackState,
REPLAY_COMPLETION_LINGER_SECS, REPLAY_MOVE_INTERVAL_SECS,
};
pub use settings_plugin::{
PendingWindowGeometry, SettingsChangedEvent, SettingsPlugin, SettingsResource, SettingsScreen,
SFX_STEP, WINDOW_GEOMETRY_DEBOUNCE_SECS,
@@ -123,7 +136,8 @@ pub use selection_plugin::{
};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{
format_replay_caption, LatestReplayPath, LatestReplayResource, StatsPlugin, StatsResource,
format_replay_caption, LatestReplayPath, ReplayHistoryResource, ReplayNextButton,
ReplayPrevButton, ReplaySelectorCaption, SelectedReplayIndex, StatsPlugin, StatsResource,
StatsScreen, StatsUpdate, WatchReplayButton,
};
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
+31 -4
View File
@@ -36,8 +36,9 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsSto
use crate::stats_plugin::StatsResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
spawn_modal_header, ButtonVariant, ModalScrim,
};
use bevy::ecs::system::SystemParam;
use crate::ui_theme::{
self, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_3,
};
@@ -126,15 +127,24 @@ impl Plugin for PausePlugin {
}
}
/// Bundles the modal-related queries `toggle_pause` reads each tick.
/// Pulled into a [`SystemParam`] so the system stays under Bevy's 16-
/// parameter cap after the cross-modal Esc guard query was added.
#[derive(SystemParam)]
struct PauseModalQueries<'w, 's> {
pause_screens: Query<'w, 's, Entity, With<PauseScreen>>,
forfeit_screens: Query<'w, 's, Entity, With<ForfeitConfirmScreen>>,
game_over_screens: Query<'w, 's, Entity, With<GameOverScreen>>,
other_modal_scrims: Query<'w, 's, Entity, (With<ModalScrim>, Without<PauseScreen>)>,
}
#[allow(clippy::too_many_arguments)]
fn toggle_pause(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<PauseRequestEvent>,
mut paused: ResMut<PausedResource>,
screens: Query<Entity, With<PauseScreen>>,
forfeit_screens: Query<Entity, With<ForfeitConfirmScreen>>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
modal_queries: PauseModalQueries<'_, '_>,
game: Option<Res<GameStateResource>>,
path: Option<Res<GameStatePath>>,
progress: Option<Res<ProgressResource>>,
@@ -145,6 +155,13 @@ fn toggle_pause(
mut changed: MessageWriter<StateChangedEvent>,
selection: Option<Res<SelectionState>>,
) {
let PauseModalQueries {
pause_screens: screens,
forfeit_screens,
game_over_screens,
other_modal_scrims,
} = modal_queries;
// Either Esc or a click on the HUD "Pause" button (which fires
// PauseRequestEvent) opens or closes the overlay. Drain the queue so a
// burst of clicks doesn't queue future toggles.
@@ -157,6 +174,16 @@ fn toggle_pause(
if !forfeit_screens.is_empty() {
return;
}
// Any other modal (Confirm New Game, Restore, Home, Onboarding,
// Settings, etc.) owns its own dismissal — pause must not stack
// on top of it. Without this guard a single Esc both closes the
// open modal AND spawns the pause overlay underneath, leaving the
// player on a screen they didn't ask for. The HUD-button path
// (`button_clicked`) is gated too; clicking Pause while another
// modal is up is almost always an accident.
if !other_modal_scrims.is_empty() {
return;
}
// If a card is currently selected, let SelectionPlugin handle this Escape
// (it will clear the selection). Pause must not also open in the same frame.
if selection.is_some_and(|s| s.selected_pile.is_some()) {
+402
View File
@@ -0,0 +1,402 @@
//! Async H-key hint solver, modelled on `PendingNewGameSeed` in
//! `game_plugin`.
//!
//! The synchronous version (v0.17.0) called
//! `solitaire_core::solver::try_solve_from_state` on the main thread on
//! every H press. Median latency was ~2 ms but pathological positions
//! can hit the `SolverConfig::default()` cap at ~120 ms, which is a
//! noticeable input-stall on the same frame the player sees the hint
//! request.
//!
//! This module hosts the resource and polling system that move the
//! solver call onto `AsyncComputeTaskPool`. `handle_keyboard_hint`
//! (input_plugin) becomes a thin spawn point: snapshot the state,
//! spawn the task, store the handle. The polling system takes the
//! result one frame later and surfaces the hint visuals via the
//! shared `emit_hint_visuals` helper.
//!
//! Cancel-on-replace: a fresh H press while a previous task is in
//! flight drops the previous task. Bevy's `Task` `Drop` cancels
//! cooperatively at the next await point.
//!
//! Stale-state drop: any `StateChangedEvent` (move applied, undo,
//! new game) drops the in-flight task — the position the solver was
//! reasoning about no longer exists, and surfacing a hint for the
//! old state would be confusing.
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::solver::{try_solve_from_state, SolverConfig, SolverResult};
use crate::card_plugin::CardEntity;
use crate::events::{HintVisualEvent, InfoToastEvent, StateChangedEvent};
use crate::input_plugin::{emit_hint_visuals, find_heuristic_hint};
use crate::resources::{GameStateResource, HintCycleIndex};
/// In-flight async work for the H-key hint.
///
/// `handle_keyboard_hint` writes here when the player presses H;
/// `poll_pending_hint_task` reads from here, polls the task, and
/// emits the hint visuals once the task completes. At most one task
/// is ever in flight: a fresh H press while a previous task is
/// running drops the previous task and queues the new one.
#[derive(Resource, Default)]
pub struct PendingHintTask {
/// `Some` while the solver is still working on a verdict.
inner: Option<HintTask>,
}
impl PendingHintTask {
/// Whether a hint task is currently in flight.
pub fn is_pending(&self) -> bool {
self.inner.is_some()
}
/// Drop any in-flight task. Bevy's `Task` `Drop` cancels the
/// underlying future cooperatively at the next await point.
pub fn cancel(&mut self) {
self.inner = None;
}
/// Spawn a new solver task for `state` with `config`. Drops any
/// previously in-flight task first (cancel-on-replace).
pub fn spawn(&mut self, state: GameState, config: SolverConfig) {
let move_count_at_spawn = state.move_count;
let handle = AsyncComputeTaskPool::get().spawn(async move {
let outcome = try_solve_from_state(&state, &config);
match outcome.result {
SolverResult::Winnable => outcome
.first_move
.map(|mv| HintTaskOutput::SolverMove {
from: mv.source,
to: mv.dest,
})
.unwrap_or(HintTaskOutput::NeedsHeuristic),
SolverResult::Unwinnable | SolverResult::Inconclusive => {
HintTaskOutput::NeedsHeuristic
}
}
});
self.inner = Some(HintTask {
handle,
move_count_at_spawn,
});
}
}
/// One in-flight hint search plus the snapshot data needed to detect
/// a stale result if the live state moved while the solver ran.
struct HintTask {
handle: Task<HintTaskOutput>,
/// `GameState.move_count` at spawn time. The poll system discards
/// the result if the live move_count has advanced — the player
/// applied a move while the solver ran, so the hint would be
/// stale even if the StateChangedEvent drop didn't fire first.
move_count_at_spawn: u32,
}
/// What the solver task carries back to the main thread.
enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the
/// solution path.
SolverMove {
from: PileType,
to: PileType,
},
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists.
NeedsHeuristic,
}
/// Drop the in-flight hint task whenever the live `GameState` shifts.
///
/// The position the solver was reasoning about no longer matches the
/// live state, so its result would be stale. Mirrors the semantics
/// of `reset_hint_cycle_on_state_change` for `HintCycleIndex`.
pub fn drop_pending_hint_on_state_change(
mut state_events: MessageReader<StateChangedEvent>,
mut pending: ResMut<PendingHintTask>,
) {
if state_events.read().next().is_some() {
pending.cancel();
}
}
/// Poll the in-flight hint solver task. When the task resolves, run
/// `emit_hint_visuals` on the result — either the solver's
/// provably-best first move (Winnable verdict) or a heuristic hint
/// over the live state (Unwinnable / Inconclusive).
///
/// Discards the result when `GameState.move_count` has moved past the
/// snapshot taken at spawn time — the player applied a move during
/// the solve and `drop_pending_hint_on_state_change` should have
/// already cleared the resource, but we double-check here for the
/// rare case where the solver task completed in the same frame the
/// move was applied.
#[allow(clippy::too_many_arguments)]
pub fn poll_pending_hint_task(
mut pending: ResMut<PendingHintTask>,
game: Option<Res<GameStateResource>>,
mut hint_cycle: ResMut<HintCycleIndex>,
mut commands: Commands,
card_entities: Query<(Entity, &CardEntity, &mut Sprite)>,
mut info_toast: MessageWriter<InfoToastEvent>,
mut hint_visual: MessageWriter<HintVisualEvent>,
) {
let Some(p) = pending.inner.as_mut() else {
return;
};
let Some(output) = future::block_on(future::poll_once(&mut p.handle)) else {
return;
};
let move_count_at_spawn = p.move_count_at_spawn;
pending.inner = None;
let Some(g) = game else { return };
if g.0.move_count != move_count_at_spawn {
return;
}
let (from, to) = match output {
HintTaskOutput::SolverMove { from, to } => (from, to),
HintTaskOutput::NeedsHeuristic => {
match find_heuristic_hint(&g.0, &mut hint_cycle) {
Some(pair) => pair,
None => {
info_toast.write(InfoToastEvent("No hints available".to_string()));
return;
}
}
}
};
emit_hint_visuals(
&g.0,
&from,
&to,
&mut commands,
card_entities,
&mut info_toast,
&mut hint_visual,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::HintVisualEvent;
use crate::input_plugin::HintSolverConfig;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
/// Build a minimal Bevy app exercising only the polling system
/// and the resources/messages it touches.
fn pending_hint_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<InfoToastEvent>();
app.add_message::<HintVisualEvent>();
app.add_message::<StateChangedEvent>();
app.init_resource::<HintCycleIndex>();
app.init_resource::<HintSolverConfig>();
app.init_resource::<PendingHintTask>();
// Chain the drop-on-state-change system before the poll
// system, mirroring how `InputPlugin::build` wires them.
// Without this, system order is unspecified and the
// state_change_drops_in_flight_task test sometimes sees the
// poll fire before the drop.
app.add_systems(
Update,
(
drop_pending_hint_on_state_change,
poll_pending_hint_task,
)
.chain(),
);
app
}
/// Same near-finished fixture used by the v0.17 hint tests:
/// foundations hold A..Q for each suit, four Kings sit on
/// tableau columns 0..3, stock and waste empty.
fn near_finished_state() -> GameState {
let mut game = GameState::new(1, DrawMode::DrawOne);
for slot in 0..4_u8 {
game.piles
.get_mut(&PileType::Foundation(slot))
.unwrap()
.cards
.clear();
}
for i in 0..7_usize {
game.piles
.get_mut(&PileType::Tableau(i))
.unwrap()
.cards
.clear();
}
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
let ranks_below_king = [
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
Rank::Jack, Rank::Queen,
];
for (slot, suit) in suits.iter().enumerate() {
let pile = game
.piles
.get_mut(&PileType::Foundation(slot as u8))
.unwrap();
for (i, rank) in ranks_below_king.iter().enumerate() {
pile.cards.push(Card {
id: (slot as u32) * 13 + i as u32,
suit: *suit,
rank: *rank,
face_up: true,
});
}
}
for (col, suit) in suits.iter().enumerate() {
game.piles
.get_mut(&PileType::Tableau(col))
.unwrap()
.cards
.push(Card {
id: 100 + col as u32,
suit: *suit,
rank: Rank::King,
face_up: true,
});
}
game
}
/// Spawning a task and pumping update() until it completes must
/// emit a HintVisualEvent. Mirrors the `winnable_seed_search_*`
/// pattern in game_plugin tests — drives a wall-clock-bounded
/// loop so the shared AsyncComputeTaskPool can schedule the
/// future under cargo-test parallelism.
#[test]
fn winnable_solver_emits_hint_after_async_completes() {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0;
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
!app.world().resource::<PendingHintTask>().is_pending(),
"hint task should have completed within 15 s wall-clock",
);
let messages = app.world().resource::<Messages<HintVisualEvent>>();
let mut cursor = messages.get_cursor();
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
assert_eq!(
collected.len(), 1,
"exactly one HintVisualEvent must fire when the solver returns Winnable",
);
assert!(
matches!(collected[0].dest_pile, PileType::Foundation(_)),
"solver hint destination must be a foundation slot; got {:?}",
collected[0].dest_pile,
);
}
/// A StateChangedEvent fired while the task is in flight must
/// drop the task; the polling system must not emit any visuals
/// once the result eventually arrives.
#[test]
fn state_change_drops_in_flight_task() {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0;
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
assert!(
app.world().resource::<PendingHintTask>().is_pending(),
"task is in flight after spawn",
);
// Fire a StateChangedEvent before draining the task. The
// drop-on-state-change system runs in the same Update tick
// and clears the resource.
app.world_mut().write_message(StateChangedEvent);
app.update();
assert!(
!app.world().resource::<PendingHintTask>().is_pending(),
"StateChangedEvent must drop the in-flight hint task",
);
// No HintVisualEvent should ever have fired.
let messages = app.world().resource::<Messages<HintVisualEvent>>();
let mut cursor = messages.get_cursor();
assert_eq!(
cursor.read(messages).count(),
0,
"dropped hint task must not emit any visuals",
);
}
/// Cancel-on-replace: spawning a fresh task while a previous one
/// is in flight must drop the previous task. Only the second
/// spawn's result is allowed to surface.
#[test]
fn second_spawn_drops_first_in_flight_task() {
let mut app = pending_hint_app();
app.insert_resource(GameStateResource(near_finished_state()));
let cfg = app.world().resource::<HintSolverConfig>().0;
// First spawn.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
assert!(first_handle_present);
// Second spawn. The `spawn` helper drops the prior task
// before assigning the new one — at no point are two tasks
// in flight.
app.world_mut()
.resource_mut::<PendingHintTask>()
.spawn(near_finished_state(), cfg);
// Resource still pending (the second task), but the first
// is gone. We can't directly observe the first handle once
// it's been overwritten — what we *can* assert is that the
// resource still holds a single task, and that task
// eventually completes producing exactly one hint visual.
assert!(app.world().resource::<PendingHintTask>().is_pending());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingHintTask>().is_pending() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
!app.world().resource::<PendingHintTask>().is_pending(),
"second hint task should have completed within 15 s wall-clock",
);
let messages = app.world().resource::<Messages<HintVisualEvent>>();
let mut cursor = messages.get_cursor();
let collected: Vec<HintVisualEvent> = cursor.read(messages).cloned().collect();
assert_eq!(
collected.len(), 1,
"cancel-on-replace: only the surviving task's result emits a visual",
);
}
}
+282 -169
View File
@@ -4,6 +4,7 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate};
@@ -19,6 +20,7 @@ use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
@@ -60,10 +62,60 @@ pub struct ProfilePlugin;
#[derive(Component, Debug)]
pub struct ProfileCloseButton;
/// Marker on the scrollable body Node inside the Profile modal.
///
/// The Profile panel renders sync info, progression (incl. 14-day
/// calendar), every unlocked achievement (up to ~18), and a stats
/// summary, which can overflow the modal on the 800x600 minimum window
/// once a player has unlocked several achievements. This marker tags
/// the inner container that carries `Overflow::scroll_y()` plus a
/// `max_height` constraint. Mirrors the `SettingsPanelScrollable`
/// pattern.
#[derive(Component, Debug)]
pub struct ProfileScrollable;
impl Plugin for ProfilePlugin {
fn build(&self, app: &mut App) {
app.add_message::<ToggleProfileRequestEvent>()
.add_systems(Update, (toggle_profile_screen, handle_profile_close_button));
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// profile-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(
toggle_profile_screen,
handle_profile_close_button,
scroll_profile_panel,
),
);
}
}
/// Routes mouse-wheel events into the Profile modal's scrollable body
/// while the panel is open. No-op when no `ProfileScrollable` exists in
/// the world (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_profile_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<ProfileScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
@@ -94,7 +146,17 @@ fn toggle_profile_screen(
screens: Query<Entity, With<ProfileScreen>>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyP) && !button_clicked {
let p_pressed = keys.just_pressed(KeyCode::KeyP);
let esc_pressed = keys.just_pressed(KeyCode::Escape);
let already_open = !screens.is_empty();
// P / button toggles open-or-close. Esc only ever closes — when
// Profile is layered over Home (clicking the new Home stats chip
// opens this on top), Esc must dismiss the *topmost* modal.
// Without this branch, Esc fell through to Home's cancel handler
// and closed the wrong modal.
let want_open = !already_open && (p_pressed || button_clicked);
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
if !want_open && !want_close {
return;
}
if let Ok(entity) = screens.single() {
@@ -133,186 +195,205 @@ fn spawn_profile_screen(
..default()
};
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
// First-launch welcome — only when the player has zero XP and
// zero daily streak, so the profile doesn't read as a wall of
// zeros to a brand-new player.
if let Some(p) = progress
&& p.0.total_xp == 0
&& p.0.daily_challenge_streak == 0
{
card.spawn((
Text::new("Welcome! Play games to earn XP and unlock achievements."),
font_section.clone(),
TextColor(ACCENT_PRIMARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
// Scrollable body — the Profile panel renders sync info,
// progression (incl. a 14-day calendar), every unlocked
// achievement (up to ~18), and a stats summary, which can
// overflow the modal on the 800x600 minimum window once the
// player has unlocked several achievements. The Done action
// stays fixed outside the scroll.
card.spawn((
ProfileScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// First-launch welcome — only when the player has zero XP and
// zero daily streak, so the profile doesn't read as a wall of
// zeros to a brand-new player.
if let Some(p) = progress
&& p.0.total_xp == 0
&& p.0.daily_challenge_streak == 0
{
body.spawn((
Text::new("Welcome! Play games to earn XP and unlock achievements."),
font_section.clone(),
TextColor(ACCENT_PRIMARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default()
},
..default()
},
));
}
// ── Sync section ────────────────────────────────────────────
card.spawn((
Text::new("Sync"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(s) = settings {
let (backend_name, username) = sync_info(&s.0.sync_backend);
card.spawn((
Text::new(format!("Account: {username} | Backend: {backend_name}")),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
if let Some(ss) = sync_status {
let status_text = match &ss.0 {
SyncStatus::Idle => "Sync: idle".to_string(),
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
SyncStatus::LastSynced(dt) => {
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
};
card.spawn((
Text::new(status_text),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
// ── Progression section ─────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Progression"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(p) = progress {
let prog = &p.0;
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
let pct = if xp_span == 0 {
100u64
} else {
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
};
card.spawn((
Text::new(format!(
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
prog.level, prog.total_xp, xp_done, xp_span, pct
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
card.spawn((
Text::new(format!(
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
prog.daily_challenge_streak,
prog.unlocked_card_backs.len(),
prog.unlocked_backgrounds.len(),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
// 14-day daily-challenge calendar row.
spawn_daily_calendar(
card,
&prog.daily_challenge_history,
prog.daily_challenge_streak,
prog.daily_challenge_longest_streak,
Local::now().date_naive(),
font_res,
);
}
// ── Achievements section ────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Achievements"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(ar) = achievements {
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
card.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
let mut any_unlocked = false;
for record in records {
let def = achievement_by_id(record.id.as_str());
let is_secret = def.is_some_and(|d| d.secret);
if is_secret && !record.unlocked {
continue;
}
if !record.unlocked {
continue;
}
any_unlocked = true;
let name = def.map_or(record.id.as_str(), |d| d.name);
let date_str = match record.unlock_date {
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
None => String::new(),
};
card.spawn((
Text::new(format!(" [x] {name}{date_str}")),
font_row.clone(),
TextColor(STATE_SUCCESS),
));
}
if !any_unlocked {
card.spawn((
Text::new(" No achievements unlocked yet."),
// ── Sync section ────────────────────────────────────────────
body.spawn((
Text::new("Sync"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(s) = settings {
let (backend_name, username) = sync_info(&s.0.sync_backend);
body.spawn((
Text::new(format!("Account: {username} | Backend: {backend_name}")),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
if let Some(ss) = sync_status {
let status_text = match &ss.0 {
SyncStatus::Idle => "Sync: idle".to_string(),
SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(),
SyncStatus::LastSynced(dt) => {
format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M"))
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
};
body.spawn((
Text::new(status_text),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
}
// ── Statistics summary section ──────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
Text::new("Statistics Summary"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(sr) = stats {
let s = &sr.0;
let best_score_str = if s.best_single_score == 0 {
"\u{2014}".to_string()
} else {
s.best_single_score.to_string()
};
card.spawn((
Text::new(format!(
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
s.games_played,
s.games_won,
format_win_rate(s),
format_fastest_win(s.fastest_win_seconds),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
// ── Progression section ─────────────────────────────────────
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Progression"),
font_section.clone(),
TextColor(STATE_INFO),
));
card.spawn((
Text::new(format!(
"Win streak: {} current, {} best | Best score: {}",
s.win_streak_current, s.win_streak_best, best_score_str,
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
if let Some(p) = progress {
let prog = &p.0;
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
let pct = if xp_span == 0 {
100u64
} else {
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
};
body.spawn((
Text::new(format!(
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
prog.level, prog.total_xp, xp_done, xp_span, pct
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
body.spawn((
Text::new(format!(
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
prog.daily_challenge_streak,
prog.unlocked_card_backs.len(),
prog.unlocked_backgrounds.len(),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
// 14-day daily-challenge calendar row.
spawn_daily_calendar(
body,
&prog.daily_challenge_history,
prog.daily_challenge_streak,
prog.daily_challenge_longest_streak,
Local::now().date_naive(),
font_res,
);
}
// ── Achievements section ────────────────────────────────────
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Achievements"),
font_section.clone(),
TextColor(STATE_INFO),
));
}
if let Some(ar) = achievements {
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
body.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
));
let mut any_unlocked = false;
for record in records {
let def = achievement_by_id(record.id.as_str());
let is_secret = def.is_some_and(|d| d.secret);
if is_secret && !record.unlocked {
continue;
}
if !record.unlocked {
continue;
}
any_unlocked = true;
let name = def.map_or(record.id.as_str(), |d| d.name);
let date_str = match record.unlock_date {
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
None => String::new(),
};
body.spawn((
Text::new(format!(" [x] {name}{date_str}")),
font_row.clone(),
TextColor(STATE_SUCCESS),
));
}
if !any_unlocked {
body.spawn((
Text::new(" No achievements unlocked yet."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
}
}
// ── Statistics summary section ──────────────────────────────
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Statistics Summary"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(sr) = stats {
let s = &sr.0;
let best_score_str = if s.best_single_score == 0 {
"\u{2014}".to_string()
} else {
s.best_single_score.to_string()
};
body.spawn((
Text::new(format!(
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
s.games_played,
s.games_won,
format_win_rate(s),
format_fastest_win(s.fastest_win_seconds),
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
body.spawn((
Text::new(format!(
"Win streak: {} current, {} best | Best score: {}",
s.win_streak_current, s.win_streak_best, best_score_str,
)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -325,6 +406,8 @@ fn spawn_profile_screen(
);
});
});
// Profile is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
/// Spawn a fixed-height vertical spacer node.
@@ -503,6 +586,36 @@ mod tests {
);
}
#[test]
fn profile_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyP);
app.update();
let count = app
.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Profile modal must spawn exactly one ProfileScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<ProfileScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn pressing_p_twice_closes_profile_screen() {
let mut app = headless_app();
+566
View File
@@ -0,0 +1,566 @@
//! On-screen overlay shown while a recorded [`Replay`] plays back.
//!
//! The overlay is a thin top-of-window banner with three pieces of UI:
//!
//! - A "Replay" label on the left so the player knows the surface is
//! under playback control rather than live input.
//! - A "Move N of M" progress indicator in the centre, recomputed every
//! frame the cursor advances.
//! - A "Stop" button on the right that aborts playback and returns
//! control to the player.
//!
//! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner
//! label swaps to "Replay complete" and stays visible until the playback
//! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`]
//! a few seconds later, at which point the overlay despawns.
//!
//! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but
//! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets
//! the player still open Settings, Pause, and Help during a replay; those
//! modals will render on top of the banner as expected.
//!
//! [`Replay`]: solitaire_data::Replay
//! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, TEXT_PRIMARY, TYPE_BODY, TYPE_HEADLINE, VAL_SPACE_2,
VAL_SPACE_4, Z_DROP_OVERLAY,
};
// ---------------------------------------------------------------------------
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
// ---------------------------------------------------------------------------
/// `bevy::ui` `ZIndex` value for the replay overlay banner.
///
/// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner
/// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally
/// **below** modals, but the overlay needs to be above HUD readouts) yet
/// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals
/// continue to render on top of the overlay during a replay.
///
/// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an
/// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used
/// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so
/// we materialise a separate constant rather than reuse the `f32` value.
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
/// Total height of the banner in pixels. Thin enough to leave the
/// gameplay surface visible underneath, tall enough to comfortably fit
/// the headline-sized "Replay" label.
const BANNER_HEIGHT: f32 = 48.0;
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
/// reads as a clear "this is a UI strip" callout while still letting the
/// felt show through enough to anchor the banner to the play surface.
const BANNER_ALPHA: f32 = 0.92;
// ---------------------------------------------------------------------------
// Marker components
// ---------------------------------------------------------------------------
/// Marker on the banner's root `Node`. Used by the spawn / despawn /
/// progress-update systems to find the overlay.
#[derive(Component, Debug)]
pub struct ReplayOverlayRoot;
/// Marker on the left-hand banner label `Text`. Carries either "Replay"
/// (during playback) or "Replay complete" (once finished); the
/// completion-text-update system swaps the contents in place.
#[derive(Component, Debug)]
pub struct ReplayOverlayBannerText;
/// Marker on the centre progress `Text`. Updated every frame to reflect
/// the current `(cursor, total)` returned by
/// [`ReplayPlaybackState::progress`].
#[derive(Component, Debug)]
pub struct ReplayOverlayProgressText;
/// Marker on the right-hand "Stop" button. Click handler queries for this
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
/// transition is seen.
#[derive(Component, Debug)]
pub struct ReplayStopButton;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin that registers every system needed to drive the replay
/// overlay's lifecycle.
///
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
/// the resource manually and exercise the overlay in isolation.
pub struct ReplayOverlayPlugin;
impl Plugin for ReplayOverlayPlugin {
fn build(&self, app: &mut App) {
// The systems are ordered so that, on a single frame:
// 1. The state-watcher spawns or despawns the overlay if the
// `ReplayPlaybackState` resource changed.
// 2. The completion-text update swaps the banner label when the
// state is `Completed`.
// 3. The progress-text update writes the latest "Move N of M".
// 4. The Stop-button click handler reads `Interaction::Pressed`
// and calls `stop_replay_playback` (which mutates the state).
// Putting Stop last means a click in frame N is observed by
// `react_to_state_change` in frame N+1, which then despawns the
// overlay in response — a clean state-driven loop.
app.add_systems(
Update,
(
react_to_state_change,
update_banner_label,
update_progress_text,
handle_stop_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------------------
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
/// spawns or despawns the overlay accordingly. Treats the resource as the
/// single source of truth — the spawn / despawn decision is derived from
/// `is_playing() || is_completed()` rather than tracking previous-state
/// transitions explicitly, which keeps the system stateless.
fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
return;
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = existing.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
for entity in &existing {
commands.entity(entity).despawn();
}
}
// The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label
// and progress readout in place without a respawn.
}
/// Spawns the banner — a flex-row Node anchored to the top edge of the
/// window with three children: the "Replay" / "Replay complete" label,
/// the centred progress text, and the right-aligned Stop button.
fn spawn_overlay(
commands: &mut Commands,
font_res: Option<&FontResource>,
state: &ReplayPlaybackState,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let banner_label = if state.is_completed() {
"Replay complete"
} else {
"Replay"
};
let progress_label = format_progress(state);
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayRoot,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(BANNER_HEIGHT),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
column_gap: VAL_SPACE_4,
..default()
},
BackgroundColor(banner_bg),
// Pin the banner to its z layer in both the local and the
// global stacking context — `GlobalZIndex` matters because
// the overlay is a top-level Node (no parent), and Bevy 0.18
// has historically had subtle stacking-context drift here.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
))
.with_children(|banner| {
// Left: "Replay" label in the cyan primary accent
// (`ACCENT_PRIMARY`) so it reads unmistakably as a
// non-gameplay surface.
banner.spawn((
ReplayOverlayBannerText,
Text::new(banner_label),
TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
// Centre: progress readout — neutral primary text colour so
// the eye treats it as data, not a callout.
banner.spawn((
ReplayOverlayProgressText,
Text::new(progress_label),
TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
));
// Right: Stop button. Tertiary variant — the action is
// available but not the loudest element in the banner; the
// "Replay" cyan accent owns that slot. `spawn_modal_button`
// gives us hover / press paint and focus rings for free via
// the existing `UiModalPlugin` paint system.
banner
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|wrap| {
spawn_modal_button(
wrap,
ReplayStopButton,
"Stop",
None,
ButtonVariant::Tertiary,
font_res,
);
});
});
}
// ---------------------------------------------------------------------------
// Per-frame text updates
// ---------------------------------------------------------------------------
/// Overwrites the banner label whenever the resource changes — covers the
/// `Playing → Completed` transition by swapping "Replay" for
/// "Replay complete" in place without despawning the overlay.
fn update_banner_label(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayBannerText>>,
) {
if !state.is_changed() {
return;
}
let label = if state.is_completed() {
"Replay complete"
} else if state.is_playing() {
"Replay"
} else {
return;
};
for mut text in &mut q {
**text = label.to_string();
}
}
/// Repaints the "Move N of M" centre readout every frame the cursor moves.
/// Cheap — early-exits if the resource has not changed since the last
/// frame so idle replays don't churn the text mesh.
fn update_progress_text(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayProgressText>>,
) {
if !state.is_changed() {
return;
}
let label = format_progress(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Pure helper — formats the centre progress readout for the given state.
/// Exposed at module scope so the spawn path and the per-frame update
/// path produce the exact same string.
fn format_progress(state: &ReplayPlaybackState) -> String {
match state.progress() {
Some((cursor, total)) => format!("Move {cursor} of {total}"),
None if state.is_completed() => "Replay complete".to_string(),
None => String::new(),
}
}
// ---------------------------------------------------------------------------
// Stop button handler
// ---------------------------------------------------------------------------
/// Watches the Stop button for `Interaction::Pressed` transitions. On a
/// click, calls [`stop_replay_playback`] which resets the state to
/// `Inactive`; the next frame's `react_to_state_change` then despawns
/// the overlay.
fn handle_stop_button(
mut commands: Commands,
mut state: ResMut<ReplayPlaybackState>,
buttons: Query<&Interaction, (With<ReplayStopButton>, Changed<Interaction>)>,
) {
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
stop_replay_playback(&mut commands, &mut state);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_data::{Replay, ReplayMove};
/// Build a minimal but well-formed [`Replay`] with `move_count` no-op
/// `StockClick` entries. Tests only ever read `replay.moves.len()`
/// (denominator of the progress indicator), so the move kind is
/// irrelevant beyond producing the right count.
fn synthetic_replay(move_count: usize) -> Replay {
Replay::new(
42,
DrawMode::DrawOne,
GameMode::Classic,
120,
1_000,
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
(0..move_count).map(|_| ReplayMove::StockClick).collect(),
)
}
/// Build a test app that has the overlay plugin but **not** the
/// playback plugin — tests insert `ReplayPlaybackState` manually so
/// they can drive every state transition deterministically.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(ReplayOverlayPlugin);
app.init_resource::<ReplayPlaybackState>();
app
}
/// Count `ReplayOverlayRoot` entities in the world — the overlay's
/// presence/absence is the spawn-test's primary observable.
fn overlay_root_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayRoot>()
.iter(app.world())
.count()
}
/// Read the current text content of the unique progress-text entity.
fn progress_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayProgressText>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Read the current text content of the unique banner-label entity.
fn banner_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayBannerText>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Set the playback resource without going through the playback core.
fn set_state(app: &mut App, state: ReplayPlaybackState) {
app.world_mut().insert_resource(state);
}
/// Find the unique `ReplayStopButton` entity for the click-handler
/// test. There must be exactly one.
fn stop_button_entity(app: &mut App) -> Entity {
let mut q = app
.world_mut()
.query_filtered::<Entity, With<ReplayStopButton>>();
q.iter(app.world())
.next()
.expect("Stop button must exist while overlay is spawned")
}
/// Going `Inactive → Playing` spawns exactly one overlay root and
/// the banner label reads "Replay".
#[test]
fn overlay_spawns_when_playback_starts() {
let mut app = headless_app();
// First update with the default `Inactive` resource — overlay
// must not exist yet.
app.update();
assert_eq!(overlay_root_count(&mut app), 0);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(
overlay_root_count(&mut app),
1,
"exactly one ReplayOverlayRoot must spawn on Inactive → Playing",
);
assert_eq!(banner_text(&mut app), "Replay");
}
/// The progress-text entity reads `"Move {cursor} of {total}"` for a
/// well-formed `Playing` state.
#[test]
fn overlay_progress_text_reflects_cursor() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(progress_text(&mut app), "Move 5 of 10");
}
/// Pressing the Stop button resets the state back to `Inactive` and
/// the next frame's `react_to_state_change` despawns the overlay.
/// Mirrors the synthetic `Interaction::Pressed` insertion pattern
/// used elsewhere in the engine for headless click tests.
#[test]
fn overlay_stop_button_click_clears_playback() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
let stop = stop_button_entity(&mut app);
app.world_mut()
.entity_mut(stop)
.insert(Interaction::Pressed);
// Tick once: the click handler runs late in the frame and resets
// the state to `Inactive`.
app.update();
// State must be back to Inactive.
let state = app.world().resource::<ReplayPlaybackState>();
assert!(
matches!(state, ReplayPlaybackState::Inactive),
"Stop click must reset ReplayPlaybackState to Inactive; got {state:?}",
);
// One more tick — `react_to_state_change` sees the resource
// change to Inactive and despawns the overlay.
app.update();
assert_eq!(
overlay_root_count(&mut app),
0,
"overlay must despawn the frame after state returns to Inactive",
);
}
/// Manually flipping the resource back to `Inactive` (e.g. via the
/// playback core's auto-clear after `Completed`) tears the overlay
/// down without any further input.
#[test]
fn overlay_despawns_when_playback_returns_to_inactive() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(3),
cursor: 1,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
overlay_root_count(&mut app),
0,
"overlay must despawn on Playing → Inactive transition",
);
}
/// On `Playing → Completed` the banner label updates in place rather
/// than respawning. The overlay must still be present, and the label
/// must read "Replay complete".
#[test]
fn overlay_text_changes_on_completed() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(7),
cursor: 7,
secs_to_next: 0.0,
},
);
app.update();
assert_eq!(banner_text(&mut app), "Replay");
set_state(&mut app, ReplayPlaybackState::Completed);
app.update();
assert_eq!(
overlay_root_count(&mut app),
1,
"overlay must remain spawned while in Completed state",
);
assert_eq!(
banner_text(&mut app),
"Replay complete",
"banner label must swap on Playing → Completed",
);
}
}
+833
View File
@@ -0,0 +1,833 @@
//! In-engine replay playback core.
//!
//! When the player clicks "Watch replay" on the Stats overlay, the live
//! game state is reset to the deal seeded from the replay's `seed` /
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
//! plays back identically to a live game.
//!
//! ## Public surface
//!
//! - [`ReplayPlaybackState`] — single source of truth for whether
//! playback is live, how far through the move list we've ticked, and
//! how long until the next advance.
//! - [`start_replay_playback`] — public entry point; the Stats
//! "Watch replay" button calls this. Resets the game to the recorded
//! deal and transitions the state machine to
//! [`ReplayPlaybackState::Playing`].
//! - [`stop_replay_playback`] — interrupts playback at any time. Safe to
//! call when [`ReplayPlaybackState::Inactive`].
//! - [`ReplayPlaybackPlugin`] — registers the resource and the tick /
//! linger systems.
//!
//! ## Coordination note
//!
//! This module is built in parallel with the Stats-side overlay. The
//! resource shape, helper signatures, and plugin marker match the
//! contract the overlay agent reads against — see also the docs on the
//! enum variants.
//!
//! ## Recording is paused during playback
//!
//! Playback fires the same [`MoveRequestEvent`] / [`DrawRequestEvent`]
//! the live engine handles. Without intervention, [`RecordingReplay`]
//! would re-record those events and a replay would re-record itself
//! indefinitely. To prevent that, [`record_replay_skip_during_playback`]
//! snapshots the recording's length at the start of playback and
//! truncates the buffer back to that length every frame. This keeps
//! the recording contract opaque to `game_plugin` — no event-source
//! flag is threaded through, no every-callsite gate is added.
use bevy::prelude::*;
use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::{GameMutation, RecordingReplay};
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
/// Default per-move duration during playback, in seconds. Acts as the
/// fallback when `SettingsResource` is absent — i.e. in headless test
/// fixtures that don't install [`crate::settings_plugin::SettingsPlugin`].
/// In production the live value is read from
/// [`solitaire_data::Settings::replay_move_interval_secs`] every frame
/// so Settings adjustments take effect on the next playback tick.
///
/// Kept in sync with `solitaire_data::settings::default_replay_move_interval_secs`
/// (the data crate cannot depend on this engine crate, so the constant
/// is duplicated). The
/// `settings_replay_move_interval_default_matches_engine_constant`
/// test in `solitaire_engine::settings_plugin` enforces equality.
pub const REPLAY_MOVE_INTERVAL_SECS: f32 = 0.45;
/// Helper: returns the live per-move replay interval. Reads
/// [`SettingsResource::replay_move_interval_secs`] when the resource is
/// installed, falling back to [`REPLAY_MOVE_INTERVAL_SECS`] otherwise.
/// Also clamps below by `f32::EPSILON` so a hand-edited 0.0 cannot
/// busy-loop the playback tick.
fn current_move_interval_secs(settings: Option<&SettingsResource>) -> f32 {
let raw = settings
.map(|s| s.0.replay_move_interval_secs)
.unwrap_or(REPLAY_MOVE_INTERVAL_SECS);
raw.max(f32::EPSILON)
}
/// How long the [`ReplayPlaybackState::Completed`] state lingers before
/// the auto-clear system transitions it back to
/// [`ReplayPlaybackState::Inactive`]. Gives the overlay UI time to
/// display "Replay complete" before dismissing.
pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
/// Lifecycle state of an in-flight replay playback.
///
/// The default state is [`Inactive`](Self::Inactive) — no replay is
/// running. The overlay (and any other consumer) reads this resource to
/// decide whether the "Replay" banner should be visible and what
/// progress to display.
///
/// Lifecycle:
/// 1. Default state is [`Inactive`](Self::Inactive).
/// 2. [`start_replay_playback`] transitions to
/// [`Playing`](Self::Playing) and resets the live `GameState` to the
/// replay's recorded deal.
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
/// for each [`ReplayMove`].
/// 4. When `cursor == replay.moves.len()`, the state transitions to
/// [`Completed`](Self::Completed). It lingers for
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
/// [`auto_clear_completed_replay`]) before returning to
/// [`Inactive`](Self::Inactive).
/// 5. [`stop_replay_playback`] interrupts at any time and forces the
/// state back to [`Inactive`](Self::Inactive).
#[derive(Resource, Debug, Default)]
pub enum ReplayPlaybackState {
/// No replay is being played back. The overlay despawns itself when
/// the resource transitions back to this variant.
#[default]
Inactive,
/// A replay is currently being played back. The overlay reads
/// `replay.moves.len()` for the denominator of the progress
/// indicator and `cursor` for the numerator.
Playing {
/// The replay being played back. Owned so the state is the
/// only place playback metadata lives — no separate resource
/// needed.
replay: Replay,
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
cursor: usize,
/// Seconds remaining until the next move is dispatched.
secs_to_next: f32,
},
/// The replay finished playing back. The overlay swaps the banner
/// label to "Replay complete" until [`auto_clear_completed_replay`]
/// transitions back to [`Inactive`](Self::Inactive) a few seconds
/// later.
Completed,
}
impl ReplayPlaybackState {
/// Returns `true` when a replay is currently being played back.
pub fn is_playing(&self) -> bool {
matches!(self, Self::Playing { .. })
}
/// Returns `true` when the replay has finished but the resource has
/// not yet been auto-cleared back to [`Self::Inactive`].
pub fn is_completed(&self) -> bool {
matches!(self, Self::Completed)
}
/// Returns `(cursor, total)` when a replay is in progress so the
/// overlay can render `"Move N of M"`. Returns `None` while
/// [`Inactive`](Self::Inactive) or [`Completed`](Self::Completed) —
/// the replay is consumed when transitioning out of `Playing`, so
/// the total is no longer available in `Completed`.
pub fn progress(&self) -> Option<(usize, usize)> {
match self {
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
Self::Inactive | Self::Completed => None,
}
}
}
/// Public entry point — call from the Stats "Watch replay" button
/// handler.
///
/// Resets the live [`GameStateResource`] to a fresh deal seeded from
/// `replay.seed` / `replay.draw_mode` / `replay.mode` (via
/// [`Commands::insert_resource`]), then transitions the state machine
/// to [`ReplayPlaybackState::Playing`] with `cursor: 0` and
/// `secs_to_next: REPLAY_MOVE_INTERVAL_SECS`.
///
/// `commands` is used to overwrite [`GameStateResource`] in a deferred
/// flush — equivalent to what `handle_new_game` does, minus the
/// [`crate::events::NewGameRequestEvent`] round-trip and the
/// abandon-current-game confirmation modal (which would block playback
/// indefinitely). Using `Commands` rather than [`crate::events::NewGameRequestEvent`]
/// also sidesteps the fact that `NewGameRequestEvent` has no
/// `draw_mode_override` field — `handle_new_game` always reads
/// `draw_mode` from `Settings`, which would silently coerce a Draw-1
/// replay into a Draw-3 game (or vice versa) when the player's
/// settings disagree with the recording.
///
/// Safe to call from any state — if a replay is already playing it is
/// dropped and the new one starts immediately.
pub fn start_replay_playback(
commands: &mut Commands,
state: &mut ResMut<ReplayPlaybackState>,
replay: Replay,
) {
use solitaire_core::game_state::GameState;
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
commands.insert_resource(GameStateResource(fresh));
// Initial `secs_to_next` uses the constant rather than reading
// `SettingsResource` because this entry point takes `Commands` /
// `ResMut<ReplayPlaybackState>` only. The first-tick latency may
// therefore lag the configured interval by up to ~0.45 s on an
// unusually short setting; subsequent ticks read the live setting
// every frame via [`tick_replay_playback`].
**state = ReplayPlaybackState::Playing {
replay,
cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
};
}
/// Aborts an in-flight replay playback and resets
/// [`ReplayPlaybackState`] back to [`ReplayPlaybackState::Inactive`].
///
/// Safe to call from any state — when already
/// [`ReplayPlaybackState::Inactive`] it simply re-asserts inactivity.
///
/// The current [`GameStateResource`] is left as-is: the player sees the
/// replay's most-recently-applied state until they start a fresh game
/// manually. This avoids forcing an extra deal animation in their face
/// the moment they cancel.
///
/// `commands` is currently unused but accepted to match the
/// [`start_replay_playback`] signature — leaves room to hook in
/// cleanup (e.g. despawning playback-only overlays) without a future
/// API break.
pub fn stop_replay_playback(
_commands: &mut Commands,
state: &mut ResMut<ReplayPlaybackState>,
) {
**state = ReplayPlaybackState::Inactive;
}
/// Tick system. Runs every frame; only does work when
/// [`ReplayPlaybackState::is_playing`].
///
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
/// expires, fires the canonical event for the move at `cursor`,
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
/// reaches `replay.moves.len()`, transitions to
/// [`ReplayPlaybackState::Completed`].
///
/// The advance loop is a `while`, not an `if`, so coarse time steps
/// (e.g. test-driven 200 ms ticks against a 450 ms interval) still
/// fire the right number of events — accumulated debt is paid off
/// across as many advances as needed in the same frame. In normal
/// gameplay frame deltas are well below `REPLAY_MOVE_INTERVAL_SECS`,
/// so the loop runs at most once per frame.
fn tick_replay_playback(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
mut state: ResMut<ReplayPlaybackState>,
mut moves_writer: MessageWriter<MoveRequestEvent>,
mut draws_writer: MessageWriter<DrawRequestEvent>,
) {
let dt = time.delta_secs();
let interval = current_move_interval_secs(settings.as_deref());
let mut transition_to_completed = false;
if let ReplayPlaybackState::Playing {
replay,
cursor,
secs_to_next,
} = state.as_mut()
{
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1;
*secs_to_next += interval;
}
if *cursor >= replay.moves.len() {
transition_to_completed = true;
}
}
if transition_to_completed {
*state = ReplayPlaybackState::Completed;
}
}
/// Local timer for the [`ReplayPlaybackState::Completed`] linger.
/// Resets to zero whenever the state transitions out of
/// [`ReplayPlaybackState::Completed`].
#[derive(Default)]
struct CompletionLinger(f32);
/// Auto-clear system. While [`ReplayPlaybackState::Completed`],
/// accumulates time and transitions back to
/// [`ReplayPlaybackState::Inactive`] once
/// [`REPLAY_COMPLETION_LINGER_SECS`] has elapsed.
fn auto_clear_completed_replay(
time: Res<Time>,
mut state: ResMut<ReplayPlaybackState>,
mut linger: Local<CompletionLinger>,
) {
if state.is_completed() {
linger.0 += time.delta_secs();
if linger.0 >= REPLAY_COMPLETION_LINGER_SECS {
*state = ReplayPlaybackState::Inactive;
linger.0 = 0.0;
}
} else {
// Reset whenever we're not in Completed so the next completion
// measures from zero rather than accumulating across cycles.
linger.0 = 0.0;
}
}
/// Local cache of the recording buffer's length at the start of
/// playback. Lets us roll back any growth during playback without
/// touching `game_plugin`'s recording call sites.
#[derive(Default)]
struct RecordingSnapshot {
/// `Some(len)` while playback is active. The recording is
/// truncated back to this length every frame so playback-driven
/// events leak no entries into the recorded move list. `None`
/// when not playing — recording behaves normally.
snapshot_len: Option<usize>,
}
/// Recording-pause system. While [`ReplayPlaybackState::is_playing`],
/// snapshots the recording's length on entry and truncates the
/// recording back to that length every frame. This keeps the live
/// [`RecordingReplay`] opaque to `game_plugin`'s `handle_move` /
/// `handle_draw` — those still push unconditionally; we just wipe the
/// playback-driven entries before any other system can read them.
///
/// Implemented this way because [`RecordingReplay`] is mutated inside
/// the [`GameMutation`] system set (the schedule set that owns
/// `handle_move` / `handle_draw`). We schedule this system
/// `.after(GameMutation)` so the truncation runs each frame *after*
/// the unconditional push, removing the same entry the playback tick
/// caused.
fn record_replay_skip_during_playback(
state: Res<ReplayPlaybackState>,
mut recording: ResMut<RecordingReplay>,
mut snap: Local<RecordingSnapshot>,
) {
// Treat `Playing` and `Completed` identically for the purpose of
// recording suppression. The tick system's final advance fires
// its event in the same frame it transitions to `Completed`; the
// event is then consumed by `handle_move` / `handle_draw` either
// this frame (race-dependent on system order) or the next. By
// suppressing recording growth across both states, we close that
// window cleanly: the snapshot survives until the resource is
// back to `Inactive` (auto-cleared after
// `REPLAY_COMPLETION_LINGER_SECS`).
if state.is_playing() || state.is_completed() {
let baseline = match snap.snapshot_len {
Some(n) => n,
None => {
let n = recording.moves.len();
snap.snapshot_len = Some(n);
n
}
};
if recording.moves.len() > baseline {
recording.moves.truncate(baseline);
}
} else {
// Drop the snapshot when neither playing nor completed so
// the next playback cycle re-anchors to whatever the
// recording is at that point.
snap.snapshot_len = None;
}
}
/// On-completion side effect: fire a single [`StateChangedEvent`] when
/// playback transitions from `Playing` to `Completed` so any UI that
/// listens for state mutations refreshes one final time. Cheap and
/// idempotent — `StateChangedEvent` is a one-shot signal.
fn fire_state_changed_on_completion(
state: Res<ReplayPlaybackState>,
mut last_was_completed: Local<bool>,
mut writer: MessageWriter<StateChangedEvent>,
) {
let now_completed = state.is_completed();
if now_completed && !*last_was_completed {
writer.write(StateChangedEvent);
}
*last_was_completed = now_completed;
}
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
/// playback ticks, completion linger, and the recording-pause guard.
///
/// Register this in the main app alongside [`crate::game_plugin::GamePlugin`].
/// Tests can install it under [`MinimalPlugins`] to exercise the public
/// API without spinning up the full client.
pub struct ReplayPlaybackPlugin;
impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>()
.add_systems(
Update,
(
tick_replay_playback,
auto_clear_completed_replay,
fire_state_changed_on_completion,
)
.chain(),
)
.add_systems(
Update,
record_replay_skip_during_playback.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::game_plugin::GamePlugin;
use bevy::time::TimeUpdateStrategy;
use chrono::NaiveDate;
use solitaire_core::game_state::{DrawMode, GameMode};
use solitaire_core::pile::PileType;
use std::time::Duration;
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
/// `ReplayPlaybackPlugin`. `GamePlugin` brings the canonical
/// `MoveRequestEvent` / `DrawRequestEvent` registrations along with
/// `RecordingReplay` so the recording-pause test can read it.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin::headless())
.add_plugins(ReplayPlaybackPlugin);
// Disable game-state persistence so tests don't touch the
// real ~/.local/share/solitaire_quest/game_state.json.
app.insert_resource(crate::game_plugin::GameStatePath(None));
app.insert_resource(crate::game_plugin::ReplayPath(None));
// Tick once so any startup systems flush before the first
// assertion.
app.update();
app
}
/// `Time<Virtual>` clamps each tick to `max_delta` (default 250 ms),
/// so we drive 200 ms steps and call `update` enough times to pass
/// the requested duration.
fn advance_by(app: &mut App, total_secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(0.2),
));
let ticks = (total_secs / 0.2).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
}
}
/// A 3-move replay covering both `Move` and `StockClick` variants.
/// Seed 12345 is arbitrary — the test asserts on event counts and
/// move shapes, not on board positions.
fn sample_replay_three_moves() -> Replay {
Replay::new(
12345,
DrawMode::DrawOne,
GameMode::Classic,
60,
500,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(3),
count: 1,
},
ReplayMove::StockClick,
],
)
}
/// Scoped helper to invoke `start_replay_playback` from within the
/// app's `World` (the public API takes `Commands`, which only
/// exists inside systems). We use a one-shot system to obtain the
/// `Commands`.
fn start_playback(app: &mut App, replay: Replay) {
#[derive(Resource)]
struct ReplayInbox(Option<Replay>);
app.insert_resource(ReplayInbox(Some(replay)));
fn run(
mut commands: Commands,
mut state: ResMut<ReplayPlaybackState>,
mut inbox: ResMut<ReplayInbox>,
) {
if let Some(replay) = inbox.0.take() {
start_replay_playback(&mut commands, &mut state, replay);
}
}
let id = app.world_mut().register_system(run);
app.world_mut()
.run_system(id)
.expect("one-shot start_playback");
}
fn stop_playback(app: &mut App) {
fn run(mut commands: Commands, mut state: ResMut<ReplayPlaybackState>) {
stop_replay_playback(&mut commands, &mut state);
}
let id = app.world_mut().register_system(run);
app.world_mut()
.run_system(id)
.expect("one-shot stop_playback");
}
/// Fresh state must be `Inactive`. After `start_replay_playback`
/// the state must be `Playing { cursor: 0, .. }` carrying the
/// supplied replay.
#[test]
fn start_replay_playback_transitions_inactive_to_playing() {
let mut app = headless_app();
assert!(matches!(
*app.world().resource::<ReplayPlaybackState>(),
ReplayPlaybackState::Inactive
));
let replay = sample_replay_three_moves();
start_playback(&mut app, replay.clone());
// Apply the deferred Commands flush.
app.update();
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing {
cursor,
replay: r,
..
} => {
assert_eq!(*cursor, 0);
assert_eq!(r.seed, replay.seed);
assert_eq!(r.moves.len(), 3);
}
other => panic!("expected Playing, got {other:?}"),
}
assert_eq!(state.progress(), Some((0, 3)));
}
/// One full interval (plus a small margin to clear the boundary)
/// must advance the cursor by at least one.
#[test]
fn tick_advances_cursor_after_interval() {
let mut app = headless_app();
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Drive virtual time forward by one interval.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.05);
let state = app.world().resource::<ReplayPlaybackState>();
match state {
ReplayPlaybackState::Playing { cursor, .. } => {
assert!(
*cursor >= 1,
"expected cursor advanced past one move, got {cursor}",
);
}
other => panic!("expected Playing, got {other:?}"),
}
}
/// Driving past `n * REPLAY_MOVE_INTERVAL_SECS` must produce
/// `n` events that match the recorded move kinds. We register a
/// pair of accumulator systems that drain `MoveRequestEvent` /
/// `DrawRequestEvent` into resources every frame — using a
/// detached cursor across many `app.update()` calls is unreliable
/// because Bevy's `Messages` double-buffer drops events older
/// than two frames.
#[test]
fn tick_fires_canonical_event_for_each_move() {
#[derive(Resource, Default)]
struct CapturedMoves(Vec<MoveRequestEvent>);
#[derive(Resource, Default)]
struct CapturedDraws(usize);
fn collect_moves(
mut events: MessageReader<MoveRequestEvent>,
mut sink: ResMut<CapturedMoves>,
) {
for ev in events.read() {
sink.0.push(ev.clone());
}
}
fn collect_draws(
mut events: MessageReader<DrawRequestEvent>,
mut sink: ResMut<CapturedDraws>,
) {
for _ in events.read() {
sink.0 += 1;
}
}
let mut app = headless_app();
app.init_resource::<CapturedMoves>()
.init_resource::<CapturedDraws>()
.add_systems(Update, (collect_moves, collect_draws));
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Drive through 3 intervals. Add a small margin to ensure the
// last firing isn't sitting exactly on the boundary.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 3.0 + 0.1);
let captured_moves = app.world().resource::<CapturedMoves>();
let captured_draws = app.world().resource::<CapturedDraws>();
// Sample replay: StockClick, Move { Waste -> Tableau(3), 1 }, StockClick.
assert_eq!(
captured_draws.0, 2,
"expected 2 DrawRequestEvent (two StockClicks)",
);
assert_eq!(
captured_moves.0.len(),
1,
"expected 1 MoveRequestEvent (the single Move variant)",
);
let m = &captured_moves.0[0];
assert!(matches!(m.from, PileType::Waste));
assert!(matches!(m.to, PileType::Tableau(3)));
assert_eq!(m.count, 1);
}
/// Driving past one interval on a single-move replay must
/// transition to `Completed`.
#[test]
fn playback_completes_when_cursor_reaches_end() {
let mut app = headless_app();
let one_move = Replay::new(
42,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick],
);
start_playback(&mut app, one_move);
app.update();
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS + 0.1);
let state = app.world().resource::<ReplayPlaybackState>();
assert!(
state.is_completed(),
"expected Completed after consuming the only move, got {state:?}",
);
}
/// `stop_replay_playback` must force the state back to `Inactive`
/// even mid-playback.
#[test]
fn stop_replay_playback_returns_to_inactive() {
let mut app = headless_app();
start_playback(&mut app, sample_replay_three_moves());
app.update();
// Tick once so the state is well and truly `Playing`.
advance_by(&mut app, 0.1);
assert!(app.world().resource::<ReplayPlaybackState>().is_playing());
stop_playback(&mut app);
app.update();
assert!(matches!(
*app.world().resource::<ReplayPlaybackState>(),
ReplayPlaybackState::Inactive
));
}
/// Recording must remain frozen during playback. Pre-populate the
/// recording with one entry, start playback, and assert the
/// recording's move list is unchanged after several ticks.
#[test]
fn recording_paused_during_playback() {
let mut app = headless_app();
// Pre-populate the recording with one entry that should
// survive playback unchanged. Mirrors the situation where the
// player partway through a game opens stats and clicks Watch
// Replay — their in-flight recording must not get clobbered.
{
let mut rec = app.world_mut().resource_mut::<RecordingReplay>();
rec.moves.push(ReplayMove::StockClick);
}
start_playback(&mut app, sample_replay_three_moves());
app.update();
let baseline_len = app.world().resource::<RecordingReplay>().moves.len();
assert_eq!(
baseline_len, 1,
"preconditions: recording starts with one entry",
);
// Drive playback through every move in the replay. Each move
// would normally append to `RecordingReplay`; the pause
// system must clamp the recording back to `baseline_len` on
// every frame.
advance_by(&mut app, REPLAY_MOVE_INTERVAL_SECS * 4.0 + 0.1);
let after_len = app.world().resource::<RecordingReplay>().moves.len();
assert_eq!(
after_len, baseline_len,
"recording must not grow while playback is active",
);
}
/// With `SettingsResource::replay_move_interval_secs` set to 0.10 s
/// (well below the 0.45 s default), playback over a fixed
/// wall-clock window must dispatch strictly more moves than the
/// same fixture would at the 0.45 s default. This is the
/// regression check that the tick reads from the live Settings
/// value rather than the hardcoded
/// [`REPLAY_MOVE_INTERVAL_SECS`] constant.
///
/// The follow-up assertion exercises the boundary condition: at
/// the 0.10 s/move setting, exactly six 0.10 s ticks must yield
/// fewer moves than six 0.20 s ticks (because the latter doubles
/// the per-update advance and pays off two intervals each tick).
#[test]
fn replay_playback_tick_uses_settings_interval() {
use solitaire_data::Settings;
#[derive(Resource, Default)]
struct CapturedDraws(usize);
fn collect_draws(
mut events: MessageReader<DrawRequestEvent>,
mut sink: ResMut<CapturedDraws>,
) {
for _ in events.read() {
sink.0 += 1;
}
}
// Long replay so the fast cadence has plenty of moves to
// chew through and the 0.45 s vs 0.10 s difference is easy
// to observe.
fn ten_draws_replay() -> Replay {
Replay::new(
7,
DrawMode::DrawOne,
GameMode::Classic,
10,
100,
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
vec![ReplayMove::StockClick; 10],
)
}
// ---- Run 1: 0.10 s/move (Settings override) ----
let mut fast_app = headless_app();
fast_app.insert_resource(SettingsResource(Settings {
replay_move_interval_secs: 0.10,
..Settings::default()
}));
fast_app
.init_resource::<CapturedDraws>()
.add_systems(Update, collect_draws);
start_playback(&mut fast_app, ten_draws_replay());
fast_app.update();
// 1.0 s of virtual time at 0.10 s/move dispatches ~5 moves
// after the default 0.45 s startup interval is consumed.
advance_by(&mut fast_app, 1.0);
let fast_count = fast_app.world().resource::<CapturedDraws>().0;
// ---- Run 2: 0.45 s/move (default — no SettingsResource) ----
let mut slow_app = headless_app();
// `tick_replay_playback` falls back to `REPLAY_MOVE_INTERVAL_SECS`
// (0.45 s) when `SettingsResource` is absent.
slow_app
.init_resource::<CapturedDraws>()
.add_systems(Update, collect_draws);
start_playback(&mut slow_app, ten_draws_replay());
slow_app.update();
advance_by(&mut slow_app, 1.0);
let slow_count = slow_app.world().resource::<CapturedDraws>().0;
assert!(
fast_count > slow_count,
"at 0.10 s/move the tick must dispatch strictly more moves \
than at the 0.45 s default over the same wall-clock window: \
fast={fast_count}, slow={slow_count}",
);
// ---- Boundary: a 0.05 s/tick cadence over the same window
// dispatches NO MORE moves than a 0.10 s/tick cadence, because
// 0.05 s < 0.10 s configured interval — the secs_to_next clock
// never crosses the threshold inside a single tick. ----
//
// We don't assert "exactly zero" because the leading update()
// after `start_playback` may run before the strategy is
// applied (cf. comments on `tick_advances_cursor_after_interval`),
// but the count must not exceed what we'd get with one-tick
// advances at the same total wall-clock window.
fn count_after_window(interval_secs: f32, tick_secs: f32, total_secs: f32) -> usize {
let mut app = headless_app();
app.insert_resource(SettingsResource(Settings {
replay_move_interval_secs: interval_secs,
..Settings::default()
}));
app.init_resource::<CapturedDraws>()
.add_systems(Update, collect_draws);
start_playback(&mut app, ten_draws_replay());
app.update();
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(tick_secs),
));
let ticks = (total_secs / tick_secs).ceil() as usize + 1;
for _ in 0..ticks {
app.update();
}
app.world().resource::<CapturedDraws>().0
}
let count_at_05 = count_after_window(0.10, 0.05, 1.0);
let count_at_20 = count_after_window(0.10, 0.20, 1.0);
assert!(
count_at_05 <= count_at_20,
"0.05 s ticks (strictly less than the 0.10 s interval) must \
dispatch no more moves than 0.20 s ticks over the same \
wall-clock window: count_at_05={count_at_05}, count_at_20={count_at_20}",
);
}
}

Some files were not shown because too many files have changed in this diff Show More