Compare commits
241 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a009a693 | |||
| fa7f98ac52 | |||
| 9891ae4ba3 | |||
| cdcaddaabe | |||
| d752870007 | |||
| 1d1543e4bc | |||
| 651f4060e6 | |||
| a1376075bd | |||
| ceec4fc486 | |||
| 0d477ac9fd | |||
| 4b51e50203 | |||
| f2d2119db5 | |||
| 59424a370c | |||
| fb8b2ac684 | |||
| 690e1d2ad6 | |||
| 35516d31f6 | |||
| 9b065e5ac6 | |||
| e1b8766e15 | |||
| 67c150bd7b | |||
| aa2a021712 | |||
| 6037596cc0 | |||
| d7ffb16df5 | |||
| b57db017d3 | |||
| 0b3140ad6d | |||
| e41def8c89 | |||
| aad8bb9c83 | |||
| 55c235b55f | |||
| 21ec03b157 | |||
| 17e3112502 | |||
| de4751115f | |||
| 9ff48ace5b | |||
| 91b7605b9f | |||
| 42d90b199c | |||
| 3e11e9e79a | |||
| bfcd05fbb5 | |||
| c497c3193c | |||
| 9aa0dd23b1 | |||
| d065d49fe7 | |||
| c30b04ec72 | |||
| 40d6e0ab17 | |||
| 9fe650fa20 | |||
| b73d246b4c | |||
| ae40a1db7a | |||
| b7c3a4996f | |||
| d48b9489db | |||
| 08b006ff30 | |||
| 17e0737a10 | |||
| dd63261999 | |||
| 93660c2217 | |||
| 56e2e6f151 | |||
| cc635328be | |||
| a4bc063497 | |||
| 540869c851 | |||
| bdac754b26 | |||
| f863d85c35 | |||
| 3c7a0eb4fb | |||
| d489e7a31b | |||
| f2f30c8002 | |||
| a49a340a30 | |||
| 27cdf78ce0 | |||
| faa6c5efc4 | |||
| 487b99bbc9 | |||
| 53e3b816cf | |||
| 87275bf340 | |||
| 56647d7f0d | |||
| cbf2483028 | |||
| a54201e97b | |||
| 48e412177c | |||
| cd54ce1bb0 | |||
| 7a3032b74c | |||
| 89699a8a86 | |||
| 70165da103 | |||
| 8a5fa8751c | |||
| bf660df971 | |||
| 13a8a012ee | |||
| 02ababa65f | |||
| 9c36b49729 | |||
| 8e90574437 | |||
| 95fcdad5d2 | |||
| d948fa862a | |||
| 1fcd032b0a | |||
| 3081505a3d | |||
| 07b8ecd9b2 | |||
| 5bed43ef32 | |||
| 23c9704887 | |||
| 93182fa251 | |||
| 89c51ab712 | |||
| 3984231c9b | |||
| d9f36bf34a | |||
| 57d1c58fdf | |||
| 42535f5109 | |||
| d5e6f8026b | |||
| 271647265c | |||
| 3eabc149a8 | |||
| f1aeb24157 | |||
| 000143231b | |||
| 1a1047664b | |||
| ba527de351 | |||
| fe41b502ac | |||
| b37f0cbec7 | |||
| a0fc0d2605 | |||
| 7ed4f2cba9 | |||
| ddc8f27c82 | |||
| 13dd44bd1b | |||
| 17f9b518f1 | |||
| 61d891fb76 | |||
| 7dba772e67 | |||
| ca5788f714 | |||
| 9887343d8b | |||
| 525fe0fe76 | |||
| 69ce9afab9 | |||
| 13aa0fd833 | |||
| 9f095c4039 | |||
| d8c70341f4 | |||
| 063269c70e | |||
| b126df82b2 | |||
| 655dfde736 | |||
| f712b89fe4 | |||
| f6c916641a | |||
| 95df5421c9 | |||
| fdb6c2ecfe | |||
| 9a3d7f3876 | |||
| c4970b16ea | |||
| 2c72e1fc87 | |||
| efa063fb8f | |||
| 78cf30e906 | |||
| 9a9026e33a | |||
| ab1d098877 | |||
| 160637d1c8 | |||
| 43f13c615e | |||
| 924a1e2af7 | |||
| a6b8348332 | |||
| b98cb8a99f | |||
| 7b59e70192 | |||
| 7f477b4ad8 | |||
| ce38b26721 | |||
| 172d7773f0 | |||
| 205ad6f646 | |||
| 936d035750 | |||
| 13d1d013e9 | |||
| b8fb3fbd6e | |||
| e510e90b95 | |||
| 902560cd68 | |||
| 912b08c719 | |||
| 3ef4ecb747 | |||
| 4b9d008be2 | |||
| 74482252d1 | |||
| 6e7705b256 | |||
| 59316de1e9 | |||
| 1719fdada0 | |||
| 8dda9541a3 | |||
| 60a80369d4 | |||
| dbe6c60133 | |||
| 74597a8c84 | |||
| 5d57b67934 | |||
| 220e3f040c | |||
| 54d34972d4 | |||
| 0c86cac2d5 | |||
| 2e080d02ce | |||
| 73e210b243 | |||
| f866299021 | |||
| b78a493a0c | |||
| 51d3454344 | |||
| 12789529a1 | |||
| c1bde18a2c | |||
| fd7fb7b6da | |||
| 138436558f | |||
| 65d595ad12 | |||
| abeb4e5cdf | |||
| b082bd65a6 | |||
| de52c8a7b7 | |||
| dcfa976dad | |||
| 71999e1062 | |||
| 5f5aba8dff | |||
| 9bfca929cb | |||
| 534870a68a | |||
| 0066ca6205 | |||
| 54e024c1b0 | |||
| 3a01318fbd | |||
| 79d391724e | |||
| ba019c0ba7 | |||
| 18d7c121a3 | |||
| cb93bd9265 | |||
| 6723416a55 | |||
| afb08799e8 | |||
| 3b619b8950 | |||
| 37681cf33e | |||
| 99064ce808 | |||
| de4dba6f98 | |||
| 75fc3aa3d6 | |||
| deb034c5fb | |||
| 242b5fef21 | |||
| 3f922ede28 | |||
| 8da62bd05f | |||
| 73cad7e205 | |||
| e14852c093 | |||
| 6240156fee | |||
| 1d9fb1884a | |||
| 97f38085e3 | |||
| 62cd1cf924 | |||
| b10e1a5a87 | |||
| 366fd6d127 | |||
| 7a77c66f6d | |||
| adece12cf1 | |||
| 2cfbc32715 | |||
| 56b37fc653 | |||
| 3ffde038c5 | |||
| ece2a55ffb | |||
| abda354562 | |||
| fbe984cf64 | |||
| efec6f22d5 | |||
| 7cda2a9f1a | |||
| 2b04718f33 | |||
| 505f0ebda3 | |||
| 0f40e717e1 | |||
| 08202f9351 | |||
| e22fcadb22 | |||
| 11d53245cf | |||
| f27a002c91 | |||
| ce8ba6a8c4 | |||
| 66695683eb | |||
| 18ac5adef5 | |||
| 41d75b50de | |||
| 4997356cb5 | |||
| 4bd562671e | |||
| 8221ebc803 | |||
| 4d6f8bccb7 | |||
| 800dfb50ce | |||
| 735d8766a2 | |||
| ccfeb055e5 | |||
| 8f957d919f | |||
| 2407686e13 | |||
| 1ec2593137 | |||
| ffc79447d4 | |||
| 71c0c273a1 | |||
| 21d0c289b5 | |||
| 648cd44387 | |||
| c8553dc8c5 | |||
| eedddb979e | |||
| 59a023ed5e | |||
| 8cd28cfb29 |
@@ -0,0 +1,88 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Install Linux audio/display dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Clippy (all crates, zero warnings)
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
- name: Test (headless crates only — no display required)
|
||||
run: |
|
||||
cargo test -p solitaire_core
|
||||
cargo test -p solitaire_sync
|
||||
cargo test -p solitaire_data
|
||||
cargo test -p solitaire_server
|
||||
|
||||
build:
|
||||
name: Release Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux audio/display dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Cache cargo registry and build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-release-
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --workspace --release
|
||||
@@ -1,7 +1,9 @@
|
||||
/target
|
||||
/.sccache-cache
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
*.tmp
|
||||
data/
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE users SET leaderboard_opt_in = 0 WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "36bab3ca8f126c66a6f4865d2699702689518bd3a796c374f932aba0434a4c94"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
# Solitaire Quest — Architecture Document
|
||||
|
||||
> **Version:** 1.1
|
||||
> **Language:** Rust (Edition 2021)
|
||||
> **Language:** Rust (Edition 2024)
|
||||
> **Engine:** Bevy (latest stable)
|
||||
> **Last Updated:** 2026-04-20
|
||||
> **Last Updated:** 2026-04-29
|
||||
|
||||
---
|
||||
|
||||
@@ -16,28 +16,25 @@
|
||||
5. [Game Engine Architecture](#5-game-engine-architecture)
|
||||
6. [Persistence & Sync Architecture](#6-persistence--sync-architecture)
|
||||
7. [Sync Server Architecture](#7-sync-server-architecture)
|
||||
8. [Google Play Games Services (Android Future)](#8-google-play-games-services-android-future)
|
||||
9. [Data Models](#9-data-models)
|
||||
10. [API Reference](#10-api-reference)
|
||||
11. [Merge Strategy](#11-merge-strategy)
|
||||
12. [Achievement System](#12-achievement-system)
|
||||
13. [Progression System](#13-progression-system)
|
||||
14. [Audio System](#14-audio-system)
|
||||
15. [Asset Pipeline](#15-asset-pipeline)
|
||||
16. [Platform Targets](#16-platform-targets)
|
||||
17. [Build & Development Guide](#17-build--development-guide)
|
||||
18. [Deployment Guide](#18-deployment-guide)
|
||||
19. [Security Model](#19-security-model)
|
||||
20. [Testing Strategy](#20-testing-strategy)
|
||||
21. [Decision Log](#21-decision-log)
|
||||
8. [Data Models](#8-data-models)
|
||||
9. [API Reference](#9-api-reference)
|
||||
10. [Merge Strategy](#10-merge-strategy)
|
||||
11. [Achievement System](#11-achievement-system)
|
||||
12. [Progression System](#12-progression-system)
|
||||
13. [Audio System](#13-audio-system)
|
||||
14. [Asset Pipeline](#14-asset-pipeline)
|
||||
15. [Platform Targets](#15-platform-targets)
|
||||
16. [Build & Development Guide](#16-build--development-guide)
|
||||
17. [Deployment Guide](#17-deployment-guide)
|
||||
18. [Security Model](#18-security-model)
|
||||
19. [Testing Strategy](#19-testing-strategy)
|
||||
20. [Decision Log](#20-decision-log)
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops (iOS/Android as a stretch goal). It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
||||
|
||||
On Android (stretch goal), sync is enhanced with Google Play Games Services (GPGS) for native achievement popups, leaderboards, and cloud saves — sitting on top of the same underlying sync payload so data stays consistent regardless of which backend was used last.
|
||||
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
|
||||
|
||||
### Sync Backend by Platform
|
||||
|
||||
@@ -46,17 +43,15 @@ On Android (stretch goal), sync is enhanced with Google Play Games Services (GPG
|
||||
| macOS | Self-hosted server | Full feature set |
|
||||
| Windows | Self-hosted server | Full feature set |
|
||||
| Linux | Self-hosted server | Full feature set |
|
||||
| Android (stretch) | Google Play Games Services | + server as fallback |
|
||||
| iOS (stretch) | Self-hosted server | GPGS not supported on iOS |
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 2. Workspace Structure
|
||||
@@ -72,26 +67,25 @@ solitaire_quest/
|
||||
├── Dockerfile # Multi-stage server build
|
||||
├── docker-compose.yml # Server + Caddy reverse proxy
|
||||
│
|
||||
├── assets/ # All runtime assets (loaded via Bevy AssetServer)
|
||||
├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
|
||||
│ ├── cards/
|
||||
│ │ ├── faces/ # Card face sprites (suit + rank)
|
||||
│ │ └── backs/ # Card back designs (back_0.png … back_4.png)
|
||||
│ ├── backgrounds/ # Table backgrounds (bg_0.png … bg_4.png)
|
||||
│ ├── fonts/ # .ttf font files
|
||||
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — rendered from hayeah/playing-cards-assets SVGs (MIT)
|
||||
│ │ └── backs/back_0.png – back_4.png # back_0 = generated default back; back_1–4 are generated patterns
|
||||
│ ├── backgrounds/bg_0.png – bg_4.png # generated textures
|
||||
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||
│ └── audio/
|
||||
│ ├── card_deal.ogg
|
||||
│ ├── card_flip.ogg
|
||||
│ ├── card_place.ogg
|
||||
│ ├── card_invalid.ogg
|
||||
│ ├── win_fanfare.ogg
|
||||
│ └── ambient_loop.ogg
|
||||
│ ├── card_deal.wav
|
||||
│ ├── card_flip.wav
|
||||
│ ├── card_place.wav
|
||||
│ ├── card_invalid.wav
|
||||
│ ├── win_fanfare.wav
|
||||
│ └── ambient_loop.wav
|
||||
│
|
||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
||||
├── solitaire_sync/ # Shared API types — used by client and server
|
||||
├── solitaire_data/ # Persistence, sync client, settings
|
||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
||||
├── solitaire_gpgs/ # Google Play Games Services bridge (Android only, stub until stretch goal)
|
||||
└── solitaire_app/ # Main binary entry point
|
||||
```
|
||||
|
||||
@@ -135,25 +129,10 @@ Owns:
|
||||
- `SyncBackend` enum and backend selection
|
||||
- Solitaire Server sync client (JWT auth, auto-refresh)
|
||||
- OS keychain integration (`keyring`)
|
||||
- `SyncProvider` trait — implemented by both `SolitaireServerClient` and `GpgsClient` (Android)
|
||||
|
||||
### `solitaire_gpgs` *(stub — implement when targeting Android)*
|
||||
**Dependencies:** `solitaire_sync`, `jni` (Android only), `solitaire_data` trait impls.
|
||||
|
||||
Android-only crate, compiled only when `target_os = "android"`. Bridges the Google Play Games Services Java SDK via JNI.
|
||||
|
||||
Owns:
|
||||
- `GpgsClient` implementing the `SyncProvider` trait from `solitaire_data`
|
||||
- GPGS Saved Games API calls (load/save cloud save slot)
|
||||
- GPGS Achievements API calls (unlock, reveal, increment)
|
||||
- GPGS Leaderboards API calls (submit score, load scores)
|
||||
- Google Sign-In token management (via JNI into Android SDK)
|
||||
- Conversion between GPGS cloud save blob ↔ `SyncPayload`
|
||||
|
||||
> **Note:** This crate contains only a trait stub and compile-time stub implementations until Android support is actively developed. Do not implement JNI bindings until Phase: Android.
|
||||
- `SyncProvider` trait — implemented by `SolitaireServerClient`
|
||||
|
||||
### `solitaire_engine`
|
||||
**Dependencies:** `bevy`, `bevy_kira_audio`, `solitaire_core`, `solitaire_data`.
|
||||
**Dependencies:** `bevy`, `kira`, `solitaire_core`, `solitaire_data`.
|
||||
|
||||
All Bevy-specific code. Structured as a collection of Plugins that `solitaire_app` registers.
|
||||
|
||||
@@ -165,6 +144,7 @@ Owns:
|
||||
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
|
||||
- Audio playback systems
|
||||
- Sync status display
|
||||
- Card, background, and font asset loading via Bevy `AssetServer` (audio is the lone exception — embedded via `include_bytes!()` in `audio_plugin.rs`)
|
||||
|
||||
### `solitaire_server`
|
||||
**Dependencies:** `solitaire_sync`, `axum`, `sqlx`, `jsonwebtoken`, `bcrypt`, `tower-governor`, `tracing`, `tokio`, `dotenvy`.
|
||||
@@ -223,8 +203,7 @@ SyncPlugin::on_startup()
|
||||
│ spawns AsyncComputeTask
|
||||
▼
|
||||
solitaire_data::sync_pull() ← dispatches to active SyncProvider
|
||||
│ SolitaireServerClient (desktop / iOS)
|
||||
│ GpgsClient (Android, future)
|
||||
│ SolitaireServerClient
|
||||
▼
|
||||
solitaire_sync::merge(local, remote)
|
||||
│
|
||||
@@ -245,7 +224,7 @@ SyncPlugin::on_exit()
|
||||
│ blocking push (acceptable on exit, not on main loop)
|
||||
▼
|
||||
active SyncProvider::push(local)
|
||||
│ POST to server — or — GPGS Saved Games PUT (Android)
|
||||
│ POST to server
|
||||
▼
|
||||
Done
|
||||
```
|
||||
@@ -256,19 +235,22 @@ Done
|
||||
|
||||
### Bevy Plugins
|
||||
|
||||
| Plugin | Key | Responsibility |
|
||||
The "Shortcut" column lists optional keyboard accelerators. Every action in this table must also be reachable from a visible UI control (button, menu item, on-screen affordance) per the UI-first design principle in §1; the shortcut is a power-user convenience, not the sole entry point.
|
||||
|
||||
| Plugin | Shortcut | Responsibility |
|
||||
|---|---|---|
|
||||
| `CardPlugin` | — | Card entity spawning, sprite management, drag-and-drop |
|
||||
| `TablePlugin` | — | Pile markers, background, layout calculation |
|
||||
| `FontPlugin` | — | Loads FiraMono-Medium via `AssetServer` at startup; exposes `FontResource` handle |
|
||||
| `AnimationPlugin` | — | Slide, flip, win cascade, toast animations |
|
||||
| `FeedbackAnimPlugin` | — | Shake, settle, and deal-stagger animations |
|
||||
| `AutoCompletePlugin` | Enter | Executes auto-complete when the HUD badge is lit |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via bevy_kira_audio |
|
||||
| `AudioPlugin` | — | Sound effect and music playback via kira |
|
||||
| `InputPlugin` | — | Keyboard and mouse input routing |
|
||||
| `CursorPlugin` | — | Custom cursor sprite during drag |
|
||||
| `SelectionPlugin` | — | Keyboard-driven card selection |
|
||||
| `GamePlugin` | N | Core game state resource, new-game flow, win/game-over overlays |
|
||||
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge |
|
||||
| `HudPlugin` | — | Score, move counter, timer, auto-complete badge, and the top-right action button bar (Undo / Pause / Help / New Game). Each button fires the same request event the corresponding hotkey does. |
|
||||
| `StatsPlugin` | S | Stats overlay and persistence |
|
||||
| `ProgressPlugin` | — | XP/level system, persistence |
|
||||
| `AchievementPlugin` | A | Unlock evaluation, toast events, persistence |
|
||||
@@ -309,6 +291,20 @@ struct StatsResource(StatsSnapshot);
|
||||
struct ProgressResource(PlayerProgress);
|
||||
struct AchievementsResource(Vec<AchievementRecord>);
|
||||
struct SettingsResource(Settings);
|
||||
|
||||
// Pre-loaded card face and back PNG handles
|
||||
struct CardImageSet {
|
||||
faces: [[Handle<Image>; 13]; 4], // [suit][rank]: Clubs=0..Spades=3, Ace=0..King=12
|
||||
backs: [Handle<Image>; 5], // indexed by selected_card_back setting
|
||||
}
|
||||
|
||||
// Project-wide font handle (FiraMono-Medium loaded via AssetServer at startup)
|
||||
struct FontResource(Handle<Font>);
|
||||
|
||||
// Pre-loaded background PNG handles
|
||||
struct BackgroundImageSet {
|
||||
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
||||
}
|
||||
```
|
||||
|
||||
### Key Bevy Events
|
||||
@@ -382,7 +378,6 @@ Implementations:
|
||||
|---|---|---|
|
||||
| `LocalOnlyProvider` | No-op (default) | All |
|
||||
| `SolitaireServerClient` | Self-hosted server | All |
|
||||
| `GpgsClient` *(future)* | Google Play Games Services | Android only |
|
||||
|
||||
Sync always runs on `bevy::tasks::AsyncComputeTaskPool` — the game thread is never blocked.
|
||||
|
||||
@@ -397,9 +392,6 @@ pub enum SyncBackend {
|
||||
// JWT access + refresh tokens stored in OS keychain
|
||||
// key: "solitaire_quest_server_{username}"
|
||||
},
|
||||
GooglePlayGames,
|
||||
// No credentials stored locally — auth managed by Google Sign-In SDK via JNI
|
||||
// Android only; selecting this on non-Android falls back to Local silently
|
||||
}
|
||||
```
|
||||
|
||||
@@ -411,10 +403,6 @@ On exit: `POST /api/sync/push` with payload
|
||||
On 401: automatically attempt `POST /api/auth/refresh`, retry once, then surface error to user.
|
||||
Credentials stored in OS keychain via `keyring` — never in plaintext on disk.
|
||||
|
||||
### Google Play Games Sync *(Android — future, see Section 8)*
|
||||
|
||||
Implemented in `solitaire_gpgs` crate. Uses the GPGS Saved Games API with named slot `"solitaire_quest_sync"`. The `GpgsClient` struct implements `SyncProvider` — the `SyncPlugin` treats it identically to `SolitaireServerClient`. The same `solitaire_sync::merge()` function applies regardless of which provider returned the remote data.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sync Server Architecture
|
||||
@@ -501,89 +489,7 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
||||
|
||||
---
|
||||
|
||||
## 8. Google Play Games Services (Android Future)
|
||||
|
||||
> **Status: Stub only.** Do not implement JNI bindings until Android is actively targeted. The `solitaire_gpgs` crate exists in the workspace with a trait stub so the compiler enforces the interface contract from day one.
|
||||
|
||||
### Why GPGS on Android
|
||||
|
||||
Google Play Games Services provides first-class Android features that would otherwise require significant backend work:
|
||||
|
||||
| Feature | GPGS Provides | Our Alternative |
|
||||
|---|---|---|
|
||||
| Cloud saves | Saved Games API | Self-hosted server |
|
||||
| Achievements | Native popups + Play profile | In-game toasts only |
|
||||
| Leaderboards | Hosted by Google, visible in Play app | Server leaderboard |
|
||||
| Auth | Google Sign-In, no registration | Username + password |
|
||||
|
||||
On Android, GPGS is the **primary** sync provider. The self-hosted server is the **fallback** if the player is not signed in or has no server configured. Both can be active simultaneously — a win pushes to both, pull merges from whichever responded last.
|
||||
|
||||
### Compatibility Reality
|
||||
|
||||
| Platform | GPGS Support |
|
||||
|---|---|
|
||||
| Android | ✅ Full |
|
||||
| Windows | ✅ GPGS for PC (optional, separate SDK) |
|
||||
| macOS | ❌ Not supported |
|
||||
| Linux | ❌ Not supported |
|
||||
| iOS | ❌ Not supported |
|
||||
|
||||
macOS, Linux, and iOS users always use the self-hosted server. This is why the server is the primary design and GPGS is an enhancement layer.
|
||||
|
||||
### `solitaire_gpgs` Crate Design
|
||||
|
||||
The crate is compiled only on Android (`#[cfg(target_os = "android")]`). On all other platforms the crate exports only the stub.
|
||||
|
||||
```rust
|
||||
// solitaire_gpgs/src/lib.rs
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
mod stub;
|
||||
|
||||
pub use stub::GpgsClient; // stub on desktop
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::GpgsClient; // real impl on Android
|
||||
```
|
||||
|
||||
### JNI Bridge (Android implementation — future)
|
||||
|
||||
The real `GpgsClient` uses the `jni` crate to call into the GPGS Android SDK:
|
||||
|
||||
```
|
||||
Rust GpgsClient
|
||||
│ jni::JNIEnv
|
||||
▼
|
||||
Java: com.google.android.gms.games.PlayGames
|
||||
├── getSnapshotsClient() → Saved Games (sync payload)
|
||||
├── getAchievementsClient() → unlock / reveal
|
||||
└── getLeaderboardsClient() → submit score
|
||||
```
|
||||
|
||||
Steps required when Android work begins:
|
||||
1. Add `cargo-mobile2` to the build toolchain
|
||||
2. Implement `GpgsClient` with `jni` bindings in `solitaire_gpgs/src/android.rs`
|
||||
3. Add `GpgsClient: SyncProvider` impl — pull/push map to Saved Games load/save
|
||||
4. Mirror achievement unlocks: on `AchievementUnlockedEvent`, call GPGS unlock API alongside in-game toast
|
||||
5. Submit scores to GPGS leaderboard on `GameWonEvent`
|
||||
6. Add Google Sign-In button to the Settings screen (Android build only, `#[cfg]` gated)
|
||||
|
||||
### Dual-Sync on Android
|
||||
|
||||
When both GPGS and the self-hosted server are configured, the `SyncPlugin` runs both providers concurrently and merges all three payloads (local + GPGS + server) using the same `solitaire_sync::merge()` function applied twice:
|
||||
|
||||
```
|
||||
local ──────┐
|
||||
├── merge() ──► intermediate ──┐
|
||||
gpgs ────────┘ ├── merge() ──► final
|
||||
server ──────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Models
|
||||
## 8. Data Models
|
||||
|
||||
### Core Game Models (`solitaire_core`)
|
||||
|
||||
@@ -677,14 +583,14 @@ pub struct Settings {
|
||||
pub music_volume: f32,
|
||||
pub animation_speed: AnimSpeed,
|
||||
pub theme: Theme,
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer | GooglePlayGames
|
||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||
pub first_run_complete: bool,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. API Reference
|
||||
## 9. API Reference
|
||||
|
||||
All endpoints are under the base URL configured by the user (e.g., `https://solitaire.example.com`).
|
||||
|
||||
@@ -727,9 +633,9 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
||||
|
||||
---
|
||||
|
||||
## 11. Merge Strategy
|
||||
## 10. Merge Strategy
|
||||
|
||||
Used identically by the `SolitaireServerClient`, `GpgsClient`, and server-side handler. Lives in `solitaire_sync` as a pure function with no I/O. Called once per provider when multiple backends are active simultaneously (e.g. GPGS + server on Android).
|
||||
Used by both `SolitaireServerClient` and the server-side handler. Lives in `solitaire_sync` as a pure function with no I/O.
|
||||
|
||||
```rust
|
||||
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
||||
@@ -769,7 +675,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
|
||||
|
||||
---
|
||||
|
||||
## 12. Achievement System
|
||||
## 11. Achievement System
|
||||
|
||||
### Definition Structure
|
||||
|
||||
@@ -809,18 +715,17 @@ 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.
|
||||
|
||||
### GPGS Mirroring *(Android, future)*
|
||||
|
||||
When the `GpgsClient` is active, every `AchievementUnlockedEvent` also triggers a GPGS `achievements.unlock()` call via JNI so the achievement appears in the player's Google Play profile. A static map in `solitaire_gpgs` maps our achievement IDs to GPGS achievement IDs (assigned in the Google Play Console). Mirroring is fire-and-forget — failures are logged but never block the in-game toast.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 13. Progression System
|
||||
## 12. Progression System
|
||||
|
||||
### XP Sources
|
||||
|
||||
@@ -849,62 +754,85 @@ Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
|
||||
|
||||
---
|
||||
|
||||
## 14. Audio System
|
||||
## 13. Audio System
|
||||
|
||||
Audio uses `bevy_kira_audio`. All sound files are `.ogg` (good compression, cross-platform, royalty-free).
|
||||
Audio uses `kira`. All sound files are `.wav`.
|
||||
|
||||
| File | Trigger |
|
||||
|---|---|
|
||||
| `card_deal.ogg` | New game deal animation |
|
||||
| `card_flip.ogg` | Card flips face-up |
|
||||
| `card_place.ogg` | Valid card placement |
|
||||
| `card_invalid.ogg` | Invalid move attempt |
|
||||
| `win_fanfare.ogg` | Game won |
|
||||
| `ambient_loop.ogg` | Looping background music (restarts seamlessly) |
|
||||
| `card_deal.wav` | New game deal animation |
|
||||
| `card_flip.wav` | Card flips face-up |
|
||||
| `card_place.wav` | Valid card placement |
|
||||
| `card_invalid.wav` | Invalid move attempt |
|
||||
| `win_fanfare.wav` | Game won |
|
||||
| `ambient_loop.wav` | Looping background music |
|
||||
|
||||
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `bevy_kira_audio` channel volumes.
|
||||
Volume is controlled by two independent sliders in Settings (`sfx_volume`, `music_volume`), each stored in `Settings` and applied as `kira` channel volumes.
|
||||
|
||||
Audio systems listen for Bevy events and never block the game thread.
|
||||
|
||||
---
|
||||
|
||||
## 15. Asset Pipeline
|
||||
## 14. Asset Pipeline
|
||||
|
||||
All assets are loaded through Bevy's `AssetServer`. No bytes are hardcoded in source.
|
||||
### Rendering approach
|
||||
|
||||
### Card Sprites
|
||||
Cards are Bevy `Sprite` entities with `Handle<Image>` from `CardImageSet`. Face-up cards use one of 52 individual face PNGs selected by `faces[suit][rank]` — rank and suit are baked into each image and no `Text2d` overlay is spawned. Face-down cards use `backs/back_N.png` indexed by `settings.selected_card_back`. `Text2d` labels are only used as a fallback when `CardImageSet` is absent (e.g. tests with `MinimalPlugins`). `CardImageSet` is populated at startup by `card_plugin::load_card_images` via `AssetServer::load()`.
|
||||
|
||||
Card faces can be either:
|
||||
- A texture atlas (`assets/cards/atlas.png` + `atlas.ron` layout) — faster to load, preferred
|
||||
- Individual files (`assets/cards/faces/2_of_clubs.png`, etc.) — easier to author
|
||||
Backgrounds are Bevy `Sprite` entities with `Handle<Image>` from `BackgroundImageSet`. `BackgroundImageSet` is populated at startup by `table_plugin::load_background_images` via `AssetServer::load()`.
|
||||
|
||||
Card backs: `assets/cards/backs/back_0.png` through `back_4.png`. Additional backs unlocked via achievements are in the same folder, gated by `PlayerProgress::unlocked_card_backs`.
|
||||
The font `FiraMono-Medium` is loaded via `AssetServer::load("fonts/main.ttf")` at startup by `FontPlugin` and exposed as `FontResource` for use by all UI and text systems.
|
||||
|
||||
### Backgrounds
|
||||
All three loaders take `Option<Res<AssetServer>>` so they degrade cleanly under `MinimalPlugins` in tests: when the server is absent, `CardImageSet`/`BackgroundImageSet` are inserted with empty handle slots and the plugins fall back to `Text2d` rank+suit overlays and solid-colour board backgrounds. The `assets/` directory must ship alongside the binary.
|
||||
|
||||
`assets/backgrounds/bg_0.png` through `bg_4.png`. Same unlock gating as card backs.
|
||||
The `assets/` directory layout:
|
||||
|
||||
### Fonts
|
||||
```
|
||||
assets/
|
||||
├── cards/
|
||||
│ ├── faces/{rank}_{suit}.png # 52 individual card faces (120×168, generated by solitaire_assetgen)
|
||||
│ └── backs/back_0.png – back_4.png # placeholder patterns
|
||||
├── backgrounds/bg_0.png – bg_4.png # placeholder textures
|
||||
├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
|
||||
└── audio/
|
||||
├── card_deal.wav
|
||||
├── card_flip.wav
|
||||
├── card_place.wav
|
||||
├── card_invalid.wav
|
||||
├── win_fanfare.wav
|
||||
└── ambient_loop.wav
|
||||
```
|
||||
|
||||
`assets/fonts/main.ttf` — used for card rank/suit text in Bevy UI.
|
||||
### Audio
|
||||
|
||||
All sound effect WAV files are embedded at compile time via `include_bytes!()` in `audio_plugin.rs`. There is no runtime asset loading — the binary is fully self-contained.
|
||||
|
||||
| File | Trigger |
|
||||
|---|---|
|
||||
| `card_deal.wav` | New game deal animation |
|
||||
| `card_flip.wav` | Card flips face-up |
|
||||
| `card_place.wav` | Valid card placement |
|
||||
| `card_invalid.wav` | Invalid move attempt |
|
||||
| `win_fanfare.wav` | Game won |
|
||||
| `ambient_loop.wav` | Looping background music |
|
||||
|
||||
---
|
||||
|
||||
## 16. Platform Targets
|
||||
## 15. Platform Targets
|
||||
|
||||
| Platform | Status | Primary Sync | Notes |
|
||||
|---|---|---|---|
|
||||
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
||||
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain; optional GPGS for PC (future) |
|
||||
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
|
||||
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
||||
| Android | Stretch | Google Play Games + server | `cargo-mobile2`, touch input, GPGS via JNI |
|
||||
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input; GPGS unavailable on iOS |
|
||||
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||
| iOS | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
||||
|
||||
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Build & Development Guide
|
||||
## 16. Build & Development Guide
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -965,7 +893,7 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
|
||||
|
||||
---
|
||||
|
||||
## 18. Deployment Guide
|
||||
## 17. Deployment Guide
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
@@ -1010,7 +938,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
||||
|
||||
---
|
||||
|
||||
## 19. Security Model
|
||||
## 18. Security Model
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
@@ -1026,7 +954,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
||||
|
||||
---
|
||||
|
||||
## 20. Testing Strategy
|
||||
## 19. Testing Strategy
|
||||
|
||||
### Unit Tests (`solitaire_core`)
|
||||
|
||||
@@ -1065,12 +993,10 @@ Using `axum::test` and an in-memory SQLite database:
|
||||
- [ ] Achievement toast appears and dismisses
|
||||
- [ ] Server sync: register, login, push, pull on second machine
|
||||
- [ ] Server sync: JWT refresh on 401 works transparently
|
||||
- [ ] GPGS sync (Android only): sign in, unlock achievement, verify appears in Play Games app
|
||||
- [ ] Dual sync (Android only): GPGS + server both configured, payloads merge correctly
|
||||
|
||||
---
|
||||
|
||||
## 21. Decision Log
|
||||
## 20. Decision Log
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|---|---|---|
|
||||
@@ -1082,7 +1008,10 @@ Using `axum::test` and an in-memory SQLite database:
|
||||
| bcrypt cost 12 | Balances security and registration latency (~300ms on modern hardware); higher than default 10 | 2026-04-19 |
|
||||
| No email required for server accounts | Reduces PII collected; simplifies self-hosted deployments; password reset handled by server admin if needed | 2026-04-19 |
|
||||
| Self-hosted server as primary sync (not WebDAV) | A proper Rust server gives us auth, leaderboards, and daily challenge seeding for minimal extra effort over WebDAV, and removes a redundant backend | 2026-04-20 |
|
||||
| `SyncProvider` trait, not `SyncBackend` match arms | Allows adding Google Play Games Services cleanly; `SyncPlugin` stays backend-agnostic and testable | 2026-04-20 |
|
||||
| GPGS as Android enhancement, not replacement | GPGS has no macOS/Linux support; the server must remain universal, with GPGS layered on top for Android players | 2026-04-20 |
|
||||
| `SyncProvider` trait, not `SyncBackend` match arms | `SyncPlugin` stays backend-agnostic and testable; new backends can be added without touching the plugin | 2026-04-20 |
|
||||
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
|
||||
| `solitaire_gpgs` crate stubbed from day one | Enforces the `SyncProvider` interface contract at compile time even before Android work begins; avoids architectural rework later | 2026-04-20 |
|
||||
| Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
|
||||
| Card, background, and font assets loaded via `AssetServer` | Reverses the earlier embed-via-`include_bytes!()` decision: PNGs and TTFs are loaded at runtime so artwork can be swapped (e.g. alternate card backs, themed backgrounds) without a recompile, and binary size stays small. Loaders take `Option<Res<AssetServer>>` and fall back gracefully under `MinimalPlugins`. The `assets/` directory must ship alongside the binary. | 2026-04-29 |
|
||||
| Audio assets remain embedded via `include_bytes!()` | Audio files are small, change rarely, and the embedded path eliminates a class of runtime-load errors during gameplay; the asset-pipeline reversal does not extend to audio | 2026-04-29 |
|
||||
| Card art swapped from xCards (LGPL-3.0) to hayeah/playing-cards-assets (MIT) | Public-release readiness. The previous xCards art carried LGPL relinking obligations that complicate a single-binary distribution; hayeah's set derives from the public-domain `vector-playing-cards` line-art and is permissively MIT-licensed. CREDITS.md license summary collapsed to MIT + OFL-1.1. The default card back is original work in this project's midnight-purple palette. | 2026-05-01 |
|
||||
| Runtime SVG card-theme system (`CARD_PLAN.md`) | User-supplied themes need to ship SVG sources so they can rasterise at any resolution on the player's hardware; baking PNGs at build time only would lock theme installation to the developer. The pipeline (usvg → resvg → tiny-skia) rasterises once per (theme, target size) at load time and caches the resulting `Image`, so the runtime cost is paid once, not per frame. The bundled default theme ships via `embedded://`; user themes via `themes://` rooted at `user_theme_dir()`. | 2026-05-01 |
|
||||
|
||||
@@ -1,113 +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_gpgs/ # Google Play Games bridge — STUB ONLY until Android phase
|
||||
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>`.
|
||||
- Assets are embedded at compile time using `include_bytes!()`. No runtime asset loading via `AssetServer`.
|
||||
- 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.
|
||||
- `solitaire_gpgs` is a stub until Android work begins. Do not write JNI bindings yet; keep the compile-time stub so the trait contract is enforced from day one.
|
||||
- `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.
|
||||
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.1–2.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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,112 @@
|
||||
# Credits
|
||||
|
||||
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
|
||||
the work of many open-source projects and a small handful of third-party
|
||||
assets. This file lists every component that ships in the binary or in the
|
||||
`assets/` directory.
|
||||
|
||||
---
|
||||
|
||||
## Code & Framework
|
||||
|
||||
| Component | License | Role |
|
||||
|---|---|---|
|
||||
| [Bevy 0.18](https://bevyengine.org/) | MIT OR Apache-2.0 | Game engine, ECS, rendering, UI |
|
||||
| [kira 0.12](https://crates.io/crates/kira) | MIT OR Apache-2.0 | Audio playback (mixer, sub-tracks, looping ambient) |
|
||||
| [serde](https://crates.io/crates/serde) / [serde_json](https://crates.io/crates/serde_json) | MIT OR Apache-2.0 | Serialization for save files and the sync API |
|
||||
| [tokio](https://crates.io/crates/tokio) | MIT | Async runtime for the sync client and server |
|
||||
| [axum 0.8](https://crates.io/crates/axum) | MIT | HTTP framework for the self-hosted sync server |
|
||||
| [sqlx 0.8](https://crates.io/crates/sqlx) | MIT OR Apache-2.0 | Compile-time-checked SQLite access on the server |
|
||||
| [reqwest 0.13](https://crates.io/crates/reqwest) | MIT OR Apache-2.0 | HTTP client for the sync provider |
|
||||
| [jsonwebtoken 10](https://crates.io/crates/jsonwebtoken) | MIT | JWT issuance and validation |
|
||||
| [bcrypt 0.19](https://crates.io/crates/bcrypt) | MIT | Password hashing on the server |
|
||||
| [keyring 4](https://crates.io/crates/keyring) | MIT OR Apache-2.0 | OS keychain integration for credential storage |
|
||||
| [tower-governor 0.8](https://crates.io/crates/tower-governor) | MIT | Rate limiting on `/api/auth/*` |
|
||||
| [chrono](https://crates.io/crates/chrono) | MIT OR Apache-2.0 | Date / time handling |
|
||||
| [uuid](https://crates.io/crates/uuid) | MIT OR Apache-2.0 | User and session identifiers |
|
||||
| [thiserror](https://crates.io/crates/thiserror) | MIT OR Apache-2.0 | Error type derive |
|
||||
| [rand 0.9](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Seeded shuffler in `solitaire_core` |
|
||||
| [png 0.17](https://crates.io/crates/png) | MIT OR Apache-2.0 | PNG encoder used by `solitaire_assetgen` |
|
||||
| [ab_glyph 0.2](https://crates.io/crates/ab_glyph) | Apache-2.0 | Glyph rasterization for generated card art |
|
||||
|
||||
The full transitive dependency tree (several hundred crates) is captured in
|
||||
`Cargo.lock` and reachable via `cargo tree`. Every crate brought in is
|
||||
MIT, Apache-2.0, BSD-style, or a dual-licensed combination thereof — no
|
||||
copyleft code is statically linked into the game binary.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
### Card artwork
|
||||
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
|
||||
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) |
|
||||
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
|
||||
| `assets/cards/backs/back_0.png` – `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
|
||||
The face SVGs come from Howard Yeh's `playing-cards-assets` repository, which
|
||||
is itself derived from the public-domain `vector-playing-cards` Google Code
|
||||
project. The art is redistributed under the MIT license — see the upstream
|
||||
repository for the full notice. The files ship unmodified in the bundled
|
||||
default theme; user-supplied themes can override them per-installation
|
||||
through the runtime SVG theming system documented in `CARD_PLAN.md`.
|
||||
|
||||
The default card back is original work by this project, midnight-purple
|
||||
themed to match the rest of the UI palette.
|
||||
|
||||
### Backgrounds
|
||||
|
||||
| File(s) | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/backgrounds/bg_0.png` – `bg_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
|
||||
|
||||
### Typography
|
||||
|
||||
| File | Source | License |
|
||||
|---|---|---|
|
||||
| `assets/fonts/main.ttf` (FiraMono-Medium) | [mozilla/Fira](https://github.com/mozilla/Fira) | SIL Open Font License 1.1 |
|
||||
|
||||
The OFL permits redistribution and embedding in software so long as the font
|
||||
file itself is not sold standalone. The file ships unmodified.
|
||||
|
||||
### Audio
|
||||
|
||||
All six WAV files in `assets/audio/` are **original work** — there are no
|
||||
third-party audio samples in this project. They are synthesized
|
||||
programmatically by `solitaire_assetgen/src/bin/gen_sfx.rs`, which writes
|
||||
44.1 kHz mono 16-bit PCM WAVs using a hand-rolled WAV writer (no `hound` or
|
||||
`dasp` dependency). The synthesis stack is entirely additive: sine /
|
||||
square waves, layered harmonics, deterministic LCG noise, AR envelopes,
|
||||
and a slow LFO for the ambient track.
|
||||
|
||||
| File | Synthesis approach |
|
||||
|---|---|
|
||||
| `card_deal.wav` | Filtered LCG noise with a sweeping low-pass cutoff for a "whoosh" |
|
||||
| `card_flip.wav` | High-passed LCG noise under a fast AR envelope |
|
||||
| `card_place.wav` | 120 Hz sine body + filtered noise click |
|
||||
| `card_invalid.wav` | Two dissonant square tones (196 Hz + 207.65 Hz) beating against each other |
|
||||
| `win_fanfare.wav` | C-major arpeggio (C5 / E5 / G5 / C6) with sine + 2nd harmonic |
|
||||
| `ambient_loop.wav` | 55 Hz fundamental with 2nd and 3rd harmonics, modulated by a 0.2 Hz LFO; loop length is chosen so the tone and LFO both complete an integer number of cycles for seamless looping |
|
||||
|
||||
Audio files are MIT-licensed alongside the rest of this project.
|
||||
|
||||
---
|
||||
|
||||
## License Summary
|
||||
|
||||
- **Project code:** MIT — see [LICENSE](LICENSE).
|
||||
- **Card face artwork (52 SVGs from hayeah/playing-cards-assets, plus the
|
||||
pre-rendered PNGs in `assets/cards/faces/`):** MIT, redistributed
|
||||
unmodified. The original `vector-playing-cards` line art is itself
|
||||
public domain.
|
||||
- **FiraMono-Medium font:** SIL Open Font License 1.1, redistributed unmodified.
|
||||
- **All other assets** (backgrounds, the default `back.svg`, generated card
|
||||
backs, every audio file) are original work covered by this project's MIT
|
||||
license.
|
||||
|
||||
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the
|
||||
`LICENSE` file alongside the binary so the MIT (project + hayeah card art)
|
||||
and OFL (FiraMono) notices remain visible to end users.
|
||||
@@ -5,42 +5,121 @@ members = [
|
||||
"solitaire_data",
|
||||
"solitaire_engine",
|
||||
"solitaire_server",
|
||||
"solitaire_gpgs",
|
||||
"solitaire_app",
|
||||
"solitaire_assetgen",
|
||||
"solitaire_wasm",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
version = "0.1.0"
|
||||
license = "MIT"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "1"
|
||||
rand = "0.8"
|
||||
thiserror = "2"
|
||||
rand = "0.9"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
dirs = "5"
|
||||
keyring = "2"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
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.15"
|
||||
kira = "0.9"
|
||||
# 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"
|
||||
|
||||
axum = "0.7"
|
||||
# SVG rasterisation pipeline for the runtime card-theme system.
|
||||
# usvg parses + simplifies; resvg renders to a tiny-skia Pixmap;
|
||||
# tiny-skia provides the CPU rasteriser. All three are maintained
|
||||
# together by the resvg-rs project and version in lockstep.
|
||||
usvg = "0.47"
|
||||
resvg = "0.47"
|
||||
tiny-skia = "0.12"
|
||||
|
||||
# Theme manifest format. RON keeps the file human-editable while
|
||||
# preserving Rust-style structures the importer can validate.
|
||||
ron = "0.12"
|
||||
|
||||
# Importer-only: reads user-supplied theme zip archives, validates
|
||||
# their contents, and unpacks them into the user themes directory.
|
||||
# Default features are disabled to keep the dependency footprint small;
|
||||
# only `deflate` is needed because the importer rejects other
|
||||
# compression methods anyway (see Phase 7 spec).
|
||||
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Importer-only test dependency: tests build zip archives in a
|
||||
# scratch directory so they don't pollute the real user themes path
|
||||
# on the developer's machine.
|
||||
tempfile = "3.27"
|
||||
|
||||
axum = "0.8"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||
jsonwebtoken = "9"
|
||||
bcrypt = "0.15"
|
||||
tower_governor = "0.4"
|
||||
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||
bcrypt = "0.19"
|
||||
tower_governor = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenvy = "0.15"
|
||||
|
||||
@@ -6,10 +6,6 @@ FROM rust:slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
# Tell sqlx to use the cached query metadata instead of a live database.
|
||||
@@ -22,11 +18,11 @@ RUN cargo build --release -p solitaire_server
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libssl3 ca-certificates \
|
||||
&& apt-get install -y ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/solitaire_server /usr/local/bin/solitaire_server
|
||||
|
||||
EXPOSE 8080
|
||||
EXPOSE ${SERVER_PORT:-8080}
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/solitaire_server"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 funman300
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Solitaire Quest
|
||||
|
||||
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; 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
|
||||
- **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 alongside the suit
|
||||
glyph
|
||||
|
||||
## Building
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
- Rust stable toolchain (`rustup install stable`)
|
||||
- Linux: `libasound2-dev libudev-dev libxkbcommon-dev`
|
||||
- macOS: Xcode Command Line Tools
|
||||
|
||||
```bash
|
||||
# Fast development build
|
||||
cargo run -p solitaire_app --features bevy/dynamic_linking
|
||||
|
||||
# Release build
|
||||
cargo build -p solitaire_app --release
|
||||
./target/release/solitaire_app
|
||||
```
|
||||
|
||||
## 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 |
|
||||
| U | Undo |
|
||||
| H | Hint (highlight a legal move) |
|
||||
| N | New game |
|
||||
| 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.
|
||||
|
||||
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 (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 --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
|
||||
[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).
|
||||
@@ -0,0 +1,282 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
**Last updated:** 2026-05-07 — v0.20.0 cut. Two through-lines closed
|
||||
in this cycle: a full **Terminal visual-identity port** (token system
|
||||
in `ui_theme` plus downstream chrome migrations across modal scaffold,
|
||||
gameplay-feedback, toasts, and the table / card / splash surfaces)
|
||||
and the **Android persistence shim** that closes the
|
||||
`dirs::data_dir() = None` pitfall flagged in CLAUDE.md §10. The
|
||||
Android *build* target landed earlier in the cycle (`fb8b2ac`); this
|
||||
session paid down the persistence half so a real APK can survive a
|
||||
cold start. The 24 Stitch-rendered mockups are now in-tree under
|
||||
`docs/ui-mockups/`; future plugin work diffs against the matching
|
||||
mockup before touching pixels.
|
||||
|
||||
## Status at pause
|
||||
|
||||
- **HEAD on origin:** 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.
|
||||
|
||||
## What shipped in v0.20.0
|
||||
|
||||
### Terminal visual-identity port
|
||||
|
||||
Top-down stack — every commit downstream of the token system
|
||||
reads from it, so swapping the palette is now a one-file edit:
|
||||
|
||||
- **`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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Design direction (now Terminal — base16-eighties)
|
||||
|
||||
- **Tone:** retro-terminal / synthwave — flat depth (no box-shadows),
|
||||
monospaced-forward typography (JetBrains Mono / FiraMono), tight
|
||||
16 px edge margins, 8 px card radius.
|
||||
- **Palette:** near-black surface ramp (`#151515` / `#202020` / `#2a2a2a`
|
||||
/ `#353535`), cyan primary CTA (`#6fc2ef`), lime success
|
||||
(`#acc267`), gold warning (`#ddb26f`), pink error / suit-red
|
||||
(`#fb9fb1`), lavender celebration (`#e1a3ee`), teal info
|
||||
(`#12cfc0`).
|
||||
- **Two-color suits.** Red = `#fb9fb1`, black = `#d0d0d0`. Outlined
|
||||
glyphs for diamonds & clubs are *always on*; the Settings
|
||||
"color-blind mode" toggle only swaps red → cyan.
|
||||
|
||||
(Was: Midnight Purple base + Balatro yellow primary + warm magenta.
|
||||
Replaced this cycle.)
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
||||
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 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 — 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 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:
|
||||
- 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) — gh auth setup-git wired on
|
||||
primary dev box; verify on laptop before first push.
|
||||
|
||||
OPEN AT THE START: ask which of A–F. Don't pick unilaterally.
|
||||
```
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -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.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Solitaire Quest — Session Handoff
|
||||
|
||||
> Last updated: 2026-04-25
|
||||
> Branch: `master` — pushed to https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
||||
> Test count: **242 passing** (83 core + 60 data + 99 engine), `cargo clippy --workspace -- -D warnings` clean
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# Android Port Investigation
|
||||
|
||||
> **Date:** 2026-04-28
|
||||
> **Author:** Claude Code
|
||||
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A working Android port is feasible but not trivial. The core game logic (`solitaire_core`, `solitaire_sync`) compiles to Android without changes. Every other crate requires at least minor surgery. The biggest blockers are the `keyring` crate (no Android backend), the `kira`/`AudioManager` audio stack (`DefaultBackend` uses CPAL which targets desktop), and the `dirs` crate returning `None` on Android in its current usage. Touch input already has a solid foundation in `input_plugin.rs`. Estimated effort from a clean Android toolchain is **12–18 developer-days** to reach a playable-but-rough state.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bevy on Android — Current Status
|
||||
|
||||
Bevy's Android support is community-maintained via the `winit` backend and is usable but carries known rough edges as of the 0.15/0.16 generation.
|
||||
|
||||
**What works:**
|
||||
- Basic rendering via Vulkan (through `wgpu`). OpenGL ES fallback is available for older devices.
|
||||
- Touch input events: Bevy's `TouchInput` events and the `Touches` resource are populated from Android `MotionEvent`s via `winit`. The existing `touch_start_drag`, `touch_follow_drag`, `touch_end_drag`, and `handle_touch_stock_tap` systems in `input_plugin.rs` will function correctly — this was already written with multi-touch in mind and uses `TouchPhase::Started/Moved/Ended/Canceled` cleanly.
|
||||
- Bevy UI (the `bevy::ui` module used for all overlays).
|
||||
- `WindowResized` events fire correctly, so the layout system will recompute for any screen size.
|
||||
|
||||
**What does not work / needs attention:**
|
||||
- **`bevy/dynamic_linking`**: The dynamic linking feature must be stripped from any Android build profile. Dynamic linking is a desktop-only development shortcut; Android requires static linking.
|
||||
- **Fixed window size**: `main.rs` sets `resolution: (1280u32, 800u32)`. On Android the window is always the full display. This value is harmlessly overridden by the OS, but `min_width`/`min_height` constraints should be removed or set to 0 for Android to avoid Winit warnings.
|
||||
- **`F11` fullscreen toggle** (`handle_fullscreen` in `input_plugin.rs`): `WindowMode::BorderlessFullscreen` is desktop-only. On Android it should be a no-op.
|
||||
- **Keyboard shortcuts**: The entire `handle_keyboard_core`, `handle_keyboard_hint`, `handle_keyboard_forfeit` systems are desktop-only workflows. They will not crash, but they are dead code on Android. No touchscreen replacement for Undo (U), New Game (N), Draw (D/Space), Hint (H), Forfeit (G) exists yet — these need an on-screen UI.
|
||||
- **`CursorPlugin`**: The custom cursor sprite plugin is irrelevant on Android (no cursor). Harmless to leave registered, but it uses `PrimaryWindow` cursor APIs that may panic or warn on Android.
|
||||
|
||||
**cargo-mobile2 integration for Bevy:**
|
||||
The standard path is:
|
||||
1. Install `cargo-mobile2`: `cargo install --locked cargo-mobile2`
|
||||
2. Run `cargo mobile init` in the workspace root. This generates an `android/` directory with the Gradle project, `AndroidManifest.xml`, and JNI glue.
|
||||
3. cargo-mobile2 targets the `solitaire_app` binary crate (the thin entry point). The generated `lib.rs` shim calls `android_main` via `bevy::winit`'s Android entry point.
|
||||
4. The `solitaire_app` crate needs a `[lib]` target added alongside the existing `[[bin]]`, with `crate-type = ["cdylib"]`, used only when building for Android.
|
||||
|
||||
**Required `Cargo.toml` changes (workspace level):**
|
||||
```toml
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# android_logger and ndk-glue wiring are handled by cargo-mobile2's generated shim.
|
||||
# No direct ndk-glue dependency is needed in app code when using Bevy + cargo-mobile2.
|
||||
```
|
||||
|
||||
**NDK version:** Android NDK r25c or r26 LTS is the tested range for `wgpu`/Vulkan on Android. NDK r27+ may work but has had compatibility reports with CPAL. Set `ANDROID_NDK_ROOT` to the NDK root; the minimum API level should be 26 (Android 8.0) for Vulkan stability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Audio — `kira` + `DefaultBackend`
|
||||
|
||||
**The problem:**
|
||||
`solitaire_engine/src/audio_plugin.rs` creates an `AudioManager<DefaultBackend>`. `kira`'s `DefaultBackend` is an alias for `CpalBackend`, which wraps CPAL. CPAL's Android backend uses OpenSL ES and is functional but historically fragile. As of kira 0.9+, `kira` no longer bundles its own CPAL backend by default in the same way — the `DefaultBackend` feature must be enabled explicitly and requires `cpal` with the Android feature.
|
||||
|
||||
**Current code behavior:**
|
||||
The `AudioPlugin::build` already handles the "no audio device" case gracefully:
|
||||
```rust
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default()).ok();
|
||||
if manager.is_none() {
|
||||
warn!("audio device unavailable; SFX disabled");
|
||||
}
|
||||
```
|
||||
This means if the audio manager fails to initialise on Android, the game continues silently. This is acceptable as a first-pass fallback.
|
||||
|
||||
**What is needed for working audio on Android:**
|
||||
- Add `kira` dependency with `cpal` backend enabled for Android: The `kira` workspace dependency currently specifies `version = "0.12"`. Verify that `kira/Cargo.toml` exposes a `cpal` feature (or that `DefaultBackend` compiles on Android targets with NDK). If not, a `CpalBackend` with `cpal = { features = ["oboe"] }` may be needed.
|
||||
- The `NonSend` resource `AudioState` should compile fine — `NonSend` is legal in Bevy Android builds.
|
||||
- `include_bytes!` for the WAV assets is compile-time and unaffected by platform.
|
||||
|
||||
**Recommendation:** Defer full audio verification to a device test. The graceful fallback means a silent-but-working first build is achievable without resolving this.
|
||||
|
||||
---
|
||||
|
||||
## 3. `keyring` Crate — No Android Backend
|
||||
|
||||
**The problem:**
|
||||
`keyring = "2"` is used in `solitaire_data/src/auth_tokens.rs` to store JWT access and refresh tokens in the OS keychain. The `keyring` crate's Android backend does not exist — as of v2.x, supported backends are: macOS Keychain, Windows Credential Manager, Linux Secret Service (D-Bus), and iOS Keychain. There is no Android KeyStore backend.
|
||||
|
||||
On Android, `Entry::new(...)` will return `keyring::Error::NoStorageAccess`, which the existing code already maps to `TokenError::KeychainUnavailable`. So the code will not crash — it will simply fail every token store/load operation.
|
||||
|
||||
**Current failure mode:**
|
||||
Every call to `store_tokens`, `load_access_token`, `load_refresh_token`, or `delete_tokens` will return `Err(TokenError::KeychainUnavailable(...))`. The sync client in `sync_client.rs` needs to be verified to handle this gracefully rather than propagating an error that disables sync entirely.
|
||||
|
||||
**Options for Android credential storage:**
|
||||
|
||||
| Option | Security | Effort | Notes |
|
||||
|---|---|---|---|
|
||||
| **In-memory only (prompt re-login each session)** | N/A | 1 day | Simplest. On `TokenError::KeychainUnavailable`, the `SyncProvider` returns `SyncError::Auth`, user is prompted to log in. Already architecturally supported. |
|
||||
| **Encrypted `SharedPreferences` equivalent via JNI** | Good | 4–6 days | Call Android's `EncryptedSharedPreferences` (Jetpack Security) via JNI. Significant JNI boilerplate. |
|
||||
| **AES-256 file encryption using Android Keystore via JNI** | Excellent | 5–8 days | Proper Android keychain equivalent. Complex JNI. |
|
||||
| **Store in app-private file, unencrypted** | Poor | 0.5 days | Only acceptable during development. Never ship. |
|
||||
|
||||
**Recommended approach (first pass):** Use the in-memory / re-login-each-session path. The existing `TokenError::KeychainUnavailable` variant already exists for exactly this reason (Linux without a running secret service). The `SyncPlugin` should detect this on startup and present a "Sync unavailable — please log in" message rather than a hard error. This requires:
|
||||
1. Conditional compilation: when `cfg(target_os = "android")`, replace the `keyring` calls with a no-op in-memory store (a simple `Mutex<HashMap<String, String>>`).
|
||||
2. A `#[cfg(not(target_os = "android"))]` guard on the `keyring` import/dependency in `solitaire_data/Cargo.toml`.
|
||||
|
||||
**Required `solitaire_data/Cargo.toml` change:**
|
||||
```toml
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
keyring = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# keyring is replaced by in-memory storage; no dependency needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `dirs` Crate — Data Directory on Android
|
||||
|
||||
**The problem:**
|
||||
`storage.rs` and other persistence modules use `dirs::data_dir()` to locate `~/.local/share/solitaire_quest/` (or platform equivalent). On Android, `dirs::data_dir()` returns `None` because there is no `XDG_DATA_HOME` and the `dirs` crate does not implement an Android-specific path.
|
||||
|
||||
**Current code behavior:**
|
||||
All persistence functions already handle `None` gracefully (returning default values or `Err`), consistent with the CLAUDE.md lesson about `dirs::data_dir()`. Stats and progress will silently not persist across sessions if `data_dir()` returns `None`.
|
||||
|
||||
**Fix required:**
|
||||
Android apps should store private data in the app's internal storage directory, obtained via JNI: `context.getFilesDir()`. This requires either:
|
||||
- A thin JNI helper (via `jni` crate) called once on startup to obtain the path and store it as a global.
|
||||
- Or passing the path in via the `android_main` entry point using `cargo-mobile2`'s `AndroidApp` handle, which exposes `internal_data_path()`.
|
||||
|
||||
The `cargo-mobile2` + Bevy path exposes an `AndroidApp` via `bevy::winit`'s Android entry point. Bevy 0.13+ passes `AndroidApp` through `WinitPlugin`, and it is accessible via a Bevy resource. A startup system can extract `app.internal_data_path()` and insert a `PlatformDataDirResource` that the storage functions read instead of calling `dirs::data_dir()`.
|
||||
|
||||
**Effort:** 1–2 days to implement the override and thread it through all `storage.rs` / `progress.rs` / `settings.rs` / `achievements.rs` call sites.
|
||||
|
||||
---
|
||||
|
||||
## 5. Touch Input — Current State and Gaps
|
||||
|
||||
**What already exists (strong foundation):**
|
||||
|
||||
The `InputPlugin` in `input_plugin.rs` has a complete parallel touch pipeline:
|
||||
|
||||
| System | Purpose | Status |
|
||||
|---|---|---|
|
||||
| `handle_touch_stock_tap` | Tap the stock pile to draw | Complete |
|
||||
| `touch_start_drag` | Begin a touch drag on a face-up card | Complete |
|
||||
| `touch_follow_drag` | Move card(s) with the active finger | Complete |
|
||||
| `touch_end_drag` | Resolve the drag (move or reject) | Complete |
|
||||
|
||||
The touch systems use `TouchInput` events and the `Touches` resource, map touch IDs to `DragState.active_touch_id` to prevent multi-finger conflicts, and share the same `DragState`, `MoveRequestEvent`, `MoveRejectedEvent`, and `StateChangedEvent` infrastructure as the mouse pipeline. The drag threshold (`tuning.drag_threshold_px`) applies identically.
|
||||
|
||||
**Gaps for a production Android experience:**
|
||||
|
||||
1. **No double-tap equivalent for auto-move**: `handle_double_click` is mouse-only. Android users need a double-tap to trigger the same "move to best destination" logic. The `handle_double_click` system checks `buttons.just_pressed(MouseButton::Left)` and will be inert on Android. Estimated: 1 day.
|
||||
|
||||
2. **No touch equivalent for keyboard actions**: Undo, New Game, Draw (when stock is visible but tapping it is awkward), Hint, and Forfeit have no on-screen buttons. These need an Android-specific UI bar or gesture (e.g. two-finger tap for undo). Estimated: 2–3 days for a minimal floating action button strip.
|
||||
|
||||
3. **Drag threshold tuning**: The threshold is in `AnimationTuning` (`tuning.drag_threshold_px`). Touch screens typically need a larger threshold than mouse (physical screens have more accidental movement during a tap). The current value should be evaluated on a real device and likely increased for touch.
|
||||
|
||||
4. **No long-press for right-click equivalent**: The right-click highlight/hint glow (`HintHighlightTimer`) is triggered via right mouse button. Long-press detection is not yet implemented. This is a missing feature but not a blocker for basic play.
|
||||
|
||||
5. **`handle_double_click` uses `LocalDateTime`-based timing via `Time`**: This will work on Android, but `DOUBLE_CLICK_WINDOW = 0.35s` may feel too tight on touch. Should be configurable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Additional Issues Not in Scope of the Four Research Areas
|
||||
|
||||
**`CursorPlugin`:** Uses Bevy's cursor APIs which are desktop-only. Should be conditionally compiled out on Android with `#[cfg(not(target_os = "android"))]`.
|
||||
|
||||
**`reqwest` with `rustls-native-certs`:** The `reqwest` dependency uses `rustls` with native root certificates. On Android, `rustls-native-certs` reads system certificates differently (via the `android_system_properties` crate internally). This generally works but should be tested; Android's certificate store is in a non-standard location vs Linux.
|
||||
|
||||
**App lifecycle (suspend/resume):** Android can suspend the process at any time. Bevy handles `WindowEvent::Suspended` and `WindowEvent::Resumed` via `winit`, pausing the render loop. The `SyncPlugin`'s "push on exit" path (`AppExit` event) should also trigger on `WindowEvent::Suspended` to avoid data loss when the user backgrounds the app. This is a separate feature (1 day).
|
||||
|
||||
**No `sqlx` on Android:** `solitaire_server` is a server binary and is never built for Android. The `sqlx` dependency only exists in `solitaire_server/Cargo.toml` and will not affect Android builds of the client crates.
|
||||
|
||||
**`solitaire_assetgen`:** The asset generation tool is desktop-only and not part of the client build. Unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Required Changes Per Crate
|
||||
|
||||
### `solitaire_core` and `solitaire_sync`
|
||||
No changes required. Both are pure Rust with no platform dependencies.
|
||||
|
||||
### `solitaire_data`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Gate `keyring` dependency on `#[cfg(not(target_os = "android"))]` | 0.5 days |
|
||||
| Implement `auth_tokens.rs` in-memory fallback for Android | 1 day |
|
||||
| Add `internal_data_path()` override for `dirs::data_dir()` on Android | 1.5 days |
|
||||
| Audit all `dirs::data_dir()` / `settings_file_path()` call sites to accept injected path | 0.5 days |
|
||||
|
||||
### `solitaire_engine`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Conditionally disable `CursorPlugin` on Android | 0.5 days |
|
||||
| Disable `handle_fullscreen` on Android (or make it a no-op) | 0.25 days |
|
||||
| Implement double-tap for auto-move (touch equivalent of `handle_double_click`) | 1 day |
|
||||
| On-screen action bar for Undo, New Game, Hint (minimal floating buttons) | 2.5 days |
|
||||
| Tune drag threshold for touch; expose as a platform-specific tuning constant | 0.5 days |
|
||||
| Trigger sync push on `WindowEvent::Suspended` in `SyncPlugin` | 1 day |
|
||||
| Verify `kira` audio on Android (test `DefaultBackend` / CPAL; implement fallback if needed) | 1–2 days |
|
||||
|
||||
### `solitaire_app`
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Add `[lib]` target with `crate-type = ["cdylib"]` for Android builds | 0.25 days |
|
||||
| Create `src/lib.rs` (or `src/android.rs`) Android entry point calling `android_main` | 0.5 days |
|
||||
| Remove or guard fixed `resolution` / `resize_constraints` for Android | 0.25 days |
|
||||
| Pass `AndroidApp::internal_data_path()` to a startup resource | 0.5 days |
|
||||
|
||||
### Build / Toolchain
|
||||
| Change | Effort |
|
||||
|---|---|
|
||||
| Install cargo-mobile2, Android NDK r25c/r26, `aarch64-linux-android` target | 1 day |
|
||||
| Run `cargo mobile init`, configure `android/` Gradle project | 0.5 days |
|
||||
| Get a first build compiling (resolve linker / NDK issues) | 1–2 days |
|
||||
|
||||
---
|
||||
|
||||
## 8. Estimated Effort
|
||||
|
||||
| Phase | Description | Days |
|
||||
|---|---|---|
|
||||
| Toolchain setup | NDK, cargo-mobile2, first compile | 2–3 |
|
||||
| `solitaire_data` Android adaptations | keyring fallback, data dir | 3 |
|
||||
| `solitaire_app` Android entry point | cdylib, AndroidApp wiring | 1 |
|
||||
| `solitaire_engine` guards and fixes | cursor, fullscreen, audio verify | 2–3 |
|
||||
| Touch UX improvements | double-tap, action bar, threshold tuning | 4–5 |
|
||||
| Testing on real device / emulator | iteration, lifecycle edge cases | 2–3 |
|
||||
| **Total** | | **14–17 days** |
|
||||
|
||||
This produces a playable, functionally complete Android build. It does not include Play Store preparation (signing keys, metadata, icon set, permissions manifest tuning) which would add 1–2 more days.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommended First Step
|
||||
|
||||
**Get the workspace to compile for `aarch64-linux-android` without running.**
|
||||
|
||||
This surfaces all the real linker and dependency errors before writing any gameplay code:
|
||||
|
||||
```bash
|
||||
# Install toolchain
|
||||
rustup target add aarch64-linux-android
|
||||
cargo install --locked cargo-mobile2
|
||||
|
||||
# In the workspace root:
|
||||
cargo mobile init # generates android/ directory
|
||||
|
||||
# Attempt a library build targeting Android
|
||||
cargo build -p solitaire_app --target aarch64-linux-android 2>&1 | head -60
|
||||
```
|
||||
|
||||
The first build will fail on `keyring` (no Android backend) and likely on `dirs`. Fixing those two in `solitaire_data` — gate `keyring` behind `cfg(not(target_os = "android"))` and stub the data directory — will probably get the workspace to a clean compile. From there, the path to a running APK is incremental.
|
||||
|
||||
Do not attempt to resolve audio or touch UX until the build compiles cleanly. Compile errors are the only true blockers; the rest are feature gaps.
|
||||
@@ -0,0 +1,318 @@
|
||||
# Sync Subsystem Manual Test Runbook
|
||||
|
||||
**Version:** 1.0
|
||||
**Last Updated:** 2026-04-28
|
||||
**Scope:** Cross-machine sync, JWT refresh, conflict resolution, account deletion
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
|
||||
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
|
||||
- Verify the server is live before starting:
|
||||
|
||||
```bash
|
||||
curl -s https://solitaire.example.com/health
|
||||
# Expected: {"status":"ok","version":"..."}
|
||||
```
|
||||
|
||||
### Accounts
|
||||
|
||||
- You will register two separate accounts (`alice` and `bob`) during the tests. You do not need to create them in advance.
|
||||
|
||||
### Tooling
|
||||
|
||||
- `curl` or a REST client (Insomnia/Postman) for manual API calls.
|
||||
- `sqlite3` CLI if you need to inspect the server database directly.
|
||||
- The game binary built in release mode on both machines:
|
||||
|
||||
```bash
|
||||
cargo build -p solitaire_app --release
|
||||
```
|
||||
|
||||
### Baseline: Clear local data on both machines
|
||||
|
||||
Before starting, delete any existing local save files to ensure a clean state:
|
||||
|
||||
```
|
||||
# Linux
|
||||
rm -rf ~/.local/share/solitaire_quest/
|
||||
|
||||
# macOS
|
||||
rm -rf ~/Library/Application\ Support/solitaire_quest/
|
||||
|
||||
# Windows
|
||||
rmdir /s %APPDATA%\solitaire_quest\
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 1 — Full Sync Round-Trip (register, play, push, verify on second machine)
|
||||
|
||||
**Goal:** Confirm that stats played on Machine A appear on Machine B after sync.
|
||||
|
||||
### Step 1 — Register on Machine A
|
||||
|
||||
1. Launch the game on Machine A.
|
||||
2. Open **Settings** (key: `O`) and locate the **Sync** section.
|
||||
3. Enter the server URL and choose a username: `alice`.
|
||||
4. Choose a password (at least 12 characters).
|
||||
5. Tap **Register** (or **Login** if the account already exists).
|
||||
6. The Settings screen should show **Status: syncing…** briefly, then **Status: last synced at HH:MM**.
|
||||
7. Close the game.
|
||||
|
||||
Verify the registration succeeded directly:
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||
# Expected: {"access_token":"...","refresh_token":"..."}
|
||||
```
|
||||
|
||||
### Step 2 — Play games on Machine A
|
||||
|
||||
1. Launch the game on Machine A.
|
||||
2. Win at least **three games** (Draw One or Draw Three — note which mode).
|
||||
3. Check the Stats overlay (key: `S`) and note:
|
||||
- `games_played`
|
||||
- `games_won`
|
||||
- `win_streak_current`
|
||||
- `fastest_win_seconds`
|
||||
4. Close the game normally (this triggers the push-on-exit path).
|
||||
|
||||
### Step 3 — Verify the push reached the server
|
||||
|
||||
```bash
|
||||
# Log in to get a fresh token
|
||||
TOKEN=$(curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq -r .access_token)
|
||||
|
||||
# Pull the server's stored state
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq .merged.stats
|
||||
```
|
||||
|
||||
Confirm `games_won` matches what you recorded in Step 2.
|
||||
|
||||
### Step 4 — Pull on Machine B
|
||||
|
||||
1. Launch the game on **Machine B** (clean local data).
|
||||
2. Open **Settings**, enter the same server URL, and log in as `alice` with the same password.
|
||||
3. The plugin will pull on startup. Wait for **Status: last synced at HH:MM**.
|
||||
4. Open the Stats overlay (key: `S`) and confirm the numbers from Step 2 are present.
|
||||
|
||||
**Pass criterion:** `games_won`, `games_played`, and `fastest_win_seconds` on Machine B match Machine A.
|
||||
|
||||
---
|
||||
|
||||
## Test 2 — JWT Refresh on 401
|
||||
|
||||
**Goal:** Confirm that an expired access token is refreshed transparently without user interaction.
|
||||
|
||||
### Step 1 — Shorten the access token TTL on the server (test environment only)
|
||||
|
||||
Edit the server `.env` and set a short expiry, then restart:
|
||||
|
||||
```
|
||||
JWT_ACCESS_EXPIRY_SECS=5
|
||||
```
|
||||
|
||||
> If you cannot modify the server config, skip to the manual token corruption method in Step 1b.
|
||||
|
||||
### Step 1b (alternative) — Corrupt the stored access token directly
|
||||
|
||||
On the machine where you want to test (Linux example):
|
||||
|
||||
```bash
|
||||
# List keychain entries (uses secret-tool on GNOME)
|
||||
secret-tool search service solitaire_quest_server
|
||||
|
||||
# Overwrite alice's access token with a deliberately invalid value
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
|
||||
```
|
||||
|
||||
### Step 2 — Trigger a sync with the expired/invalid token
|
||||
|
||||
1. Launch the game.
|
||||
2. Either wait for the startup pull (for the short-TTL method), or open **Settings** and tap **Sync Now**.
|
||||
3. Observe the **Status** field.
|
||||
|
||||
**Pass criterion (transparent refresh):** Status briefly shows "syncing…" and then shows "last synced at HH:MM" — no auth error is displayed. The access token in the keychain has been silently replaced.
|
||||
|
||||
**Verify the new token is valid:**
|
||||
|
||||
```bash
|
||||
# Extract the new token from the keychain
|
||||
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
|
||||
# Should look like a valid JWT (three base64 segments separated by dots)
|
||||
```
|
||||
|
||||
### Step 3 — Test failed refresh (both tokens expired)
|
||||
|
||||
1. Corrupt both the access token and the refresh token in the keychain:
|
||||
|
||||
```bash
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
|
||||
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
|
||||
```
|
||||
|
||||
2. Launch the game and trigger a sync.
|
||||
|
||||
**Pass criterion:** The Settings screen shows an error message matching: "Login expired — tap Sync Now after re-logging in". The game must not crash. No data must be lost (local files are untouched).
|
||||
|
||||
3. Restore: log in again via Settings to get fresh tokens.
|
||||
|
||||
---
|
||||
|
||||
## Test 3 — Conflict Scenario (offline play on both machines, then sync)
|
||||
|
||||
**Goal:** Confirm that progress made on both devices offline is merged correctly, with no data silently discarded.
|
||||
|
||||
### Step 1 — Take both machines offline
|
||||
|
||||
Disable network on both Machine A and Machine B (e.g. airplane mode, or block the server URL in `/etc/hosts`).
|
||||
|
||||
### Step 2 — Play on Machine A (offline)
|
||||
|
||||
1. Win 5 games. Note the resulting streak and `games_won`.
|
||||
2. Close the game.
|
||||
|
||||
### Step 3 — Play on Machine B (offline)
|
||||
|
||||
1. Win 3 different games. Note the resulting streak and `games_won`.
|
||||
2. Close the game.
|
||||
|
||||
At this point Machine A and Machine B have divergent state.
|
||||
|
||||
### Step 4 — Re-enable network, sync Machine A first
|
||||
|
||||
1. Restore network.
|
||||
2. Launch the game on Machine A. The push-on-exit from Step 2 did not reach the server, so:
|
||||
- Open Settings, tap **Sync Now** to force a pull.
|
||||
- Close the game (triggers push-on-exit).
|
||||
3. Verify the server has Machine A's state:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_won'
|
||||
```
|
||||
|
||||
### Step 5 — Sync Machine B
|
||||
|
||||
1. Launch the game on Machine B.
|
||||
2. The startup pull fetches the server's merged state (which now contains Machine A's wins).
|
||||
3. Open Settings — wait for **Status: last synced at HH:MM**.
|
||||
4. Open the Stats overlay.
|
||||
|
||||
**Pass criteria:**
|
||||
- `games_won` = max(Machine A wins, Machine B wins) — at minimum the higher of the two counts.
|
||||
- No games are lost — both machines' win counts contribute.
|
||||
- If the two machines had different `win_streak_current` values, a conflict should be recorded (visible if you inspect the server response directly):
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.conflicts'
|
||||
```
|
||||
|
||||
- The `win_streak_current` conflict entry will show `local_value` and `remote_value`. The higher value is used as the best-effort resolution.
|
||||
|
||||
---
|
||||
|
||||
## Test 4 — Account Deletion
|
||||
|
||||
**Goal:** Confirm that `DELETE /api/account` removes all server-side data and that a subsequent authenticated request is rejected.
|
||||
|
||||
### Step 1 — Confirm data exists before deletion
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull | jq '.merged.stats.games_played'
|
||||
# Expected: a non-zero number
|
||||
```
|
||||
|
||||
### Step 2 — Delete the account via the API
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/account | jq .
|
||||
# Expected: {"ok":true}
|
||||
```
|
||||
|
||||
### Step 3 — Verify all data is gone from the server
|
||||
|
||||
```bash
|
||||
# Try to pull with the (now-invalid) token
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
https://solitaire.example.com/api/sync/pull
|
||||
# Expected: HTTP 401 Unauthorized
|
||||
|
||||
# Try to log in again with the same credentials
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<your-password>"}' | jq .
|
||||
# Expected: HTTP 401 or error body indicating invalid credentials
|
||||
```
|
||||
|
||||
### Step 4 — Verify local data is NOT deleted
|
||||
|
||||
1. Open the game. The local files (`stats.json`, `progress.json`, etc.) must still be present and intact — account deletion only affects the server.
|
||||
2. Check the Stats overlay and confirm local game history is visible.
|
||||
3. The Settings screen may show an auth error on next sync attempt, which is expected.
|
||||
|
||||
### Step 5 — Re-register with the same username (optional)
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://solitaire.example.com/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"<new-password>"}' | jq .
|
||||
# Expected: {"access_token":"...","refresh_token":"..."} — fresh empty account
|
||||
```
|
||||
|
||||
**Pass criterion:** Re-registration succeeds, and a subsequent pull returns a payload with all-zero stats (completely fresh account, no residual data from the deleted account).
|
||||
|
||||
---
|
||||
|
||||
## Test 5 — Server Errors Do Not Show "Login Expired"
|
||||
|
||||
**Goal:** Verify that a 500 Internal Server Error or 429 Too Many Requests shows a network error, not an auth error, to the user.
|
||||
|
||||
### Step 1 — Simulate a 500 with a reverse proxy rule
|
||||
|
||||
Add a temporary nginx/Caddy rule to return 500 for `/api/sync/*`:
|
||||
|
||||
```nginx
|
||||
location /api/sync/ {
|
||||
return 500;
|
||||
}
|
||||
```
|
||||
|
||||
Or use a local proxy like `mitmproxy` to intercept and rewrite responses.
|
||||
|
||||
### Step 2 — Trigger a sync
|
||||
|
||||
Open Settings and tap **Sync Now**.
|
||||
|
||||
**Pass criterion:** The Status field shows "Can't reach server — check your connection" (network error message), NOT "Login expired — tap Sync Now after re-logging in" (auth error message).
|
||||
|
||||
Remove the nginx rule after this test.
|
||||
|
||||
---
|
||||
|
||||
## Regression Checklist
|
||||
|
||||
After running all tests above, confirm:
|
||||
|
||||
- [ ] No crash occurred during any test on either machine.
|
||||
- [ ] Local save files (`stats.json`, `progress.json`, `achievements.json`) are present and valid JSON after all tests.
|
||||
- [ ] The game launches and plays normally after all sync operations (sync is additive — never blocks gameplay).
|
||||
- [ ] The Stats overlay shows correct numbers on both machines after a successful sync round-trip.
|
||||
- [ ] An expired token is refreshed transparently without the user having to log in again.
|
||||
- [ ] A doubly-expired token surfaces a clear error message to the user.
|
||||
- [ ] Account deletion removes all server data; local data is preserved.
|
||||
- [ ] HTTP 5xx and 429 responses show a network error, not an auth error.
|
||||
@@ -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&family=Inter:wght@400;500;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
|
After Width: | Height: | Size: 41 KiB |
@@ -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&family=Inter:wght@400;500;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
|
After Width: | Height: | Size: 67 KiB |
@@ -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&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -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 00–07 form a monochrome ramp and 08–0F 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).
|
||||
@@ -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&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -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&family=Inter:wght@400&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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>
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -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&family=Inter:wght@400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||