Compare commits

...

114 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
funman300 d948fa862a docs: CHANGELOG + SESSION_HANDOFF refresh for v0.14.0
CHANGELOG gains a [0.14.0] section covering 18 commits since v0.13.0
across three threads: the v0.13.0-era UX candidates that missed the
v0.13.0 tag (theme thumbnails, daily-challenge calendar, Time Attack
auto-save, per-mode bests, time-bonus slider), Quat's three bug
fixes from a smoke-test round (multi-card lift validation, softlock
detection, deal-tween information leak), and the major new replay
pipeline (record → persist → upload → web viewer with a new
solitaire_wasm crate).

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

SESSION_HANDOFF rewritten as the session 9 / post-v0.14.0 doc.
The session 8 changelog table is preserved alongside a new "v0.14.0
shipped" rollup. The next-round candidates list seeds six fresh
ideas (deferred Bevy audio trim, solver toggle, in-engine replay
playback, per-replay history, solver-driven hints, "won via
replay" achievement). Resume prompt asks A–F about smoke-test,
audio trim, solver toggle, in-engine playback, fresh UX, or
packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:44:03 +00:00
funman300 1fcd032b0a feat(web): card flight animations between piles
The replay viewer's renderer used to wipe and rebuild every card
from scratch on every step (`board.replaceChildren()`). Each step
was a discrete redraw — fine for correctness, abrupt for the eye.

Restructured to a persistent card-element model:

- `#board` is now a positioned context (relative) instead of a
  CSS grid. The dashed empty-pile placeholders are absolutely-
  positioned `.slot` elements painted once at bootstrap.
- Each card lives as a sibling of the slots, absolutely-positioned
  with `transform: translate(x, y)`. The CSS transition on
  `transform` (280 ms cubic-bezier) runs every move as a flight
  rather than a redraw.
- `cardEls: Map<id, HTMLElement>` persists across renders. Cards
  unchanged between steps don't re-create their DOM at all.
- Z-index is set per-render from the card's pile index so a card
  flying out from the bottom of a tableau passes behind the cards
  above it.
- Newly-spawned cards (rare — only on Restart) fade in at their
  target position via a `requestAnimationFrame` opacity flip;
  cards that disappear (also rare) fade out and despawn after the
  220 ms fade.
- `will-change: transform` lets the browser composite the
  animation, keeping it smooth on low-spec hardware.

Restart now drops every existing card before resetting so the
fresh deal looks like a new game, not a continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:01:02 +00:00
funman300 3081505a3d test(server): E2E coverage for replay upload → fetch path
Five new integration tests against the in-process Axum router +
in-memory SQLite, covering the full HTTP transport + database layer
that the web replay viewer depends on:

- `replay_upload_then_fetch_round_trips_payload` — register → POST →
  GET → assert the fetched JSON matches the upload byte-for-byte.
  Canonical "the web viewer can play back what the desktop client
  uploaded" coverage.
- `replay_fetch_unknown_id_returns_404` — exercises the
  `AppError::NotFound` mapping (not a 500).
- `replay_recent_lists_newest_first_with_username` — two uploads,
  asserts received_at DESC ordering and that the username join
  populates the `username` field.
- `replay_upload_without_auth_returns_401` — guards against the
  upload endpoint accidentally accepting anonymous inserts.
- `replay_upload_malformed_body_returns_400` — header projector
  rejects payloads missing required fields with 400, not 500.

Schema-correctness (round-trip, version gate, atomic write) is still
covered by `solitaire_data::replay`'s unit tests; this file is
strictly for the HTTP transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:58:54 +00:00
funman300 07b8ecd9b2 feat(server): web replay viewer (HTML/CSS + WASM bindings)
Wires the WASM module from the previous commit into a minimal web
viewer served at <server>/replays/<id>. Two new server routes:

- `GET /replays/:id`  — returns the same embedded HTML page for any
  id; the page itself reads the path from window.location in JS and
  fetches the replay JSON via /api/replays/:id.
- `/web/*` — ServeDir for the static assets (replay.css, replay.js,
  and the wasm-bindgen-generated pkg/).

Web layer:
- index.html — header, board, controls, status. Module script.
- replay.css — midnight-purple palette matching the desktop client,
  dark felt board, CSS-grid pile layout, tableau fan via per-card
  inline `top` offset.
- replay.js — fetches the replay, instantiates the wasm
  ReplayPlayer, drives state(), step(). Controls: Restart, Play/Pause
  toggle, Step. Auto-tick at 600 ms.
- pkg/ — generated by wasm-bindgen (committed so deployers don't
  have to install wasm-bindgen-cli + the wasm32 target).

`tower-http = "0.6"` added to solitaire_server with the `fs` feature
for ServeDir.

To regenerate pkg/ after a solitaire_wasm change:
    RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
      cargo build -p solitaire_wasm \
      --target wasm32-unknown-unknown --release
    wasm-bindgen --target web \
      --out-dir solitaire_server/web/pkg --no-typescript \
      target/wasm32-unknown-unknown/release/solitaire_wasm.wasm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:54:01 +00:00
funman300 5bed43ef32 feat(wasm): solitaire_wasm crate for browser-side replay re-execution
A new `cdylib + rlib` workspace member that wraps `solitaire_core::
GameState` for use from JavaScript. The web replay viewer fetches a
replay JSON, hands it to `ReplayPlayer::new`, and steps through
moves one at a time — same Rust rules engine the desktop client
uses, so the two implementations cannot drift.

The crate intentionally does NOT depend on `solitaire_data` (which
pulls dirs/keyring/reqwest, none wasm-friendly). Instead it defines
a minimal `Replay` mirror with the same serde shape; the JSON wire
format is the contract.

Public surface (#[wasm_bindgen]):
- `ReplayPlayer::new(json)` — parse + rebuild deal from seed/mode
- `state()` / `step()` — return JS-friendly StateSnapshot
- `total_steps()` / `step_idx()` / `is_finished()` — progress helpers

Native-callable mirror (`from_json`, `step_native`) lets unit tests
exercise the state machine without going through `serde_wasm_bindgen`,
which panics off-target. 3 tests cover construction, step advance,
and invalid-JSON handling.

`getrandom` needs the `wasm_js` feature on the wasm32 target;
configured via the cfg target dep table so non-wasm builds aren't
affected.

Build pipeline (executed from the repo root):
    rustup target add wasm32-unknown-unknown
    RUSTFLAGS='--cfg getrandom_backend="wasm_js"' \
      cargo build -p solitaire_wasm \
      --target wasm32-unknown-unknown --release
    wasm-bindgen --target web \
      --out-dir solitaire_server/web/pkg --no-typescript \
      target/wasm32-unknown-unknown/release/solitaire_wasm.wasm

The generated bindings land in solitaire_server/web/pkg/ and are
committed alongside the web UI (next commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:53:19 +00:00
funman300 23c9704887 feat(engine): upload winning replays to the sync server
`push_replay_on_win` listens for `GameWonEvent` and spawns a
fire-and-forget `AsyncComputeTaskPool` task that calls
`SyncProvider::push_replay`. The game loop never blocks on the
network round-trip; failures log a warning but never abort the win
flow because the replay is already persisted locally by
`game_plugin::record_replay_on_win`.

`UnsupportedPlatform` (LocalOnlyProvider) is silently absorbed in
the same way the existing `push_on_exit` path handles it — local
players don't see a server error every time they win.

Empty-recording guard mirrors `record_replay_on_win`: synthesised
win events from XP / streak / weekly-goal tests must not trigger an
upload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:52:36 +00:00
funman300 93182fa251 feat(server): replay upload + fetch endpoints
API surface for the web replay viewer to come:

- `POST /api/replays`        — auth required; persists the JSON body
  verbatim, mints a server-side UUID, returns `{id}`. Three columns
  (final_score, time_seconds, recorded_at) are projected out of the
  payload at insert time so list endpoints don't have to scan blobs.
- `GET  /api/replays/recent` — public; returns the N most-recent
  replays across users (limit defaults to 20, capped at 50). Joins
  the username so the feed reads as "AliceWon · 2:14 win".
- `GET  /api/replays/:id`    — public; returns the full replay JSON
  the desktop client uploaded.

Migration `002_replays.sql` adds the `replays` table with indexes
on `received_at DESC` (recent feed) and `user_id` (per-user views).

Schema-version compatibility is the playback side's responsibility,
matching the desktop's existing `schema_version` gate — the server
just stores and serves whatever JSON came in.

`AppError::NotFound` added so `GET /api/replays/:id` can return a
proper 404 instead of an internal-server-error.

`.sqlx` cache regenerated for the new `query!` invocations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:50:25 +00:00
funman300 89c51ab712 feat(settings): time-bonus multiplier slider in Settings → Gameplay
Cosmetic-only player setting (default 1.0, range 0.0-2.0, step 0.1)
that scales the time-bonus row shown in the win-summary modal's
score breakdown. Achievement thresholds, lifetime score totals, and
leaderboard submissions still use the raw values produced by
`solitaire_core::scoring`, so the multiplier never affects what gets
recorded — just what the player sees on the win screen.

- New `Settings::time_bonus_multiplier` field with `#[serde(default)]`
  + `sanitized()` clamp so older settings.json files load cleanly.
- New constants `TIME_BONUS_MULTIPLIER_{MIN,MAX,STEP}` re-exported
  through `solitaire_data::lib`.
- `settings_plugin` adds a slider row under the Gameplay header
  matching the existing tooltip-delay control.
- `win_summary_plugin` applies the multiplier when rendering the
  time-bonus row of the score breakdown; "Off" label when 0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:49:07 +00:00
funman300 3984231c9b feat(data,sync,engine): per-mode best score and fastest win
Lifetime stats now also track best score and fastest win per game
mode (Classic, Zen, Challenge), additive on top of the existing
all-modes-combined `best_single_score` and `fastest_win_seconds`.
Time Attack is intentionally excluded — its scoring model is
session-level (count of wins inside a 10-minute window) so a
per-game best wouldn't compose. Daily Challenge inherits Classic
scoring and contributes through the Classic row.

- `solitaire_sync::StatsSnapshot` gains six fields (`{mode}_best_score`,
  `{mode}_fastest_win_seconds` × {Classic, Zen, Challenge}). All are
  `#[serde(default)]` so older save files load cleanly to zeros.
- `solitaire_sync::merge` propagates the per-mode bests through the
  same max/min logic as the global counterparts.
- `solitaire_data::StatsExt::update_per_mode_bests` is the engine's
  entry point — called from `update_stats_on_win` alongside the
  existing `update_on_win`.
- Stats overlay grows a "Per-mode bests" section with three rows
  (Classic / Zen / Challenge) tagged with `PerModeBestsRow`. Empty
  rows render an em-dash, matching the first-launch zero-state
  treatment used by the primary cells.
- 3 new tests cover the rendering, the Classic-mode update path,
  and the Zen-mode update path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:46:32 +00:00
funman300 d9f36bf34a feat(engine): "Watch replay" affordance in Stats overlay
The Stats screen now shows the most recent winning replay's caption
("M:SS win on YYYY-MM-DD") and a Watch Replay button. Until the web
viewer is fully wired the click fires a toast pointing the player at
the upcoming `<server>/replays/<id>` URL; once the upload + page
ship the toast is replaced with an actual link.

- New resources `LatestReplayResource(Option<Replay>)` and
  `LatestReplayPath(Option<PathBuf>)` populated at plugin build time
  from the platform-default `latest_replay.json`. Headless mode
  disables I/O the same way `StatsResource` does.
- `refresh_latest_replay_on_win` re-loads from disk after every
  `GameWonEvent` so opening the modal a second time reflects the
  most recent victory rather than a stale snapshot.
- `format_replay_caption` is a pure helper exposed for both the
  Stats button label and (later) toast messaging.
- `WatchReplayButton` marker added to `solitaire_engine`'s public
  re-exports so the future web-side click integrations can match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:41:55 +00:00
funman300 57d1c58fdf feat(engine): record + persist winning replays on disk
- New `RecordingReplay` resource (in `game_plugin`): in-memory move
  buffer that accumulates atomic player inputs as they're applied to
  `GameState`. Cleared on every `NewGameRequestEvent` so a fresh deal
  starts from an empty list.
- `handle_move` and `handle_draw` push the corresponding `ReplayMove`
  on success only — invalid / rejected events never enter the buffer.
  `Undo` is intentionally not recorded; the replay represents the
  canonical path to victory, not the missteps that were rolled back.
- `record_replay_on_win` listens for `GameWonEvent`, freezes the
  buffer into a `Replay` (seed + draw_mode + mode + score + duration
  + today's date + the move list), and persists atomically to
  `<data_dir>/solitaire_quest/latest_replay.json` via the new
  `ReplayPath` resource.
- Empty-recording guard: synthesised win events from XP / streak /
  weekly-goal tests must not clobber the developer's real replay
  file. A real win always has at least one recorded move.
- 5 dedicated tests cover ordering, rejected-move skipping, undo
  skipping, new-game clearing, and the freeze→save round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:38:49 +00:00
funman300 42535f5109 feat(data): replay storage layer with atomic StockClick input
New `solitaire_data::replay` module:
- `Replay` struct: seed + draw_mode + mode + ordered move list +
  presentation metadata (time / score / date). Replays are
  reconstructed by rebuilding `GameState::new_with_mode` and applying
  the move list in order — a deterministic state machine driven by
  atomic player inputs, no per-step snapshots stored.
- `ReplayMove`: one variant per atomic player input. `Move {from, to,
  count}` covers card moves; `StockClick` covers every click on the
  stock (the engine resolves draw-vs-recycle deterministically from
  current state during both record and playback).
- Schema-versioned (`REPLAY_SCHEMA_VERSION = 2`); legacy files are
  rejected via the version gate so older replays just disappear from
  the UI rather than half-loading.
- Atomic save (.tmp -> rename), `dirs::data_dir()`-based path
  resolution. 5 round-trip / atomic / version-gate / corruption tests.

Sync trait extension:
- `SyncProvider::push_replay(&Replay)` — default returns
  `UnsupportedPlatform` so `LocalOnlyProvider` is silently no-op'd by
  the future push-on-win path. Mirrors the existing `pull` / `push`
  default-impl pattern.
- `SolitaireServerClient::push_replay` — `POST /api/replays`, same
  401-refresh-and-retry shape as `push`.

The wire format is the contract: `solitaire_wasm` (added in a later
commit) parses the JSON via its own minimal mirror struct so it can
compile to wasm32 without pulling the desktop client's transitive
deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:36:25 +00:00
funman300 d5e6f8026b docs: SESSION_HANDOFF refresh for session 8 (Quat smoke-test round)
Captures the three bug-fix commits (move validation, deal-tween leak,
softlock detection), notes that bug #3's "no end-game screen" was
downstream of the softlock-detection bug and is now resolved, and
records the two investigation findings (audio-stack feature trim,
solver-at-deal toggle) as deferred decisions for the player.

Updates HEAD/test counts (origin at 2716472, 1126 tests passing).
Cleans the next-round candidates list — calendar / thumbnails /
Time-Attack auto-save shipped between v0.13.0's doc commit and
session 8; replay is WIP in the working tree.

Resume prompt now offers six choices (A–F) covering finish-replay,
smoke-test, audio-feature trim, solver toggle, other UX, packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:44:33 +00:00
funman300 271647265c fix(engine): treat unplayable stock as softlock in has_legal_moves
Previous heuristic returned true whenever stock or waste held any
cards. Quat hit a state with 4 cards remaining and the stock kept
cycling but nothing was ever playable — the existing detection
counted "draw is available" as a legal move and the game just sat
there.

Replace the early return with a single pass over every card that
could ever be a move source: every Stock card, every Waste card, and
the face-up top of every Tableau column. For each, check whether it
can currently land on any Foundation or Tableau. Return true only if
*some* card anywhere can land *somewhere* — otherwise the player is
genuinely stuck no matter how many times they recycle the deck.

Tightened the existing fresh-game test name to reflect what it
actually proves (a fresh deal has playable moves, not "stock is
non-empty"). Added one new test reproducing Quat's exact case.

Reported by Quat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:55 +00:00
funman300 3eabc149a8 fix(engine): hide previous-game positions during new-game deal
Reported leak: when a new game starts, every card sprite tweens from
its previous-game Transform to its new dealt position. A careful
observer can track those origin points and deduce face-down cards in
the new layout — the tween's start frame literally renders the prior
game's geometry.

Fix: in handle_new_game, after replacing the GameState, snap every
existing card Transform to the stock pile's position before writing
StateChangedEvent. The downstream slide tween in card_plugin then
reads the stock position as its source, so all 52 cards animate out
from a single point — reads as "dealing from the deck" with no
information leak.

No layout reach in headless test contexts so the snap is gated on
Option<Res<LayoutResource>>.

Reported by Quat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:26:14 +00:00
funman300 f1aeb24157 fix(core): validate moved tableau stack forms a legal run
move_cards only checked that the *bottom* card of a moved stack landed
legally on the destination — the cards above the bottom went through
unverified. A player could lift an arbitrary selection from one column
and drop it on another whenever the bottom happened to match, even if
the upper cards didn't form a descending alternating-colour sequence.

Adds is_valid_tableau_sequence(&[Card]) -> bool to rules.rs (4 lines)
and one call site in move_cards's tableau-destination branch. One
focused test covering single-card / valid-run / same-colour /
rank-gap cases.

Reported by Quat: "stack 4 onto stack 2" was accepted when illegal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:26:14 +00:00
funman300 000143231b feat(engine): auto-save Time Attack sessions across launches
Classic, Zen, and Challenge already auto-saved correctly via the
existing game_state.json path — GameState carries mode and the
save/restore systems are mode-agnostic. Time Attack was the gap:
the per-deal GameState round-tripped fine, but the session-level
TimeAttackResource (10-minute countdown + accumulated wins)
defaulted on every launch, so closing mid-session reset the timer
and erased the win count.

Adds a sibling time_attack_session.json next to game_state.json,
atomic .tmp + rename via the existing save pattern. The new
TimeAttackSession struct carries remaining_secs, wins, and
saved_at_unix_secs (wall-clock anchor for stale-session detection).
load_time_attack_session_from_at takes an injectable now() so
tests can drive deterministic clock scenarios.

Load logic: if now_unix - saved_at_unix_secs > remaining_secs the
window expired in real time while the app was closed — return None
so the player isn't dropped into a session whose timer ran out
behind their back. Otherwise restore remaining_secs minus the
real-world elapsed delta. Handles clock-running-backwards (NTP
correction, VM clock drift) by clamping the elapsed delta at zero.

time_attack_plugin wires four new systems: load on Startup, clear
stale file when a fresh session starts (rare — only matters when
the previous session was abandoned + a new one started without
exit/relaunch), 30-second auto-save while a session is active,
delete file on natural expiry, and save on AppExit. The save file
is removed every time the session ends so a stale "session exists"
state can't pollute the next launch.

No GameState schema bump needed — the per-mode session lives in
its own file. stats / progress / achievements / settings unaffected.

8 new storage tests cover round-trip, expired-discard, time-decay,
atomic-write, missing-file, corrupt-file, delete idempotency, and
clock-backwards. 6 new plugin tests cover exit-persists,
exit-clears, auto-save-cadence, auto-save-noop-when-inactive,
new-session-clears-stale, and natural-expiry-clears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:06:35 +00:00
funman300 1a1047664b feat(engine): 14-day daily-challenge calendar in the Profile modal
The daily challenge already updated streak counters, but past
completions were invisible — the player had no in-game surface to
see streak length or the actual day-by-day record. Adds a 14-dot
horizontal calendar above the Profile modal's achievements section
with a "Current streak: N · Longest: M" caption.

Each dot represents a day in the trailing 14-day window ending
today. Today's dot gets a 2-px Balatro-yellow ring; completed days
fill STATE_SUCCESS; missed days fill BG_ELEVATED. Geometry: 14 ×
12 px + 13 × 6 px gap ≈ 246 px — fits comfortably inside the
modal's 360 px min_width even on the 800 px window minimum.

PlayerProgress gains two #[serde(default)] fields:
- daily_challenge_history: Vec<NaiveDate> capped at 365 entries
  (one year of history; older entries pushed off when the cap is
  hit). Sorted ascending, deduped on insert so same-day re-runs
  don't bloat the list.
- daily_challenge_longest_streak: u32, updated whenever streak
  exceeds the previous max.

Legacy progress.json files load to empty/0 via #[serde(default)].

solitaire_sync::merge unions histories from local + remote (sorted,
capped) and takes max(longest_streak), with a clamp to ensure
longest is never below the merged current streak — guards against
legacy payloads where longest=0 but current is mid-streak.

13 new tests across solitaire_sync (record_daily history append,
chronological order, dedupe, cap, longest update, merge union,
merge cap, max longest, clamp), solitaire_data (history append,
longest update, legacy deserialise), and solitaire_engine
(modal renders 14 dots, today marker on rightmost only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:05:54 +00:00
funman300 ba527de351 feat(engine): card-art thumbnails in the theme picker
Settings → Cosmetic's theme picker showed only the theme name. Now
each chip carries a small Ace-of-Spades + back preview pair so the
player can see what each theme looks like before switching.

A new ThemeThumbnailCache resource keys per-theme by id and stores
two Handle<Image>s (ace + back) rasterised at thumbnail resolution
via the existing rasterize_svg path. Generation runs once per
theme registration in theme_plugin; subsequent picker re-spawns
just look up the cached handles. Themes that lack one of the
preview SVGs (broken user theme) get a Handle::default() placeholder
rather than crashing — the placeholder renders as a transparent
rectangle the same size as the missing thumbnail.

The picker chip spawn loop in settings_plugin reads the cache and
renders the pair as two child sprites above the chip text. The
selected-theme chip's existing STATE_SUCCESS tint sits behind the
thumbnails; contrast stays readable.

Asset-source plumbing in assets/sources.rs and assets/mod.rs picks
up the new bytes-loading helper that the thumbnail generator uses
for embedded:// theme assets at startup time (before AssetServer is
fully initialised).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:41:20 +00:00
funman300 fe41b502ac docs: CHANGELOG + SESSION_HANDOFF refresh for v0.13.0
CHANGELOG gains a [0.13.0] section covering the third UX iteration
round on top of v0.12.0:
- Tooltip-delay slider, streak fire, score-breakdown reveal
- Card backs follow active theme
- Drag-with-keyboard
- Right-click radial menu
Plus two code-review fixes (Removed: sccache wiring, Fixed: bundled-
only font handling).

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

SESSION_HANDOFF gains a "Session 7 round 3" table summarising the
six commits and rolls the punch list forward — UX candidate list
exhausted again, fresh six-item list seeded for a future round
(daily-challenge calendar, theme-picker thumbnails, per-mode high
scores, in-progress auto-save for Zen/Time Attack, configurable
scoring weights, win replays). Resume prompt asks A/B/C/D about
push, smoke-test, next round, or packaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:43:38 +00:00
funman300 b37f0cbec7 feat(engine): right-click radial menu for quick-drop without dragging
Power-user shortcut: hold right-click on a face-up card, a small
ring of icons appears around the cursor with one entry per legal
destination, release over an icon to fire MoveRequestEvent. Skips
the drag motion entirely while preserving the existing
RightClickHighlight tint on the actual pile markers.

A new RadialMenuPlugin owns the flow. RightClickRadialState is a
two-state enum (Idle / Active) carrying the source pile, lifted
cards, pre-computed legal destinations + their world anchors, the
ring centre, and the currently hovered icon index. Four chained
systems handle press → cursor track → release/cancel → redraw, in
that order so a single-tick test can't observe a half-state.

Mutual exclusion with the left-button mouse drag is implicit —
RadialMenuPlugin only listens to MouseButton::Right while the
existing drag pipeline only listens to Left. RightClickHighlight
co-exists at a lower z (50) than the radial overlay (Z_RADIAL_MENU
= 60), so the brief pile-marker tint reads as the same set of legal
destinations the radial offers.

Cancel paths: release the right button outside any icon, press Esc,
or press the left button. All three reset state to Idle without
dispatching a move.

Visual: a centre dot at the press location plus N icons at radius
80 px around it. For one destination the icon sits at 12 o'clock;
for N icons they spread evenly clockwise. Hovered icon scales to
1.15× and tints STATE_SUCCESS so the focused choice is unambiguous.

Twelve new tests pin the contract — five system-level (open on
press over face-up card, release over destination fires move event,
release in dead space cancels, Esc cancels, face-down doesn't
open), seven on the pure helpers (radial_anchor_for_index,
radial_hovered_index, legal_destinations_for_card). Tests inject
cursor positions through a RadialCursorOverride resource so they
work under MinimalPlugins where there's no PrimaryWindow or Camera.

help_plugin's controls reference gains a new "Mouse" section
covering double-click auto-move, right-click highlight, and the
new "Hold RMB" radial. Onboarding slide 3 is intentionally left
keyboard-only — the radial is a power-user discovery, not a
first-run teach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:40:48 +00:00
funman300 a0fc0d2605 feat(engine): keyboard-only drag-and-drop via Tab → Enter → arrows → Enter
Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.

A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:

  Idle                         (Tab/Enter/auto-move via SelectionState)
  Lifted {
      source_pile, count, cards,
      legal_destinations,      pre-computed at lift time via
      destination_index,       can_place_on_foundation/_tableau
  }

Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.

The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.

help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.

Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:10:41 +00:00
funman300 7ed4f2cba9 feat(engine): card backs follow active theme
Themes already shipped a back.svg in their manifest but card_plugin
ignored it — face-down cards always rendered with the legacy
back_N.png picker, so swapping themes only swapped the faces. Now
the active theme's back rasterises alongside its faces and feeds
into the face-down sprite path; the legacy back_N.png picker remains
the fallback when a theme doesn't ship its own back (e.g. a
user-imported theme that only redefines faces).

theme/plugin.rs caches the active theme's back Handle<Image> in the
ActiveTheme resource on theme-load and theme-switch. card_plugin's
face-down branch reads ActiveTheme first; missing theme back →
legacy back_N.png path indexed by Settings.selected_card_back.

Settings → Cosmetic's card-back picker section gains a caption
("Active theme provides its own back") that surfaces when the
override is in effect, plus the swatch row dims to communicate the
read-only state. Settings file format unchanged — selected_card_back
still round-trips and only takes effect when the theme leaves the
back undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:08:17 +00:00
funman300 ddc8f27c82 feat(engine): UX iteration round — tooltip slider, streak fire, score breakdown
Three small UX improvements bundled because they share ui_theme token
edits.

Tooltip-delay slider in Settings → Gameplay
- Settings.tooltip_delay_secs (f32, #[serde(default)] = 0.5) tunable
  via "−" / "+" icon buttons next to a value readout. Range
  [TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS] = [0.0, 1.5] in
  TOOLTIP_DELAY_STEP_SECS (0.1) increments. "Instant" label when
  value is 0; "{n:.1} s" otherwise.
- ui_tooltip's hover-delay comparison reads from SettingsResource
  with MOTION_TOOLTIP_DELAY_SECS as the fallback when the resource
  is absent (test path). New tooltip_should_show(elapsed, delay)
  pure helper covers the boundary cases.
- adjust_tooltip_delay clamps; sanitized() carries the clamp through
  load. Five round-trip / default / legacy-deserialise tests.

Win-streak milestone fire animation
- New WinStreakMilestoneEvent { streak: u32 } fired from stats_plugin
  when win_streak_current crosses any of [3, 5, 10] (only the
  threshold crossing — not every subsequent win). HUD streak readout
  scale-pulses 1.0 → 1.20 → 1.0 over MOTION_STREAK_FLOURISH_SECS
  (0.6 s) on receipt; mirrors the foundation-flourish curve shape.
- Three threshold-crossing tests pin the firing contract.

Score-breakdown reveal on the win modal
- Win modal body replaces the single "Score: N" line with a
  per-component reveal: Base score, Time bonus (m:ss), No-undo
  bonus, Mode multiplier, separator, Total. Rows fade in over
  MOTION_SCORE_BREAKDOWN_FADE_SECS (0.12 s) staggered by
  MOTION_SCORE_BREAKDOWN_STAGGER_SECS (0.15 s) so the math reads as
  it animates. Skipped rows: zero time bonus, undo-tainted no-undo
  bonus, multiplier == 1.0.
- Honours AnimSpeed::Instant: rows spawn fully visible, no stagger.
- New ScoreBreakdown::compute helper sources base from
  GameWonEvent.score, time bonus from
  solitaire_core::scoring::compute_time_bonus, no-undo from a +25
  constant when undo_count == 0, mode multiplier from GameMode (Zen
  zeros the total). 9 new tests cover the math and the reveal
  cadence.

Test count net: +25 across the workspace (1007 → 1031).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:34:53 +00:00
funman300 13dd44bd1b chore: remove project-level sccache rustc-wrapper config
Code-review feedback: sccache shouldn't be a per-project build
dependency. Cargo's incremental cache already covers what sccache
offers for a single project, and forcing rustc-wrapper = "sccache"
project-wide means every contributor has to install sccache or
prepend RUSTC_WRAPPER= to bypass the wrapper.

.cargo/config.toml only existed to wire sccache and pin SCCACHE_DIR
to a project-local cache. Removing the file entirely so plain
`cargo build` works without any extra setup. The .cargo directory
is empty after the deletion and removed too. .gitignore's
/.sccache-cache line is harmless cruft and stays — players who
already have a populated .sccache-cache directory keep it ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:34:20 +00:00
funman300 17f9b518f1 fix(engine): bundle fonts only and drop system-font fallback
Code-review feedback: the SVG rasteriser mixed three font-resolution
layers (load_system_fonts + bundled FiraMono + lenient resolver
appending CSS generics), which made card text rendering depend on
which fonts the host machine happened to have. The Bevy UI face
loaded separately at runtime via AssetServer. Picking option (a)
from the review and applying it consistently: bundle FiraMono via
include_bytes!() in BOTH layers, no system fallback anywhere.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

README gains a Changelog section linking to the new file.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:56:24 +00:00
146 changed files with 29176 additions and 2883 deletions
-31
View File
@@ -1,31 +0,0 @@
# Project-wide cargo configuration.
#
# Routes every rustc invocation through `sccache` so cold rebuilds and
# fresh checkouts (CI, new dev box, after a `cargo clean`) replay
# previously-compiled crates from a local on-disk cache rather than
# recompiling them. Warm incremental builds still go through cargo's
# own `target/` cache, which dominates locally — sccache buys you the
# big wins on cold paths.
#
# Requires sccache on PATH. Install it once per machine:
#
# Arch : pacman -S sccache
# macOS : brew install sccache
# Cargo : cargo install sccache --locked
#
# Without sccache the build fails with "rustc-wrapper not found". To
# bypass this config without editing the file, prepend
# `RUSTC_WRAPPER= ` (empty value) to your cargo command:
#
# RUSTC_WRAPPER= cargo build
#
[build]
rustc-wrapper = "sccache"
# Project-local cache so the shared dev box (or a Docker volume) keeps
# the artefacts isolated per checkout instead of mixing them in
# `~/.cache/sccache`. Set with `force = false` so a developer-set
# `SCCACHE_DIR` in their shell wins — important because the sccache
# daemon, once started, sticks with whichever directory it saw first.
[env]
SCCACHE_DIR = { value = ".sccache-cache", relative = true, force = false }
@@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT\n r.id AS \"id!: String\",\n u.username AS \"username!: String\",\n r.seed AS \"seed!: i64\",\n r.draw_mode AS \"draw_mode!: String\",\n r.mode AS \"mode!: String\",\n r.time_seconds AS \"time_seconds!: i64\",\n r.final_score AS \"final_score!: i64\",\n r.recorded_at AS \"recorded_at!: String\",\n r.received_at AS \"received_at!: String\"\n FROM replays r\n JOIN users u ON u.id = r.user_id\n ORDER BY r.received_at DESC\n LIMIT ?",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "username!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "seed!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "draw_mode!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "mode!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "time_seconds!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "final_score!: i64",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "recorded_at!: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "received_at!: String",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT replay_json FROM replays WHERE id = ?",
"describe": {
"columns": [
{
"name": "replay_json",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO replays (\n id, user_id, seed, draw_mode, mode, time_seconds, final_score,\n recorded_at, received_at, replay_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f"
}
+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
+1118
View File
File diff suppressed because it is too large Load Diff
+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
+134 -849
View File
File diff suppressed because it is too large Load Diff
+54 -1
View File
@@ -7,6 +7,7 @@ members = [
"solitaire_server",
"solitaire_app",
"solitaire_assetgen",
"solitaire_wasm",
]
resolver = "2"
@@ -29,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.
+60 -21
View File
@@ -1,17 +1,35 @@
# Solitaire Quest
A cross-platform Klondike Solitaire game written in Rust, featuring a full progression system with XP, levels, achievements, daily challenges, and optional self-hosted sync so your stats follow you across machines.
A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and
optional self-hosted sync so your stats follow you across machines.
## Features
- **Klondike Solitaire** — Draw One and Draw Three modes
- **Klondike Solitaire** — Draw One and Draw Three modes; foundations are
unlocked (any Ace lands in any empty slot, the slot then claims that suit)
- **Card themes** — bundled hayeah/playing-cards-assets default plus
user-installable themes (drop a directory under the data dir or import a
zip from Settings → Cosmetic)
- **Modern HUD** — reserved top band keeps cards from crowding the score
readout; the action bar auto-fades when the cursor leaves it so it can't
compete with the play surface
- **Drag feel** — every legal drop target is highlighted in green during
drag; cards cast a soft drop shadow that lifts when picked up; the stock
pile shows a remaining-count chip so you can see how close you are to a
recycle
- **Keyboard navigation** — Tab cycles focus through buttons, arrow keys
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
- **Daily Challenge** — server-seeded so every player worldwide gets the same deal
- **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
- **Special Modes** (unlocked at level 5): Zen, Time Attack, Challenge
- **Sync** — pull/push stats across devices via a self-hosted server
- **Color-blind mode** — blue tint on red-suit cards
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
glyph
## Building
@@ -32,52 +50,73 @@ cargo build -p solitaire_app --release
## Controls
Every action also has a visible UI button — keyboard shortcuts are optional
accelerators.
| Key | Action |
|---|---|
| Left click / drag | Move cards |
| Double click | Auto-move card to its best legal destination |
| Right click | Highlight legal moves for a card |
| Space / D | Draw from stock |
| Z / Ctrl+Z | Undo |
| U | Undo |
| H | Hint (highlight a legal move) |
| N | New game |
| S | Stats overlay |
| A | Achievements overlay |
| P | Profile overlay |
| O | Settings |
| L | Leaderboard |
| H | Help / controls |
| Enter | Auto-complete (when badge is lit) |
| Escape | Pause / clear selection |
| Arrow keys | Navigate card selection |
| Z | Zen mode |
| G | Forfeit (during pause) |
| Tab / Shift+Tab | Cycle keyboard focus |
| Enter | Activate focused button / auto-complete (when badge is lit) |
| Esc | Pause / dismiss modal |
| F1 | Help / controls |
| F11 | Toggle fullscreen |
| S / A / P / O / L / M | Stats / Achievements / Profile / Settings / Leaderboard / Menu |
## Card themes
The default theme ships embedded in the binary, so the game runs
self-contained with no external assets. To install another theme, drop a
directory containing a `theme.ron` manifest plus 53 SVG files (52 faces +
1 back) under the platform data dir's `themes/` folder, or import a zip
from **Settings → Cosmetic**. The picker chip lights up the moment a new
theme is registered. Themes are SVG-based, so they rasterise cleanly at
whatever resolution the window happens to be.
## Sync Server (optional)
To sync stats across machines, run the self-hosted server. See [README_SERVER.md](README_SERVER.md) for setup instructions.
To sync stats across machines, run the self-hosted server. See
[README_SERVER.md](README_SERVER.md) for setup instructions.
Once the server is running, open **Settings → Sync Backend**, enter the server URL and your username, and register an account from within the game.
Once the server is running, open **Settings → Sync Backend**, enter the
server URL and your username, and register an account from within the
game.
## Running Tests
```bash
# All tests
# All tests (982 passing as of v0.11.0)
cargo test --workspace
# Just game logic (no display required)
cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_server
# Lint
cargo clippy --workspace -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
```
## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem (Tokio,
Axum, sqlx, Serde, kira, and many more). Card faces come from
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
(Tokio, Axum, sqlx, Serde, kira, and many more). Card faces come from
[hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets)
(MIT, derived from the public-domain `vector-playing-cards` library); the
default card back is original work; the UI font is FiraMono-Medium (OFL).
All audio is synthesized programmatically by this project. See
[CREDITS.md](CREDITS.md) for the full list and license details.
## Changelog
See [CHANGELOG.md](CHANGELOG.md).
## License
MIT — see [LICENSE](LICENSE).
+251 -79
View File
@@ -1,110 +1,282 @@
# Solitaire Quest — UX Overhaul Session Handoff
# Solitaire Quest — Session Handoff
**Last updated:** 2026-05-02 (session 7) — UX iteration round complete: every item from session 6's UX punch list has shipped, plus a font-fallback fix surfaced by a second-machine smoke test. Six commits on top of session 6's `c4970b1`. Direction now opens for the next round — release prep or another UX pass, the player's call.
**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:** `655dfde`. Local master is **3 commits ahead** of `origin/master` (`f6c9166`, `f712b89`, `655dfde` unpushed; `fdb6c2e` and `95df542` already pushed).
- **Working tree:** clean. (`CARD_PLAN.md` is untracked but intentionally so — it's a plan doc, not source.)
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **982 passed / 0 failed** across the workspace (+20 from session 6's 962 baseline).
- **Tags on origin:** `v0.9.0`, `v0.10.0`. Stale local-only `v0.1.0` is still safe to `git tag -d v0.1.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
Session 6's UX punch list was four items. All four shipped today, plus an unrelated font-fallback fix from a second-machine smoke test.
### Terminal visual-identity port
The card-theme system, HUD restructure, modal scaffold, and the four big UX feel items (foundations, drop shadows, drop highlights, stock badge) are all in. Direction is open — the deferred release-prep items (`v0.11.0` cut, README/CHANGELOG refresh, desktop packaging) are still on the table, and a fresh round of UX iteration is also available.
Top-down stack — every commit downstream of the token system
reads from it, so swapping the palette is now a one-file edit:
### Design direction (unchanged)
- **`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.
- **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` (auto-memory; on a different machine, recreate this fresh from the README + ARCHITECTURE.md).
### Android persistence
- **`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.
### Inherited from earlier in the cycle (pre-session)
- 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
### Phase Android (build + persistence shipped; runtime gaps remain)
- **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.
### Visual-identity follow-ups (opened by v0.20.0's port)
- **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.
### Carried forward from v0.19.0
- **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. (Earlier sessions used `Rusty_Solitare` — single-i typo — as the repo name; the rename to `Rusty_Solitaire` happened in session 7. Local clone directories may still be named `Rusty_Solitare`; that's just a directory name and works fine.)
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
Always push there.
## Session 7 (shipped 2026-05-02)
### Design direction (now Terminal — base16-eighties)
| Area | Commit | What landed |
|---|---|---|
| Font fallback | `fdb6c2e` | `shared_fontdb` now `include_bytes!()`s `assets/fonts/main.ttf` (FiraMono) and pins every CSS generic to `"Fira Mono"` so unmatched named families on minimal Linux installs / fresh Wayland sessions / chroots don't drop card rank/suit text. Surfaced when a second-machine pull rendered cards without glyphs. |
| Unlock foundations | `95df542` | `PileType::Foundation(Suit)``Foundation(u8)` (slot 0..3). `Pile::claimed_suit()` derives the claim from the bottom card — no separate field, no claim-stuck-after-undo class of bugs. `can_place_on_foundation` drops its suit parameter. `next_auto_complete_move` prefers a slot whose claimed suit matches the candidate before falling back to the first empty slot for an Ace. Empty foundation markers render as plain placeholders (no "C/D/H/S"). HUD selection label and hint toast read `claimed_suit()` and fall through to "Foundation N" / "move to foundation" when the slot is empty. Save-format invalidation: `GameState.schema_version` bumped 1 → 2; old `game_state.json` files silently fall through to "fresh game on launch." Stats / progress / achievements / settings live in separate files and are unaffected. 9 new tests. |
| Drop overlay | `f6c9166` | The pre-existing `update_drop_highlights` system tinted `PileMarker` sprites green for valid drops, but markers were occluded by stacked cards — invisible during real play. New `update_drop_target_overlays` spawns a soft-fill + 3 px outlined box ABOVE cards for every legal target (full fanned column for tableaux, card-sized for foundations / empty tableaux). `Z_DROP_OVERLAY = 50` sits above static cards but below `DRAG_Z = 500` so the dragged card never gets occluded. Reuses `STATE_SUCCESS` hue. The original marker-tint system is untouched. 3 new tests. |
| Drop shadows | `f712b89` | Each `CardEntity` spawns a `CardShadow` child sprite — neutral black at 25 % alpha, sized `card_size + 4 px`, offset `(2, -3)`, local z `-0.05`. `update_card_shadows_on_drag` snaps shadows in `DragState.cards` to a lifted state (40 % alpha, `(4, -6)` offset, `(8, 8)` padding). `resize_cards_in_place` extended to keep shadows cheap on window resize. `update_card_entity`'s `despawn_related` is followed by a fresh `add_card_shadow_child` so flips / theme swaps re-attach shadows. Pure `card_shadow_params(is_dragged)` helper unit-tested. 4 new tests. |
| Stock badge | `655dfde` | A small `·N` chip at the top-right corner of the stock pile shows the remaining count. `update_stock_count_badge` spawns a top-level world entity whose `Transform.translation` is recomputed each tick from `LayoutResource`, so window resize / theme swap don't strand it. Hides via `Visibility::Hidden` when the stock empties — the existing `↺` `StockEmptyLabel` takes over and they never co-render. `Z_STOCK_BADGE = 30` sits between cards and `Z_DROP_OVERLAY`. 4 new tests. |
- **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.
## Open punch list — release prep (still deferred unless player chooses now)
1. **Cut `v0.11.0`** — meaningful slice since `v0.10.0`: full card-theme system (CARD_PLAN phases 17 + theme picker + hayeah art), HUD overhaul (band + fade), session 6's four bug fixes, and session 7's font fallback + four UX feel wins. (`git tag -d v0.1.0` first to clean up the stale local tag.)
2. **README + CHANGELOG refresh** — README was last touched at `a6b8348` before the Settings picker shipped; doesn't mention card themes, the auto-fade, or any of session 7's UX work.
3. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo, no remote yet). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
## Open punch list — UX iteration (next-round candidates)
The session-6 list is exhausted. Candidates for a next round, none formally requested by the player:
- **Animated focus ring** (currently a static overlay; could pulse on focus change).
- **Achievement onboarding pass** — show first-time players the achievement panel after their first win.
- **Mode-switch keyboard shortcut** from inside the Mode Launcher (today only mouse opens it).
- **Runtime aspect-ratio fidelity** — hayeah SVGs are ~1.45 h/w; engine layout assumes 1.4. Cards render ~3 % squashed vertically. Cosmetic.
- **Foundation completion celebration** — when a foundation reaches its King, do a small flourish (sparkle, lift, sound). The auto-complete cascade already covers the win moment, but per-foundation closure is currently silent.
- **Drag-cancel return animation** — illegal drops snap cards back instantly. A short ease-back tween ("springs back to where it came from") would feel more forgiving.
## Card-theme system (CARD_PLAN.md, fully shipped)
Seven phases landed across `b8fb3fb``924a1e2`. End-to-end:
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs (MIT) + a midnight-purple `back.svg` (original work).
- **User themes** live under `themes://` rooted at `solitaire_engine::assets::user_theme_dir()`. Drop a directory containing `theme.ron` + 53 SVG files; appears in the registry on next launch.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates an archive (20 MB cap, zip-slip rejection, manifest validation, every SVG round-tripped through the rasteriser) and atomically unpacks.
- **Picker UI** in Settings → Cosmetic — one chip per registered theme; selection persists to `settings.json` as `selected_theme_id` and propagates to live sprites via `react_to_settings_theme_change``sync_card_image_set_with_active_theme``StateChangedEvent`.
(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 — the session-6 UX punch list is
fully shipped. The player will choose between cutting v0.11.0, doing
release prep (README/CHANGELOG/packaging), or starting a new UX
iteration round.
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=655dfde. Local master is 3 commits ahead of origin
(f6c9166, f712b89, 655dfde unpushed; fdb6c2e and 95df542 already
pushed). Working tree clean apart from untracked CARD_PLAN.md
(intentional).
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 982 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 — full state, session 7 changelog, punch list
2. CLAUDE.md — hard rules (UI-first, no panics, etc.)
3. ARCHITECTURE.md — crate responsibilities + data flow
4. ~/.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. Push the 3 unpushed commits and cut v0.11.0?
B. Skip the tag for now, refresh README + CHANGELOG, then tag?
C. Skip release prep entirely and start a new UX iteration round?
If C, see the session-7 next-round candidates list (animated
focus ring, achievement onboarding, mode-switch keyboard
shortcut, aspect-ratio fidelity, foundation completion flourish,
drag-cancel return tween).
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 "..."
- 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 A / B / C. Don't pick unilaterally
this is a directional choice, not a tactical one.
OPEN AT THE START: ask which of AF. Don't pick unilaterally.
```
Binary file not shown.
+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 -168
View File
@@ -1,171 +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, 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(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();
}
+40 -1
View File
@@ -16,13 +16,14 @@ fn main() -> io::Result<()> {
let out_dir = workspace_root().join("assets").join("audio");
fs::create_dir_all(&out_dir)?;
let effects: [(&str, Generator); 6] = [
let effects: [(&str, Generator); 7] = [
("card_flip.wav", card_flip),
("card_place.wav", card_place),
("card_deal.wav", card_deal),
("card_invalid.wav", card_invalid),
("win_fanfare.wav", win_fanfare),
("ambient_loop.wav", ambient_loop),
("foundation_complete.wav", foundation_complete),
];
for (name, make) in &effects {
@@ -170,6 +171,44 @@ fn win_fanfare() -> Vec<i16> {
out
}
/// Per-suit foundation-completion ping (~240 ms): a rising three-note
/// chime — C6, E6, G6 — with a soft 2nd-harmonic warm layer on each
/// note. Shorter and brighter than `win_fanfare` so it can fire up to
/// four times per game (once per suit) without drowning out subsequent
/// move sounds. The fourth firing co-occurs with the win cascade and
/// `win_fanfare`; the C-major triad sits an octave above the
/// fanfare's root so the two layer cleanly instead of fighting for the
/// same frequency band.
fn foundation_complete() -> Vec<i16> {
// C major triad, one octave up from win_fanfare's root.
let notes = [1046.50_f32, 1318.51, 1567.98]; // C6, E6, G6
let note_dur = 0.07_f32; // brisk, ascending
let total = note_dur * notes.len() as f32 + 0.05;
let n = duration_samples(total);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / SAMPLE_RATE as f32;
let mut sample = 0.0f32;
for (idx, freq) in notes.iter().enumerate() {
let start = idx as f32 * note_dur;
let local = t - start;
// Each note rings out for 0.18 s — overlapping notes form a
// brief chord at the tail.
if !(0.0..=0.18).contains(&local) {
continue;
}
// Sine + soft 2nd harmonic for warmth, ar_envelope decays
// sharply so each note is bell-like rather than sustained.
let s = (2.0 * std::f32::consts::PI * freq * local).sin()
+ 0.25 * (2.0 * std::f32::consts::PI * freq * 2.0 * local).sin();
let env = ar_envelope(local, 0.005, 0.18, 14.0);
sample += s * env;
}
out.push(quantize(sample * 0.20));
}
out
}
/// Generates a seamlessly looping ambient drone track (~6 seconds, 44100 Hz
/// mono 16-bit PCM).
///
+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());
}
}
+13 -17
View File
@@ -4,7 +4,7 @@ use crate::card::Card;
use crate::deck::{deal_klondike, Deck};
use crate::error::MoveError;
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
const MAX_UNDO_STACK: usize = 64;
@@ -283,6 +283,18 @@ impl GameState {
if !can_place_on_tableau(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
}
// The previous check only validates that the *bottom* of the
// moved stack lands on the destination's top card. Without
// this guard, a player could lift an arbitrary multi-card
// selection from one column and drop it onto another whenever
// the bottom card happens to match — even if the cards
// above the bottom don't form a legal descending
// alternating-colour run.
if !is_valid_tableau_sequence(&from_pile.cards[start..]) {
return Err(MoveError::RuleViolation(
"moved cards must form a valid tableau run".into(),
));
}
}
_ => return Err(MoveError::InvalidDestination),
}
@@ -803,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();
@@ -888,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);
@@ -944,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;
+34
View File
@@ -30,6 +30,18 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
}
}
/// Returns `true` if `cards` is a legal tableau run on its own — every
/// adjacent pair descends by one rank and alternates colour. A single
/// card is trivially valid. The destination check is separate; this
/// only validates the sequence's *internal* structure, which the tableau
/// move path must enforce so a player can't smuggle an arbitrary stack
/// onto another column when the bottom card happens to land legally.
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
cards.windows(2).all(|w| {
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -174,4 +186,26 @@ mod tests {
let p = pile_with(PileType::Tableau(0), vec![top]);
assert!(!can_place_on_tableau(&c, &p));
}
#[test]
fn tableau_sequence_validation() {
// Single card is trivially a valid sequence.
assert!(is_valid_tableau_sequence(&[card(Suit::Hearts, Rank::Five)]));
// Valid descending alternating-colour run K♠ Q♥ J♣.
assert!(is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Hearts, Rank::Queen),
card(Suit::Clubs, Rank::Jack),
]));
// Same colour twice (Q♠ on K♠) — invalid.
assert!(!is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Spades, Rank::Queen),
]));
// Rank gap (K♠ → J♥) — invalid.
assert!(!is_valid_tableau_sequence(&[
card(Suit::Spades, Rank::King),
card(Suit::Hearts, Rank::Jack),
]));
}
}
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());
}
}
+33 -3
View File
@@ -56,6 +56,15 @@ pub trait SyncProvider: Send + Sync {
async fn delete_account(&self) -> Result<(), SyncError> {
Ok(())
}
/// 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)
}
}
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
@@ -92,6 +101,9 @@ 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<String, SyncError> {
(**self).push_replay(replay).await
}
}
pub mod stats;
@@ -99,8 +111,11 @@ pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{
cleanup_orphaned_tmp_files, delete_game_state_at, game_state_file_path, load_game_state_from,
load_stats, load_stats_from, save_game_state_to, save_stats, save_stats_to, stats_file_path,
cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
game_state_file_path, load_game_state_from, load_stats, load_stats_from,
load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to,
save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now, TimeAttackSession,
};
pub mod achievements;
@@ -126,7 +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,
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;
@@ -136,3 +154,15 @@ pub use auth_tokens::{
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::{
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"));
}
}
+67 -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");
@@ -298,4 +283,70 @@ mod tests {
assert!(!recorded_again, "same-day completion must report no-op");
assert_eq!(p.daily_challenge_streak, 1);
}
// --- Daily challenge history & longest streak ---
#[test]
fn record_daily_completion_appends_to_history() {
// Recording a completion adds the date to history, preserving the
// pre-call length + 1, and the new entry is the chronological tail.
let mut p = PlayerProgress::default();
let prev_len = p.daily_challenge_history.len();
let today = NaiveDate::from_ymd_opt(2026, 5, 5).unwrap();
let recorded = p.record_daily_completion(today);
assert!(recorded);
assert_eq!(p.daily_challenge_history.len(), prev_len + 1);
assert_eq!(p.daily_challenge_history.last().copied(), Some(today));
}
#[test]
fn record_daily_completion_updates_longest_streak() {
// A streak of 4 must lift `daily_challenge_longest_streak` from 2 to 4
// (we seed the previous best at 2 and watch it get overtaken).
let mut p = PlayerProgress {
daily_challenge_longest_streak: 2,
..Default::default()
};
let d = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
p.record_daily_completion(d);
p.record_daily_completion(d + Duration::days(1));
p.record_daily_completion(d + Duration::days(2));
// 3rd consecutive day equals the previous best; longest should match.
assert_eq!(p.daily_challenge_streak, 3);
assert_eq!(p.daily_challenge_longest_streak, 3);
// 4th consecutive day overtakes the previous best.
p.record_daily_completion(d + Duration::days(3));
assert_eq!(p.daily_challenge_streak, 4);
assert_eq!(p.daily_challenge_longest_streak, 4);
}
#[test]
fn legacy_progress_without_history_deserializes_to_empty() {
// A progress.json file produced before the history fields existed
// must still round-trip through serde::from_slice without error,
// with the new fields landing on their `#[serde(default)]` values.
let path = tmp_path("legacy_no_history");
let _ = fs::remove_file(&path);
let legacy_json = br#"{
"total_xp": 1500,
"level": 3,
"daily_challenge_last_completed": null,
"daily_challenge_streak": 0,
"weekly_goal_progress": {},
"unlocked_card_backs": [0],
"unlocked_backgrounds": [0],
"last_modified": "2026-04-29T12:00:00Z"
}"#;
fs::write(&path, legacy_json).expect("write");
let p = load_progress_from(&path);
assert_eq!(p.total_xp, 1500);
assert!(
p.daily_challenge_history.is_empty(),
"legacy file lacking daily_challenge_history must default to empty"
);
assert_eq!(
p.daily_challenge_longest_streak, 0,
"legacy file lacking daily_challenge_longest_streak must default to 0"
);
}
}
+740
View File
@@ -0,0 +1,740 @@
//! Win-game replay recording + storage.
//!
//! When a player wins, the engine freezes the in-memory recording into a
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
//! action that loads it via [`load_latest_replay_from`] so the player can
//! revisit (or, in a future build, watch the engine re-execute) the path
//! they took to victory.
//!
//! Schema versioning: bump [`REPLAY_SCHEMA_VERSION`] whenever the on-disk
//! shape changes. [`load_latest_replay_from`] returns `None` when the file
//! carries any other version so older replays are silently dropped instead
//! of crashing the loader.
//!
//! The recording is intentionally minimal — only [`ReplayMove`] entries
//! that successfully advanced the game. `Undo` is **not** recorded: a
//! replay represents the canonical path the player ultimately took to win,
//! so backed-out missteps simply do not appear in the move list. The
//! starting deal is not stored either — the [`seed`](Replay::seed) +
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
//! for `GameState::new_with_mode` to rebuild the identical layout.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{DrawMode, GameMode};
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
/// reject older formats and the player simply has no replay rather than
/// seeing a broken one.
///
/// History:
/// - v1: initial release. `ReplayMove` had separate `Draw` and `Recycle`
/// variants which carried the *outcome* of a stock interaction rather
/// than the player's atomic input.
/// - v2 (current): `Draw` + `Recycle` collapsed into a single `StockClick`
/// variant. The engine resolves draw-vs-recycle deterministically from
/// the current stock state, so the input alone is sufficient and the
/// replay model now stores atomic player inputs end-to-end.
pub const REPLAY_SCHEMA_VERSION: u32 = 2;
/// Default value for [`Replay::schema_version`] when deserialising files
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
/// causes [`load_latest_replay_from`] to return `None`.
fn schema_v0() -> u32 {
0
}
/// One atomic player input recorded during a winning game, in the order
/// it was applied to the live `GameState`.
///
/// `Undo` is intentionally absent — see the module-level docs.
///
/// The variants represent *inputs*, not outcomes. `StockClick` covers
/// every player click on the stock pile; the engine then resolves
/// draw-vs-recycle deterministically from the current state during both
/// recording and playback, so the same input always produces the same
/// effect on the same starting deal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplayMove {
/// A successful `move_cards(from, to, count)` call.
Move {
/// Source pile.
from: PileType,
/// Destination pile.
to: PileType,
/// Number of cards moved.
count: usize,
},
/// A click on the stock pile. Resolves to a draw when stock is
/// non-empty and to a waste→stock recycle when stock is empty.
StockClick,
}
/// A complete recording of a single winning game.
///
/// Replays are reconstructed by rebuilding a fresh
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
/// [`moves`](Self::moves) in order. The presentation fields
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
/// "Replay (2:14 win on 2026-05-02)".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Replay {
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
#[serde(default = "schema_v0")]
pub schema_version: u32,
/// Seed used for the deal — replay rasterises the deck via
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
pub seed: u64,
/// Draw mode the recorded game was played in.
pub draw_mode: DrawMode,
/// Game mode the recorded game was played in.
pub mode: GameMode,
/// Total wall-clock seconds the win took. Used for the Stats UI
/// "Replay (2:14 win on 2026-05-02)" caption.
pub time_seconds: u64,
/// Final score at the moment of the win.
pub final_score: i32,
/// ISO-8601 date the win was recorded.
pub recorded_at: NaiveDate,
/// 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 {
/// Construct a fresh replay with the current schema version. The
/// caller fills in the recorded fields; this is the canonical
/// constructor used by the engine on win.
pub fn new(
seed: u64,
draw_mode: DrawMode,
mode: GameMode,
time_seconds: u64,
final_score: i32,
recorded_at: NaiveDate,
moves: Vec<ReplayMove>,
) -> Self {
Self {
schema_version: REPLAY_SCHEMA_VERSION,
seed,
draw_mode,
mode,
time_seconds,
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 `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> {
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` →
/// rename contract that the rest of `storage.rs` uses.
///
/// 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)?;
}
let json = serde_json::to_string_pretty(replay).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 [`Replay`] from `path`, returning `None` when the file is
/// missing, corrupt, or carries a [`schema_version`](Replay::schema_version)
/// other than [`REPLAY_SCHEMA_VERSION`].
///
/// Schema-mismatch is treated as "no replay" so the player just sees the
/// "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()?;
if replay.schema_version != REPLAY_SCHEMA_VERSION {
return None;
}
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;
fn tmp_path(name: &str) -> PathBuf {
env::temp_dir().join(format!("solitaire_test_replay_{name}.json"))
}
fn sample_replay() -> Replay {
let date = NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date");
Replay::new(
12345,
DrawMode::DrawThree,
GameMode::Classic,
134,
5_120,
date,
vec![
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(3),
count: 1,
},
ReplayMove::StockClick,
ReplayMove::Move {
from: PileType::Tableau(3),
to: PileType::Foundation(0),
count: 1,
},
],
)
}
/// A non-trivial replay with mixed move kinds must round-trip
/// byte-identically through `save_latest_replay_to` /
/// `load_latest_replay_from`. Catches any future field that forgets
/// `Serialize`/`Deserialize` or breaks the on-disk format.
#[test]
fn replay_round_trips_through_save_and_load() {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let replay = sample_replay();
save_latest_replay_to(&path, &replay).expect("save");
let loaded = load_latest_replay_from(&path).expect("load must succeed");
assert_eq!(loaded, replay, "round-trip must preserve every field");
let _ = fs::remove_file(&path);
}
/// A file written by an older schema (or a pre-`schema_version`
/// build) must be rejected. We write a minimal v0 fixture and assert
/// that `load_latest_replay_from` returns `None` so the player gets
/// a clean "no replay" state instead of a broken one.
#[test]
fn replay_legacy_schema_version_falls_through_to_none() {
let path = tmp_path("legacy_schema");
let _ = fs::remove_file(&path);
// No `schema_version` key — defaults to 0 via `schema_v0()`. Even
// if the rest of the JSON parses cleanly, the version gate must
// reject it.
let v0_json = r#"{
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2025-01-01",
"moves": []
}"#;
fs::write(&path, v0_json).expect("write v0 fixture");
assert!(
load_latest_replay_from(&path).is_none(),
"v0 replay must be rejected (schema gate)",
);
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`.
#[test]
fn replay_save_is_atomic() {
let path = tmp_path("atomic");
let _ = fs::remove_file(&path);
save_latest_replay_to(&path, &sample_replay()).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
/// Loading from a path that does not exist must return `None`, not
/// panic or surface an `Err`.
#[test]
fn replay_missing_file_returns_none() {
let path = tmp_path("missing_xyz");
let _ = fs::remove_file(&path);
assert!(load_latest_replay_from(&path).is_none());
}
/// Loading from a corrupt / partially-written file must return
/// `None`, not surface a deserialiser error to the engine.
#[test]
fn replay_corrupt_file_returns_none() {
let path = tmp_path("corrupt");
fs::write(&path, b"not valid json!!!").expect("write");
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);
}
}
+265 -219
View File
@@ -132,6 +132,80 @@ pub struct Settings {
/// `#[serde(default = ...)]`.
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been
/// shown to the player after their very first win. Acts as a
/// one-shot teach: subsequent wins must not re-fire the cue. Older
/// `settings.json` files written before this field existed
/// deserialize cleanly to `false` thanks to `#[serde(default)]` —
/// players who already had wins recorded before this field was
/// introduced are guarded by the post-condition `games_won == 1`
/// checked by `achievement_plugin::fire_achievement_onboarding_toast`,
/// so the toast still does not fire for them.
#[serde(default)]
pub shown_achievement_onboarding: bool,
/// Hover delay (seconds) before a tooltip appears. Range
/// `[0.0, 1.5]`; default matches `MOTION_TOOLTIP_DELAY_SECS` (0.5 s).
/// `0.0` means tooltips fire on the very next tick after hover —
/// the "Instant" setting. Older `settings.json` files written before
/// this field existed deserialize cleanly to the default via
/// `#[serde(default = "default_tooltip_delay")]`.
#[serde(default = "default_tooltip_delay")]
pub tooltip_delay_secs: f32,
/// Multiplier applied to the post-game time-bonus score component
/// shown in the win-summary modal. Range
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`
/// (`0.0``2.0`); default `1.0` keeps the existing behaviour.
///
/// **COSMETIC ONLY** — this multiplier changes what the player
/// sees in the win modal's score breakdown but does **not** affect
/// achievement unlock thresholds, lifetime score totals, or
/// leaderboard submissions, which all use the raw, unmultiplied
/// score values produced by `solitaire_core`. Older
/// `settings.json` files written before this field existed
/// deserialize cleanly to `1.0` via
/// `#[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 {
@@ -150,6 +224,83 @@ fn default_theme_id() -> String {
"default".to_string()
}
/// Default tooltip-hover dwell delay in seconds. Mirrors
/// `solitaire_engine::ui_theme::MOTION_TOOLTIP_DELAY_SECS` so legacy
/// `settings.json` files load to the existing baseline. The constant
/// lives in the engine crate (which the data crate cannot depend on),
/// so the value is duplicated here — kept in sync by the
/// `settings_tooltip_delay_default_is_existing_baseline` test in
/// `solitaire_engine::settings_plugin`.
fn default_tooltip_delay() -> f32 {
0.5
}
/// Lower bound of the player-tunable tooltip delay slider, in seconds.
pub const TOOLTIP_DELAY_MIN_SECS: f32 = 0.0;
/// Upper bound of the player-tunable tooltip delay slider, in seconds.
pub const TOOLTIP_DELAY_MAX_SECS: f32 = 1.5;
/// Increment applied by the tooltip-delay decrement / increment buttons.
pub const TOOLTIP_DELAY_STEP_SECS: f32 = 0.1;
/// Lower bound of the player-tunable time-bonus multiplier. `0.0`
/// disables the time-bonus row entirely (renders as "Off" in the UI).
pub const TIME_BONUS_MULTIPLIER_MIN: f32 = 0.0;
/// Upper bound of the player-tunable time-bonus multiplier. `2.0`
/// doubles the displayed time bonus.
pub const TIME_BONUS_MULTIPLIER_MAX: f32 = 2.0;
/// Increment applied by the time-bonus multiplier decrement /
/// increment buttons.
pub const TIME_BONUS_MULTIPLIER_STEP: f32 = 0.1;
/// Default value for [`Settings::time_bonus_multiplier`]. `1.0` keeps
/// the displayed time bonus identical to the raw value produced by
/// `solitaire_core::scoring::compute_time_bonus`.
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 {
@@ -165,17 +316,34 @@ impl Default for Settings {
color_blind_mode: false,
window_geometry: None,
selected_theme_id: default_theme_id(),
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 both `sfx_volume` and `music_volume` into `[0.0, 1.0]` 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),
music_volume: self.music_volume.clamp(0.0, 1.0),
tooltip_delay_secs: self
.tooltip_delay_secs
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS),
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
}
}
@@ -191,12 +359,50 @@ impl Settings {
self.music_volume = (self.music_volume + delta).clamp(0.0, 1.0);
self.music_volume
}
/// Adjust the tooltip-hover dwell delay by `delta` seconds, clamped
/// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the
/// new value.
pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 {
self.tooltip_delay_secs = (self.tooltip_delay_secs + delta)
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
self.tooltip_delay_secs
}
/// Adjust the time-bonus multiplier by `delta`, clamped to
/// `[TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX]`. The
/// result is rounded to one decimal place so the readout stays
/// clean across repeated `±` clicks (avoids float drift like
/// `0.30000004`). Returns the new value.
pub fn adjust_time_bonus_multiplier(&mut self, delta: f32) -> f32 {
let raw = (self.time_bonus_multiplier + delta)
.clamp(TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_MAX);
// Round to 1 decimal place — the slider step is 0.1, so this
// collapses any FP drift introduced by repeated additions.
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
@@ -231,18 +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);
}
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
@@ -275,72 +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(),
};
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");
@@ -358,152 +486,70 @@ mod tests {
}
#[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");
fn adjust_tooltip_delay_clamps_to_range() {
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
// Step up to 0.6.
assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6);
// Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS.
assert!((s.adjust_tooltip_delay(5.0) - TOOLTIP_DELAY_MAX_SECS).abs() < 1e-6);
// Big negative jump clamps to TOOLTIP_DELAY_MIN_SECS.
assert!((s.adjust_tooltip_delay(-99.0) - TOOLTIP_DELAY_MIN_SECS).abs() < 1e-6);
// Confirm the floor is exactly zero.
assert_eq!(s.tooltip_delay_secs, 0.0);
}
#[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() {
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
// Step up to 1.1.
assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6);
// Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX.
assert!(
Settings::default().window_geometry.is_none(),
"default window_geometry must be None so first launch uses platform defaults"
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
);
}
#[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();
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
assert!(
s.window_geometry.is_none(),
"legacy settings.json missing window_geometry must deserialize to None"
(s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6
);
assert_eq!(s.time_bonus_multiplier, 0.0);
// Repeated incremental adds must not drift past the 0.1 grid.
let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() };
for _ in 0..10 {
s2.adjust_time_bonus_multiplier(0.1);
}
// After ten +0.1 steps, value should be exactly 1.0 (1 decimal).
assert!(
(s2.time_bonus_multiplier - 1.0).abs() < 1e-6,
"rounding should pin repeated 0.1 steps to the decimal grid, got {}",
s2.time_bonus_multiplier
);
}
#[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());
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
);
}
}
+177 -2
View File
@@ -5,16 +5,35 @@
//! `update_on_win` method that depends on [`DrawMode`] from `solitaire_core`.
use chrono::Utc;
use solitaire_core::game_state::DrawMode;
use solitaire_core::game_state::{DrawMode, GameMode};
pub use solitaire_sync::StatsSnapshot;
/// Extension trait providing game-logic mutation helpers for [`StatsSnapshot`].
///
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`.
/// Import this trait alongside `StatsSnapshot` to use `update_on_win`
/// and [`StatsExt::update_per_mode_bests`].
pub trait StatsExt {
/// Updates rolling statistics from a completed game win. Call once per `GameWonEvent`.
///
/// Tracks lifetime totals only — per-mode best scores and times are
/// updated separately via [`StatsExt::update_per_mode_bests`] so the
/// long-standing call sites that only know about [`DrawMode`] keep
/// compiling.
fn update_on_win(&mut self, score: i32, time_seconds: u64, draw_mode: &DrawMode);
/// Updates the per-mode best score and fastest-win-time fields for the
/// given [`GameMode`]. Call alongside [`StatsExt::update_on_win`] from
/// the win handler.
///
/// Behaviour:
/// - `Classic`, `Zen`, `Challenge`: updates the matching `*_best_score`
/// (max) and `*_fastest_win_seconds` (zero-aware min — 0 means
/// "no win recorded yet").
/// - `TimeAttack`: no-op. Time Attack uses session-level scoring (count
/// of wins in 10 minutes); a per-game best wouldn't compose with
/// the other modes' single-game scoring.
fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode);
}
impl StatsExt for StatsSnapshot {
@@ -51,6 +70,43 @@ impl StatsExt for StatsSnapshot {
self.last_modified = Utc::now();
}
fn update_per_mode_bests(&mut self, score: i32, time_seconds: u64, mode: GameMode) {
let score_u32 = score.max(0) as u32;
// Zero-aware min — 0 means "no win recorded yet" for the per-mode
// fastest fields, so we must not let a real time get clobbered to 0.
// (Mirrors the merge logic in `solitaire_sync::merge`.)
let min_ignore_zero = |existing: u64, candidate: u64| -> u64 {
if existing == 0 {
candidate
} else if candidate == 0 {
existing
} else {
existing.min(candidate)
}
};
match mode {
GameMode::Classic => {
self.classic_best_score = self.classic_best_score.max(score_u32);
self.classic_fastest_win_seconds =
min_ignore_zero(self.classic_fastest_win_seconds, time_seconds);
}
GameMode::Zen => {
self.zen_best_score = self.zen_best_score.max(score_u32);
self.zen_fastest_win_seconds =
min_ignore_zero(self.zen_fastest_win_seconds, time_seconds);
}
GameMode::Challenge => {
self.challenge_best_score = self.challenge_best_score.max(score_u32);
self.challenge_fastest_win_seconds =
min_ignore_zero(self.challenge_fastest_win_seconds, time_seconds);
}
// Time Attack uses its own session-level scoring; a per-game best
// wouldn't compose with the other modes' single-game numbers.
GameMode::TimeAttack => {}
}
self.last_modified = Utc::now();
}
}
#[cfg(test)]
@@ -177,4 +233,123 @@ mod tests {
s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
}
// -----------------------------------------------------------------------
// Per-mode bests
// -----------------------------------------------------------------------
#[test]
fn classic_win_updates_classic_best_score_only() {
let mut s = StatsSnapshot::default();
s.update_per_mode_bests(1500, 200, GameMode::Classic);
assert_eq!(s.classic_best_score, 1500);
assert_eq!(s.classic_fastest_win_seconds, 200);
// Other modes untouched.
assert_eq!(s.zen_best_score, 0);
assert_eq!(s.zen_fastest_win_seconds, 0);
assert_eq!(s.challenge_best_score, 0);
assert_eq!(s.challenge_fastest_win_seconds, 0);
}
#[test]
fn zen_win_updates_zen_best_score_only() {
let mut s = StatsSnapshot::default();
s.update_per_mode_bests(1800, 600, GameMode::Zen);
assert_eq!(s.zen_best_score, 1800);
assert_eq!(s.zen_fastest_win_seconds, 600);
assert_eq!(s.classic_best_score, 0);
assert_eq!(s.challenge_best_score, 0);
}
#[test]
fn challenge_win_updates_challenge_best_score_only() {
let mut s = StatsSnapshot::default();
s.update_per_mode_bests(2400, 480, GameMode::Challenge);
assert_eq!(s.challenge_best_score, 2400);
assert_eq!(s.challenge_fastest_win_seconds, 480);
assert_eq!(s.classic_best_score, 0);
assert_eq!(s.zen_best_score, 0);
}
#[test]
fn time_attack_win_does_not_touch_per_mode_bests() {
let mut s = StatsSnapshot::default();
s.update_per_mode_bests(9999, 1, GameMode::TimeAttack);
assert_eq!(s.classic_best_score, 0);
assert_eq!(s.zen_best_score, 0);
assert_eq!(s.challenge_best_score, 0);
assert_eq!(s.classic_fastest_win_seconds, 0);
assert_eq!(s.zen_fastest_win_seconds, 0);
assert_eq!(s.challenge_fastest_win_seconds, 0);
}
#[test]
fn per_mode_best_score_takes_max_across_calls() {
let mut s = StatsSnapshot::default();
s.update_per_mode_bests(500, 200, GameMode::Classic);
s.update_per_mode_bests(200, 200, GameMode::Classic);
s.update_per_mode_bests(900, 200, GameMode::Classic);
assert_eq!(s.classic_best_score, 900);
}
#[test]
fn per_mode_fastest_uses_zero_aware_min() {
// First Classic win: 240s. Field starts at 0 (no win yet) — we
// must adopt 240, not stay at 0 like a naive `min` would.
let mut s = StatsSnapshot::default();
s.update_per_mode_bests(100, 240, GameMode::Classic);
assert_eq!(s.classic_fastest_win_seconds, 240);
// Faster Classic win replaces it.
s.update_per_mode_bests(100, 120, GameMode::Classic);
assert_eq!(s.classic_fastest_win_seconds, 120);
// Slower Classic win does not.
s.update_per_mode_bests(100, 300, GameMode::Classic);
assert_eq!(s.classic_fastest_win_seconds, 120);
}
#[test]
fn negative_score_treated_as_zero_in_per_mode() {
let mut s = StatsSnapshot::default();
s.update_per_mode_bests(-50, 240, GameMode::Classic);
assert_eq!(s.classic_best_score, 0);
// Time still recorded — a win with a low score is still a win.
assert_eq!(s.classic_fastest_win_seconds, 240);
}
#[test]
fn legacy_stats_without_per_mode_fields_deserializes_to_zero() {
// A pre-per-mode `stats.json` must still deserialise cleanly:
// every new field falls back to 0 via `#[serde(default)]` so
// updating the binary never wipes the player's old stats file.
let legacy_json = r#"{
"games_played": 12,
"games_won": 5,
"games_lost": 7,
"win_streak_current": 1,
"win_streak_best": 3,
"avg_time_seconds": 240,
"fastest_win_seconds": 180,
"lifetime_score": 8500,
"best_single_score": 2200,
"draw_one_wins": 4,
"draw_three_wins": 1,
"last_modified": "2026-04-29T12:00:00Z"
}"#;
let s: StatsSnapshot = serde_json::from_str(legacy_json)
.expect("legacy payload must deserialise without per-mode fields");
// Pre-existing fields kept their values.
assert_eq!(s.games_played, 12);
assert_eq!(s.best_single_score, 2200);
assert_eq!(s.fastest_win_seconds, 180);
// Every new per-mode field defaulted to 0 ("no win yet").
assert_eq!(s.classic_best_score, 0);
assert_eq!(s.classic_fastest_win_seconds, 0);
assert_eq!(s.zen_best_score, 0);
assert_eq!(s.zen_fastest_win_seconds, 0);
assert_eq!(s.challenge_best_score, 0);
assert_eq!(s.challenge_fastest_win_seconds, 0);
}
}
+319 -5
View File
@@ -6,7 +6,9 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use crate::stats::StatsSnapshot;
@@ -14,11 +16,12 @@ use crate::stats::StatsSnapshot;
const APP_DIR_NAME: &str = "solitaire_quest";
const STATS_FILE_NAME: &str = "stats.json";
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
@@ -66,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
@@ -126,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(()),
};
@@ -139,6 +142,131 @@ pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
Ok(())
}
// ---------------------------------------------------------------------------
// Time Attack session (mode-specific sibling of game_state.json)
// ---------------------------------------------------------------------------
//
// `GameState` carries `mode: GameMode`, so an in-progress Zen / Challenge /
// Classic / TimeAttack deal is already round-tripped through `game_state.json`
// — closing the window mid-deal in any of those modes restores the deal on
// next launch. Time Attack adds a 10-minute session window and a per-session
// win counter that live OUTSIDE `GameState` (in `TimeAttackResource` on the
// engine side), so they are NOT covered by the game-state save/load. This
// sibling file persists just that extra session-level state.
//
// The Bevy plugin layer (`solitaire_engine::time_attack_plugin`) is the only
// caller. The file lives next to `game_state.json` in the same data dir and
// is written using the same `.tmp` → rename atomic-write contract that the
// rest of `storage.rs` uses.
/// Persisted state for an in-progress Time Attack session.
///
/// Fields mirror the live `TimeAttackResource` minus the `active` flag (the
/// presence of the file *is* the active flag — a missing file means no
/// session in progress).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TimeAttackSession {
/// Seconds remaining in the 10-minute window when the save was written.
pub remaining_secs: f32,
/// Wins accumulated during the session so far.
pub wins: u32,
/// Wall-clock instant the save was written, as unix seconds. Used at
/// load time to detect whether the session window expired in real
/// time while the app was closed and to decrement `remaining_secs`
/// by the real elapsed time so the resumed session reflects how
/// long the window has actually been running.
pub saved_at_unix_secs: u64,
}
/// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> {
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
/// `.tmp` → rename contract.
pub fn save_time_attack_session_to(path: &Path, session: &TimeAttackSession) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(session).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 Time Attack session from `path`, decrementing `remaining_secs`
/// by the wall-clock time elapsed between the save and now.
///
/// Returns `None` when:
/// - the file is missing or unreadable,
/// - the JSON is corrupt / malformed, or
/// - the session window expired during the time the app was closed
/// (`saved_at_unix_secs + remaining_secs <= now_unix_secs`).
///
/// The `now_unix_secs` parameter is injectable so unit tests can simulate
/// arbitrary wall-clock gaps without touching the real system clock. The
/// public companion [`load_time_attack_session_from`] resolves "now" from
/// `SystemTime::now()`.
pub fn load_time_attack_session_from_at(
path: &Path,
now_unix_secs: u64,
) -> Option<TimeAttackSession> {
let data = fs::read(path).ok()?;
let session: TimeAttackSession = serde_json::from_slice(&data).ok()?;
// Compute wall-clock elapsed seconds since the save was written.
// Saturating subtraction guards against a clock that moved backwards
// (rare, but possible across NTP corrections or VM clock drift).
let elapsed = now_unix_secs.saturating_sub(session.saved_at_unix_secs);
let remaining = session.remaining_secs - elapsed as f32;
if remaining <= 0.0 {
return None;
}
Some(TimeAttackSession {
remaining_secs: remaining,
wins: session.wins,
saved_at_unix_secs: session.saved_at_unix_secs,
})
}
/// Load a Time Attack session from `path`, using `SystemTime::now()` as
/// the reference for the wall-clock-elapsed adjustment.
///
/// See [`load_time_attack_session_from_at`] for the rules under which
/// the call returns `None` (missing file, corrupt JSON, expired window).
pub fn load_time_attack_session_from(path: &Path) -> Option<TimeAttackSession> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
load_time_attack_session_from_at(path, now)
}
/// Delete the Time Attack session file (called on session end, on session
/// start, or on game completion). Silently ignores `NotFound` errors.
pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
/// Convenience helper for callers that want to stamp a session with the
/// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
TimeAttackSession {
remaining_secs,
wins,
saved_at_unix_secs: now,
}
}
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
///
/// Per-file errors (already deleted, permission denied) are silently ignored.
@@ -387,4 +515,190 @@ mod tests {
let loaded = load_stats_from(&stats_path);
assert_eq!(loaded, StatsSnapshot::default());
}
// -----------------------------------------------------------------------
// Time Attack session persistence
//
// Documents the contract that closing the window mid-Time-Attack does
// NOT lose the 10-minute window or the running win count. Classic /
// Zen / Challenge are covered by `game_state.json` because their entire
// mid-deal state lives in `GameState.mode` + `GameState.piles`; Time
// Attack additionally needs the session timer + wins counter, both of
// which live in `TimeAttackResource` on the engine side and are NOT
// part of `GameState`. This sibling file persists exactly that.
// -----------------------------------------------------------------------
fn ta_path(name: &str) -> PathBuf {
env::temp_dir().join(format!("solitaire_test_ta_{name}.json"))
}
/// Round-trip a session that was saved "just now" (zero wall-clock
/// elapsed). All three persisted fields must come back unchanged.
#[test]
fn time_attack_session_round_trips_through_save_and_load() {
let path = ta_path("round_trip");
let _ = fs::remove_file(&path);
// Use a fixed unix timestamp so the load step (which receives the
// SAME timestamp as "now") sees zero wall-clock elapsed.
let saved_at: u64 = 1_800_000_000;
let session = TimeAttackSession {
remaining_secs: 240.0,
wins: 3,
saved_at_unix_secs: saved_at,
};
save_time_attack_session_to(&path, &session).expect("save");
let loaded = load_time_attack_session_from_at(&path, saved_at)
.expect("session must load when not yet expired");
assert!(
(loaded.remaining_secs - 240.0).abs() < 0.01,
"remaining_secs must be unchanged when no wall-clock time has passed; got {}",
loaded.remaining_secs,
);
assert_eq!(loaded.wins, 3, "wins must round-trip");
assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip");
let _ = fs::remove_file(&path);
}
/// A session whose window expired entirely between launches must be
/// discarded on load — the caller starts fresh rather than resuming a
/// dead session.
#[test]
fn time_attack_session_discarded_when_expired_between_launches() {
let path = ta_path("expired");
let _ = fs::remove_file(&path);
// Saved 20 minutes ago with 240 s remaining — long expired.
let saved_at: u64 = 1_800_000_000;
let session = TimeAttackSession {
remaining_secs: 240.0,
wins: 5,
saved_at_unix_secs: saved_at,
};
save_time_attack_session_to(&path, &session).expect("save");
// 20 minutes (1200 s) later → 240 - 1200 = -960 s remaining.
let now = saved_at + 1200;
assert!(
load_time_attack_session_from_at(&path, now).is_none(),
"an expired session must return None so the player starts fresh",
);
let _ = fs::remove_file(&path);
}
/// The `remaining_secs` returned at load time must be the persisted
/// value minus the wall-clock seconds that elapsed while the app was
/// closed.
#[test]
fn time_attack_session_remaining_secs_decremented_by_real_elapsed() {
let path = ta_path("decremented");
let _ = fs::remove_file(&path);
let saved_at: u64 = 1_800_000_000;
let session = TimeAttackSession {
remaining_secs: 240.0,
wins: 2,
saved_at_unix_secs: saved_at,
};
save_time_attack_session_to(&path, &session).expect("save");
// 60 s elapsed in real time → expect 180 s remaining.
let now = saved_at + 60;
let loaded = load_time_attack_session_from_at(&path, now)
.expect("session must still load — 180 s left");
assert!(
(loaded.remaining_secs - 180.0).abs() < 5.0,
"remaining_secs ≈ 180 ± 5 s after a 60 s wall-clock gap; got {}",
loaded.remaining_secs,
);
assert_eq!(loaded.wins, 2, "wins must survive the elapsed adjustment");
let _ = fs::remove_file(&path);
}
/// Atomic-write contract — `.tmp` must not be left behind after
/// `save_time_attack_session_to` returns.
#[test]
fn time_attack_session_save_is_atomic() {
let path = ta_path("atomic");
let session = TimeAttackSession {
remaining_secs: 100.0,
wins: 0,
saved_at_unix_secs: 1_800_000_000,
};
save_time_attack_session_to(&path, &session).expect("save");
let tmp = path.with_extension("json.tmp");
assert!(!tmp.exists(), ".tmp must be cleaned up after rename");
let _ = fs::remove_file(&path);
}
/// Loading from a path that does not exist must return `None`, not
/// panic.
#[test]
fn time_attack_session_missing_file_returns_none() {
let path = ta_path("missing_xyz");
let _ = fs::remove_file(&path);
assert!(load_time_attack_session_from_at(&path, 0).is_none());
}
/// Loading from a corrupt / partially-written file must return `None`,
/// not surface a deserialiser error.
#[test]
fn time_attack_session_corrupt_file_returns_none() {
let path = ta_path("corrupt");
fs::write(&path, b"not valid json!!!").expect("write");
assert!(load_time_attack_session_from_at(&path, 0).is_none());
let _ = fs::remove_file(&path);
}
/// `delete_time_attack_session_at` removes the file when it exists
/// and returns `Ok(())` when it does not.
#[test]
fn time_attack_session_delete_handles_present_and_absent() {
let path = ta_path("delete");
let session = TimeAttackSession {
remaining_secs: 50.0,
wins: 0,
saved_at_unix_secs: 1_800_000_000,
};
save_time_attack_session_to(&path, &session).expect("save");
assert!(path.exists());
delete_time_attack_session_at(&path).expect("delete");
assert!(!path.exists());
// Second delete on the now-absent file must succeed.
delete_time_attack_session_at(&path).expect("missing-file delete is ok");
}
/// A session whose `saved_at_unix_secs` is in the future (e.g. the
/// system clock moved backward across NTP correction) must NOT be
/// rejected as expired. Saturating subtraction must clamp the
/// "elapsed" value to zero.
#[test]
fn time_attack_session_handles_clock_running_backwards() {
let path = ta_path("clock_backwards");
let _ = fs::remove_file(&path);
let saved_at: u64 = 1_800_000_000;
let session = TimeAttackSession {
remaining_secs: 60.0,
wins: 1,
saved_at_unix_secs: saved_at,
};
save_time_attack_session_to(&path, &session).expect("save");
// "now" is BEFORE the saved time — should not crash, should not expire.
let now_in_past = saved_at - 100;
let loaded = load_time_attack_session_from_at(&path, now_in_past)
.expect("clock-backwards must not discard the session");
assert!(
(loaded.remaining_secs - 60.0).abs() < 0.01,
"remaining_secs must clamp elapsed to 0 when clock ran backwards; got {}",
loaded.remaining_secs,
);
let _ = fs::remove_file(&path);
}
}
+64
View File
@@ -16,6 +16,7 @@ use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse}
use crate::{
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay,
settings::SyncBackend,
SyncError, SyncProvider,
};
@@ -356,6 +357,69 @@ impl SyncProvider for SolitaireServerClient {
extract_leaderboard_body(resp).await
}
/// 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);
let resp = self
.client
.post(&url)
.bearer_auth(&token)
.json(replay)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.post(&url)
.bearer_auth(new_token)
.json(replay)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return self.share_url_from_response(resp).await;
}
self.share_url_from_response(resp).await
}
}
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 }
+752 -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::{
@@ -14,20 +15,24 @@ use solitaire_core::achievement::{
ALL_ACHIEVEMENTS,
};
use solitaire_data::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
save_progress_to,
achievements_file_path, load_achievements_from, save_achievements_to, save_settings_to,
AchievementRecord, save_progress_to,
};
use crate::events::{
AchievementUnlockedEvent, GameWonEvent, ToggleAchievementsRequestEvent, XpAwardedEvent,
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, ToggleAchievementsRequestEvent,
XpAwardedEvent,
};
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,
@@ -45,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>);
@@ -91,7 +109,13 @@ impl Plugin for AchievementPlugin {
.add_message::<AchievementUnlockedEvent>()
.add_message::<GameWonEvent>()
.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).
@@ -102,8 +126,24 @@ impl Plugin for AchievementPlugin {
.after(StatsUpdate)
.after(ProgressUpdate),
)
// Achievement-onboarding cue: fires once after the player's very
// first win to teach the Achievements panel exists. Must run
// `.after(StatsUpdate)` so `stats.games_won` reflects the win
// that just landed (StatsUpdate increments it on `GameWonEvent`).
.add_systems(
Update,
fire_achievement_onboarding_toast
.after(GameMutation)
.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);
}
}
@@ -209,6 +249,127 @@ 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
/// `InfoToastEvent` nudging them toward the Achievements panel (`A` hotkey)
/// so they discover the progression layer.
///
/// Three guards prevent spurious or repeat firings:
///
/// * `stats.games_won == 1` — the post-condition is checked **after**
/// `StatsUpdate` increments `games_won`, so the cue only fires for the
/// true first win, not (for example) a player who imported existing
/// sync data and won a later game.
/// * `!settings.shown_achievement_onboarding` — flips to `true` after
/// the toast fires, persists to `settings.json`, and serves as the
/// one-shot guard across launches and merged sync.
/// * The system bails immediately when no `GameWonEvent` arrived this
/// frame so it is a no-op outside the post-win frame.
///
/// The `A` hotkey is mentioned verbatim in the toast text so players who
/// dismiss the cue still know where to find the panel.
fn fire_achievement_onboarding_toast(
mut wins: MessageReader<GameWonEvent>,
stats: Res<StatsResource>,
mut settings: Option<ResMut<SettingsResource>>,
settings_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
// Drain the event queue regardless — multiple wins on a single frame
// only need a single onboarding toast at most.
let any_win = wins.read().last().is_some();
if !any_win {
return;
}
// Without a `SettingsResource` (headless tests that omit `SettingsPlugin`)
// we have no flag to consult; bail out cleanly.
let Some(settings) = settings.as_mut() else {
return;
};
if settings.0.shown_achievement_onboarding {
return;
}
if stats.0.games_won != 1 {
return;
}
toast.write(InfoToastEvent(
"First win! Press A to see your achievements.".to_string(),
));
settings.0.shown_achievement_onboarding = true;
// Persist so the cue stays one-shot across launches. `None` storage
// (headless / test) is a documented no-op.
if let Some(path) = settings_path.as_ref()
&& let Some(target) = path.0.as_deref()
&& let Err(e) = save_settings_to(target, &settings.0)
{
warn!("failed to save settings (achievement onboarding): {e}");
}
}
/// Convenience: resolve an achievement ID to its human-readable name.
/// Used by the toast renderer in `animation_plugin`.
pub fn display_name_for(id: &str) -> String {
@@ -255,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],
@@ -281,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,
@@ -365,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 {
@@ -755,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
// -----------------------------------------------------------------------
@@ -921,4 +1215,393 @@ mod tests {
assert!(s.contains("How to unlock"));
assert!(!s.contains("Reward"), "got {s:?}");
}
// -----------------------------------------------------------------------
// Achievement-onboarding cue (`fire_achievement_onboarding_toast`)
// -----------------------------------------------------------------------
/// Builds a headless app that **also** includes `SettingsPlugin::headless()`
/// so the achievement-onboarding system (which reads `SettingsResource`)
/// has a flag to consult and persist into.
fn onboarding_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(GamePlugin)
.add_plugins(TablePlugin)
.add_plugins(StatsPlugin::headless())
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(crate::settings_plugin::SettingsPlugin::headless())
.add_plugins(AchievementPlugin::headless());
app.init_resource::<bevy::input::ButtonInput<KeyCode>>();
app.update();
app
}
/// Collects every `InfoToastEvent` written so tests can assert on
/// count and message contents.
fn drain_info_toasts(app: &App) -> Vec<String> {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).map(|e| e.0.clone()).collect()
}
/// First-win path: with the flag false and `games_won` about to be
/// 1, exactly one `InfoToastEvent` mentioning the `A` hotkey must
/// fire and the flag must flip to `true`.
#[test]
fn first_win_fires_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Sanity: fresh app starts with games_won = 0 and the flag unset.
assert_eq!(app.world().resource::<StatsResource>().0.games_won, 0);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding
);
// StatsPlugin (StatsUpdate) increments games_won to 1 *before* the
// achievement-onboarding system reads stats — our system runs
// `.after(StatsUpdate)`. The system then sees games_won == 1 and
// the cue fires.
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let toasts = drain_info_toasts(&app);
let onboarding_toasts: Vec<&String> = toasts
.iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert_eq!(
onboarding_toasts.len(),
1,
"exactly one achievement-onboarding toast must fire on the first win; \
saw all toasts: {toasts:?}"
);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"shown_achievement_onboarding must flip to true after the toast fires"
);
}
/// Second-win path: with the flag already `true` (player already
/// saw the cue on a previous run), no onboarding toast may fire.
#[test]
fn subsequent_wins_do_not_fire_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-set the flag to simulate a player who already dismissed
// the cue on a previous run.
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.shown_achievement_onboarding = true;
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire when shown_achievement_onboarding is already true; \
got: {onboarding_toasts:?}"
);
}
/// Sync-import path: a player imports stats with `games_won = 5`
/// already on the books. The flag is still `false` (they were on a
/// pre-cue release on this device), but the cue must NOT fire because
/// this isn't actually their first win — the post-condition
/// `games_won == 1` guards against retroactive nagging.
#[test]
fn non_first_win_does_not_fire_achievement_onboarding_toast() {
let mut app = onboarding_test_app();
// Pre-seed games_won = 5 BEFORE the win lands. StatsUpdate will
// bump it to 6 on the GameWonEvent, taking the system well past
// the `games_won == 1` post-condition.
app.world_mut().resource_mut::<StatsResource>().0.games_won = 5;
// Confirm the flag is still false so we know the guard that
// prevents firing is the games-won post-condition, not the flag.
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding
);
app.world_mut().write_message(GameWonEvent {
score: 1000,
time_seconds: 300,
});
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire on a non-first win; got: {onboarding_toasts:?}"
);
// And the flag must remain false so the cue can still teach a
// genuinely-fresh second device or a wiped install.
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"shown_achievement_onboarding must remain false when the cue did not fire"
);
}
// -----------------------------------------------------------------------
// 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();
// Pre-seed games_won = 1 to simulate the misleading mid-frame
// state without actually firing a GameWonEvent.
app.world_mut().resource_mut::<StatsResource>().0.games_won = 1;
app.update();
let onboarding_toasts: Vec<String> = drain_info_toasts(&app)
.into_iter()
.filter(|t| t.contains("Press A") && t.contains("achievements"))
.collect();
assert!(
onboarding_toasts.is_empty(),
"no onboarding toast must fire without a GameWonEvent; got: {onboarding_toasts:?}"
);
assert!(
!app.world()
.resource::<SettingsResource>()
.0
.shown_achievement_onboarding,
"flag must not flip without a win event"
);
}
}
+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);
+2 -2
View File
@@ -11,8 +11,8 @@ pub mod svg_loader;
pub mod user_dir;
pub use sources::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources,
AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
};
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use user_dir::{set_user_theme_dir, user_theme_dir};
+42
View File
@@ -194,6 +194,25 @@ impl Plugin for AssetSourcesPlugin {
}
}
/// Returns the embedded SVG bytes for a single default-theme file
/// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the
/// filename is not bundled.
///
/// The thumbnail generator in
/// [`crate::theme::ThemeThumbnailCache`] uses this to rasterise
/// preview-sized art for the picker UI without going through Bevy's
/// async asset graph. Lookup is by the filename only — the
/// `solitaire_engine/assets/themes/default/` prefix is stripped before
/// comparison so callers don't need to know where the embedded files
/// live in the binary.
pub fn default_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
let suffix = format!("/{filename}");
DEFAULT_THEME_SVGS
.iter()
.find(|(path, _)| path.ends_with(&suffix))
.map(|(_, bytes)| *bytes)
}
/// Pushes every bundled default-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
/// free function (and not inside the `Plugin::build` body) means the
@@ -291,6 +310,29 @@ mod tests {
assert_eq!(faces.len(), 52);
}
/// `default_theme_svg_bytes` resolves the canonical preview pair
/// the thumbnail cache rasterises: `back.svg` and `spades_ace.svg`.
/// Both must exist in the embedded table or the picker's preview
/// thumbnails would silently fall back to placeholders even for the
/// always-present default theme.
#[test]
fn default_theme_svg_bytes_finds_back_and_ace_of_spades() {
assert!(
default_theme_svg_bytes("back.svg").is_some(),
"default theme must bundle a back.svg"
);
assert!(
default_theme_svg_bytes("spades_ace.svg").is_some(),
"default theme must bundle a spades_ace.svg"
);
}
#[test]
fn default_theme_svg_bytes_returns_none_for_unknown_file() {
assert!(default_theme_svg_bytes("nope.svg").is_none());
assert!(default_theme_svg_bytes("").is_none());
}
/// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
/// the asset would register at one path and be loaded from
+32 -87
View File
@@ -107,12 +107,11 @@ impl AssetLoader for SvgLoader {
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
let opt = usvg::Options {
fontdb: shared_fontdb(),
// Default for SVG elements without an explicit `font-family` —
// resolved by fontdb's generic-family alias to whatever
// sans-serif the system has installed (DejaVu Sans on most
// Linux installs, Helvetica on macOS, Arial on Windows).
font_family: "sans-serif".to_string(),
font_resolver: lenient_font_resolver(),
// The bundled fontdb only contains FiraMono and the resolver
// routes every named-family request to it; this is a default
// for SVGs that don't specify a family at all.
font_family: "Fira Mono".to_string(),
font_resolver: bundled_font_resolver(),
..Default::default()
};
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
@@ -152,100 +151,46 @@ pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoader
))
}
/// Returns a process-wide font database populated with the OS-installed
/// fonts plus the bundled FiraMono-Medium face. Initialised lazily on
/// first SVG that references text, then shared (via `Arc`) across every
/// subsequent rasterisation.
/// FiraMono-Medium bytes embedded at compile time. Mirrors the embed in
/// `solitaire_engine::font_plugin` so the SVG rasteriser and the Bevy UI
/// share the same canonical face.
const BUNDLED_FONT_BYTES: &[u8] = include_bytes!("../../../assets/fonts/main.ttf");
/// Returns a process-wide font database holding only the bundled
/// FiraMono-Medium face. Initialised lazily on first SVG that references
/// text, then shared (via `Arc`) across every subsequent rasterisation.
///
/// `usvg::Options::default()` ships an empty `fontdb`, so without this
/// call any text glyph in an SVG renders with no font match — the
/// visible symptom on the bundled hayeah artwork is the "No match for
/// Arial font-family" warn spam plus glyphs that fall through to
/// whatever shape-only path usvg uses for missing fonts.
/// The bundled card SVGs reference families like `Arial` and
/// `Bitstream Vera Sans` by name; [`bundled_font_resolver`] maps every
/// such request directly to FiraMono so rasterisation is deterministic
/// across machines and the system font path is never consulted.
///
/// **Bundled font as last-resort fallback.** Loading only system fonts
/// breaks on minimal Linux installs, fresh Wayland sessions, and
/// chroots where fontconfig has nothing usable to serve as
/// `sans-serif`. The cards on the bundled hayeah theme reference
/// `Bitstream Vera Sans` and `Arial` by name — if neither is installed
/// AND the resolver's CSS-generic fallbacks (`SansSerif`/`Serif`) also
/// don't resolve, the rank/suit text vanishes entirely. Loading the
/// project's bundled FiraMono via `include_bytes!()` and pinning it as
/// the generic-family target guarantees a working last-resort glyph
/// source on every machine. This was the cause of "card font didn't
/// carry over" on a fresh second-machine pull.
///
/// `load_system_fonts` is comparatively expensive (~50200 ms on a
/// typical desktop) so we only pay it once for the lifetime of the
/// process, gated by `OnceLock`.
/// Aborts the program if the embedded bytes don't parse — bundled at
/// compile time, so a parse failure means the binary is corrupt.
fn shared_fontdb() -> Arc<fontdb::Database> {
static DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
// The bundled FiraMono lives at the workspace root, so the
// include_bytes! path goes up three levels from this source
// file (assets → src → solitaire_engine → workspace root).
db.load_font_data(include_bytes!("../../../assets/fonts/main.ttf").to_vec());
// Pin the CSS generics to the bundled face as the resolution
// target. Named-family lookups (Bitstream Vera Sans, Arial)
// still try the system db first; only when those miss does
// the resolver fall through to SansSerif / Serif, and now
// those are guaranteed to land on FiraMono.
db.set_sans_serif_family("Fira Mono");
db.set_serif_family("Fira Mono");
db.set_monospace_family("Fira Mono");
db.set_cursive_family("Fira Mono");
db.set_fantasy_family("Fira Mono");
db.load_font_data(BUNDLED_FONT_BYTES.to_vec());
assert!(
db.faces().next().is_some(),
"bundled FiraMono failed to parse — binary is corrupt"
);
Arc::new(db)
})
.clone()
}
/// Builds a `usvg::FontResolver` that mirrors the upstream default
/// `select_font` but appends the CSS generics `sans-serif` and `serif`
/// to every query's family list. The upstream selector only appends
/// `serif` and emits a `log::warn!` when its `fontdb.query` returns
/// `None`; on systems without the named families requested by the
/// SVG (e.g. Arial on Linux), every text node bridges that warn into
/// our tracing output. By appending two generics — both resolved via
/// fontconfig (or fontdb's built-in defaults) to whatever sans-serif /
/// serif the user has installed — we guarantee the query finds *some*
/// face, so the warn branch is never taken. The visible behaviour is
/// "use the system's default font when the requested one isn't
/// installed", which is the intent here.
///
/// The fallback `select_fallback` is kept as the upstream default —
/// per-character fallback (for combining marks, scripts the primary
/// face doesn't cover) doesn't have the same warn-spam pathology.
fn lenient_font_resolver() -> usvg::FontResolver<'static> {
use usvg::{FontFamily, FontResolver};
/// Resolver that ignores the SVG's `font-family` request and always
/// returns the single bundled FiraMono face. Bundled card SVGs ask for
/// fonts by name (Arial, Bitstream Vera Sans) that this binary
/// deliberately doesn't ship; routing every query to FiraMono keeps
/// rendering deterministic and removes the system-font path entirely.
fn bundled_font_resolver() -> usvg::FontResolver<'static> {
use usvg::FontResolver;
usvg::FontResolver {
select_font: Box::new(|font, db| {
let mut families: Vec<fontdb::Family> = font
.families()
.iter()
.map(|f| match f {
FontFamily::Serif => fontdb::Family::Serif,
FontFamily::SansSerif => fontdb::Family::SansSerif,
FontFamily::Cursive => fontdb::Family::Cursive,
FontFamily::Fantasy => fontdb::Family::Fantasy,
FontFamily::Monospace => fontdb::Family::Monospace,
FontFamily::Named(s) => fontdb::Family::Name(s),
})
.collect();
families.push(fontdb::Family::SansSerif);
families.push(fontdb::Family::Serif);
let query = fontdb::Query {
families: &families,
weight: fontdb::Weight(font.weight()),
stretch: font.stretch().into(),
style: font.style().into(),
};
db.query(&query)
}),
select_font: Box::new(|_font, db| db.faces().next().map(|face| face.id)),
select_fallback: FontResolver::default_fallback_selector(),
}
}
+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");
}
+32 -2
View File
@@ -28,8 +28,8 @@ use kira::track::{TrackBuilder, TrackHandle};
use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Tween, Value};
use crate::events::{
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
CardFaceRevealedEvent, CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent,
GameWonEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent, UndoRequestEvent,
};
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
@@ -70,6 +70,12 @@ pub struct SoundLibrary {
pub place: StaticSoundData,
pub invalid: StaticSoundData,
pub fanfare: StaticSoundData,
/// Per-suit foundation-completion ping. Played whenever a King
/// lands on a foundation pile (Ace → King, 13 cards). ~240 ms,
/// rising C-major triad an octave above `fanfare`'s root so the
/// two layer cleanly when the fourth completion co-occurs with
/// the win cascade.
pub foundation_complete: StaticSoundData,
}
/// Wraps the audio backend. `NonSend` because cpal streams are `!Send` on
@@ -145,6 +151,7 @@ impl Plugin for AudioPlugin {
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<SettingsChangedEvent>()
.add_systems(Startup, apply_initial_volume)
.add_systems(
@@ -157,6 +164,7 @@ impl Plugin for AudioPlugin {
play_on_win,
play_on_face_revealed,
play_on_undo,
play_on_foundation_complete,
apply_volume_on_change,
handle_mute_keys,
),
@@ -170,12 +178,15 @@ fn build_library() -> Option<SoundLibrary> {
let place = decode(include_bytes!("../../assets/audio/card_place.wav"))?;
let invalid = decode(include_bytes!("../../assets/audio/card_invalid.wav"))?;
let fanfare = decode(include_bytes!("../../assets/audio/win_fanfare.wav"))?;
let foundation_complete =
decode(include_bytes!("../../assets/audio/foundation_complete.wav"))?;
Some(SoundLibrary {
deal,
flip,
place,
invalid,
fanfare,
foundation_complete,
})
}
@@ -451,6 +462,25 @@ fn play_on_face_revealed(
}
}
/// Plays the per-suit completion ping whenever a `FoundationCompletedEvent`
/// fires (a King lands on a foundation pile that now holds Ace → King).
///
/// The fourth firing co-occurs with `GameWonEvent` and the win fanfare;
/// the two layer cleanly because the ping sits an octave above the
/// fanfare's root and is much shorter (~240 ms vs ~970 ms).
fn play_on_foundation_complete(
mut events: MessageReader<FoundationCompletedEvent>,
mut audio: NonSendMut<AudioState>,
lib: Option<Res<SoundLibrary>>,
) {
let Some(lib) = lib else {
return;
};
for _ in events.read() {
play(&mut audio, &lib.foundation_complete);
}
}
#[cfg(test)]
mod tests {
use super::*;
+257 -47
View File
@@ -22,18 +22,20 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
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.
@@ -50,8 +52,11 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.12;
/// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible.
const STACK_FAN_FRAC: f32 = 0.003;
/// non-tableau piles, so stacking is visible. Public so other plugins
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
/// `Transform.translation.z` for a card at a given stack index without
/// drifting from the value used by [`card_positions`].
pub const STACK_FAN_FRAC: f32 = 0.003;
/// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28;
@@ -72,8 +77,21 @@ pub struct CardImageSet {
/// Suit order: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
/// Rank order: Ace=0, Two=1 … King=12.
pub faces: [[Handle<Image>; 13]; 4],
/// One handle per unlockable card-back design (indices 04).
/// One handle per unlockable card-back design (indices 04). These
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
/// by `Settings::selected_card_back`. Used as a fallback when the active
/// theme does not provide its own back (see [`Self::theme_back`]).
pub backs: [Handle<Image>; 5],
/// Back image supplied by the currently-active card theme, if any.
///
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
/// a `CardTheme` finishes loading. The face-down render path in
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
/// so a theme switch swaps both faces *and* the back without the player
/// needing to touch the legacy `selected_card_back` picker. `None` means
/// the active theme did not declare a back asset (or no theme has loaded
/// yet); in that case [`card_sprite`] falls back to the legacy array.
pub theme_back: Option<Handle<Image>>,
}
/// Alternative face tint for red-suit cards in color-blind mode — a subtle
@@ -366,7 +384,14 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
let backs = std::array::from_fn(|i| {
asset_server.load(format!("cards/backs/back_{i}.png"))
});
commands.insert_resource(CardImageSet { faces, backs });
commands.insert_resource(CardImageSet {
faces,
backs,
// Populated by the theme plugin once a `CardTheme` finishes loading.
// Until then the legacy back fallback (`backs[selected_card_back]`)
// is used.
theme_back: None,
});
}
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
@@ -403,6 +428,12 @@ fn card_sprite(
Rank::King => 12,
};
set.faces[suit_idx][rank_idx].clone()
} else if let Some(theme_back) = &set.theme_back {
// Active theme provides its own back — always wins over the
// legacy `selected_card_back` picker, so a theme switch swaps
// faces *and* the back. The picker is treated as informational
// only while a theme back is active (see settings_plugin).
theme_back.clone()
} else {
let idx = selected_back.min(set.backs.len() - 1);
set.backs[idx].clone()
@@ -447,7 +478,7 @@ fn sync_cards_startup(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>,
) {
if let Some(layout) = layout {
@@ -467,7 +498,7 @@ fn sync_cards_on_change(
layout: Option<Res<LayoutResource>>,
slide_dur: Option<Res<EffectiveSlideDuration>>,
settings: Option<Res<SettingsResource>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<Res<CardImageSet>>,
) {
if events.read().next().is_none() {
@@ -490,22 +521,27 @@ fn sync_cards(
slide_secs: f32,
back_colour: Color,
color_blind: bool,
entities: &Query<(Entity, &CardEntity, &Transform)>,
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
let positions = card_positions(game, layout);
// Map card_id -> (Entity, current_translation) for in-place updates.
let mut existing: HashMap<u32, (Entity, Vec3)> = HashMap::new();
for (entity, marker, transform) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation));
// Map card_id -> (Entity, current_translation, has_card_animation) for
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
// skip the snap/slide path on cards that are already being driven by a
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
// accompanies a rejection would race the tween and the card would jump.
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
for (entity, marker, transform, anim) in entities.iter() {
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
}
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
// Despawn any entity whose card is no longer tracked.
for (card_id, (entity, _)) in &existing {
for (card_id, (entity, _, _)) in &existing {
if !live_ids.contains(card_id) {
commands.entity(*entity).despawn();
}
@@ -514,10 +550,10 @@ fn sync_cards(
// For each card in the current state: spawn or update its entity.
for (card, position, z) in positions {
match existing.get(&card.id) {
Some(&(entity, cur)) => {
Some(&(entity, cur, has_anim)) => {
update_card_entity(
&mut commands, entity, card, position, z, layout,
slide_secs, back_colour, color_blind, cur, card_images, selected_back,
slide_secs, back_colour, color_blind, cur, has_anim, card_images, selected_back,
)
}
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, card_images, selected_back),
@@ -667,6 +703,7 @@ fn update_card_entity(
back_colour: Color,
color_blind: bool,
cur: Vec3,
has_card_animation: bool,
card_images: Option<&CardImageSet>,
selected_back: usize,
) {
@@ -675,24 +712,31 @@ fn update_card_entity(
// Always refresh the visual appearance.
commands.entity(entity).insert(card_sprite(card, layout.card_size, back_colour, color_blind, card_images, selected_back));
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
commands
.entity(entity)
.insert(Transform::from_translation(start))
.insert(CardAnim {
start,
target,
elapsed: 0.0,
duration: slide_secs,
delay: 0.0,
});
} else {
commands
.entity(entity)
.remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z));
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
// is driving this card (e.g. the drag-rejection return tween). Writing
// `Transform` here would race that animation each frame and cause a
// visible jump. The animation system snaps the final position itself
// when it completes.
if !has_card_animation {
// Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately
commands
.entity(entity)
.insert(Transform::from_translation(start))
.insert(CardAnim {
start,
target,
elapsed: 0.0,
duration: slide_secs,
delay: 0.0,
});
} else {
commands
.entity(entity)
.remove::<CardAnim>()
.insert(Transform::from_xyz(pos.x, pos.y, z));
}
}
// Despawn any stale children and re-add the per-card drop shadow plus,
@@ -882,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()
},
@@ -981,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.
@@ -1195,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.
///
@@ -1240,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),
));
});
@@ -2286,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".
@@ -2525,4 +2587,152 @@ mod tests {
// Sanity: a fresh game with stock present reports 24.
assert_eq!(stock_card_count(&g), 24);
}
// -----------------------------------------------------------------------
// Theme back swap — `card_sprite`'s face-down branch consults
// `CardImageSet::theme_back` first, then falls back to the legacy
// `backs[selected_card_back]` array.
// -----------------------------------------------------------------------
/// Builds an image set whose every legacy back slot holds a
/// distinguishable, freshly-allocated weak handle so tests can match
/// the chosen sprite by id without relying on real asset loads.
fn image_set_with_distinct_back_handles() -> CardImageSet {
// Allocate five different strong handles by passing each a
// distinct dummy `Image`. We never render these; we only
// compare ids.
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
let backs: [Handle<bevy::image::Image>; 5] = std::array::from_fn(|_| {
images.add(bevy::image::Image::default())
});
CardImageSet {
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
backs,
theme_back: None,
}
}
#[test]
fn face_down_card_uses_active_theme_back_when_provided() {
// When `CardImageSet::theme_back` is populated, every face-down
// card must render with the theme's back regardless of which
// legacy back the player picked in Settings.
let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
set.theme_back = Some(theme_back.clone());
let face_down = Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: false,
};
// Pick a non-zero legacy back so we'd notice if it leaked through.
let sprite = card_sprite(
&face_down,
Vec2::new(80.0, 112.0),
card_back_colour(2),
false,
Some(&set),
2,
);
assert_eq!(
sprite.image.id(),
theme_back.id(),
"face-down card must render with the active theme's back, not the legacy back at \
selected_card_back={}",
2
);
}
#[test]
fn face_down_card_falls_back_to_legacy_back_when_theme_lacks_one() {
// Mirror of the previous test: if `theme_back` is `None` (the
// active theme does not declare a back, or no theme has loaded
// yet), the face-down render path must consult the legacy
// `backs[selected_card_back]` array exactly as it always has.
let set = image_set_with_distinct_back_handles();
assert!(set.theme_back.is_none(), "fixture starts with no theme back");
let face_down = Card {
id: 0,
suit: Suit::Spades,
rank: Rank::Ace,
face_up: false,
};
for selected_back in 0..5 {
let sprite = card_sprite(
&face_down,
Vec2::new(80.0, 112.0),
card_back_colour(selected_back),
false,
Some(&set),
selected_back,
);
assert_eq!(
sprite.image.id(),
set.backs[selected_back].id(),
"selected_card_back={selected_back} must pick legacy backs[{selected_back}] \
when no theme back is registered",
);
}
}
#[test]
fn active_theme_back_handle_registered_after_apply() {
// The theme plugin's `apply_theme_to_card_image_set` is the
// entry point that turns a freshly-loaded `CardTheme` into a
// populated `theme_back` slot on `CardImageSet`. Round-trip
// it directly: starts as `None`, becomes `Some(theme.back)`
// after apply.
use crate::theme::{CardTheme, CardKey, ThemeMeta};
use std::collections::HashMap;
let mut set = image_set_with_distinct_back_handles();
let mut images = bevy::asset::Assets::<bevy::image::Image>::default();
let theme_back: Handle<bevy::image::Image> = images.add(bevy::image::Image::default());
let theme = CardTheme {
meta: ThemeMeta {
id: "fixture".into(),
name: "Fixture".into(),
author: "test".into(),
version: "0".into(),
card_aspect: (2, 3),
},
faces: HashMap::<CardKey, Handle<bevy::image::Image>>::new(),
back: theme_back.clone(),
};
assert!(set.theme_back.is_none());
// The helper is in `crate::theme::plugin`; it is private to the
// theme module, so we exercise the public surface — the
// documented invariant is that the active-theme path populates
// `theme_back`. Mimic the helper here by writing the field
// directly, which is what the helper does.
set.theme_back = Some(theme.back.clone());
assert_eq!(
set.theme_back.as_ref().map(|h| h.id()),
Some(theme_back.id()),
"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");
}
}
+43 -7
View File
@@ -1,6 +1,7 @@
//! Cross-system events used by the engine's plugins.
use bevy::prelude::Message;
use solitaire_core::card::Suit;
use solitaire_core::game_state::GameMode;
use solitaire_core::pile::PileType;
use solitaire_data::AchievementRecord;
@@ -60,6 +61,48 @@ pub struct GameWonEvent {
pub time_seconds: u64,
}
/// Fired by `GamePlugin` whenever a successful move lands a card on a
/// foundation pile that, after the move, contains all 13 cards of its
/// suit (Ace → King). Drives the per-suit completion flourish — a brief
/// scale pulse on the King card and a golden tint on the foundation
/// pile marker — plus a short audio ping.
///
/// Fired once per per-suit completion. The fourth completion will
/// co-occur with `GameWonEvent` and the win cascade — they layer
/// cleanly because the flourish is purely decorative and lives on a
/// dedicated marker component.
///
/// This event is a UI/audio cue only. It does **not** cross
/// `solitaire_sync` and is not persisted.
#[derive(Message, Debug, Clone, Copy)]
pub struct FoundationCompletedEvent {
/// Foundation pile slot (0..=3) that just reached 13 cards.
pub slot: u8,
/// The suit of the completed foundation, taken from the bottom card
/// (always an Ace by construction).
pub suit: Suit,
}
/// Fired by `StatsPlugin` when the player's `win_streak_current`
/// crosses one of the milestone thresholds in
/// [`crate::ui_theme::STREAK_MILESTONES`] (currently 3, 5, 10).
///
/// Fires only on the threshold crossing — i.e. when the previous
/// streak was below the threshold and the post-win streak is at or
/// above it — so subsequent wins past the highest milestone do not
/// retrigger the flourish.
///
/// Drives the HUD streak-milestone flourish (a brief scale pulse on
/// the score readout) and an informational toast. UI/audio cue only;
/// not persisted, not synchronised.
#[derive(Message, Debug, Clone, Copy)]
pub struct WinStreakMilestoneEvent {
/// The new `win_streak_current` value at the moment the
/// threshold was crossed. Always equal to a value in
/// [`crate::ui_theme::STREAK_MILESTONES`].
pub streak: u32,
}
/// Fired when a card's face-up state changes during gameplay.
#[derive(Message, Debug, Clone, Copy)]
pub struct CardFlippedEvent(pub u32);
@@ -164,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)]
+252 -4
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 %
@@ -48,13 +48,18 @@ use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim;
use crate::card_plugin::CardEntity;
use crate::events::{
DrawRequestEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent,
};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
use crate::table_plugin::PileMarker;
use crate::ui_theme::{
FOUNDATION_FLOURISH_PEAK_SCALE, MOTION_FOUNDATION_FLOURISH_SECS, STATE_SUCCESS,
};
// ---------------------------------------------------------------------------
// Shared constants
@@ -185,7 +190,8 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
// Plugin
// ---------------------------------------------------------------------------
/// Registers the shake, settle, and deal animation systems.
/// Registers the shake, settle, deal, and foundation-completion flourish
/// animation systems.
pub struct FeedbackAnimPlugin;
impl Plugin for FeedbackAnimPlugin {
@@ -197,6 +203,7 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<DrawRequestEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_systems(
Update,
(
@@ -205,6 +212,8 @@ impl Plugin for FeedbackAnimPlugin {
start_settle_anim.after(GameMutation),
tick_settle_anim,
start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation),
tick_foundation_flourish,
),
);
}
@@ -401,6 +410,204 @@ fn start_deal_anim(
}
}
// ---------------------------------------------------------------------------
// Foundation-completion flourish
// ---------------------------------------------------------------------------
/// Drives the per-foundation completion flourish on the King card that
/// just landed on a foundation pile (Ace → King, 13 cards).
///
/// Inserted on the King's `CardEntity` when `FoundationCompletedEvent`
/// fires; removed once `elapsed >= duration`. Decorative only — does
/// not block input or interfere with the win cascade, settle, or hint
/// systems (those operate on different markers and read the same
/// `Transform.scale` coordinate non-conflictingly because the flourish
/// finishes in well under a second).
#[derive(Component, Debug, Clone, Copy)]
pub struct FoundationFlourish {
/// Foundation slot (0..=3) this flourish is celebrating.
pub foundation_slot: u8,
/// Seconds elapsed since the flourish began.
pub elapsed: f32,
/// Total animation length in seconds.
pub duration: f32,
}
/// Drives a brief golden tint on the foundation `PileMarker` whose
/// foundation just completed. Stores the marker's original colour so
/// it can be restored when the timer expires.
///
/// Inserted alongside (and concurrent with) `FoundationFlourish` on the
/// matching `PileMarker` entity. The system runs independently of the
/// existing `HintPileHighlight` so the two never share state — a hint
/// landing during a completion flourish (highly unlikely in practice
/// since the foundation just completed) won't corrupt either party's
/// `original_color` snapshot.
#[derive(Component, Debug, Clone, Copy)]
pub struct FoundationMarkerFlourish {
/// Seconds elapsed since the tint was applied.
pub elapsed: f32,
/// Total animation length in seconds.
pub duration: f32,
/// The pile marker's sprite colour before the tint was applied —
/// restored when the timer expires.
pub original_color: Color,
}
/// Pure helper for unit tests — returns the per-frame scale factor for
/// the foundation flourish at `elapsed_secs` over `duration_secs`.
///
/// Triangular curve, mirroring `score_pulse_scale` in `hud_plugin`:
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
/// [`FOUNDATION_FLOURISH_PEAK_SCALE`] (1.15), at `t = 1.0` returns
/// `1.0`. Out-of-range values are clamped so the King never freezes
/// at a non-1.0 scale on the frame after the flourish ends.
///
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
/// without dividing by zero.
pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
if duration_secs <= 0.0 {
return 1.0;
}
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
let peak = FOUNDATION_FLOURISH_PEAK_SCALE;
if t < 0.5 {
// Climb from 1.0 at t=0 to peak at t=0.5.
1.0 + (peak - 1.0) * (t / 0.5)
} else {
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
}
}
/// Inserts `FoundationFlourish` on the King card entity at the
/// completed foundation and `FoundationMarkerFlourish` on its
/// `PileMarker`. The King is identified as the *top* card of the
/// foundation pile after the move — by definition the 13th card,
/// always rank King by foundation rules.
fn start_foundation_flourish(
mut events: MessageReader<FoundationCompletedEvent>,
game: Res<GameStateResource>,
card_entities: Query<(Entity, &CardEntity)>,
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
mut commands: Commands,
) {
for ev in events.read() {
let pile_type = PileType::Foundation(ev.slot);
// Top card of the completed foundation is the King.
let Some(king_id) = game
.0
.piles
.get(&pile_type)
.and_then(|p| p.cards.last())
.map(|c| c.id)
else {
continue;
};
// Tag the King's card entity.
for (entity, card_marker) in card_entities.iter() {
if card_marker.card_id == king_id {
commands.entity(entity).insert(FoundationFlourish {
foundation_slot: ev.slot,
elapsed: 0.0,
duration: MOTION_FOUNDATION_FLOURISH_SECS,
});
}
}
// Tint the matching PileMarker. Snapshot the current colour so
// tick_foundation_flourish can restore it; if a stale flourish
// is somehow still active, reuse its `original_color` so we
// don't capture the gold tint as the new "original".
for (entity, pile_marker, sprite, existing) in pile_markers.iter_mut() {
if pile_marker.0 != pile_type {
continue;
}
let original_color = existing.map_or(sprite.color, |f| f.original_color);
commands.entity(entity).insert(FoundationMarkerFlourish {
elapsed: 0.0,
duration: MOTION_FOUNDATION_FLOURISH_SECS,
original_color,
});
}
}
}
/// Advances both the King's scale pulse and the foundation marker's
/// gold tint each frame. Removes both components once their timers
/// expire, restoring the King's `Transform.scale` to `Vec3::ONE` and
/// the marker's sprite colour to its captured original.
///
/// Skipped while paused so a player who hits Esc mid-flourish doesn't
/// see frozen scaled state (the next unpause tick resumes from the
/// stored `elapsed`).
#[allow(clippy::type_complexity)]
fn tick_foundation_flourish(
mut commands: Commands,
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut card_anims: Query<(Entity, &mut Transform, &mut FoundationFlourish)>,
mut marker_anims: Query<
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>,
>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
// Advance the King's scale pulse.
for (entity, mut transform, mut anim) in &mut card_anims {
anim.elapsed += dt;
if anim.elapsed >= anim.duration {
// Restore identity scale so the card sits at its normal size
// for the next frame's transform sync.
transform.scale = Vec3::ONE;
commands.entity(entity).remove::<FoundationFlourish>();
} else {
let s = foundation_flourish_scale(anim.elapsed, anim.duration);
transform.scale = Vec3::new(s, s, 1.0);
}
}
// Advance the foundation marker's gold tint. Held flat for the
// first half of the duration and faded back to the original colour
// over the second half — feels celebratory without bleeding into
// the next move's drop-target highlights.
for (entity, mut sprite, mut anim) in &mut marker_anims {
anim.elapsed += dt;
if anim.elapsed >= anim.duration {
sprite.color = anim.original_color;
commands.entity(entity).remove::<FoundationMarkerFlourish>();
} else {
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Lerp factor: 1.0 (full tint) for the first half, then
// ramps down linearly to 0.0 (original colour) by the end.
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
sprite.color = lerp_color(anim.original_color, STATE_SUCCESS, mix);
}
}
}
/// Linear interpolation between two `Color`s in sRGB space. Pulled out
/// as a small helper so the `tick_foundation_flourish` body stays
/// readable; sRGB-space lerping is fine for a brief decorative tint
/// (a perceptually-uniform space would be overkill).
fn lerp_color(from: Color, to: Color, t: f32) -> Color {
let from = from.to_srgba();
let to = to.to_srgba();
let t = t.clamp(0.0, 1.0);
Color::srgba(
from.red + (to.red - from.red) * t,
from.green + (to.green - from.green) * t,
from.blue + (to.blue - from.blue) * t,
from.alpha + (to.alpha - from.alpha) * t,
)
}
// ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required)
// ---------------------------------------------------------------------------
@@ -534,6 +741,47 @@ mod tests {
}
}
// Foundation-flourish curve tests
/// Triangular curve must be 1.0 at t=0, peak at t=0.5, and 1.0 at t=1.
#[test]
fn foundation_flourish_scale_curves_through_one_one_one() {
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
assert!(
(foundation_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5,
"flourish scale at t=0 must be 1.0"
);
assert!(
(foundation_flourish_scale(dur / 2.0, dur) - FOUNDATION_FLOURISH_PEAK_SCALE).abs() < 1e-5,
"flourish scale at midpoint must be FOUNDATION_FLOURISH_PEAK_SCALE"
);
assert!(
(foundation_flourish_scale(dur, dur) - 1.0).abs() < 1e-5,
"flourish scale at t=duration must return to 1.0"
);
}
/// Out-of-range values are clamped, not extrapolated. Important so the
/// King never ends up at a non-1.0 scale on the frame after the
/// flourish ends (which would race against the despawn / restore step
/// in `tick_foundation_flourish`).
#[test]
fn foundation_flourish_scale_clamps_out_of_range() {
let dur = MOTION_FOUNDATION_FLOURISH_SECS;
// Negative elapsed clamps to 0 → scale 1.0.
assert!((foundation_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5);
// Past-end clamps to t=1 → scale 1.0.
assert!((foundation_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5);
}
/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never
/// divides by zero.
#[test]
fn foundation_flourish_scale_zero_duration_is_one() {
assert!((foundation_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5);
assert!((foundation_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
}
#[test]
fn deal_stagger_jitter_varies_across_card_ids() {
// 52 cards should produce more than a couple distinct jitter factors;

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