Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 859b69b3c5 | |||
| 24ab25b0b7 | |||
| 918d83420b | |||
| a381a42f21 | |||
| 04f3dab563 | |||
| d204662415 | |||
| 4f0080dfbc | |||
| 46c3bf4bb2 | |||
| 6beb9f68ac | |||
| a0081a251c | |||
| 7411468e10 | |||
| 9af4046ac3 | |||
| d06af28aef | |||
| 27b58a5b71 | |||
| 3b6c8d2aab | |||
| 51fc8f65b1 | |||
| 65cb41461f | |||
| 24f5d140df | |||
| 03be4fcc67 | |||
| 9564f54fc0 | |||
| b4ada2a07e | |||
| d44cedbea0 | |||
| 75146847f6 | |||
| 566b112d9e | |||
| 198df75f94 | |||
| 40d07122ba | |||
| 08f74d1e25 | |||
| 6e6f3ef1ff | |||
| 549a817bb1 | |||
| 613bbf8799 | |||
| b129664344 | |||
| 7d7c83ab28 | |||
| bd388fef26 | |||
| 272d31f851 | |||
| 6ce55646d8 | |||
| 432061c3ec | |||
| 22303c62ff | |||
| b1731fe68a | |||
| 2b01f741b4 | |||
| 3110702c74 | |||
| 33fb9627a8 | |||
| 4398403418 | |||
| 002d96f2c8 | |||
| cc161cc37f | |||
| 8a3e30bd16 | |||
| 2a206b994c | |||
| ae7c6c97f1 | |||
| 016fb7214d | |||
| 948864e653 | |||
| 76a754d8e5 | |||
| 9fb59c7d47 | |||
| d714a11cfb | |||
| e107f5e218 | |||
| 463b7465ed | |||
| 92a5ebb15e | |||
| 89a21c0587 | |||
| 304cb050a7 | |||
| fcc7337c97 | |||
| 16ce2b88d2 | |||
| b9aa2620b8 | |||
| 47f02a60ae | |||
| a5c3188686 | |||
| 6a289b7b50 | |||
| bee712c5ab | |||
| 0db5e9dac4 | |||
| 681a54d9bb | |||
| 7894559ca7 | |||
| ab803c07af | |||
| e43b329fc1 | |||
| 7c07f71f02 | |||
| c1329bbb21 | |||
| 4303ef3f5b | |||
| 4df962ee07 | |||
| f281425b45 | |||
| 2c822ba2d7 | |||
| 7ddf2733c9 | |||
| 585570559c | |||
| 45436d0eda | |||
| 2062bd06f3 | |||
| 0cb15872b1 | |||
| 395a322adc | |||
| 5199a5e499 | |||
| 16242e6d77 | |||
| 202a64db45 | |||
| c0415eb0ee | |||
| a449f60bc5 | |||
| ad5f613277 | |||
| c50eaf81f7 | |||
| b44d2777ec | |||
| 52407e7256 | |||
| da3e5423dc | |||
| a1864271de | |||
| f63db769ae | |||
| 4437a1aaf9 | |||
| e7345aed6c | |||
| 140251beae | |||
| d6f32d3154 | |||
| 8fdc41f36f | |||
| 2e25476d0a | |||
| d3cb1a51d4 | |||
| c8358f4275 | |||
| a2432dfe7a | |||
| 511550232c | |||
| e5c4f51a6e | |||
| 23902cdc44 | |||
| 3cc8eacafa | |||
| 90e24d9711 | |||
| decbe0bbd9 | |||
| 1873b3f9be | |||
| d11d97e677 | |||
| d322abf67b | |||
| c9e4c0b4cd | |||
| fe68861e10 | |||
| c33b39cf11 | |||
| 23ff62c397 | |||
| 0b2ffca016 | |||
| fbe48acef6 | |||
| cd79877933 | |||
| 52befa6199 | |||
| e63046700c | |||
| ab857bbb6e | |||
| 886e0cf8a1 | |||
| 3d92a91e3b | |||
| 9113cdb483 | |||
| c153363626 | |||
| 93b67f1d0b | |||
| 279e23d0af | |||
| 12fba2157a | |||
| f23df3b805 | |||
| 68d50b5021 | |||
| ec804d54c6 | |||
| d87761d451 | |||
| 2fb2d638bf | |||
| c9af1ead22 | |||
| ed152e2d8f | |||
| 279a834f9d | |||
| daa655a0af | |||
| 4d48cad4e3 | |||
| dd970215cc | |||
| ddb65403c2 | |||
| 62b61cc786 | |||
| 31139ae455 | |||
| 07e035771c | |||
| c5787c6953 | |||
| 716a025352 | |||
| 3eb3a26789 | |||
| 0c1cc40266 | |||
| 04f9bf9be3 | |||
| a292a7ead0 | |||
| d109c32b75 | |||
| dd101b3d54 | |||
| af414b6aed | |||
| ae84dc1504 | |||
| 8719f77ec2 | |||
| a14200ac2f | |||
| e8bf9d79da | |||
| 48b28d29f8 | |||
| babe5cc9c8 | |||
| 3a4bb63a6f | |||
| 56233687b0 | |||
| 73ac67d76b | |||
| a27cf5a020 | |||
| 29136d815d | |||
| ef54cdeb65 | |||
| e080b49914 | |||
| 54005d5494 | |||
| 44f5972edd | |||
| 13ae16051d | |||
| a65e5b8c7b | |||
| 6204db8bb1 | |||
| c84d9f445c | |||
| cacb19c03f | |||
| 39b84965b6 | |||
| 41a009a693 | |||
| fa7f98ac52 | |||
| 9891ae4ba3 | |||
| cdcaddaabe | |||
| d752870007 | |||
| 1d1543e4bc | |||
| 651f4060e6 | |||
| a1376075bd | |||
| ceec4fc486 | |||
| 0d477ac9fd | |||
| 4b51e50203 | |||
| f2d2119db5 | |||
| 59424a370c | |||
| fb8b2ac684 | |||
| 690e1d2ad6 | |||
| 35516d31f6 | |||
| 9b065e5ac6 | |||
| e1b8766e15 | |||
| 67c150bd7b |
@@ -1,88 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -7,3 +7,11 @@
|
|||||||
*.tmp
|
*.tmp
|
||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# IDE project files
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Android signing keystores — never commit
|
||||||
|
*.jks
|
||||||
|
*.jks.bak
|
||||||
|
*.keystore
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE jti = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "168e205e3eb832b78d085b48281a1bae74d5a0e64c4c793c18a6400605bacf76"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "40db0910531d4418d4d58d31f0f8ea3894248406cc016020a6e211ed66da91c0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT jti FROM refresh_tokens WHERE jti = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "jti",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "893c45c27854ba15ea611e8254ef980ced21dc64e6ca95fde56af513f86ffa46"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO refresh_tokens (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c9ee5c64ca547f0c730379919a642bd649cbf81fc1804159101a70efabf08b33"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE users SET password_hash = ? WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e4eb622073cbdf868ec1568a6bdb132e962480b0530d542102c05aa9e901463b"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ef7af925a8715c329dcafca5257c691e6bca31755eb5f54be47114f21fc04c8c"
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Solitaire Quest — Architecture Document
|
# Solitaire Quest — Architecture Document
|
||||||
|
|
||||||
> **Version:** 1.1
|
> **Version:** 1.3
|
||||||
> **Language:** Rust (Edition 2024)
|
> **Language:** Rust (Edition 2024)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **Engine:** Bevy (latest stable)
|
||||||
> **Last Updated:** 2026-04-29
|
> **Last Updated:** 2026-05-12
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, tar
|
|||||||
| macOS | Self-hosted server | Full feature set |
|
| macOS | Self-hosted server | Full feature set |
|
||||||
| Windows | Self-hosted server | Full feature set |
|
| Windows | Self-hosted server | Full feature set |
|
||||||
| Linux | Self-hosted server | Full feature set |
|
| Linux | Self-hosted server | Full feature set |
|
||||||
|
| Android | Self-hosted server | Touch input; safe-area insets via JNI; `cargo-apk` build |
|
||||||
|
|
||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ solitaire_quest/
|
|||||||
├── solitaire_data/ # Persistence, sync client, settings
|
├── solitaire_data/ # Persistence, sync client, settings
|
||||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
||||||
|
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
||||||
└── solitaire_app/ # Main binary entry point
|
└── solitaire_app/ # Main binary entry point
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,6 +162,20 @@ Owns:
|
|||||||
- Daily challenge seed generation
|
- Daily challenge seed generation
|
||||||
- Leaderboard management
|
- Leaderboard management
|
||||||
|
|
||||||
|
### `solitaire_wasm`
|
||||||
|
**Dependencies:** `solitaire_core`, `serde`, `serde_json`, `chrono`, `wasm-bindgen`, `serde-wasm-bindgen`.
|
||||||
|
|
||||||
|
WebAssembly bindings for browser-side replay playback. Compiled to `cdylib` via `wasm-pack build`; the output lives in `solitaire_server/web/pkg/` and is served statically by the server.
|
||||||
|
|
||||||
|
Intentionally **does not** depend on `solitaire_data` (which pulls in `dirs`, `keyring`, `reqwest`, and other non-WASM crates). Instead it defines a minimal `Replay` mirror with the same serde shape as `solitaire_data::Replay` — the JSON wire format is the compatibility contract.
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
- `ReplayPlayer` — WASM-exported state machine; steps through a replay's `Vec<ReplayMove>` against a live `GameState`
|
||||||
|
- `StateSnapshot` — JS-facing pile snapshot returned by each `step()` call
|
||||||
|
- `ReplayMove` / `Replay` mirrors — same serde shape as `solitaire_data` v2 equivalents
|
||||||
|
|
||||||
|
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||||
|
|
||||||
### `solitaire_app`
|
### `solitaire_app`
|
||||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
**Dependencies:** `bevy`, `solitaire_engine`.
|
||||||
|
|
||||||
@@ -261,6 +277,8 @@ The "Shortcut" column lists optional keyboard accelerators. Every action in this
|
|||||||
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
| `HomePlugin` | M | Main-menu overlay with keyboard shortcut reference |
|
||||||
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
| `ProfilePlugin` | P | Player profile overlay: level, XP, achievements, sync status |
|
||||||
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
| `SettingsPlugin` | O | Settings panel: audio, draw mode, theme, sync, cosmetics |
|
||||||
|
| `ThemePlugin` | — | Owns `ActiveTheme` resource; registers the `CardTheme` SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting `Image`; handles the embedded default theme and user themes from `user_theme_dir()` |
|
||||||
|
| `SyncSetupPlugin` | — | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when `SyncError::Auth` is returned by a pull |
|
||||||
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
| `LeaderboardPlugin` | L | Leaderboard overlay |
|
||||||
| `HelpPlugin` | H | Help / controls overlay |
|
| `HelpPlugin` | H | Help / controls overlay |
|
||||||
| `PausePlugin` | Esc | Pause and resume |
|
| `PausePlugin` | Esc | Pause and resume |
|
||||||
@@ -305,6 +323,12 @@ struct FontResource(Handle<Font>);
|
|||||||
struct BackgroundImageSet {
|
struct BackgroundImageSet {
|
||||||
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OS-reserved edge insets (physical px); zero on desktop
|
||||||
|
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
|
||||||
|
|
||||||
|
// Whether the HUD band is visible (auto-hide chrome feature)
|
||||||
|
enum HudVisibility { Visible, Hidden }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Bevy Events
|
### Key Bevy Events
|
||||||
@@ -365,10 +389,22 @@ All sync backends implement a single trait in `solitaire_data`. The `SyncPlugin`
|
|||||||
```rust
|
```rust
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SyncProvider: Send + Sync {
|
pub trait SyncProvider: Send + Sync {
|
||||||
|
// Required — must be implemented by every backend:
|
||||||
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
async fn pull(&self) -> Result<SyncPayload, SyncError>;
|
||||||
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
|
||||||
fn backend_name(&self) -> &'static str;
|
fn backend_name(&self) -> &'static str;
|
||||||
fn is_authenticated(&self) -> bool;
|
fn is_authenticated(&self) -> bool;
|
||||||
|
|
||||||
|
// Optional — all have default no-op / empty implementations:
|
||||||
|
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
|
||||||
|
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
|
||||||
|
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
|
||||||
|
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
|
||||||
|
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
|
||||||
|
async fn delete_account(&self) -> Result<(), SyncError>;
|
||||||
|
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
|
||||||
|
// so LocalOnlyProvider silently no-ops the push-on-win path.
|
||||||
|
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -454,6 +490,24 @@ CREATE TABLE leaderboard (
|
|||||||
recorded_at TEXT NOT NULL,
|
recorded_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id)
|
PRIMARY KEY (user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- migrations/002_replays.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS replays (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
seed INTEGER NOT NULL,
|
||||||
|
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
|
||||||
|
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
|
||||||
|
time_seconds INTEGER NOT NULL,
|
||||||
|
final_score INTEGER NOT NULL,
|
||||||
|
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
|
||||||
|
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
|
||||||
|
replay_json TEXT NOT NULL -- full Replay serialisation
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Request Lifecycle
|
### Request Lifecycle
|
||||||
@@ -579,12 +633,25 @@ pub struct AchievementRecord {
|
|||||||
|
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawMode,
|
||||||
pub sfx_volume: f32, // 0.0–1.0
|
pub sfx_volume: f32, // 0.0–1.0
|
||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub animation_speed: AnimSpeed,
|
pub animation_speed: AnimSpeed,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
pub sync_backend: SyncBackend, // Local | SolitaireServer
|
||||||
|
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
|
||||||
|
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
|
||||||
pub first_run_complete: bool,
|
pub first_run_complete: bool,
|
||||||
|
pub color_blind_mode: bool, // blue tint on red suits
|
||||||
|
pub high_contrast_mode: bool, // boosted luminance for low-vision users
|
||||||
|
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
|
||||||
|
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WindowGeometry {
|
||||||
|
pub width: u32, // logical pixels
|
||||||
|
pub height: u32,
|
||||||
|
pub x: i32, // physical pixels, top-left corner
|
||||||
|
pub y: i32,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -600,7 +667,7 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
| POST | `/api/auth/register` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||||
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
| POST | `/api/auth/login` | None | `{username, password}` | `{access_token, refresh_token}` |
|
||||||
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token}` |
|
| POST | `/api/auth/refresh` | None | `{refresh_token}` | `{access_token, refresh_token}` (rotated) |
|
||||||
|
|
||||||
### Sync
|
### Sync
|
||||||
|
|
||||||
@@ -617,6 +684,21 @@ All endpoints are under the base URL configured by the user (e.g., `https://soli
|
|||||||
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
| GET | `/api/leaderboard` | Bearer JWT | — | `Vec<LeaderboardEntry>` |
|
||||||
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
| POST | `/api/leaderboard/opt-in` | Bearer JWT | — | `{ok: true}` |
|
||||||
|
|
||||||
|
### Replays
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/api/replays` | Bearer JWT | Replay JSON | `{id, share_url}` |
|
||||||
|
| GET | `/api/replays/recent` | None | — (`?limit=N`) | `Vec<ReplaySummary>` |
|
||||||
|
| GET | `/api/replays/:id` | None | — | Full Replay JSON |
|
||||||
|
|
||||||
|
### Web Replay Player
|
||||||
|
|
||||||
|
| Method | Path | Auth | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/replays/:id` | None | Serves `web/index.html`; JS fetches `/api/replays/:id` and steps through via the `solitaire_wasm` WASM module |
|
||||||
|
| GET | `/web/*` | None | Static assets served via `ServeDir` from `solitaire_server/web/` (includes `web/pkg/` with wasm-bindgen output) |
|
||||||
|
|
||||||
### Account Management
|
### Account Management
|
||||||
|
|
||||||
| Method | Path | Auth | Body | Response |
|
| Method | Path | Auth | Body | Response |
|
||||||
@@ -825,7 +907,7 @@ All sound effect WAV files are embedded at compile time via `include_bytes!()` i
|
|||||||
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via `cargo-lipo`) |
|
||||||
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
|
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
|
||||||
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
|
||||||
| Android | Stretch | Self-hosted server | `cargo-mobile2`, touch input |
|
| Android | Active | Self-hosted server | `cargo-apk`; touch + long-press + double-tap; safe-area JNI; portrait layout |
|
||||||
| iOS | 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`.
|
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on `WindowResized`.
|
||||||
@@ -945,6 +1027,7 @@ Migrations run automatically on startup via `sqlx::migrate!()`.
|
|||||||
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
|
||||||
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
| Token security | JWTs signed with HS256, stored in OS keychain via `keyring` crate |
|
||||||
| Token expiry | Access: 24h, Refresh: 30d |
|
| Token expiry | Access: 24h, Refresh: 30d |
|
||||||
|
| Refresh token rotation | Each `/api/auth/refresh` call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
|
||||||
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
| Brute force | `tower-governor`: 10 req/min per IP on `/api/auth/*` |
|
||||||
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
| Payload abuse | 1MB max request body, enforced by Axum middleware |
|
||||||
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
| Data deletion | `DELETE /api/account` removes all rows via `ON DELETE CASCADE` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
version: unified-3.0
|
version: unified-4.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,8 +29,9 @@ solitaire_sync/ # Shared API + merge logic
|
|||||||
solitaire_data/ # Persistence + sync client
|
solitaire_data/ # Persistence + sync client
|
||||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||||
solitaire_server/ # Axum backend (optional sync layer)
|
solitaire_server/ # Axum backend (optional sync layer)
|
||||||
|
solitaire_wasm/ # WASM bindings for browser-side replay player
|
||||||
solitaire_app/ # Entry binary
|
solitaire_app/ # Entry binary
|
||||||
assets/ # Runtime assets (except audio)
|
assets/ # Runtime assets (except audio + default theme)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -72,12 +73,16 @@ These override all other instructions.
|
|||||||
|
|
||||||
* NO `unwrap()`
|
* NO `unwrap()`
|
||||||
* NO `panic!()` in runtime/game logic
|
* NO `panic!()` in runtime/game logic
|
||||||
* All state transitions:
|
* Core game state mutations MUST return:
|
||||||
|
|
||||||
```rust id="err_model"
|
```rust id="err_model"
|
||||||
Result<T, MoveError>
|
Result<T, MoveError>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
* Engine / UI state changes follow ECS patterns (Resources, Events) —
|
||||||
|
they do not return `MoveError`
|
||||||
|
* Use `thiserror`-derived types for any new error enums outside `solitaire_core`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2.4 Threading Rules
|
## 2.4 Threading Rules
|
||||||
@@ -126,10 +131,15 @@ trait SyncProvider
|
|||||||
## 3.1 ECS Design
|
## 3.1 ECS Design
|
||||||
|
|
||||||
* systems = single responsibility
|
* systems = single responsibility
|
||||||
* communication = Events only
|
* cross-system communication = Events (fire-and-forget triggers)
|
||||||
* shared state = Resources only
|
* persistent shared state = Resources (polled every frame or on change)
|
||||||
* per-entity state = Components only
|
* per-entity state = Components only
|
||||||
|
|
||||||
|
Events and Resources are both valid communication paths — use Events when
|
||||||
|
the receiver needs to react once; use Resources when the receiver polls
|
||||||
|
or when multiple systems read the same value (e.g. `SafeAreaInsets`,
|
||||||
|
`HudVisibility`, `LayoutResource`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3.2 Game State Authority
|
## 3.2 Game State Authority
|
||||||
@@ -149,11 +159,22 @@ Every player action MUST:
|
|||||||
Keyboard shortcuts are:
|
Keyboard shortcuts are:
|
||||||
→ optional accelerators only
|
→ optional accelerators only
|
||||||
|
|
||||||
|
**Exception — UI chrome gestures:**
|
||||||
|
Tap-to-toggle visibility of UI chrome (e.g. auto-hiding HUD band) is
|
||||||
|
permitted without a visible button. The gesture MUST:
|
||||||
|
* affect only chrome visibility, never game state
|
||||||
|
* restore chrome automatically when any modal opens
|
||||||
|
* be purely additive (game remains fully playable with chrome always visible)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3.4 Layout System
|
## 3.4 Layout System
|
||||||
|
|
||||||
* recompute on `WindowResized`
|
* recompute on `WindowResized`
|
||||||
|
* recompute on `SafeAreaInsets` changed
|
||||||
|
* recompute on `HudVisibility` changed
|
||||||
|
* `compute_layout` MUST accept `hud_visible: bool`; pass `HUD_BAND_HEIGHT`
|
||||||
|
when `true`, `0.0` when `false`
|
||||||
* no fixed resolution assumptions
|
* no fixed resolution assumptions
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -178,11 +199,18 @@ Includes:
|
|||||||
|
|
||||||
## 4.2 Embedded Assets
|
## 4.2 Embedded Assets
|
||||||
|
|
||||||
Only audio:
|
Embed via `include_bytes!()` only when ALL of the following are true:
|
||||||
|
|
||||||
```text id="audio_rule"
|
* the asset is small (< 500 KB uncompressed)
|
||||||
include_bytes!()
|
* it changes rarely (not user-customisable)
|
||||||
```
|
* a missing file would be a hard crash, not a graceful degradation
|
||||||
|
|
||||||
|
Currently embedded:
|
||||||
|
* **Audio** — all `.wav` files in `audio_plugin.rs`
|
||||||
|
* **Default card theme** — shipped via `embedded://` scheme in `ThemePlugin`
|
||||||
|
|
||||||
|
Do NOT embed card face PNGs, background images, or user fonts —
|
||||||
|
these are loaded via `AssetServer` so art can be swapped without recompile.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -210,7 +238,9 @@ Must degrade gracefully under `MinimalPlugins`.
|
|||||||
## 5.2 Public API Rules
|
## 5.2 Public API Rules
|
||||||
|
|
||||||
* prefer `Into<T>` over concrete types
|
* prefer `Into<T>` over concrete types
|
||||||
* all public items require doc comments
|
* publicly exported functions, traits, and non-trivial types require doc comments
|
||||||
|
* simple marker components, newtype wrappers, and internal `pub` items
|
||||||
|
used only within the same crate are exempt from doc comment requirements
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -276,11 +306,13 @@ NEVER commit otherwise
|
|||||||
|
|
||||||
Claude must request confirmation before:
|
Claude must request confirmation before:
|
||||||
|
|
||||||
* adding dependencies
|
* adding dependencies to `solitaire_core` or `solitaire_sync`
|
||||||
* modifying `solitaire_sync`
|
(engine/server crates may add deps without confirmation)
|
||||||
* changing DB schema
|
* modifying `solitaire_sync` types or the `SyncProvider` trait
|
||||||
|
* changing DB schema (migrations are append-only)
|
||||||
* introducing `unsafe`
|
* introducing `unsafe`
|
||||||
* changing merge strategy
|
* changing the merge strategy in `solitaire_sync::merge`
|
||||||
|
* changing the `SyncPayload` wire format (breaking change for existing servers)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -304,10 +336,29 @@ Core is always the source of truth.
|
|||||||
|
|
||||||
Must always be handled explicitly:
|
Must always be handled explicitly:
|
||||||
|
|
||||||
|
**All platforms**
|
||||||
* Bevy `Time` uses `f32`
|
* Bevy `Time` uses `f32`
|
||||||
* `sqlx::migrate!()` path is crate-relative
|
* `sqlx::migrate!()` path is crate-relative
|
||||||
* `dirs::data_dir()` may return `None`
|
* `dirs::data_dir()` may return `None`
|
||||||
* Linux may lack keyring backend
|
* Linux may lack keyring backend — handle `keyring::Error` gracefully
|
||||||
|
|
||||||
|
**Android (active target — not stretch)**
|
||||||
|
* Safe-area insets arrive in frames 1–3 via JNI polling, not at startup;
|
||||||
|
UI that depends on them must handle the zero-inset initial state
|
||||||
|
* Physical pixels ≠ logical pixels: `SafeAreaInsets` values are physical
|
||||||
|
(from `WindowInsets` API); divide by `window.scale_factor()` before
|
||||||
|
passing to Bevy `Val::Px`
|
||||||
|
* `adb shell input tap` uses physical pixel coordinates
|
||||||
|
* FiraMono (bundled font) covers: ASCII, card suits U+2660–2666,
|
||||||
|
Arrows U+2190–21FF. It does NOT cover Geometric Shapes (U+25xx) —
|
||||||
|
those render as missing-glyph rectangles on Android
|
||||||
|
* The gesture/navigation bar at the bottom (≈132px physical on common
|
||||||
|
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
|
||||||
|
avoid placing interactive elements in that zone
|
||||||
|
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
|
||||||
|
layout constants are `#[cfg(target_os = "android")]` gated
|
||||||
|
* JNI calls must use `attach_current_thread_permanently` — not
|
||||||
|
`attach_current_thread` — to avoid detach-on-drop panics
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -318,6 +369,12 @@ Must always be handled explicitly:
|
|||||||
* blocking async calls in ECS
|
* blocking async calls in ECS
|
||||||
* insecure credential storage
|
* insecure credential storage
|
||||||
* bypassing core logic layer
|
* bypassing core logic layer
|
||||||
|
* hardcoded pixel coordinates in layout — always derive from `compute_layout`
|
||||||
|
* Unicode Geometric Shapes block (U+25xx) in UI text — not in FiraMono
|
||||||
|
* spawning a second `ModalScrim` while one already exists without first
|
||||||
|
dismissing the existing one (use `scrims.is_empty()` guard)
|
||||||
|
* reading `SafeAreaInsets` physical values directly into `Val::Px` without
|
||||||
|
dividing by `window.scale_factor()`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,9 +402,74 @@ If unclear:
|
|||||||
| Both combined | full system understanding |
|
| Both combined | full system understanding |
|
||||||
|
|
||||||
---
|
---
|
||||||
# 14. Context Injection System (AUTOMATIC SCOPE FILTER)
|
# 14. Modal System Conventions
|
||||||
|
|
||||||
## 14.1 Purpose
|
All full-screen overlay panels MUST use the `spawn_modal` / `ModalScrim` pattern
|
||||||
|
from `solitaire_engine::ui_modal`.
|
||||||
|
|
||||||
|
## 14.1 Spawn pattern
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let scrim = spawn_modal(commands, MyScreenMarker, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, "Title", font_res);
|
||||||
|
// ... body nodes ...
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(actions, MyCloseButton, "Done", None,
|
||||||
|
ButtonVariant::Primary, font_res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Optional: allow clicking the scrim outside the card to dismiss
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14.2 Guard rule
|
||||||
|
|
||||||
|
Before spawning a new modal, check `scrims: Query<(), With<ModalScrim>>`
|
||||||
|
and return early if `!scrims.is_empty()` — unless the new modal is
|
||||||
|
explicitly replacing the current one (despawn first, then spawn).
|
||||||
|
|
||||||
|
## 14.3 Safe area
|
||||||
|
|
||||||
|
Every `ModalScrim` automatically receives `padding.bottom` equal to the
|
||||||
|
logical gesture-bar height via `apply_safe_area_to_modal_scrims` in
|
||||||
|
`SafeAreaInsetsPlugin`. Do not manually add bottom padding to scrim nodes.
|
||||||
|
|
||||||
|
## 14.4 Z-ordering
|
||||||
|
|
||||||
|
Use `Z_MODAL_PANEL` from `ui_theme` for all modal scrims. Do not use
|
||||||
|
raw `z_index` values — they drift and cause ordering bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Android Build & Verification
|
||||||
|
|
||||||
|
## 15.1 Build command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo apk build --package solitaire_app --lib
|
||||||
|
adb install -r target/debug/apk/solitaire-quest.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15.2 Coordinate system reminder
|
||||||
|
|
||||||
|
Device physical: 1080×2400. Bevy logical: 900×2000. Scale factor: 1.20.
|
||||||
|
`adb shell input tap X Y` takes PHYSICAL coordinates.
|
||||||
|
To convert from what you see on screen (logical): multiply by 1.20.
|
||||||
|
|
||||||
|
## 15.3 Android-specific test checklist
|
||||||
|
|
||||||
|
Before shipping any Android build:
|
||||||
|
- [ ] Safe area insets arrive and shift HUD correctly (check after 3s)
|
||||||
|
- [ ] All modal Done buttons are above the gesture bar
|
||||||
|
- [ ] No Geometric Shapes glyphs in UI text
|
||||||
|
- [ ] HUD band does not overlap the top status bar
|
||||||
|
- [ ] Touch drag-and-drop works on all pile types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Context Injection System (AUTOMATIC SCOPE FILTER)
|
||||||
|
|
||||||
|
## 16.1 Purpose
|
||||||
|
|
||||||
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
Before generating any response, Claude MUST construct a **minimal relevant context set**.
|
||||||
|
|
||||||
@@ -360,7 +482,7 @@ This prevents:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.2 Input Classification Step (MANDATORY)
|
## 16.2 Input Classification Step (MANDATORY)
|
||||||
|
|
||||||
Every request MUST be classified into exactly one task type:
|
Every request MUST be classified into exactly one task type:
|
||||||
|
|
||||||
@@ -381,13 +503,13 @@ If uncertain → ask clarification.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.3 Context Selection Engine
|
## 16.3 Context Selection Engine
|
||||||
|
|
||||||
After classification, Claude MUST include ONLY the relevant sections below.
|
After classification, Claude MUST include ONLY the relevant sections below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.4 Context Map (CORE RULESET)
|
## 16.4 Context Map (CORE RULESET)
|
||||||
|
|
||||||
### feature
|
### feature
|
||||||
|
|
||||||
@@ -495,7 +617,7 @@ Include:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.5 Context Compression Rules
|
## 16.5 Context Compression Rules
|
||||||
|
|
||||||
Claude MUST obey:
|
Claude MUST obey:
|
||||||
|
|
||||||
@@ -506,7 +628,7 @@ Claude MUST obey:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.6 Context Priority Order
|
## 16.6 Context Priority Order
|
||||||
|
|
||||||
When space is limited:
|
When space is limited:
|
||||||
|
|
||||||
@@ -517,7 +639,7 @@ When space is limited:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.7 “No Context Pollution” Rule
|
## 16.7 “No Context Pollution” Rule
|
||||||
|
|
||||||
Claude must NOT include:
|
Claude must NOT include:
|
||||||
|
|
||||||
@@ -529,7 +651,7 @@ Claude must NOT include:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.8 Self-Check Before Execution
|
## 16.8 Self-Check Before Execution
|
||||||
|
|
||||||
Before writing code, Claude MUST verify:
|
Before writing code, Claude MUST verify:
|
||||||
|
|
||||||
@@ -542,7 +664,7 @@ If any fail → revise context selection.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.9 Injection Output Format (Internal Model)
|
## 16.9 Injection Output Format (Internal Model)
|
||||||
|
|
||||||
Claude should behave as if it constructed:
|
Claude should behave as if it constructed:
|
||||||
|
|
||||||
@@ -560,7 +682,7 @@ Claude should behave as if it constructed:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14.10 Relationship to ARCHITECTURE.md
|
## 16.10 Relationship to ARCHITECTURE.md
|
||||||
|
|
||||||
* ARCHITECTURE.md = source of truth
|
* ARCHITECTURE.md = source of truth
|
||||||
* CLAUDE.md = execution constraints
|
* CLAUDE.md = execution constraints
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ You must follow CLAUDE_SPEC.md strictly.
|
|||||||
Rules:
|
Rules:
|
||||||
- Do not expand scope beyond what is defined
|
- Do not expand scope beyond what is defined
|
||||||
- Do not refactor unrelated code
|
- Do not refactor unrelated code
|
||||||
- Do not introduce new dependencies
|
- Do not introduce new dependencies to solitaire_core or solitaire_sync without confirmation
|
||||||
- Prefer minimal, surgical changes
|
- Prefer minimal, surgical changes
|
||||||
- Use existing patterns in the codebase
|
- Use existing patterns in the codebase
|
||||||
- Return minimal diffs or changed functions only
|
- Return minimal diffs or changed functions only
|
||||||
@@ -360,7 +360,7 @@ notes:
|
|||||||
target:
|
target:
|
||||||
"<what is slow>"
|
"<what is slow>"
|
||||||
|
|
||||||
constraints:CLAUDE_WORKFLOW.md
|
constraints:
|
||||||
- no behavior change
|
- no behavior change
|
||||||
- no architecture change
|
- no architecture change
|
||||||
- minimal code changes
|
- minimal code changes
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ solitaire_server:
|
|||||||
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
depends_on: [solitaire_sync, axum, sqlx, jsonwebtoken]
|
||||||
role: "backend"
|
role: "backend"
|
||||||
|
|
||||||
|
solitaire_wasm:
|
||||||
|
depends_on: [solitaire_core, wasm-bindgen, serde-wasm-bindgen]
|
||||||
|
role: "wasm_replay_player"
|
||||||
|
|
||||||
solitaire_app:
|
solitaire_app:
|
||||||
depends_on: [solitaire_engine]
|
depends_on: [solitaire_engine]
|
||||||
role: "entrypoint"
|
role: "entrypoint"
|
||||||
@@ -180,7 +184,7 @@ threading:
|
|||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
pattern: "feature_isolation"
|
pattern: "feature_isolation"
|
||||||
communication: "events"
|
communication: "events and resources"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6957,6 +6957,8 @@ dependencies = [
|
|||||||
"keyring",
|
"keyring",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
"solitaire_engine",
|
"solitaire_engine",
|
||||||
|
"tiny-skia 0.12.0",
|
||||||
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6965,6 +6967,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"png 0.17.16",
|
"png 0.17.16",
|
||||||
|
"solitaire_core",
|
||||||
|
"solitaire_data",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6982,8 +6986,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"keyring-core",
|
"keyring-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -7007,6 +7013,7 @@ dependencies = [
|
|||||||
"bevy",
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"jni 0.21.1",
|
||||||
"kira",
|
"kira",
|
||||||
"resvg",
|
"resvg",
|
||||||
"ron",
|
"ron",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ keyring = "4"
|
|||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
arboard = { version = "3", default-features = false }
|
arboard = { version = "3", default-features = false }
|
||||||
|
jni = { version = "0.21", default-features = false }
|
||||||
|
|
||||||
solitaire_core = { path = "solitaire_core" }
|
solitaire_core = { path = "solitaire_core" }
|
||||||
solitaire_sync = { path = "solitaire_sync" }
|
solitaire_sync = { path = "solitaire_sync" }
|
||||||
@@ -54,7 +55,7 @@ bevy = { version = "0.18", default-features = false, features = [
|
|||||||
"bevy_window",
|
"bevy_window",
|
||||||
"custom_cursor",
|
"custom_cursor",
|
||||||
"reflect_auto_register",
|
"reflect_auto_register",
|
||||||
# default_platform (desktop subset; no android/webgl/gilrs/sysinfo)
|
# default_platform (desktop subset)
|
||||||
"std",
|
"std",
|
||||||
"bevy_winit",
|
"bevy_winit",
|
||||||
"default_font",
|
"default_font",
|
||||||
@@ -65,6 +66,13 @@ bevy = { version = "0.18", default-features = false, features = [
|
|||||||
# the game in an X11 frame inside the Wayland compositor.
|
# the game in an X11 frame inside the Wayland compositor.
|
||||||
"wayland",
|
"wayland",
|
||||||
"x11",
|
"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
|
# common_api
|
||||||
"bevy_color",
|
"bevy_color",
|
||||||
"bevy_image",
|
"bevy_image",
|
||||||
|
|||||||
@@ -42,3 +42,29 @@ git pull
|
|||||||
docker compose build
|
docker compose build
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Admin — Password Reset
|
||||||
|
|
||||||
|
If a player loses access to their account, the server binary includes a
|
||||||
|
built-in password reset command. Run it on the host (or inside the container)
|
||||||
|
with `DATABASE_URL` pointing at your database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive (prompts for the new password):
|
||||||
|
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||||
|
./solitaire_server --reset-password <username>
|
||||||
|
|
||||||
|
# Non-interactive (piped from a script or password manager):
|
||||||
|
echo "new_password" | \
|
||||||
|
DATABASE_URL=sqlite://./data/solitaire.db \
|
||||||
|
./solitaire_server --reset-password <username>
|
||||||
|
|
||||||
|
# Inside a running Docker container:
|
||||||
|
docker compose exec server sh -c \
|
||||||
|
'echo "new_password" | ./solitaire_server --reset-password alice'
|
||||||
|
```
|
||||||
|
|
||||||
|
On success the user's `password_hash` is updated and **all active refresh
|
||||||
|
tokens are deleted**, so every open session must log in again with the new
|
||||||
|
password. `JWT_SECRET` does not need to be set for this command.
|
||||||
|
|||||||
@@ -1,180 +1,177 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-05-06 (post-v0.19.0) — Tagged + pushed at
|
**Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin.
|
||||||
`6037596`. v0.19.0 closes the v0.18.0 punch list (async H-key hint,
|
|
||||||
persistent replay share URLs), expands desktop platform fit (Wayland
|
|
||||||
session support + monitor-aware default window size), polishes the
|
|
||||||
win-celebration and double-click animation paths, and clears two
|
|
||||||
test-flake contributors. A short-lived "Rusty Pixel" pixel-art card
|
|
||||||
theme was prototyped and reverted in the same window.
|
|
||||||
|
|
||||||
## Status at pause
|
Phase 8 closes the self-hosted-server connection arc end-to-end: login/register
|
||||||
|
modal, re-auth on token expiry, account deletion flow, server deployment
|
||||||
|
artifacts (Dockerfile + docker-compose), replay upload on win, web replay
|
||||||
|
player (WASM + HTML/CSS/JS served by the server), leaderboard opt-in/out,
|
||||||
|
and full server integration tests.
|
||||||
|
|
||||||
- **HEAD on origin:** `6037596` (post-tag commit; the tag itself
|
---
|
||||||
points at this commit).
|
|
||||||
- **Working tree:** modified — `CHANGELOG.md` and
|
|
||||||
`SESSION_HANDOFF.md` carry the v0.19.0 promotion + this refresh,
|
|
||||||
ready to commit.
|
|
||||||
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
|
|
||||||
clean (verified this session).
|
|
||||||
- **Tests:** **1170 passing / 0 failing** across the workspace
|
|
||||||
(verified this session). One known flake remains:
|
|
||||||
`solitaire_engine::sync_plugin::tests::pull_failure_sets_error_status`
|
|
||||||
occasionally fails when cargo-test parallelism starves the
|
|
||||||
`AsyncComputeTaskPool` within the test's 5-update budget. Same
|
|
||||||
shape as the auto-save flake before v0.19.0's hardening; could be
|
|
||||||
fixed similarly with a wall-clock-bounded loop.
|
|
||||||
- **Tags on origin:** `v0.9.0` through `v0.18.0` (v0.19.0 ready to
|
|
||||||
push once committed).
|
|
||||||
|
|
||||||
## Where we are
|
## Current state
|
||||||
|
|
||||||
v0.18.0's resume-prompt menu (A–D) is closed:
|
- **HEAD locally:** `03be4fc` (feat: leaderboard custom display name).
|
||||||
|
- **HEAD on origin:** `03be4fc` (fully pushed).
|
||||||
|
- **Working tree:** clean (only `solitaire-release.jks.bak2` untracked — intentional).
|
||||||
|
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||||
|
- **Tests:** **1300+ passing / 0 failing** across the workspace.
|
||||||
|
- **Tags on origin:** `v0.9.0` through `v0.22.0`.
|
||||||
|
|
||||||
- ~~**A — Tag v0.18.0:**~~ shipped at `bfcd05f`.
|
---
|
||||||
- ~~**B — Solver-on-`AsyncComputeTaskPool` for the H-key hint:**~~
|
|
||||||
shipped at `3e11e9e`.
|
|
||||||
- **C — Desktop packaging:** still gated on artwork + signing
|
|
||||||
certs. Icon export PNGs (11 sizes, 16–1024 px) sit in
|
|
||||||
`artwork/` from the v0.18-era export; not yet wired into the
|
|
||||||
Bevy window or assembled into `.icns` / `.ico`. App icon is
|
|
||||||
the first natural step.
|
|
||||||
- ~~**D — Persistent share link:**~~ shipped at `42d90b1`.
|
|
||||||
|
|
||||||
The Rusty Pixel theme arc is documented as a sub-history but
|
## What shipped in Phase 8 (432061c – bd388fe)
|
||||||
not part of v0.19.0's content:
|
|
||||||
|
|
||||||
| Commit | Status |
|
| Commit | Summary |
|
||||||
|---|---|
|
|--------|---------|
|
||||||
| `de47511` PNG-format thumbnail support | reverted |
|
| `432061c` | Sync setup modal (login/register/connect/disconnect) |
|
||||||
| `17e3112` `pixel_art: bool` field + nearest-sampling opt-in | reverted |
|
| `6ce5564` | Re-auth on expired session + server deployment artifacts |
|
||||||
| `21ec03b` bundle Rusty Pixel as `embedded://` theme | reverted |
|
| `272d31f` | Account deletion flow + `handle_sync_buttons` refactor |
|
||||||
| `aad8bb9` / `e41def8` / `0b3140a` reverts | landed |
|
| `bd388fe` | CHANGELOG v0.23.0 documentation |
|
||||||
|
|
||||||
The arc remains in commit history for archaeology but the
|
Also shipped (pre-Phase 8 but post-v0.22.0, already in CHANGELOG):
|
||||||
codebase reaches v0.19.0's HEAD identical to where it would be if
|
- `solitaire_wasm` crate: WASM ReplayPlayer bindings for browser-side replay playback
|
||||||
the arc had never landed.
|
- Server replay API: `POST /api/replays`, `GET /api/replays/recent`, `GET /api/replays/:id`
|
||||||
|
- Server web UI: `/replays/:id` HTML route + `ServeDir /web` static assets
|
||||||
|
- DB migration 002: `replays` table + two indexes
|
||||||
|
- Full server integration tests for replay endpoints
|
||||||
|
- `push_replay` in `sync_plugin` (uploads on win, writes share URL into replay history)
|
||||||
|
- Stats panel "Copy Share Link" button reads `share_url` from replay history
|
||||||
|
|
||||||
### Design direction (unchanged)
|
---
|
||||||
|
|
||||||
- **Tone:** Balatro — chunky readable type, theatrical hierarchy,
|
## Open punch list (ordered by priority)
|
||||||
satisfying micro-interactions.
|
|
||||||
- **Palette:** Midnight Purple base + Balatro yellow primary + warm
|
|
||||||
magenta secondary.
|
|
||||||
|
|
||||||
### Canonical remote
|
### 1. Documentation debt (no code)
|
||||||
|
- [x] CHANGELOG [Unreleased] → v0.23.0 — done this session
|
||||||
|
- [x] ARCHITECTURE.md update — all 8 gaps closed, bumped to v1.3
|
||||||
|
- [x] SESSION_HANDOFF.md update — this file
|
||||||
|
|
||||||
`github.com/funman300/Rusty_Solitaire` is the canonical repo.
|
### 2. Leaderboard wiring gaps
|
||||||
Always push there.
|
- [x] **Best-score auto-post.** Done (`303c78a`): `update_leaderboard_if_opted_in`
|
||||||
|
called from both first-push and merge paths in `sync.rs`; uses SQLite `MIN`/`MAX`
|
||||||
|
in the UPDATE so scores never regress on stale data.
|
||||||
|
- [x] **Display name = username.** Done (`03be4fc`): `leaderboard_display_name:
|
||||||
|
Option<String>` added to `Settings`; editor modal in leaderboard panel; persists
|
||||||
|
to `settings.json`; `handle_opt_in_button` prefers custom name over username.
|
||||||
|
|
||||||
## v0.19.0 (2026-05-06)
|
### 3. Security hardening
|
||||||
|
- [x] **Refresh token rotation.** Done (`b129664`): `refresh_tokens` table
|
||||||
|
(migration 003); jti embedded in JWT; rotate-on-use pattern; 3 integration
|
||||||
|
tests.
|
||||||
|
- [x] **Sync endpoint rate limiting.** Done (`6e6f3ef`): `UserIdKeyExtractor`
|
||||||
|
decodes JWT for per-user identity; falls back to IP; burst 10 / 6 min
|
||||||
|
steady-state; integration test passes.
|
||||||
|
|
||||||
| Area | Commits | What landed |
|
### 4. Android validation
|
||||||
|---|---|---|
|
- [x] **Android Keystore functional test.** Done (2026-05-11, Pixel 7 AVD,
|
||||||
| Async H-key hint | `3e11e9e` | New `pending_hint.rs` module: `PendingHintTask` resource, `poll_pending_hint_task` + `drop_pending_hint_on_state_change` systems, cancel-on-replace, stale-state guard via `move_count_at_spawn`. Removes the last synchronous solver hot path. |
|
Android 14): `load_access_token()` exercised via `start_pull`; logcat confirmed
|
||||||
| Persistent share URLs | `42d90b1` | `Replay.share_url: Option<String>` with `#[serde(default)]`. `poll_replay_upload_result` writes into `replays[0].share_url` + persists. Stats Copy button reads from selected replay. `LastSharedReplayUrl` deleted. |
|
`NotFound` returned cleanly — no JNI panic. See `docs/android/PLAYABILITY_TODO.md` P4.
|
||||||
| Auto-save flake fix | `91b7605` | `test_app` clears `PendingRestoredGame(None)` after plugin build; test re-arms the timer in a bounded loop. No production-code change. |
|
- [x] **JNI clipboard functional test.** Done (2026-05-11): temporary `KEYCODE_C`
|
||||||
| Wayland support | `b57db01` | Adds `wayland` to Bevy features. winit prefers Wayland when `WAYLAND_DISPLAY` is set, falls back to X11. Native Wayland surface instead of XWayland frame. |
|
hook confirmed `ClipboardManager.setPrimaryClip()` succeeds on Android 14.
|
||||||
| Smart default window size | `b57db01` | New `apply_smart_default_window_size` Update system queries `PrimaryMonitor` and resizes the window to ~70 % of monitor's logical size on the first frame. Skipped when saved geometry was applied. |
|
Hook reverted. Production path requires Interaction::Pressed + non-null `share_url`.
|
||||||
| Win-celebration cleanup | `55c235b` | Drops the duplicate "You Win" toast that rendered behind the WinSummary modal. Cards-fly-off cascade kept; toast removed. |
|
Note: `adb shell input tap` doesn't deliver touch events on headless AVD (documented).
|
||||||
| Double-click reject animation | `d7ffb16` | Single-card double-clicks with no destination now play the same shake + sound as multi-card stack misses. Both priorities' failure paths converge on one `MoveRejectedEvent` write. |
|
- [x] **`cargo apk build --lib` noisy stderr** — upstream cargo-apk bug; `--lib`
|
||||||
| Double-click animation dedup | `6037596` | Drops the redundant `StateChangedEvent` write in `end_drag`'s uncommitted-drag branch; previously raced an in-flight CardAnim and restarted the slide visibly. |
|
is the canonical command (CLAUDE.md §15.1, docs/ANDROID.md). No in-repo fix possible.
|
||||||
|
|
||||||
## Open punch list
|
### 5. Feature completeness
|
||||||
|
- [x] **Theme importer UI.** Done (`613bbf8`): "Scan for new themes" button in
|
||||||
|
Settings Appearance section. Shows import path label, scans user_theme_dir()
|
||||||
|
for .zip archives, fires InfoToastEvent per file, refreshes ThemeRegistry.
|
||||||
|
- [x] **`mirror_achievement` removed.** Done (`549a817`): method was a no-op
|
||||||
|
default never overridden and never called; achievements already sync via
|
||||||
|
`SyncPayload` push. Deleted from trait and blanket impl.
|
||||||
|
- [x] **WASM build script.** Done (`40d0712`): `build_wasm.sh` at repo root
|
||||||
|
documents `wasm-pack build --target web`, cleans up pkg metadata files,
|
||||||
|
includes dependency guard + install instructions.
|
||||||
|
- [x] **Server password reset.** Done (`7514684`): `--reset-password <username>`
|
||||||
|
subcommand reads new password from stdin, bcrypt-hashes it, invalidates all
|
||||||
|
active sessions for the user.
|
||||||
|
|
||||||
### Carried forward
|
### 5b. Android UX polish (2026-05-12)
|
||||||
|
|
||||||
- **Desktop packaging** per `ARCHITECTURE.md §17`. Eleven icon
|
- [x] **UX-1 — Modal Done button in gesture zone.** `apply_safe_area_to_modal_scrims` system
|
||||||
PNG sizes (16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024)
|
added to `SafeAreaInsetsPlugin` (`safe_area.rs`). Pads every `ModalScrim` bottom by
|
||||||
exported via `artwork/Icon Export.html` sit in `artwork/`
|
`insets.bottom / scale`. Fires on resource change + `Added<ModalScrim>`. Verified on device.
|
||||||
pending wiring. Pending: actual Bevy window-icon hookup,
|
- [x] **UX-5b — Home mode glyph corruption.** Geometric Shapes (U+25xx, absent from FiraMono)
|
||||||
macOS `.icns` assembly via `iconutil`, Windows `.ico` via
|
replaced with card suits U+2660–2666 in `home_plugin.rs`. Affects Zen/Challenge/Daily mode
|
||||||
`magick convert`, Linux hicolor PNG hierarchy install,
|
selector buttons at level 5+.
|
||||||
AppImage recipe, macOS notarisation cert, Windows
|
- [x] **UX-7 — Help text wrap.** Android HUD entry shortened to
|
||||||
Authenticode cert.
|
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs` — fits one line.
|
||||||
|
- [x] **BUG-3 — Multi-modal stacking.** `handle_menu_button` now checks
|
||||||
|
`scrims: Query<(), With<ModalScrim>>` and guards `spawn_menu_popover` with `scrims.is_empty()`.
|
||||||
|
Verified on device: ≡ tap while Stats open does nothing.
|
||||||
|
|
||||||
### Possible next-round candidates
|
**Note:** These 4 fixes are implemented and verified but not yet committed.
|
||||||
|
|
||||||
- **App icon round** — wire the icon into the Bevy window via
|
### 6. Testing gaps
|
||||||
`Window::icon`, generate `.icns` and `.ico` from the existing
|
- [x] **Server 401 → refresh → retry path.** Done (`198df75`): both
|
||||||
PNGs. Half-day task; doesn't depend on signing certs.
|
`jwt_refresh_on_401_succeeds` (pull) and
|
||||||
- **`pull_failure_sets_error_status` flake fix** — same pattern
|
`push_retries_after_401_on_expired_access_token` (push) in
|
||||||
as the auto-save flake. Wall-clock-bounded loop instead of
|
`solitaire_data/tests/sync_round_trip.rs`.
|
||||||
fixed 5-update budget. ~10 lines.
|
- [x] **WASM winning-replay step-through.** Done (`b4ada2a`): greedy solver
|
||||||
- **Settings UI for "open at this size on launch"** — once the
|
searches seeds 1–200 at test time; steps every move through `ReplayPlayer`;
|
||||||
smart-default-size system is shipping, expose a checkbox to
|
asserts `is_won = true` on the final `StateSnapshot`.
|
||||||
*disable* it (player who specifically wants 1280×800 every
|
|
||||||
time). Trivial.
|
|
||||||
- **Persistent share link URL on selector caption** — surface
|
|
||||||
whether the currently-selected replay has a `share_url`
|
|
||||||
populated (e.g. "Replay 3 / 8 \u{2022} Shareable") so players
|
|
||||||
know which entries the Copy button can copy.
|
|
||||||
|
|
||||||
### Process notes (from this round)
|
---
|
||||||
|
|
||||||
- **Async port template (worked again):** the H-key port
|
## ARCHITECTURE.md gaps (for the update pass)
|
||||||
followed `d489e7a`'s `PendingNewGameSeed` shape one-to-one
|
|
||||||
and the second async port required no new infrastructure.
|
Items missing from the doc:
|
||||||
Future async ports (e.g. moving `try_solve_with_first_move`'s
|
1. `solitaire_wasm` crate (§2 workspace + §3 responsibilities)
|
||||||
full-search variant, if it ever surfaces in the picker UI)
|
2. Replay API endpoints (§9 API Reference — 3 new routes)
|
||||||
should follow the same shape.
|
3. Web replay player route (`/replays/:id` + `ServeDir /web`)
|
||||||
- **Rusty Pixel reverted cleanly:** `git revert` of three
|
4. `SyncProvider` trait: 6 added methods
|
||||||
contiguous feature commits produced a clean three-revert
|
5. Theme system in Bevy plugin table (§5)
|
||||||
sequence with no manual conflict resolution. Bisect remains
|
6. `Settings` new fields: `color_blind_mode`, `high_contrast_mode`,
|
||||||
fast over the full v0.19.0 history because the reverts are
|
`reduce_motion_mode`, `window_geometry`, `selected_card_back`,
|
||||||
individual commits, not a squash.
|
`selected_background`
|
||||||
- **Defensive event writes pattern:** the
|
7. DB migration 002 (§7)
|
||||||
`auto_save_writes_after_30_seconds` flake AND the
|
8. Update "Last Updated" date
|
||||||
`end_drag` double-animation bug shared a root cause:
|
|
||||||
defensive `MessageWriter` writes that originally covered an
|
---
|
||||||
edge case which no longer holds, but became load-bearing
|
|
||||||
once another system started paying attention to the event.
|
## Process notes
|
||||||
Worth a periodic pass: any event write that doesn't
|
|
||||||
correspond to a real state change is a candidate for
|
- **Commit attribution:** use `funman300` as git user. Co-author line:
|
||||||
removal.
|
`Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
|
||||||
|
- **Commit format:** `type(scope): description` per CLAUDE.md §7.
|
||||||
|
- **Never commit without:** `cargo test --workspace` passing + clippy clean.
|
||||||
|
- **Sub-agents** stage/verify only; orchestrator commits.
|
||||||
|
- **`CARD_PLAN.md`** referenced in `theme/` module comments but not present in
|
||||||
|
repo. Clean up references or commit the file.
|
||||||
|
- **Token-port pattern** (v0.20.0): when migrating tokens, walk every concrete
|
||||||
|
artifact downstream — PNGs, SVGs, literals, comments. Three "walked past this"
|
||||||
|
follow-ups in v0.21.0 all had this shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Resume prompt
|
## Resume prompt
|
||||||
|
|
||||||
```
|
```
|
||||||
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
You are a senior Rust + Bevy developer working on Solitaire Quest.
|
||||||
Working directory: <Rusty_Solitaire clone path on this machine>.
|
Working directory: <Rusty_Solitaire clone path>.
|
||||||
Branch: master. v0.19.0 just shipped. The next natural item is
|
Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
|
||||||
desktop-packaging follow-through, starting with the app icon.
|
|
||||||
|
|
||||||
State: HEAD at 6037596 + the v0.19.0 docs commit on top (this
|
READ FIRST (in order):
|
||||||
session). Tag v0.19.0 points at the docs commit.
|
|
||||||
|
|
||||||
READ FIRST (in order, before doing anything):
|
|
||||||
1. SESSION_HANDOFF.md — this file
|
1. SESSION_HANDOFF.md — this file
|
||||||
2. CHANGELOG.md — [Unreleased] is empty; [0.19.0] just landed
|
2. CHANGELOG.md — [0.23.0] section has full Phase 8 detail
|
||||||
3. CLAUDE.md — unified-3.0 rule set
|
3. CLAUDE.md — unified-4.0 rule set
|
||||||
4. CLAUDE_SPEC.md — formal architecture spec
|
4. ARCHITECTURE.md — v1.3, fully up to date
|
||||||
5. ARCHITECTURE.md — crate responsibilities + data flow
|
5. docs/ui-mockups/ — design system + mockup library
|
||||||
6. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
6. docs/android/ — Android setup + build runbook
|
||||||
— saved feedback / project context
|
7. ~/.claude/projects/<this-project>/memory/MEMORY.md
|
||||||
(machine-local; may be missing on a
|
|
||||||
fresh machine)
|
|
||||||
|
|
||||||
DECISION TO ASK THE PLAYER FIRST:
|
OPEN WORK:
|
||||||
A. App icon — wire artwork/icon-{size}.png into Bevy's
|
Phase 8 punch list is fully closed. All items verified complete.
|
||||||
Window::icon, generate .icns + .ico, drop into Linux
|
Remaining nuisance: `cargo apk build --lib` noisy stderr (cosmetic, non-blocking).
|
||||||
hicolor hierarchy. Half-day task. No cert dependency.
|
|
||||||
B. Desktop packaging continued — AppImage recipe, .desktop
|
|
||||||
file, install scripts. Larger task; unlocks distro
|
|
||||||
packaging. No cert dependency.
|
|
||||||
C. macOS / Windows signing cert acquisition — needs user
|
|
||||||
action; agent can't drive.
|
|
||||||
D. `pull_failure_sets_error_status` flake fix — small, well-
|
|
||||||
scoped. Same pattern as the v0.19.0 auto-save flake fix.
|
|
||||||
|
|
||||||
WORKFLOW NOTES:
|
4 Android UX fixes are implemented and verified but NOT YET COMMITTED:
|
||||||
- Use the system git config (already correct).
|
- BUG-3 (hud_plugin.rs): multi-modal stacking guard
|
||||||
- When attributing playtester feedback in commits/docs, use
|
- UX-7 (help_plugin.rs): help text wrap on Android
|
||||||
"Quat" not "Rhys" (saved feedback memory).
|
- UX-5b (home_plugin.rs): FiraMono glyph corruption in mode selector
|
||||||
- Sub-agents stage + verify only; orchestrator commits.
|
- UX-1 (safe_area.rs): modal Done button in gesture zone
|
||||||
- Every commit must pass build / clippy / test before pushing.
|
|
||||||
- Push to GitHub (origin) — gh auth setup-git is already
|
|
||||||
wired on this machine.
|
|
||||||
|
|
||||||
OPEN AT THE START: ask which of A–D. Don't pick unilaterally.
|
Commit those first, then suggest Phase 9 planning.
|
||||||
```
|
```
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 369 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 489 B |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 927 B |
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuild the solitaire_wasm crate and install the output into
|
||||||
|
# solitaire_server/web/pkg/ so the server can serve the replay viewer.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# cargo install wasm-pack
|
||||||
|
# rustup target add wasm32-unknown-unknown
|
||||||
|
#
|
||||||
|
# Run from the repo root:
|
||||||
|
# ./build_wasm.sh
|
||||||
|
#
|
||||||
|
# The generated files (solitaire_wasm.js + solitaire_wasm_bg.wasm) are
|
||||||
|
# committed to git so self-hosters who don't touch the WASM crate can
|
||||||
|
# skip this step. Regenerate after any change to solitaire_wasm/ or
|
||||||
|
# solitaire_core/.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
|
||||||
|
|
||||||
|
if ! command -v wasm-pack &> /dev/null; then
|
||||||
|
echo "error: wasm-pack not found." >&2
|
||||||
|
echo " Install with: cargo install wasm-pack" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building solitaire_wasm (target: web)..."
|
||||||
|
wasm-pack build \
|
||||||
|
--target web \
|
||||||
|
--out-dir "$OUT_DIR" \
|
||||||
|
--no-typescript \
|
||||||
|
"$REPO_ROOT/solitaire_wasm"
|
||||||
|
|
||||||
|
# wasm-pack writes a package.json and .gitignore into the output dir.
|
||||||
|
# Remove them — we manage the output directory ourselves.
|
||||||
|
rm -f "$OUT_DIR/package.json" "$OUT_DIR/.gitignore"
|
||||||
|
|
||||||
|
echo "Done. Output:"
|
||||||
|
ls -lh "$OUT_DIR"
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# 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 tries to
|
||||||
|
also wrap the desktop `[[bin]]` target alongside the `[lib]`. The APK
|
||||||
|
is valid — the panic is cosmetic. **Always use `--lib`**, which is the
|
||||||
|
canonical build command (see `CLAUDE.md §15.1`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo apk build -p solitaire_app --lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Root cause: upstream cargo-apk bug — it does not skip `[[bin]]` targets
|
||||||
|
when building for Android. No in-repo fix is possible; `--lib` is the
|
||||||
|
accepted workaround.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,4 +1,8 @@
|
|||||||
# Solitaire Quest — Session Handoff
|
# Solitaire Quest — Session Handoff (ARCHIVED)
|
||||||
|
|
||||||
|
> **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical
|
||||||
|
> reference only. The authoritative session handoff is at the repo root:
|
||||||
|
> `SESSION_HANDOFF.md`.**
|
||||||
|
|
||||||
> Last updated: 2026-04-25
|
> Last updated: 2026-04-25
|
||||||
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
> Branch: `master` — pushed to https://github.com/funman300/Rusty_Solitaire.git
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# Android Playability TODO
|
||||||
|
|
||||||
|
**Started:** 2026-05-10 — first hardware screenshot of v0.22.3 APK
|
||||||
|
running on a real device showed the desktop HUD projected onto a
|
||||||
|
360 dp portrait viewport with no mobile adaptation. This list
|
||||||
|
tracks the work needed to make the APK genuinely playable, not
|
||||||
|
just "boots without crashing."
|
||||||
|
|
||||||
|
**Context:** v0.22.3 (signed release APK) builds and launches.
|
||||||
|
JNI bridges (clipboard, keystore) compile but are untested on
|
||||||
|
hardware. The work below is UI/UX port work — no architectural
|
||||||
|
rewrites required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading from the v0.22.3 screenshot
|
||||||
|
|
||||||
|
| Region | Observation |
|
||||||
|
|--------|-------------|
|
||||||
|
| Top ~5 % | System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset |
|
||||||
|
| HUD text row | `Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08` all overlapping — desktop layout crammed into 360 dp |
|
||||||
|
| Keyboard hints | `Esc`, `A`, `[]`, `N` shown next to buttons — meaningless on touch |
|
||||||
|
| Foundations row | Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right |
|
||||||
|
| Card backs | Face-down cards render as solid red squares, not back-art texture |
|
||||||
|
| Vertical use | Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout |
|
||||||
|
| Bottom edge | No accommodation for Android gesture / home-indicator area |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Blocking playability
|
||||||
|
|
||||||
|
- [x] **Safe-area insets (top + bottom).** *Closed 2026-05-10 by
|
||||||
|
`b9aa262`.* `SafeAreaInsets` resource + `SafeAreaInsetsPlugin`
|
||||||
|
query `WindowInsets.getInsets(systemBars())` via JNI on Android;
|
||||||
|
HUD anchors carry `SafeAreaAnchoredTop { base_top }` and the
|
||||||
|
change-detection fix-up system re-applies `base_top + insets.top`
|
||||||
|
whenever the resource updates. Bottom inset is captured but not
|
||||||
|
yet consumed (waits for bottom-anchored UI).
|
||||||
|
- [x] **Mobile HUD layout.** *Closed 2026-05-10.* Both the left HUD
|
||||||
|
column and the right action button row are now capped at
|
||||||
|
`max_width: 50 %` and the button row + tier-row child Nodes carry
|
||||||
|
`flex_wrap: Wrap`. On a 360 dp viewport the 6-button row breaks
|
||||||
|
to multiple lines (right-justified) and the tier rows wrap
|
||||||
|
individually instead of overflowing into the action column. On
|
||||||
|
desktop (≥ 1280 px) the 50 % cap is wider than any natural row
|
||||||
|
width so the existing single-line layout is unchanged.
|
||||||
|
- [x] **Card-back asset not rendering.** *Closed 2026-05-10 by
|
||||||
|
`fcc7337`.* `AssetPlugin::file_path = "../assets"` was set
|
||||||
|
unconditionally to fix the desktop `cargo run -p solitaire_app`
|
||||||
|
CWD relativity, but on Android cargo-apk packages the same
|
||||||
|
directory into the APK at `assets/` and Bevy's
|
||||||
|
AndroidAssetReader is already rooted there — prepending `../`
|
||||||
|
walked the reader out of the APK assets root and every load
|
||||||
|
failed silently. The face-down branch then fell through to the
|
||||||
|
`card_back_colour(0)` solid-red brick fallback. Gated the
|
||||||
|
override behind `#[cfg(not(target_os = "android"))]`.
|
||||||
|
- [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout`
|
||||||
|
was clamping the input window up to `MIN_WINDOW = 800 × 600`,
|
||||||
|
so a 360 dp phone got laid out as if it were 800-wide and the
|
||||||
|
outer piles fell outside the actual viewport. Lowered the floor
|
||||||
|
to 320 × 400 (below the smallest reasonable phone) so real
|
||||||
|
Android resolutions flow through without clamping, while keeping
|
||||||
|
a sentinel to guard against degenerate / startup-zero windows.
|
||||||
|
New regression test `phone_portrait_layout_fits_horizontally`
|
||||||
|
asserts all 13 piles fit a 360 × 800 viewport.
|
||||||
|
|
||||||
|
## P1 — Touch UX
|
||||||
|
|
||||||
|
- [x] **Suppress keyboard-hint labels on Android.** *Closed
|
||||||
|
2026-05-10.* `spawn_action_button` now nulls the `hotkey`
|
||||||
|
argument on Android via a `#[cfg(target_os = "android")]` rebind,
|
||||||
|
so the U / Esc / F1 / N chips next to the action row labels
|
||||||
|
disappear on touch builds. Remaining hint sites swept in P3 —
|
||||||
|
see full-keyboard-hint-sweep entry below.
|
||||||
|
- [x] **Thumb-sized hit targets.** *Closed 2026-05-10.* Action
|
||||||
|
button Node carries `min_width: Val::Px(48.0), min_height:
|
||||||
|
Val::Px(48.0)` — meets Material's 48 dp baseline on touch and is
|
||||||
|
a no-op for buttons whose content already exceeds 48 px in
|
||||||
|
either axis. Applied universally rather than cfg-gated since
|
||||||
|
Material's guideline applies to all input modes. Cards, pile
|
||||||
|
markers, modal close buttons not yet audited — track as P3 if
|
||||||
|
they fall below threshold on hardware.
|
||||||
|
- [x] **Portrait-first card spacing.** *Closed 2026-05-11.*
|
||||||
|
`compute_layout` now derives an adaptive `tableau_fan_frac` from the
|
||||||
|
available vertical space below the tableau row. On height-limited
|
||||||
|
(desktop) windows the formula returns ≈ 0.25 and the clamp keeps the
|
||||||
|
existing behaviour. On width-limited (portrait phone) windows — where
|
||||||
|
card size is constrained by the 9-column horizontal packing — the fan
|
||||||
|
fraction expands to fill the viewport (≈ 0.84 at 360 × 800 dp).
|
||||||
|
`tableau_facedown_fan_frac` scales proportionally. Both values live in
|
||||||
|
the `Layout` struct; `card_plugin::card_positions` and
|
||||||
|
`input_plugin::card_position` / `pile_drop_rect` read from the struct
|
||||||
|
so rendering and hit-testing stay in sync across viewport sizes.
|
||||||
|
- [x] **Double-tap auto-move visible feedback.** *Closed 2026-05-11.*
|
||||||
|
On a recognised double-tap (priority 1 single-card or priority 2
|
||||||
|
stack move), the moved card(s) receive a 0.35 s lime flash
|
||||||
|
(`STATE_SUCCESS` tint + `HintHighlight { remaining: 0.35 }`) before
|
||||||
|
the move request is written. The flash persists through the card
|
||||||
|
animation and is cleaned up by the existing `tick_hint_highlight`
|
||||||
|
system. Hardware trigger-verification remains a manual step — connect
|
||||||
|
AVD or device and confirm two rapid `TouchPhase::Ended` events within
|
||||||
|
0.5 s produce the lime flash.
|
||||||
|
|
||||||
|
## P2 — Polish
|
||||||
|
|
||||||
|
- [x] **Drag responsiveness on touch.** *Closed 2026-05-11.*
|
||||||
|
Two code-side improvements shipped; final feel confirmation still needs
|
||||||
|
hardware:
|
||||||
|
1. `start_drag` (mouse path) now bails out when a touch is just-pressed
|
||||||
|
(`Touches::iter_just_pressed()`), ensuring `touch_start_drag` always
|
||||||
|
owns the drag state on touch-screen devices — including Bevy/Winit
|
||||||
|
versions that simulate `MouseButton::Left` from the primary touch.
|
||||||
|
2. Mobile drag commit threshold lowered 10 px → 8 px, matching Android's
|
||||||
|
`ViewConfiguration.getScaledTouchSlop()` spec. Smaller threshold →
|
||||||
|
smaller snap-on-commit and faster perceived response.
|
||||||
|
**Remaining:** connect AVD or device and verify drag feels responsive
|
||||||
|
with no stutter; tune threshold further if needed.
|
||||||
|
- [x] **Long-press menu.** *Closed 2026-05-11.* New system
|
||||||
|
`radial_open_on_long_press` in `radial_menu.rs` counts up while a
|
||||||
|
touch is held (`drag.active_touch_id.is_some() && !drag.committed`)
|
||||||
|
and opens `RightClickRadialState::Active` after 0.5 s — the same
|
||||||
|
state the right-click path uses. Existing radial infrastructure
|
||||||
|
then handles everything:
|
||||||
|
- `radial_track_cursor` extended to fall back to the first active
|
||||||
|
touch when no cursor position is available, so sliding the held
|
||||||
|
finger moves the hover ring.
|
||||||
|
- `radial_handle_release_or_cancel` extended to confirm/cancel on
|
||||||
|
`Touches::iter_just_released()` in addition to right-mouse release.
|
||||||
|
- `handle_double_tap` skips when the radial is active (guards a
|
||||||
|
narrow edge case where the finger lifts at exactly the same frame
|
||||||
|
the 0.5 s threshold fires).
|
||||||
|
Hardware verification needed: confirm the 0.5 s hold feel, verify
|
||||||
|
sliding to a destination and lifting confirms the move.
|
||||||
|
- [x] **HUD typography.** *Closed 2026-05-11.* New system
|
||||||
|
`update_hud_typography` fires on `WindowResized` and adjusts Tier-1
|
||||||
|
font sizes based on viewport width. Below 480 logical px: Score
|
||||||
|
`TYPE_HEADLINE` (26) → `TYPE_BODY_LG` (18), Moves/Timer
|
||||||
|
`TYPE_BODY_LG` (18) → `TYPE_CAPTION` (11), so all three items fit
|
||||||
|
in the 180 dp HUD column on a 360 dp phone. At ≥ 480 px the
|
||||||
|
original sizes are restored — desktop/tablet layout unchanged.
|
||||||
|
`add_message::<WindowResized>()` added defensively to `HudPlugin`
|
||||||
|
so the system works under `MinimalPlugins` in tests.
|
||||||
|
- [x] **Orientation lock.** *Closed 2026-05-11.* Added
|
||||||
|
`[package.metadata.android.application.activity]` section to
|
||||||
|
`solitaire_app/Cargo.toml` with `orientation = "portrait"`.
|
||||||
|
cargo-apk/ndk-build maps this to `android:screenOrientation="portrait"`
|
||||||
|
in the generated `AndroidManifest.xml`. Remove (or add a landscape
|
||||||
|
layout) before enabling auto-rotate.
|
||||||
|
|
||||||
|
## P3 — Asset density
|
||||||
|
|
||||||
|
- [x] **Density-aware card scaling.** *Closed 2026-05-11 — no code change
|
||||||
|
required.* `WindowResized` fires with **logical** pixels; sprites are
|
||||||
|
sized in world units (1 world unit = 1 logical pixel); Bevy's renderer
|
||||||
|
maps logical → physical via `scale_factor` internally. On a 360 dp
|
||||||
|
3×-DPI phone, cards are 40 logical dp = 120 physical px. The 256 × 384 px
|
||||||
|
card textures are **downscaled** to fit (256 → 120 px) — quality is fine.
|
||||||
|
Upscaling only occurs if `card_width × scale_factor > 256`, i.e. a
|
||||||
|
tablet with a logical width > 765 dp at 3× DPI — no current target
|
||||||
|
device falls in that range. Revisit if the game ships on large-screen
|
||||||
|
high-DPI tablets.
|
||||||
|
- [x] **App-icon density buckets.** *Closed 2026-05-11.* Created
|
||||||
|
`solitaire_app/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png`
|
||||||
|
from the existing `assets/icon/` PNGs (48→mdpi, 64→hdpi, 128→xhdpi,
|
||||||
|
256→xxhdpi+xxxhdpi). Added `resources = "res"` to
|
||||||
|
`[package.metadata.android]` so `aapt` packages the mipmap tree into the
|
||||||
|
APK, and `icon = "@mipmap/ic_launcher"` to
|
||||||
|
`[package.metadata.android.application]` so the launcher references it.
|
||||||
|
- [x] **Full keyboard-hint sweep.** *Closed 2026-05-11.* Extended the
|
||||||
|
P1 suppression to cover all remaining hint sites:
|
||||||
|
- `ui_modal.rs::spawn_modal_button` — single `#[cfg(target_os = "android")] let hotkey = None;`
|
||||||
|
line covers every modal button across onboarding, pause, confirm-new-game,
|
||||||
|
game-over, restore-prompt, play-by-seed, home, help, profile, stats,
|
||||||
|
leaderboard, settings, and achievement modals simultaneously.
|
||||||
|
- `home_plugin.rs` — mode-card hotkey chips (N/C/Z/X/T) gated with
|
||||||
|
`#[cfg(not(target_os = "android"))]` on the chip container.
|
||||||
|
- `replay_overlay.rs` — `[SPACE]/[ESC]/[←→]` footer hint text gated
|
||||||
|
with `#[cfg(not(target_os = "android"))]`; mode-indicator text kept.
|
||||||
|
- `help_plugin.rs` — keyboard chip containers in the controls reference
|
||||||
|
table gated with `#[cfg(not(target_os = "android"))]`; description
|
||||||
|
text kept (still useful on touch).
|
||||||
|
|
||||||
|
## P4 — Stability / runtime
|
||||||
|
|
||||||
|
- [x] **B0004 ECS hierarchy warnings.** *Investigated 2026-05-11 — no
|
||||||
|
fix required.* B0004 fires via Bevy's `validate_parent_has_component<C>`
|
||||||
|
hook when a child entity has UI component `C` (e.g. `Node`,
|
||||||
|
`InheritedVisibility`) but its parent doesn't yet. In Bevy 0.18,
|
||||||
|
`.despawn()` is recursive (docs: "When a parent is despawned, all
|
||||||
|
children will also be despawned"), so all `.despawn()` calls in the
|
||||||
|
engine are safe. The warnings seen on the Pixel 7 AVD during startup
|
||||||
|
are a component-propagation timing artifact — UI children reach the
|
||||||
|
hook before the parent's inherited components finish initialising —
|
||||||
|
not a gameplay defect. `despawn_related::<Children>()` in
|
||||||
|
`card_plugin.rs` is explicit child-only teardown (parent kept alive)
|
||||||
|
and is correct. No gameplay bugs attributed to these warnings over 2+
|
||||||
|
min AVD runtime.
|
||||||
|
- [x] **AVD functional tests for JNI bridges.** *Closed 2026-05-11.*
|
||||||
|
Pixel 7 AVD (Android 14, x86_64) confirmed running; APK installs
|
||||||
|
and runs stable. Key findings:
|
||||||
|
|
||||||
|
**Keystore JNI — verified working.** Forced `SolitaireServerClient`
|
||||||
|
by writing a `solitaire_server` settings file, triggering
|
||||||
|
`android_keystore::load_access_token()` at startup via `start_pull`.
|
||||||
|
Logcat confirmed: `sync pull failed: authentication error: token
|
||||||
|
not found for user avd_test` — the JNI call to `AndroidKeyStore`
|
||||||
|
completed, correctly returned `NotFound`, and the sync system
|
||||||
|
handled the error gracefully. No panic, no crash from the JNI layer.
|
||||||
|
|
||||||
|
**Clipboard JNI — verified working.** Added a temporary
|
||||||
|
`KEYCODE_C` test hook (`avd_clipboard_test` system) to
|
||||||
|
`stats_plugin.rs`, rebuilt the APK, pressed C on the AVD.
|
||||||
|
Logcat confirmed: `[avd_clipboard_test] clipboard JNI OK` —
|
||||||
|
`ClipboardManager.setPrimaryClip()` succeeded on Android 14.
|
||||||
|
Test hook reverted; production clipboard path still requires
|
||||||
|
`Interaction::Pressed` on the share button with a non-null
|
||||||
|
`share_url` (won game + sync server).
|
||||||
|
|
||||||
|
**Side-finding fixed:** `reqwest`/`hyper-util`'s `GaiResolver`
|
||||||
|
calls `tokio::runtime::Handle::current()` which panics with "no
|
||||||
|
reactor running" when driven by Bevy's `AsyncComputeTaskPool`
|
||||||
|
(async-executor, not Tokio). Fixed in `sync_plugin.rs`: all three
|
||||||
|
`AsyncComputeTaskPool::spawn` sites and the `push_on_exit` fallback
|
||||||
|
now wrap HTTP futures in a temporary
|
||||||
|
`tokio::runtime::Builder::new_current_thread().enable_all()` runtime.
|
||||||
|
|
||||||
|
**Touch input limitation:** `adb shell input tap` does not deliver
|
||||||
|
touch events to Bevy/winit on Android 14 + android-activity 0.6.1
|
||||||
|
in headless AVD mode. Keyboard events (`KEYCODE_*`) work normally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P5 — UX polish (2026-05-12)
|
||||||
|
|
||||||
|
- [x] **UX-1 — Modal Done button unreachable in gesture zone.** *Closed
|
||||||
|
2026-05-12.* New `apply_safe_area_to_modal_scrims` system in
|
||||||
|
`safe_area.rs` pads every `ModalScrim` bottom by `insets.bottom /
|
||||||
|
window.scale_factor()` (logical pixels). Fires when `SafeAreaInsets`
|
||||||
|
changes AND when a new `ModalScrim` is spawned (`Added<ModalScrim>`
|
||||||
|
filter). Verified on device: Settings Done button reachable at physical
|
||||||
|
y ≈ 1800–2000 (was y ≈ 2232+, inside gesture zone).
|
||||||
|
- [x] **UX-5b — Home mode selector glyph corruption.** *Closed
|
||||||
|
2026-05-12.* `home_plugin.rs` mode glyphs changed from Geometric Shapes
|
||||||
|
block (U+25xx — absent from FiraMono, renders as rectangles) to card
|
||||||
|
suits U+2660 ♠ / U+2665 ♥ / U+2666 ♦. Affects Zen, Challenge, and
|
||||||
|
Daily mode selector buttons shown at level 5+.
|
||||||
|
- [x] **UX-7 — Help screen HUD button entry wraps to two lines.** *Closed
|
||||||
|
2026-05-12.* Android `CONTROL_SECTIONS` entry for ≡ button shortened
|
||||||
|
from `"Menu: Stats, Settings, Profile, Achievements"` to
|
||||||
|
`"Open menu (Stats, Settings, Profile...)"` in `help_plugin.rs`.
|
||||||
|
Fits on one line at 360 dp.
|
||||||
|
- [x] **BUG-3 — Multi-modal stacking (Stats + Profile simultaneously).** *Closed
|
||||||
|
2026-05-12.* `handle_menu_button` in `hud_plugin.rs` now checks
|
||||||
|
`scrims: Query<(), With<ModalScrim>>` and only calls
|
||||||
|
`spawn_menu_popover` when `scrims.is_empty()`. Tapping ≡ while any
|
||||||
|
modal is open is a no-op. Verified on device.
|
||||||
|
|
||||||
|
## Notes / decisions
|
||||||
|
|
||||||
|
* This list is screenshot-driven; expect more items to surface once
|
||||||
|
P0 unblocks actually moving cards on hardware.
|
||||||
|
* The pattern across all the bugs is "no one ran the relevant code
|
||||||
|
path on Android yet." The hard work — Bevy 0.18 on Android,
|
||||||
|
JNI bridges, signed CI builds — is done. What's left is a
|
||||||
|
coordinated pass of `#[cfg(target_os = "android")]` gates plus
|
||||||
|
making `LayoutResource` query the real surface size.
|
||||||
|
* Where possible, prefer responsive layout (query window size) over
|
||||||
|
branching `#[cfg]` blocks. Branches are fine for input methods
|
||||||
|
(touch vs. mouse) but not for screen geometry — a foldable or
|
||||||
|
desktop window of equivalent size should look the same.
|
||||||
@@ -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,251 @@
|
|||||||
|
# Card-face artwork migration plan
|
||||||
|
|
||||||
|
**Status:** planning artifact (no code changed by this document).
|
||||||
|
**Tracks:** the "Card-face / suit / card-back artwork regeneration"
|
||||||
|
item in `SESSION_HANDOFF.md` → "Visual-identity follow-ups"
|
||||||
|
(SESSION_HANDOFF Resume prompt option D).
|
||||||
|
**Companion to:** `docs/ui-mockups/design-system.md` (Game Cards
|
||||||
|
spec, lines 214–233) and `docs/ui-mockups/desktop-adaptation.md`
|
||||||
|
(rules-based companion to the mockups).
|
||||||
|
|
||||||
|
## Why this is a multi-session arc
|
||||||
|
|
||||||
|
Every post-v0.20.0 visual-identity port to date (modal scaffold,
|
||||||
|
toasts, table chrome, splash boot screen, replay overlay) was a
|
||||||
|
**single rendering path** — change tokens, change comments, ship.
|
||||||
|
Cards have **two** rendering paths that are visually identical
|
||||||
|
today and would visually disagree the moment one moves:
|
||||||
|
|
||||||
|
1. **PNG path (production).** `assets/cards/faces/<rank><suit>.png`
|
||||||
|
loaded into `CardImageSet.faces[suit][rank]` at startup; card
|
||||||
|
sprites blit the texture. 52 face PNGs + 5 back PNGs already
|
||||||
|
in `assets/`, all the legacy white-card aesthetic from the
|
||||||
|
pre-Terminal design system.
|
||||||
|
2. **Constant fallback (tests + asset-missing edge).** When
|
||||||
|
`CardImageSet` isn't a registered resource (the case under
|
||||||
|
`MinimalPlugins` test fixtures, and the bare-bones path the
|
||||||
|
first-frame of production hits before assets resolve), the
|
||||||
|
renderer falls back to solid-colour sprites driven by the
|
||||||
|
`card_plugin` constants:
|
||||||
|
- `CARD_FACE_COLOUR` — `(0.98, 0.98, 0.95)` cream-ish white.
|
||||||
|
- `RED_SUIT_COLOUR` — `(0.78, 0.12, 0.15)` warm red.
|
||||||
|
- `BLACK_SUIT_COLOUR` — `(0.08, 0.08, 0.08)` near-black.
|
||||||
|
- `CARD_FACE_COLOUR_RED_CBM` — `(0.85, 0.92, 1.0, 1.0)` light
|
||||||
|
blue (the legacy color-blind tint).
|
||||||
|
- `card_back_colour(idx)` — five legacy back themes.
|
||||||
|
|
||||||
|
A single-path migration leaves a known-broken state where tests
|
||||||
|
pass against Terminal constants while a human sees legacy artwork
|
||||||
|
on screen — the exact bisection-hostile drift the handoff's
|
||||||
|
"in lockstep" warning preempts.
|
||||||
|
|
||||||
|
## Target state — Terminal aesthetic
|
||||||
|
|
||||||
|
Per `design-system.md` § Game Cards (lines 214–233):
|
||||||
|
|
||||||
|
### Card face
|
||||||
|
|
||||||
|
| Element | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Background | `#1a1a1a` |
|
||||||
|
| Border | 1 px solid in **suit colour** (pink for ♥/♦, foreground gray for ♠/♣) |
|
||||||
|
| Corner radius | 8 px |
|
||||||
|
| Top-left | rank in JetBrains Mono **Bold 18 px** + small suit glyph (10 px) |
|
||||||
|
| Bottom-right | large suit glyph (32 px), rotated 180° |
|
||||||
|
| Glyph fill rule | ♥ ♠ filled; ♦ ♣ outlined (1.5 px stroke). Always on, not a toggle. |
|
||||||
|
|
||||||
|
### Suit colours (always-on glyph differentiation is the *primary*
|
||||||
|
distinguishing mechanism; colour is supplementary):
|
||||||
|
|
||||||
|
| Suit | Default | Color-blind mode |
|
||||||
|
|---|---|---|
|
||||||
|
| Hearts | `#fb9fb1` (pink) | `#6fc2ef` (cyan) |
|
||||||
|
| Diamonds | `#fb9fb1` (pink) | `#6fc2ef` (cyan) |
|
||||||
|
| Spades | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) |
|
||||||
|
| Clubs | `#d0d0d0` (gray) | `#d0d0d0` (unchanged) |
|
||||||
|
|
||||||
|
### Card back ("Terminal" theme)
|
||||||
|
|
||||||
|
| Element | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Background | `#151515` |
|
||||||
|
| Pattern | horizontal scanlines at 2 px pitch in `#1a1a1a` (1 px line, 1 px gap), full bleed |
|
||||||
|
| Border | 1 px solid `#353535` |
|
||||||
|
| Top-left badge | 12×16 px solid `#6fc2ef` block, 6 px from corner |
|
||||||
|
| Bottom-right monogram | `▌RS` in JetBrains Mono 12 px `#505050`, 6 px from corner |
|
||||||
|
| Corner radius | 8 px |
|
||||||
|
| Theme name / author | `"Terminal"` / `"Rusty Solitaire"` |
|
||||||
|
|
||||||
|
## Generation pipeline — programmatic SVG via the existing
|
||||||
|
`resvg` stack
|
||||||
|
|
||||||
|
### Why this path (vs. external tooling or direct `tiny_skia`)
|
||||||
|
|
||||||
|
The codebase already ships an SVG-to-PNG rasteriser at
|
||||||
|
`solitaire_engine/src/assets/svg_loader.rs`:
|
||||||
|
|
||||||
|
- Public `rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, _>`
|
||||||
|
- Backed by `usvg` (parser) + `resvg` (renderer) + `tiny_skia`
|
||||||
|
(CPU pixmap)
|
||||||
|
- Bundled font db includes JetBrains-style mono (FiraMono — same
|
||||||
|
face the splash uses; close enough to JetBrains Mono for
|
||||||
|
rasterisation purposes, and identical to what the Bevy UI
|
||||||
|
consumes in the rest of the app)
|
||||||
|
- `RenderAssetUsages::default()` is the call-site convention here
|
||||||
|
|
||||||
|
This means: **generating new card PNGs is one new file
|
||||||
|
(`solitaire_engine/examples/card_face_generator.rs`) calling an
|
||||||
|
existing public function.** No new dependencies, no asset-pipeline
|
||||||
|
changes, no build-script machinery. Anyone who runs the example
|
||||||
|
gets bit-identical artwork.
|
||||||
|
|
||||||
|
The two alternatives are weaker:
|
||||||
|
|
||||||
|
- **External tool (Inkscape / Figma / hand-design)** — produces
|
||||||
|
one-off PNGs that can't be re-generated reproducibly without
|
||||||
|
re-opening the source files in a specific tool. Iteration cost
|
||||||
|
is high; design tweaks (e.g. "make the suit glyph 2 px larger")
|
||||||
|
require a designer-in-the-loop.
|
||||||
|
- **Direct `tiny_skia` painting calls** — bypasses SVG entirely,
|
||||||
|
but loses the readability of "open the SVG to see exactly what
|
||||||
|
the card looks like." Also reinvents primitives (rounded
|
||||||
|
rectangles, text layout) that `usvg` already handles.
|
||||||
|
|
||||||
|
### Output format
|
||||||
|
|
||||||
|
PNG, RGBA8 sRGB, **dimensions 256 × 384** (2:3 aspect, half the
|
||||||
|
default `SvgLoaderSettings` of 512 × 768).
|
||||||
|
|
||||||
|
Rationale: cards never exceed ~250 px wide on desktop windows
|
||||||
|
today, and 256 × 384 PNGs are ~6 KB each at this content density
|
||||||
|
(13.4 KB total for a full deck of 52 + 5 backs). The default 512 ×
|
||||||
|
768 is 2× what's needed and quadruples the on-disk asset weight.
|
||||||
|
The existing legacy PNGs are 512 × 768 — reducing the new ones
|
||||||
|
halves the runtime asset size.
|
||||||
|
|
||||||
|
## Lockstep migration — recommended order
|
||||||
|
|
||||||
|
Each step is a separate commit; the constraint is that **steps 4
|
||||||
|
and 5 must land in the same commit** (or at most adjacent commits
|
||||||
|
on the same branch) so the rendered output never diverges between
|
||||||
|
the two paths.
|
||||||
|
|
||||||
|
1. **(Done — this commit)** Land the migration plan doc.
|
||||||
|
2. **Land the SVG generator example.** New
|
||||||
|
`solitaire_engine/examples/card_face_generator.rs`. Output
|
||||||
|
goes to `assets/cards/faces/` and `assets/cards/backs/`. Run
|
||||||
|
once locally to seed the new artwork. The example file stays
|
||||||
|
in-tree as a regenerator for future tweaks.
|
||||||
|
3. **(Optional — can land separately)** Add a one-shot regression
|
||||||
|
test that re-runs the generator into a `tempdir` and compares
|
||||||
|
the resulting bytes against the on-disk artwork; pinning the
|
||||||
|
generator output prevents silent drift if `usvg`/`resvg` ever
|
||||||
|
tweak rendering. Skip if the test runtime cost is unacceptable.
|
||||||
|
4. **Land the new artwork** (PNG bytes from step 2 committed to
|
||||||
|
`assets/cards/`) **and** the constant migration in the *same
|
||||||
|
commit*:
|
||||||
|
- `CARD_FACE_COLOUR` → `Color::srgb(0.102, 0.102, 0.102)` (`#1a1a1a`)
|
||||||
|
- `RED_SUIT_COLOUR` → `Color::srgb(0.984, 0.624, 0.694)` (`#fb9fb1`)
|
||||||
|
- `BLACK_SUIT_COLOUR` → `Color::srgb(0.816, 0.816, 0.816)` (`#d0d0d0`)
|
||||||
|
- `CARD_FACE_COLOUR_RED_CBM` → `Color::srgb(0.435, 0.761, 0.937)` (`#6fc2ef`) — note this is now the colour-blind *suit* colour, not a face tint; semantics shift slightly.
|
||||||
|
- `card_back_colour(idx)` — re-author for the Terminal palette;
|
||||||
|
index 0 stays the canonical "Terminal" back from `design-system.md`.
|
||||||
|
5. **Test updates land in step 4's commit.** The pinning tests at
|
||||||
|
`card_plugin.rs` lines 1749, 1750, 1767, 1768, 2057, 2063,
|
||||||
|
2071, 2081 all assert against the old constants. New
|
||||||
|
assertions update in lockstep with the constant changes.
|
||||||
|
|
||||||
|
## CBM (color-blind mode) semantics shift — flag
|
||||||
|
|
||||||
|
The **legacy** `CARD_FACE_COLOUR_RED_CBM` was a *face tint* — red
|
||||||
|
suits got a light-blue background wash. The **Terminal** spec
|
||||||
|
moves CBM into the *suit colour* itself (red glyphs swap to cyan).
|
||||||
|
Step 4 will rename / repurpose this constant; it's not a 1:1
|
||||||
|
replacement.
|
||||||
|
|
||||||
|
Two options:
|
||||||
|
|
||||||
|
- **Rename + repurpose:** `CARD_FACE_COLOUR_RED_CBM` →
|
||||||
|
`RED_SUIT_COLOUR_CBM`. Communicates the semantic shift in the
|
||||||
|
symbol name. Requires touching every callsite.
|
||||||
|
- **Keep the name, change the meaning:** less code churn but
|
||||||
|
worse for greppability — a future reader hitting the legacy
|
||||||
|
name will assume face-tint behaviour.
|
||||||
|
|
||||||
|
Recommendation: **rename**. The CBM swap is a one-frame operation
|
||||||
|
even if it touches every existing callsite (currently lines 642,
|
||||||
|
2071, 2081 per `grep -n CARD_FACE_COLOUR_RED_CBM`).
|
||||||
|
|
||||||
|
## Theme system — out of scope here
|
||||||
|
|
||||||
|
The card-theme system (`docs/CARD_PLAN.md`, `theme/plugin.rs`)
|
||||||
|
already supports user-supplied themes via `assets/themes/<theme>/`
|
||||||
|
SVG files rasterised by `svg_loader.rs`. The new Terminal artwork
|
||||||
|
is the **default theme**, not a new entry in the theme picker —
|
||||||
|
the theme system continues to overlay user themes on top of the
|
||||||
|
default at runtime.
|
||||||
|
|
||||||
|
If the next session wants to also ship Terminal as a *named theme
|
||||||
|
slot* (so a user can switch back to the legacy artwork via the
|
||||||
|
theme picker), that's an additive change after step 4 and lives
|
||||||
|
in `theme::plugin::apply_theme_to_card_image_set`.
|
||||||
|
|
||||||
|
## Test impact summary
|
||||||
|
|
||||||
|
`grep -n CARD_FACE_COLOUR\\b\|RED_SUIT_COLOUR\\b\|BLACK_SUIT_COLOUR\\b` in
|
||||||
|
`card_plugin.rs`:
|
||||||
|
|
||||||
|
- Line 1749–1750: red-suit text colour assertions (♥ + ♦).
|
||||||
|
- Line 1767–1768: black-suit text colour assertions (♠ + ♣).
|
||||||
|
- Line 2057, 2063: face-colour assertion in default mode.
|
||||||
|
- Line 2071, 2081: face-colour assertion in CBM.
|
||||||
|
|
||||||
|
The four suit-colour and two face-colour tests are **invariant
|
||||||
|
guards** — they exist precisely so a constant tweak surfaces here
|
||||||
|
rather than in a visual review. Step 4 updates each in lockstep
|
||||||
|
with the constant value change. No new test infrastructure
|
||||||
|
needed.
|
||||||
|
|
||||||
|
## Open questions to resolve before step 4
|
||||||
|
|
||||||
|
1. **Border colour conflict.** The spec (line 218) says "Border:
|
||||||
|
1 px solid in suit colour." The fallback path doesn't draw a
|
||||||
|
border today — it draws solid-colour sprites. Step 4 either:
|
||||||
|
(a) leaves the fallback as solid-colour squares (the test
|
||||||
|
environment doesn't visually validate borders anyway), or
|
||||||
|
(b) extends the fallback renderer to paint a 1 px outline.
|
||||||
|
Recommend (a) — fallback fidelity isn't load-bearing.
|
||||||
|
2. **Glyph rendering in the constant fallback.** The fallback
|
||||||
|
today doesn't render suit glyphs at all — it's a coloured
|
||||||
|
square. The spec's filled-vs-outlined glyph differentiation
|
||||||
|
only matters in the PNG path. No change to the constant
|
||||||
|
fallback for glyphs.
|
||||||
|
3. **High-contrast mode.** `design-system.md` line 274 mentions
|
||||||
|
a high-contrast accessibility mode (boosts foreground from
|
||||||
|
`#d0d0d0` to `#f5f5f5`, suit-red from `#fb9fb1` to `#ff8aa0`).
|
||||||
|
Not currently implemented anywhere; out of scope for this
|
||||||
|
migration but worth flagging for a future accessibility pass.
|
||||||
|
|
||||||
|
## Post-migration — what's still open
|
||||||
|
|
||||||
|
- **High-contrast mode** (above).
|
||||||
|
- **Reduced-motion mode** for card lift / drop transitions
|
||||||
|
(also a `design-system.md` accessibility item, separate from
|
||||||
|
artwork).
|
||||||
|
- **The 9 missing-plugin screens** (splash, challenge,
|
||||||
|
time-attack, weekly-goals, leaderboard, sync, level-up,
|
||||||
|
replay, radial-menu) per `project_ui_overhaul` memory still
|
||||||
|
need their plugin ports — separate from the cards arc.
|
||||||
|
|
||||||
|
## Sign-off criteria for "D closed"
|
||||||
|
|
||||||
|
D from the SESSION_HANDOFF Resume prompt is closed when **all of
|
||||||
|
the following hold simultaneously**:
|
||||||
|
|
||||||
|
- The 52 face PNGs + 5 back PNGs in `assets/cards/` are the
|
||||||
|
Terminal-aesthetic artwork (regeneratable via the example).
|
||||||
|
- The five `card_plugin` constants reflect the Terminal palette.
|
||||||
|
- All pinning tests pass against the new values.
|
||||||
|
- A human boots the game and sees Terminal cards (not white
|
||||||
|
cards). This sign-off needs a real `cargo run`, not just
|
||||||
|
`cargo test`.
|
||||||
@@ -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,285 @@
|
|||||||
|
---
|
||||||
|
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: '#a54242'
|
||||||
|
primary: '#a54242'
|
||||||
|
on-primary: '#151515'
|
||||||
|
primary-container: '#3a1f1f'
|
||||||
|
on-primary-container: '#d5a8a8'
|
||||||
|
inverse-primary: '#993e3e'
|
||||||
|
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: '#acc267'
|
||||||
|
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 — historically the primary CTA; now reserved for ad-hoc accents only |
|
||||||
|
| base0D | `#6fc2ef` | (alias) |
|
||||||
|
| base08 (project) | `#a54242` | brick red — primary CTA, focus ring, `selection` (project-specific extension; the base16-eighties `base08` slot is `#fb9fb1` pink which we keep as `error`/`suit-red`) |
|
||||||
|
| `suit-red-cb` slot | `#acc267` | lime — color-blind-mode swap for red suits (was `#6fc2ef` cyan before the 2026-05-08 primary-accent swap; lime is the next-best non-red base16-eighties accent) |
|
||||||
|
| base0E | `#e1a3ee` | violet — used for celebration (level-up, achievement unlock) |
|
||||||
|
| base0F | `#fb9fb1` | (alias) |
|
||||||
|
|
||||||
|
### Semantic assignments
|
||||||
|
|
||||||
|
- **CTA / Primary action**: brick red `#a54242`. Reserved for "Play," "New Game," "Save," "Resume," and the focus ring on selected cards. Never used decoratively. (Was cyan `#6fc2ef` before the 2026-05-08 swap.)
|
||||||
|
- **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 pairing**, with mandatory color-blind
|
||||||
|
support. Saturated red for hearts + diamonds, near-white for clubs
|
||||||
|
+ spades — the "Microsoft Solitaire on dark mode" feel of a real
|
||||||
|
playing-card deck. (A brief 4-color-deck experiment shipped between
|
||||||
|
v0.21.0 and the next post-cut commit; reverted to traditional
|
||||||
|
2-color at the player's request.)
|
||||||
|
|
||||||
|
| Suit | Default | Color-blind mode | Glyph differentiation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Hearts | `#e35353` (saturated red) | `#acc267` (lime) | Solid filled glyph |
|
||||||
|
| Diamonds | `#e35353` (saturated red) | `#acc267` (lime) | **Outlined glyph (1.5px stroke)** |
|
||||||
|
| Spades | `#e8e8e8` (near-white) | `#e8e8e8` (unchanged) | Solid filled glyph |
|
||||||
|
| Clubs | `#e8e8e8` (near-white) | `#e8e8e8` (unchanged) | **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 swaps both red suits (hearts + diamonds) from `#e35353` to `#acc267` (lime); clubs + spades stay at the near-white. The toggle does not turn the outlined glyphs on or off, because outlined glyphs are always on. (Was red→cyan before the 2026-05-08 primary-accent swap; CBM moved to lime to stay hue-distinct from the new red-family primary.)
|
||||||
|
|
||||||
|
## 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 `#a54242` 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 `#a54242` 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: none. The card shape is defined by the body fill alone against the play surface. The earlier 1px suit-coloured border was removed because it produced visible anti-aliasing artifacts at the rounded corners (a "gray sliver" where the colored stroke faded through gray pixels into the dark play surface). The 5-unit brightness gap between `#1a1a1a` body and `#151515` surface is enough to read as a card edge without an explicit stroke.
|
||||||
|
- Top-left: rank in JetBrains Mono Bold 18px + small suit glyph (10px)
|
||||||
|
- Bottom-right: large suit glyph (32px), upright (same orientation as the top-left small glyph — single-orientation digital play does not benefit from the traditional 180° inverted-corner indicator)
|
||||||
|
- 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 `#a54242` 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 `#a54242` fill, `#151515` text, JetBrains Mono Medium 14px uppercase with `+0.08em` tracking. 4px corner radius. Pressed state: darken to `#7a3030`. Disabled: `#353535` fill, `#505050` text.
|
||||||
|
|
||||||
|
### Secondary Buttons
|
||||||
|
|
||||||
|
Transparent fill, 1px `#505050` border, `#d0d0d0` text. Hover/press: border becomes `#a54242`, text becomes `#a54242`.
|
||||||
|
|
||||||
|
### 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 `#a54242`.
|
||||||
|
|
||||||
|
### 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 the red suits' default `#e35353` for `#acc267` (lime). 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).
|
||||||