Compare commits

...

54 Commits

Author SHA1 Message Date
funman300 396ba6bc97 fix(ci): always install Java regardless of SDK cache hit; harden release creation
Android Build / build-apk (push) Failing after 13s
Build and Deploy / build-and-push (push) Successful in 38s
Android Release / build-release-apk (push) Failing after 11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:11:42 -07:00
Gitea CI 88298206bb chore(deploy): bump image to 0f650311 [skip ci] 2026-05-14 17:11:05 +00:00
funman300 0f65031114 ci: add Android release workflow — sign and publish APK on version tag
Android Build / build-apk (push) Failing after 11s
Build and Deploy / build-and-push (push) Successful in 23s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:10:27 -07:00
funman300 c91ce9436e fix(deploy): copy classic theme assets into Docker runtime image
Build and Deploy / build-and-push (push) Failing after 27s
solitaire_engine/assets/themes/classic/ was absent from the container
because only the workspace-root assets/ directory was copied. The
AssetServer serves themes/classic/ from that same root, so the classic
theme manifested as a missing-asset load failure at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:00:43 -07:00
Gitea CI ace96b4a47 chore(deploy): bump image to ea079af9 [skip ci] 2026-05-14 05:58:01 +00:00
funman300 ea079af9e1 ci: add Android APK build workflow
Android Build / build-apk (push) Failing after 59s
Build and Deploy / build-and-push (push) Successful in 26s
Triggers on every master push that touches app/engine/asset code
(ignores deploy/, argocd/, solitaire_server/, *.md).

Three-layer cache strategy:
  1. Android SDK + NDK keyed by NDK + build-tools versions (~2 GB, stable)
  2. cargo-apk binary keyed by OS + toolchain (avoids recompiling the tool)
  3. Cargo registry + build artifacts keyed by Cargo.lock + SHA

Outputs a debug APK as a workflow artifact retained for 30 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:56:34 -07:00
Gitea CI c66d81c73a chore(deploy): bump image to 20b7a617 [skip ci] 2026-05-14 05:53:08 +00:00
funman300 20b7a617e0 feat(engine): rename themes — Classic is default, Dark replaces Default
Build and Deploy / build-and-push (push) Successful in 33s
- Rename assets/themes/default/ → assets/themes/dark/; update theme.ron
  id/name to "dark"/"Dark"
- Rename all DEFAULT_THEME_* constants → DARK_THEME_* and
  default_theme_svg_bytes / populate_embedded_default_theme → dark_*
- Add bundled_theme_url() helper for URL resolution without needing the
  registry (used by Startup systems where ordering isn't guaranteed)
- Registry now lists Classic first (new player default), Dark second
- settings.rs default_theme_id() returns "classic" so fresh installs
  start on the white card theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:52:44 -07:00
funman300 7a0d57b2b1 feat(engine): add Classic card theme
White/cream card faces with traditional red (hearts/diamonds) and black
(clubs/spades) colours, plus a navy diamond-pattern card back. Shipped
as a bundled AssetServer theme alongside the existing Default theme.

Registry updated to include the Classic entry; registry tests updated
to reflect the new BUNDLED_COUNT of 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:52:44 -07:00
Gitea CI 93ec4a7478 chore(deploy): bump image to 72dfd741 [skip ci] 2026-05-14 05:34:53 +00:00
funman300 72dfd741c4 fix(web): add Matomo tracking snippet to all pages
Build and Deploy / build-and-push (push) Successful in 4m10s
Only game.html had the snippet; the other five pages were missing it,
causing the Matomo installation verification check to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:30:08 -07:00
funman300 3837a10b15 fix(deploy): use matomo.php for liveness/readiness probes
/index.php returns 302 after tables are created (installer redirect),
which fails k8s HTTP probes. /matomo.php is the tracker endpoint and
always returns 200 regardless of installation state. Also add
timeoutSeconds: 5 since PHP startup can exceed the 1s default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:03:07 -07:00
funman300 574115cb71 fix(deploy): switch matomo to official image 5.10.0
bitnami/matomo was removed from Docker Hub (0 tags). Switch to the
official matomo:5.10.0 image; update port 8080→80, volume path to
/var/www/html, and env var names to match the official image schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:52:00 -07:00
Gitea CI 1707553790 chore(deploy): bump image to 6905f26b [skip ci] 2026-05-14 04:37:19 +00:00
funman300 6905f26b56 security: remove secrets from git, gitignore k8s secret files
Build and Deploy / build-and-push (push) Successful in 35s
Secrets committed in prior commits (matomo-secret.yaml,
secret-analytics-auth.yaml) have been scrubbed from history via
filter-branch — rotate those credentials immediately.

Going forward:
- deploy/*-secret.yaml is gitignored; apply manually with kubectl
- deploy/matomo-secret.yaml.example shows the required shape
- ArgoCD ignoreDifferences on matomo-secret prevents it pruning a
  manually-applied secret
- Remove matomo-secret.yaml from kustomization.yaml so ArgoCD never
  manages it again

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:36:46 -07:00
funman300 1b7c4d92aa fix(web): auto-complete now works with cards remaining in waste
check_auto_complete no longer requires the waste pile to be empty —
only the stock must be exhausted and all tableau cards face-up.
next_auto_complete_move checks the waste top card before scanning
tableau, and auto_complete_step falls back to draw() when no direct
foundation move is available so the waste drains automatically.

Fixes the end-game state where the player could see a clear win but
the auto-complete interval never fired because the waste was non-empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:30:46 -07:00
Gitea CI d685224ce6 chore(deploy): bump image to 3e006a1e [skip ci] 2026-05-14 04:14:55 +00:00
funman300 539779d78b feat(analytics): replace custom pipeline with Matomo
Removes the hand-rolled analytics endpoint and SQLite event table in favour
of Matomo — a self-hosted, full-featured analytics platform.

k8s:
- Deploy MariaDB 11 + Bitnami Matomo 5 in the solitaire namespace
- Route analytics.aleshym.co ingress to the Matomo service
- Remove Datasette sidecar and its BasicAuth middleware/secret
- Remove the analytics port from the solitaire-server Service

Rust:
- Replace AnalyticsClient (custom HTTP endpoint) with MatomoClient (Matomo
  HTTP Tracking API bulk endpoint); maps game events to Matomo categories
- Add matomo_url + matomo_site_id fields to Settings (serde default → None/1)
- Privacy toggle in Settings now activates when matomo_url is set (not tied
  to SyncBackend::SolitaireServer)
- Remove POST /api/analytics route from solitaire_server

Web:
- Add Matomo JS tracking snippet to game.html (/play page)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:10:15 -07:00
funman300 f6506c57e5 feat(deploy): Datasette analytics sidecar + analytics.aleshym.co ingress
Adds a Datasette container alongside the existing server in the same pod so
it can read the SQLite PVC without a second ReadWriteOnce mount. Protected
by a Traefik BasicAuth middleware at analytics.aleshym.co.

Also fixes the ArgoCD repoURL to point to the migrated Gitea hostname
(git.aleshym.co) instead of the old bare IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:17:20 -07:00
Gitea CI b88f3df119 chore(deploy): bump image to 3cec200a [skip ci] 2026-05-14 03:10:52 +00:00
funman300 0dcb783e94 feat(analytics): opt-in usage analytics with server ingest and settings toggle
- Server: POST /api/analytics endpoint with per-IP rate limit (5/min),
  batch validation (≤50 events, event_type regex, UUID dedup, clock check),
  INSERT OR IGNORE for idempotency, and migration 004_analytics.sql
- Client (solitaire_data): AnalyticsClient with in-memory Mutex buffer,
  UUID session_id per launch, async flush via background task
- Engine: AnalyticsPlugin records game_won, game_forfeit, game_start,
  achievement_unlocked; flushes immediately on game-end, every 60 s otherwise
- Settings UI: Privacy section with ON/OFF toggle, hidden in local-only mode
- Default: analytics_enabled = false (explicit opt-in required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:06:34 -07:00
Gitea CI ea17f94b6c chore(deploy): bump image to 09fcd209 [skip ci] 2026-05-14 02:43:38 +00:00
funman300 d60dc18add fix(server): add CSP/security headers middleware, gitignore jks.bak*
Content-Security-Policy, X-Content-Type-Options, and X-Frame-Options are
now injected by a single Axum middleware on the web router subtree, so
all HTML pages get consistent headers without touching each file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:41:50 -07:00
funman300 38eefb22e8 fix(server): XSS, missing score submission, leaderboard never updated, no LIMIT
- leaderboard.html, replays.html: escape user-supplied display_name and
  username before inserting into innerHTML to prevent stored XSS
- game.js: call POST /api/replays on win so browser-game completions are
  recorded; scores were never submitted before this fix
- replays.rs: after replay insert, upsert leaderboard best_score /
  best_time_secs for opted-in users when the new score beats their current
  best (classic mode only); scores were never updated before this fix
- leaderboard.rs: add LIMIT 100 to GET /api/leaderboard to prevent
  unbounded query growth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:32:14 -07:00
Gitea CI a579c25d5c chore(deploy): bump image to d5c95f9a [skip ci] 2026-05-14 00:21:16 +00:00
funman300 c40817d845 fix(web): preload card images to prevent white-flash on flip
When a card flipped face-up, the browser fetched the PNG on demand,
showing the cream fallback colour until the image arrived. Preloading
all 52 faces and the back at module load ensures they are cached before
any flip can occur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:17:33 -07:00
Gitea CI c6c03b8bff chore(deploy): bump image to b0478117 [skip ci] 2026-05-14 00:14:00 +00:00
funman300 5b3925a619 feat(web): account page with sign in / sign up tabs
- Add account.html: tabbed form for login and registration, signed-in
  state with sign-out, links to leaderboard and replays
- Wire /account route in build_router_inner
- Add Account card to landing page
- Link leaderboard login prompt to /account for new users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:09:57 -07:00
Gitea CI 8485b3d1e0 chore(deploy): bump image to e6c67d03 [skip ci] 2026-05-14 00:09:08 +00:00
funman300 8325bf6cf7 chore: rename app from Solitaire Quest to Ferrous Solitaire
Replace all display-name occurrences across web pages, Rust source,
docs, and Cargo metadata. Update localStorage token key from sq_token
to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:04:45 -07:00
Gitea CI ea58f5dd64 chore(deploy): bump image to 4315c0ae [skip ci] 2026-05-13 23:54:33 +00:00
funman300 c518255a2d feat(web): leaderboard and replays pages with nav from landing
- Add leaderboard.html: JWT login form + localStorage token + table
- Add replays.html: public listing of recent replays, row click to viewer
- Wire /leaderboard and /replays routes in build_router_inner
- Fix home.html Recent Replays link from /api/replays/recent to /replays

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:50:54 -07:00
Gitea CI f5da9398f2 chore(deploy): bump image to 31d0a1b6 [skip ci] 2026-05-13 23:43:30 +00:00
funman300 b82573e7b1 feat(web): add home arrow link to game page header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:38:58 -07:00
Gitea CI 40818f5bd2 chore(deploy): bump image to 56dbc3ff [skip ci] 2026-05-13 23:37:19 +00:00
funman300 228ebbad8a fix(ci): rebase before kustomization push to handle concurrent runs
Two runs for the same SHA racing to push the kustomization update
caused the second to fail with "failed to push some refs".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:36:42 -07:00
Gitea CI 2b33feafc9 chore(deploy): bump image to 3e98872f [skip ci] 2026-05-13 23:33:23 +00:00
funman300 f8c8c9158e ci: add Docker BuildKit registry cache to speed up Rust builds
Caches compiled dependency layers in the Gitea registry under
:buildcache. Subsequent builds that only touch solitaire_server/src/
skip recompiling the full workspace dependency tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:28:10 -07:00
Gitea CI 9cc0837088 chore(deploy): bump image to 98f9933e [skip ci] 2026-05-13 23:28:10 +00:00
funman300 b47462bd27 fix(web): apply Terminal palette and UX fixes to game page
Aligns /play with the landing page and app color scheme — same
bg, panel, accent, and felt tokens from ui_theme.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:26:51 -07:00
Gitea CI 08d22c822a chore(deploy): bump image to a6030f4b [skip ci] 2026-05-13 23:24:43 +00:00
funman300 feb581005c fix(web): align replay and landing page to Terminal (base16-eighties) palette
Replay viewer was using the old midnight-purple palette. Both pages now
use the exact color tokens from ui_theme.rs — matching the desktop and
Android app exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:23:16 -07:00
funman300 00f2d890f1 feat(web): add landing page at / with links to play, leaderboard, replays
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:21:38 -07:00
Gitea CI 9533a7d420 chore(deploy): bump image to 022a749f [skip ci] 2026-05-13 22:45:42 +00:00
funman300 5ec5ac1a19 fix(server): create SQLite database file if missing on first start
SqlitePool::connect defaults create_if_missing=false in SQLx 0.8, causing
SQLITE_CANTOPEN (error 14) when the PVC is empty on first deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:44:22 -07:00
Gitea CI 86aea206b8 chore(deploy): bump image to 0c673e3b [skip ci] 2026-05-13 22:32:46 +00:00
funman300 1bd1c0f927 fix(docker): add libsqlite3-0 to runtime image to fix SQLite CANTOPEN error
The server binary dynamically links against libsqlite3.so.0, which is not
present in debian:bookworm-slim by default, causing SQLite error code 14
at startup when connecting to the database.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:32:09 -07:00
Gitea CI 7be7f4395c chore(deploy): bump image to 597aba20 [skip ci] 2026-05-13 15:04:01 -07:00
funman300 66c2907c25 fix(docker): rename binary to ./server to avoid collision with solitaire_server/web dir 2026-05-13 15:03:45 -07:00
funman300 c2811fa661 ci: trigger with dockerfile change for debug 2026-05-13 14:46:09 -07:00
funman300 933cc55ea9 fix(docker): copy web/ to builder stage for include_str! macros 2026-05-13 14:18:05 -07:00
funman300 58faae1911 fix(docker): stub all workspace crates for cargo fetch in CI 2026-05-13 14:15:24 -07:00
funman300 96be1b85fb ci: retrigger after fixing runner instance URL 2026-05-13 14:11:54 -07:00
funman300 bbf7709912 ci: retrigger build after enabling Actions 2026-05-13 14:05:23 -07:00
183 changed files with 3826 additions and 475 deletions
+118
View File
@@ -0,0 +1,118 @@
name: Android Build
on:
push:
branches: [master]
# Rebuild whenever app/engine/asset code changes.
# Skip server-only, deploy, and doc changes.
paths-ignore:
- 'deploy/**'
- 'argocd/**'
- 'solitaire_server/**'
- '**.md'
env:
ANDROID_SDK_ROOT: /opt/android-sdk
NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0"
jobs:
build-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set short SHA
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
# ── Android SDK + NDK ──────────────────────────────────────────────
# Cache the entire SDK root so subsequent runs skip the ~2 GB download.
- name: Cache Android SDK
uses: actions/cache@v4
id: sdk-cache
with:
path: ${{ env.ANDROID_SDK_ROOT }}
key: android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
- name: Install system dependencies
run: sudo apt-get install -y openjdk-17-jdk-headless unzip
- name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true'
run: |
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
curl -sL \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
-o /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
mv /tmp/cmdtools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
# Accept all SDK licences non-interactively.
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \
> /dev/null 2>&1 || true
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"build-tools;$BUILD_TOOLS_VERSION" \
"platforms;android-34" \
"ndk;$NDK_VERSION"
- name: Export Android environment
run: |
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
# ── Rust toolchain ─────────────────────────────────────────────────
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --no-modify-path
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Add Android cross-compilation targets
run: |
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android
# ── Cargo caches ───────────────────────────────────────────────────
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry-
- name: Cache cargo-apk binary
uses: actions/cache@v4
id: apk-tool-cache
with:
path: ~/.cargo/bin/cargo-apk
key: cargo-apk-${{ runner.os }}-stable
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: target
key: android-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
restore-keys: android-target-${{ hashFiles('**/Cargo.lock') }}-
# ── Build ──────────────────────────────────────────────────────────
- name: Install cargo-apk
if: steps.apk-tool-cache.outputs.cache-hit != 'true'
run: cargo install cargo-apk --locked
- name: Build debug APK
run: cargo apk build --package solitaire_app --lib
# ── Artifact ───────────────────────────────────────────────────────
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: solitaire-quest-debug-${{ steps.meta.outputs.sha }}
path: target/debug/apk/solitaire-quest.apk
retention-days: 30
+168
View File
@@ -0,0 +1,168 @@
name: Android Release
on:
push:
tags:
- 'v*.*.*'
env:
ANDROID_SDK_ROOT: /opt/android-sdk
NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0"
GITEA_API: https://git.aleshym.co/api/v1
REPO: funman300/Rusty_Solitare
jobs:
build-release-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from tag
id: meta
run: echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
# ── Android SDK + NDK ──────────────────────────────────────────────
# Shared cache key with the debug workflow so a warm debug run
# saves the ~2 GB SDK download for the release run too.
- name: Cache Android SDK
uses: actions/cache@v4
id: sdk-cache
with:
path: ${{ env.ANDROID_SDK_ROOT }}
key: android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
# Java and jq are always needed (apksigner requires Java even on cache hits).
- name: Install system dependencies
run: sudo apt-get install -y openjdk-17-jdk-headless unzip jq
- name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true'
run: |
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
curl -sL \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
-o /tmp/cmdtools.zip
unzip -q /tmp/cmdtools.zip -d /tmp/cmdtools
mv /tmp/cmdtools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \
> /dev/null 2>&1 || true
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
"build-tools;$BUILD_TOOLS_VERSION" \
"platforms;android-34" \
"ndk;$NDK_VERSION"
- name: Export Android environment
run: |
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
# ── Rust toolchain ─────────────────────────────────────────────────
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --no-modify-path
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Add Android cross-compilation targets
run: |
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android
# ── Cargo caches ───────────────────────────────────────────────────
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-registry-
- name: Cache cargo-apk binary
uses: actions/cache@v4
id: apk-tool-cache
with:
path: ~/.cargo/bin/cargo-apk
key: cargo-apk-${{ runner.os }}-stable
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: target
key: android-release-target-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
restore-keys: android-release-target-${{ hashFiles('**/Cargo.lock') }}-
# ── Build ──────────────────────────────────────────────────────────
- name: Install cargo-apk
if: steps.apk-tool-cache.outputs.cache-hit != 'true'
run: cargo install cargo-apk --locked
- name: Build release APK
run: cargo apk build --release --package solitaire_app --lib
# ── Sign ───────────────────────────────────────────────────────────
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > /tmp/solitaire-release.jks
- name: Align and sign APK
run: |
TAG="${{ steps.meta.outputs.tag }}"
UNSIGNED="target/release/apk/solitaire-quest.apk"
ALIGNED="/tmp/solitaire-quest-aligned.apk"
SIGNED="ferrous-solitaire-${TAG}.apk"
"$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION/zipalign" -v 4 \
"$UNSIGNED" "$ALIGNED"
"$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION/apksigner" sign \
--ks /tmp/solitaire-release.jks \
--ks-pass "pass:${{ secrets.KEYSTORE_PASS }}" \
--ks-key-alias "${{ secrets.KEY_ALIAS }}" \
--key-pass "pass:${{ secrets.KEY_PASS }}" \
--out "$SIGNED" \
"$ALIGNED"
- name: Verify APK signature
run: |
TAG="${{ steps.meta.outputs.tag }}"
"$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION/apksigner" verify \
--verbose "ferrous-solitaire-${TAG}.apk"
# ── Publish ────────────────────────────────────────────────────────
- name: Create Gitea release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
# Try to create; fall back to fetching the existing release on 409.
RESPONSE=$(curl -s -o /tmp/release.json -w "%{http_code}" \
-X POST "$GITEA_API/repos/$REPO/releases" \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}")
if [ "$RESPONSE" = "409" ]; then
curl -sf "$GITEA_API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
> /tmp/release.json
elif [ "$RESPONSE" != "201" ]; then
echo "Release creation failed with HTTP $RESPONSE"
cat /tmp/release.json
exit 1
fi
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
- name: Upload signed APK
run: |
TAG="${{ steps.meta.outputs.tag }}"
APK="ferrous-solitaire-${TAG}.apk"
curl -sf -X POST \
"$GITEA_API/repos/$REPO/releases/${{ steps.release.outputs.release_id }}/assets?name=$APK" \
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$APK"
+8
View File
@@ -36,6 +36,11 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.CI_TOKEN }} password: ${{ secrets.CI_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -45,6 +50,8 @@ jobs:
tags: | tags: |
${{ env.IMAGE }}:${{ steps.meta.outputs.sha }} ${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
${{ env.IMAGE }}:latest ${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
- name: Install kustomize - name: Install kustomize
run: | run: |
@@ -62,4 +69,5 @@ jobs:
git config user.name "Gitea CI" git config user.name "Gitea CI"
git add deploy/kustomization.yaml git add deploy/kustomization.yaml
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true
git pull --rebase origin master
git push git push
+6
View File
@@ -14,4 +14,10 @@ data/
# Android signing keystores — never commit # Android signing keystores — never commit
*.jks *.jks
*.jks.bak *.jks.bak
*.jks.bak*
*.keystore *.keystore
# Kubernetes secrets — apply manually, never commit
deploy/matomo-secret.yaml
deploy/*-secret.yaml
deploy/*-auth-secret.yaml
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE leaderboard\n SET best_score = ?,\n best_time_secs = ?,\n recorded_at = ?\n WHERE user_id = ?\n AND (\n best_score IS NULL\n OR ? > best_score\n OR (? = best_score AND (best_time_secs IS NULL OR ? < best_time_secs))\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "0e199cafab7e71b0c7f28ede85a622e38649d2fe5a73a5c715f2319f5450f729"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC", "query": "SELECT l.display_name, l.best_score, l.best_time_secs, l.recorded_at\n FROM leaderboard l\n JOIN users u ON u.id = l.user_id\n WHERE u.leaderboard_opt_in = 1\n ORDER BY\n CASE WHEN l.best_score IS NULL THEN 1 ELSE 0 END ASC,\n l.best_score DESC,\n CASE WHEN l.best_time_secs IS NULL THEN 1 ELSE 0 END ASC,\n l.best_time_secs ASC\n LIMIT 100",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -34,5 +34,5 @@
false false
] ]
}, },
"hash": "57c93a6acd7eea44d00412e62f0d3fed7ffbe4cd759353d29f38a8eb37f69112" "hash": "2b814989a6632ca930ae1e895f97a7fc3389c91d1d2abf6900a21fb0d6e94ef3"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
}
+2 -2
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Architecture Document # Ferrous Solitaire — Architecture Document
> **Version:** 1.3 > **Version:** 1.3
> **Language:** Rust (Edition 2024) > **Language:** Rust (Edition 2024)
@@ -34,7 +34,7 @@
## 1. Project Overview ## 1. Project Overview
Solitaire Quest is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices. Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
### Sync Backend by Platform ### Sync Backend by Platform
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
All notable changes to Solitaire Quest are documented here. The format is All notable changes to Ferrous Solitaire are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
project follows [Semantic Versioning](https://semver.org/). project follows [Semantic Versioning](https://semver.org/).
+3 -3
View File
@@ -1,6 +1,6 @@
# Credits # Credits
Solitaire Quest is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of Ferrous Solitaire is MIT-licensed (see [LICENSE](LICENSE)). It is built on top of
the work of many open-source projects and a small handful of third-party the work of many open-source projects and a small handful of third-party
assets. This file lists every component that ships in the binary or in the assets. This file lists every component that ships in the binary or in the
`assets/` directory. `assets/` directory.
@@ -43,7 +43,7 @@ copyleft code is statically linked into the game binary.
| File(s) | Source | License | | File(s) | Source | License |
|---|---|---| |---|---|---|
| `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT | | `solitaire_engine/assets/themes/default/{suit}_{rank}.svg` (52 SVGs) | [hayeah/playing-cards-assets](https://github.com/hayeah/playing-cards-assets) | MIT |
| `solitaire_engine/assets/themes/default/back.svg` | Original — Solitaire Quest | MIT (this project) | | `solitaire_engine/assets/themes/default/back.svg` | Original — Ferrous Solitaire | MIT (this project) |
| `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) | | `assets/cards/faces/{RANK}{SUIT}.png` (52 PNGs) | Pre-rendered from the same `playing-cards-assets` SVGs | MIT (passed through from hayeah) |
| `assets/cards/backs/back_0.png` `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) | | `assets/cards/backs/back_0.png` `back_4.png` | Original — generated by `solitaire_assetgen::gen_art` | MIT (this project) |
@@ -107,6 +107,6 @@ Audio files are MIT-licensed alongside the rest of this project.
backs, every audio file) are original work covered by this project's MIT backs, every audio file) are original work covered by this project's MIT
license. license.
If you redistribute Solitaire Quest, you must ship this `CREDITS.md` and the If you redistribute Ferrous Solitaire, you must ship this `CREDITS.md` and the
`LICENSE` file alongside the binary so the MIT (project + hayeah card art) `LICENSE` file alongside the binary so the MIT (project + hayeah card art)
and OFL (FiraMono) notices remain visible to end users. and OFL (FiraMono) notices remain visible to end users.
Generated
+1
View File
@@ -7018,6 +7018,7 @@ dependencies = [
"resvg", "resvg",
"ron", "ron",
"serde", "serde",
"serde_json",
"solitaire_core", "solitaire_core",
"solitaire_data", "solitaire_data",
"solitaire_sync", "solitaire_sync",
+1 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest # Ferrous Solitaire
A cross-platform Klondike Solitaire game written in Rust, with a card-theme A cross-platform Klondike Solitaire game written in Rust, with a card-theme
system, full progression (XP / levels / achievements / daily challenges), and system, full progression (XP / levels / achievements / daily challenges), and
+1 -1
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Self-Hosting Guide # Ferrous Solitaire — Self-Hosting Guide
## Prerequisites ## Prerequisites
+2 -2
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Session Handoff # Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin. **Last updated:** 2026-05-12 — Leaderboard display name shipped (`03be4fc`). All commits pushed to origin.
@@ -150,7 +150,7 @@ Items missing from the doc:
## Resume prompt ## Resume prompt
``` ```
You are a senior Rust + Bevy developer working on Solitaire Quest. You are a senior Rust + Bevy developer working on Ferrous Solitaire.
Working directory: <Rusty_Solitaire clone path>. Working directory: <Rusty_Solitaire clone path>.
Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed. Branch: master. v0.23.0 is the current version (HEAD: 03be4fc). Fully pushed.
+9 -1
View File
@@ -6,12 +6,20 @@ metadata:
spec: spec:
project: default project: default
source: source:
repoURL: http://10.10.0.64:3000/funman300/Rusty_Solitare.git repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
targetRevision: master targetRevision: master
path: deploy path: deploy
destination: destination:
server: https://kubernetes.default.svc server: https://kubernetes.default.svc
namespace: solitaire namespace: solitaire
# Secrets are applied manually and must not be pruned by ArgoCD.
ignoreDifferences:
- group: ""
kind: Secret
name: matomo-secret
namespace: solitaire
jsonPointers:
- /data
syncPolicy: syncPolicy:
automated: automated:
prune: true prune: true
+25
View File
@@ -0,0 +1,25 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: solitaire-analytics
namespace: solitaire
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: analytics.aleshym.co
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: matomo
port:
name: http
tls:
- hosts:
- analytics.aleshym.co
secretName: analytics-tls
+8 -1
View File
@@ -7,10 +7,17 @@ resources:
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml
- mariadb-pvc.yaml
- mariadb-deployment.yaml
- mariadb-service.yaml
- matomo-pvc.yaml
- matomo-deployment.yaml
- matomo-service.yaml
- ingress-analytics.yaml
# CI updates this block automatically via `kustomize edit set image`. # CI updates this block automatically via `kustomize edit set image`.
# The image name here matches the `image: solitaire-server` stub in deployment.yaml. # The image name here matches the `image: solitaire-server` stub in deployment.yaml.
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: latest newTag: 0f650311
+72
View File
@@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mariadb
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: mariadb
strategy:
type: Recreate
template:
metadata:
labels:
app: mariadb
spec:
containers:
- name: mariadb
image: mariadb:11
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_ROOT_PASSWORD
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
ports:
- containerPort: 3306
volumeMounts:
- name: mariadb-data
mountPath: /var/lib/mysql
livenessProbe:
exec:
command:
- healthcheck.sh
- --connect
- --innodb_initialized
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
exec:
command:
- healthcheck.sh
- --connect
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: mariadb-data
persistentVolumeClaim:
claimName: mariadb-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: solitaire
spec:
selector:
app: mariadb
ports:
- name: mysql
port: 3306
targetPort: 3306
clusterIP: None
+79
View File
@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: matomo
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: matomo
strategy:
type: Recreate
template:
metadata:
labels:
app: matomo
spec:
containers:
- name: matomo
image: matomo:5.10.0
env:
- name: MATOMO_DATABASE_HOST
value: mariadb
- name: MATOMO_DATABASE_PORT
value: "3306"
- name: MATOMO_DATABASE_ADAPTER
value: PDO\MYSQL
- name: MATOMO_DATABASE_DBNAME
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MATOMO_DATABASE_USERNAME
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MATOMO_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
# Traefik terminates SSL; tell Matomo to trust X-Forwarded-* headers
- name: MATOMO_GENERAL_ASSUME_SECURE_PROTOCOL
value: "1"
- name: MATOMO_GENERAL_PROXY_CLIENT_HEADERS
value: HTTP_X_FORWARDED_FOR
- name: MATOMO_GENERAL_PROXY_HOST_HEADERS
value: HTTP_X_FORWARDED_HOST
ports:
- containerPort: 80
volumeMounts:
- name: matomo-data
mountPath: /var/www/html
livenessProbe:
httpGet:
path: /matomo.php
port: 80
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /matomo.php
port: 80
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: matomo-data
persistentVolumeClaim:
claimName: matomo-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: matomo-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
+22
View File
@@ -0,0 +1,22 @@
# DO NOT COMMIT THE REAL VERSION OF THIS FILE.
# deploy/matomo-secret.yaml is gitignored — apply it manually once:
#
# cp deploy/matomo-secret.yaml.example deploy/matomo-secret.yaml
# # edit the passwords below, then:
# kubectl apply -f deploy/matomo-secret.yaml
# kubectl annotate secret matomo-secret -n solitaire \
# argocd.argoproj.io/sync-options=Prune=false --overwrite
#
# Generate strong passwords with:
# python3 -c "import secrets; print(secrets.token_urlsafe(18))"
apiVersion: v1
kind: Secret
metadata:
name: matomo-secret
namespace: solitaire
stringData:
MYSQL_ROOT_PASSWORD: "CHANGE_ME"
MYSQL_DATABASE: matomo
MYSQL_USER: matomo
MYSQL_PASSWORD: "CHANGE_ME"
MATOMO_ADMIN_PASSWORD: "CHANGE_ME"
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: matomo
namespace: solitaire
spec:
selector:
app: matomo
ports:
- name: http
port: 80
targetPort: 80
+2 -2
View File
@@ -1,4 +1,4 @@
# Solitaire Quest — Session Handoff (ARCHIVED) # Ferrous Solitaire — Session Handoff (ARCHIVED)
> **This file is from Phase 2 (2026-04-25, 242 tests). It is kept for historical > **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: > reference only. The authoritative session handoff is at the repo root:
@@ -24,7 +24,7 @@ All seven Cargo crates created and compiling cleanly:
| `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 | | `solitaire_engine` | Stub | Bevy ECS systems — all plugins added in Phase 3 |
| `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C | | `solitaire_server` | Stub | Axum sync server — implemented in Phase 8C |
| `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android | | `solitaire_gpgs` | Compile-time stub | Google Play Games bridge — Android only, JNI in Phase: Android |
| `solitaire_app` | Working | Opens blank Bevy window titled "Solitaire Quest" at 1280×800 | | `solitaire_app` | Working | Opens blank Bevy window titled "Ferrous Solitaire" at 1280×800 |
Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place. Fast compile profiles, `assets/` directory structure, and `.env.example` are all in place.
+1 -1
View File
@@ -2,7 +2,7 @@
> **Date:** 2026-04-28 > **Date:** 2026-04-28
> **Author:** Claude Code > **Author:** Claude Code
> **Scope:** Feasibility analysis for porting Solitaire Quest to Android using cargo-mobile2 > **Scope:** Feasibility analysis for porting Ferrous Solitaire to Android using cargo-mobile2
--- ---
@@ -1,4 +1,4 @@
# Solitaire Quest — Phase 1 + 2: Workspace & Core Game Engine # Ferrous Solitaire — Phase 1 + 2: Workspace & Core Game Engine
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@@ -555,7 +555,7 @@ fn main() {
.add_plugins( .add_plugins(
DefaultPlugins.set(WindowPlugin { DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Solitaire Quest".into(), title: "Ferrous Solitaire".into(),
resolution: (1280.0, 800.0).into(), resolution: (1280.0, 800.0).into(),
..default() ..default()
}), }),
@@ -571,7 +571,7 @@ fn main() {
```bash ```bash
cargo run -p solitaire_app --features bevy/dynamic_linking cargo run -p solitaire_app --features bevy/dynamic_linking
``` ```
Expected: A blank Bevy window titled "Solitaire Quest" opens. Press Escape or close the window to exit. No panics or errors in the terminal. Expected: A blank Bevy window titled "Ferrous Solitaire" opens. Press Escape or close the window to exit. No panics or errors in the terminal.
--- ---
@@ -1210,7 +1210,7 @@ fn main() {
.add_plugins( .add_plugins(
DefaultPlugins.set(WindowPlugin { DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Solitaire Quest".into(), title: "Ferrous Solitaire".into(),
resolution: (1280.0, 800.0).into(), resolution: (1280.0, 800.0).into(),
..default() ..default()
}), }),
+1 -1
View File
@@ -11,7 +11,7 @@
### Infrastructure ### Infrastructure
- Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network. - Two machines (or VMs) referred to as **Machine A** and **Machine B** throughout this runbook. Both must be able to reach the sync server over the network.
- A running Solitaire Quest sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup. - A running Ferrous Solitaire sync server reachable at a known URL, e.g. `https://solitaire.example.com`. See `README_SERVER.md` for setup.
- Verify the server is live before starting: - Verify the server is live before starting:
```bash ```bash
+1 -1
View File
@@ -3,7 +3,7 @@
> **Why this exists.** The 24 mockups in this directory are mobile > **Why this exists.** The 24 mockups in this directory are mobile
> (390 × 844 logical, iPhone 14 Pro frame) with one exception > (390 × 844 logical, iPhone 14 Pro frame) with one exception
> (`home-menu-desktop.html`). The Stitch project that produced them > (`home-menu-desktop.html`). The Stitch project that produced them
> is named "Solitaire Quest *Mobile* Redesign" — the mobile-first > is named "Ferrous Solitaire *Mobile* Redesign" — the mobile-first
> framing was deliberate when the new Android target opened, but > framing was deliberate when the new Android target opened, but
> desktop is still the primary delivery surface. Porting the mobile > desktop is still the primary delivery surface. Porting the mobile
> mockups 1:1 would land a 390-px-wide column floating in the middle > mockups 1:1 would land a 390-px-wide column floating in the middle
+1 -1
View File
@@ -87,7 +87,7 @@ required = true
name = "android.permission.INTERNET" name = "android.permission.INTERNET"
[package.metadata.android.application] [package.metadata.android.application]
label = "Solitaire Quest" label = "Ferrous Solitaire"
# Launcher icon — references the density-bucketed mipmap resource above. # Launcher icon — references the density-bucketed mipmap resource above.
icon = "@mipmap/ic_launcher" icon = "@mipmap/ic_launcher"
# `debuggable` defaults to false on release builds; cargo-apk flips it # `debuggable` defaults to false on release builds; cargo-apk flips it
+3 -2
View File
@@ -25,7 +25,7 @@ use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
use bevy::winit::WinitWindows; use bevy::winit::WinitWindows;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{ use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin,
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
@@ -106,7 +106,7 @@ pub fn run() {
DefaultPlugins DefaultPlugins
.set(WindowPlugin { .set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Solitaire Quest".into(), title: "Ferrous Solitaire".into(),
// X11/Wayland WM_CLASS so taskbar managers group // X11/Wayland WM_CLASS so taskbar managers group
// multiple windows of this app correctly. // multiple windows of this app correctly.
name: Some("solitaire-quest".into()), name: Some("solitaire-quest".into()),
@@ -194,6 +194,7 @@ pub fn run() {
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncPlugin::new(sync_provider))
.add_plugins(SyncSetupPlugin) .add_plugins(SyncSetupPlugin)
.add_plugins(AnalyticsPlugin)
.add_plugins(LeaderboardPlugin) .add_plugins(LeaderboardPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
+1 -1
View File
@@ -1,4 +1,4 @@
//! Generates PNG assets for Solitaire Quest. //! Generates PNG assets for Ferrous Solitaire.
//! //!
//! Produces: //! Produces:
//! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and //! - 52 card face PNGs (120×168) — one per card, with rank, suit symbol, and
+36 -26
View File
@@ -426,12 +426,11 @@ impl GameState {
/// Returns `true` when stock and waste are empty and all tableau cards are face-up. /// Returns `true` when stock and waste are empty and all tableau cards are face-up.
/// At that point the game can be completed without further player input. /// At that point the game can be completed without further player input.
pub fn check_auto_complete(&self) -> bool { pub fn check_auto_complete(&self) -> bool {
// Stock must be empty; waste may still have cards (they are resolved
// by draw() calls inside next_auto_complete_move / auto_complete_step).
if !self.piles[&PileType::Stock].cards.is_empty() { if !self.piles[&PileType::Stock].cards.is_empty() {
return false; return false;
} }
if !self.piles[&PileType::Waste].cards.is_empty() {
return false;
}
(0..7).all(|i| { (0..7).all(|i| {
self.piles[&PileType::Tableau(i)] self.piles[&PileType::Tableau(i)]
.cards .cards
@@ -459,17 +458,36 @@ impl GameState {
if !self.is_auto_completable || self.is_won { if !self.is_auto_completable || self.is_won {
return None; return None;
} }
// Check waste top first — when stock is exhausted the waste may still
// contain cards that can go directly to a foundation.
let waste = PileType::Waste;
if let Some((card, slot)) = self.piles[&waste].cards.last()
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
{
let _ = card; // borrow ends here
return Some((waste, PileType::Foundation(slot)));
}
for i in 0..7 { for i in 0..7 {
let tableau = PileType::Tableau(i); let tableau = PileType::Tableau(i);
if let Some(card) = self.piles[&tableau].cards.last() { if let Some(slot) = self.piles[&tableau].cards.last()
// Prefer the slot that already claims this card's suit so .and_then(|c| self.foundation_slot_for(c))
// Aces don't sometimes land in slot 0 and then leave the {
// matching suit-claimed slot empty. return Some((tableau, PileType::Foundation(slot)));
}
}
None
}
/// Return the foundation slot index that `card` can legally move to, or
/// `None` if no such slot exists.
///
/// Prefers the slot already claiming this card's suit so Aces always land
/// in a consistent column. Falls back to an empty slot only for Aces.
fn foundation_slot_for(&self, card: &crate::card::Card) -> Option<u8> {
let mut candidate: Option<u8> = None; let mut candidate: Option<u8> = None;
let mut empty_slot: Option<u8> = None; let mut empty_slot: Option<u8> = None;
for slot in 0..4_u8 { for slot in 0..4_u8 {
let foundation = PileType::Foundation(slot); let pile = &self.piles[&PileType::Foundation(slot)];
let pile = &self.piles[&foundation];
if pile.cards.is_empty() { if pile.cards.is_empty() {
if empty_slot.is_none() { if empty_slot.is_none() {
empty_slot = Some(slot); empty_slot = Some(slot);
@@ -479,20 +497,12 @@ impl GameState {
break; break;
} }
} }
let target_slot = candidate.or_else(|| { let target = candidate.or_else(|| {
// Only fall back to an empty slot if the card is an Ace,
// which is the only rank that can claim an empty slot.
if card.rank.value() == 1 { empty_slot } else { None } if card.rank.value() == 1 { empty_slot } else { None }
}); });
if let Some(slot) = target_slot { target.filter(|&slot| {
let foundation = PileType::Foundation(slot); can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
if can_place_on_foundation(card, &self.piles[&foundation]) { })
return Some((tableau, foundation));
}
}
}
}
None
} }
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0). /// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
@@ -1022,24 +1032,24 @@ mod tests {
} }
#[test] #[test]
fn auto_complete_false_when_waste_not_empty() { fn auto_complete_true_when_stock_empty_waste_has_cards() {
// Waste no longer blocks auto-complete — draw() drains it during
// auto-complete steps. Only stock-not-empty and face-down tableau
// cards block the flag.
let mut g = new_game(); let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear(); g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
// Leave the waste pile untouched (it may be empty after clearing stock,
// so add a card explicitly to ensure the waste guard is exercised).
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card { g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
id: 99, id: 99,
suit: Suit::Clubs, suit: Suit::Clubs,
rank: Rank::Ace, rank: Rank::Ace,
face_up: true, face_up: true,
}); });
// Make all tableau cards face-up so only the waste guard is the blocker.
for i in 0..7 { for i in 0..7 {
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() { for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
c.face_up = true; c.face_up = true;
} }
} }
assert!(!g.check_auto_complete()); assert!(g.check_auto_complete());
} }
#[test] #[test]
+1
View File
@@ -15,6 +15,7 @@ async-trait = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by # `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in # `auth_tokens`. The crate's own dependency tree pulls in
+3
View File
@@ -163,5 +163,8 @@ pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
}; };
pub mod matomo_client;
pub use matomo_client::MatomoClient;
pub mod platform; pub mod platform;
pub use platform::data_dir; pub use platform::data_dir;
+122
View File
@@ -0,0 +1,122 @@
//! Matomo HTTP Tracking API client.
//!
//! Buffers game-play events and flushes them via the Matomo bulk tracking
//! endpoint. Errors are silently discarded — analytics must never affect
//! gameplay or block the UI.
use std::sync::Mutex;
use reqwest::Client;
use uuid::Uuid;
/// Sends game-play events to a self-hosted Matomo instance via the
/// [HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api).
///
/// Construct once per session and share via `Arc`. `event` is cheap and
/// can be called from the Bevy main thread; `flush` is async and must be
/// called from a background task.
pub struct MatomoClient {
tracking_url: String,
site_id: u32,
/// 16 hex-char visitor ID, stable for the lifetime of this client.
visitor_id: String,
uid: Option<String>,
client: Client,
/// Pre-encoded query strings, one per buffered event.
pending: Mutex<Vec<String>>,
}
impl MatomoClient {
/// Create a new client targeting `base_url` (e.g. `"https://analytics.example.com"`).
pub fn new(base_url: impl AsRef<str>, site_id: u32, uid: Option<String>) -> Self {
let base = base_url.as_ref().trim_end_matches('/');
let tracking_url = format!("{}/matomo.php", base);
// Take the lower 64 bits of a v4 UUID and format as 16 hex chars.
let visitor_id = format!("{:016x}", Uuid::new_v4().as_u128() as u64);
Self {
tracking_url,
site_id,
visitor_id,
uid,
client: Client::new(),
pending: Mutex::new(Vec::new()),
}
}
/// Buffer one Matomo custom event. Never blocks; never fails visibly.
///
/// When the buffer exceeds 100 events the oldest 50 are dropped to
/// prevent unbounded memory growth during extended offline play.
pub fn event(
&self,
category: &str,
action: &str,
name: Option<&str>,
value: Option<f64>,
) {
let Ok(mut guard) = self.pending.lock() else {
return;
};
let mut qs = format!(
"idsite={}&rec=1&apiv=1&send_image=0\
&url=game%3A%2F%2Fsolitaire%2Fevent\
&_id={}&e_c={}&e_a={}",
self.site_id,
self.visitor_id,
url_encode(category),
url_encode(action),
);
if let Some(n) = name {
qs.push_str(&format!("&e_n={}", url_encode(n)));
}
if let Some(v) = value {
qs.push_str(&format!("&e_v={v}"));
}
if let Some(uid) = &self.uid {
qs.push_str(&format!("&uid={}", url_encode(uid)));
}
guard.push(qs);
if guard.len() > 100 {
guard.drain(0..50);
}
}
/// Drain the pending buffer and POST it to Matomo's bulk tracking endpoint.
///
/// The buffer is drained *before* the HTTP call so events recorded during
/// an in-flight flush are not lost. Network errors are silently discarded.
pub async fn flush(&self) {
let pending = {
let Ok(mut guard) = self.pending.lock() else {
return;
};
if guard.is_empty() {
return;
}
std::mem::take(&mut *guard)
};
let requests: Vec<String> = pending.into_iter().map(|qs| format!("?{qs}")).collect();
let body = serde_json::json!({ "requests": requests });
let _ = self
.client
.post(&self.tracking_url)
.json(&body)
.send()
.await;
}
}
fn url_encode(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
}
c => format!("%{:02X}", c as u32).chars().collect(),
})
.collect()
}
+28 -7
View File
@@ -49,7 +49,7 @@ pub enum SyncBackend {
#[default] #[default]
#[serde(rename = "local")] #[serde(rename = "local")]
Local, Local,
/// Sync with a self-hosted Solitaire Quest server. /// Sync with a self-hosted Ferrous Solitaire server.
#[serde(rename = "solitaire_server")] #[serde(rename = "solitaire_server")]
SolitaireServer { SolitaireServer {
/// Base URL of the server, e.g. `"https://solitaire.example.com"`. /// Base URL of the server, e.g. `"https://solitaire.example.com"`.
@@ -143,11 +143,10 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
pub window_geometry: Option<WindowGeometry>, pub window_geometry: Option<WindowGeometry>,
/// Identifier of the active card-art theme. Matches `meta.id` from /// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"default"` is the bundled /// the theme's `theme.ron` manifest. `"classic"` and `"dark"` are
/// theme and is always present in the registry; user-supplied /// always present; user-supplied themes register under their own ids.
/// themes register under their own ids when they're imported. /// Older `settings.json` files that stored `"default"` will fall
/// Older `settings.json` files default cleanly to `"default"` via /// back to the dark embedded theme at runtime.
/// `#[serde(default = ...)]`.
#[serde(default = "default_theme_id")] #[serde(default = "default_theme_id")]
pub selected_theme_id: String, pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been /// Set to `true` once the achievement-onboarding info-toast has been
@@ -243,6 +242,21 @@ pub struct Settings {
/// `false` via `#[serde(default)]`. /// `false` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub take_from_foundation: bool, pub take_from_foundation: bool,
/// When `true`, anonymous game-play events (game start, game won, etc.)
/// are sent to the configured Matomo instance. Opt-in; defaults to `false`.
/// Requires `matomo_url` to be set. Older `settings.json` files deserialize
/// cleanly to `false` via `#[serde(default)]`.
#[serde(default)]
pub analytics_enabled: bool,
/// Base URL of the Matomo instance to send events to, e.g.
/// `"https://analytics.example.com"`. When `None` the analytics toggle has
/// no effect. Older `settings.json` files deserialize cleanly to `None`.
#[serde(default)]
pub matomo_url: Option<String>,
/// Matomo site ID assigned when the tracked site was created in Matomo.
/// Defaults to `1` (the first site created in a fresh Matomo install).
#[serde(default = "default_matomo_site_id")]
pub matomo_site_id: u32,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -258,7 +272,7 @@ fn default_music_volume() -> f32 {
} }
fn default_theme_id() -> String { fn default_theme_id() -> String {
"default".to_string() "classic".to_string()
} }
/// Default tooltip-hover dwell delay in seconds. Mirrors /// Default tooltip-hover dwell delay in seconds. Mirrors
@@ -311,6 +325,10 @@ fn default_replay_move_interval_secs() -> f32 {
0.45 0.45
} }
fn default_matomo_site_id() -> u32 {
1
}
/// Lower bound of the player-tunable replay-playback per-move interval, /// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before /// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible. /// the next move fires; the cap keeps the playback legible.
@@ -364,6 +382,9 @@ impl Default for Settings {
last_difficulty: None, last_difficulty: None,
leaderboard_display_name: None, leaderboard_display_name: None,
take_from_foundation: false, take_from_foundation: false,
analytics_enabled: false,
matomo_url: None,
matomo_site_id: default_matomo_site_id(),
} }
} }
} }
+2 -2
View File
@@ -6,7 +6,7 @@
//! | Struct | Backend | //! | Struct | Backend |
//! |---|---| //! |---|---|
//! | [`LocalOnlyProvider`] | No-op; used when sync is disabled | //! | [`LocalOnlyProvider`] | No-op; used when sync is disabled |
//! | [`SolitaireServerClient`] | Self-hosted Solitaire Quest server (JWT auth) | //! | [`SolitaireServerClient`] | Self-hosted Ferrous Solitaire server (JWT auth) |
//! //!
//! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>` //! Use [`provider_for_backend`] to obtain a `Box<dyn SyncProvider + Send + Sync>`
//! without matching on [`SyncBackend`] anywhere else in the codebase. //! without matching on [`SyncBackend`] anywhere else in the codebase.
@@ -55,7 +55,7 @@ impl SyncProvider for LocalOnlyProvider {
// SolitaireServerClient // SolitaireServerClient
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// HTTP sync client for the self-hosted Solitaire Quest server. /// HTTP sync client for the self-hosted Ferrous Solitaire server.
/// ///
/// Authenticates via JWT stored in the OS keychain. On a 401 response the /// Authenticates via JWT stored in the OS keychain. On a 401 response the
/// client automatically attempts a token refresh and retries the request once /// client automatically attempts a token refresh and retries the request once
+1
View File
@@ -14,6 +14,7 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
usvg = { workspace = true } usvg = { workspace = true }
resvg = { workspace = true } resvg = { workspace = true }
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<defs>
<pattern id="dp" x="0" y="0" width="28" height="28" patternUnits="userSpaceOnUse">
<rect width="28" height="28" fill="#1a3a6e"/>
<polygon points="14,2 26,14 14,26 2,14" fill="#2255aa"/>
<polygon points="14,7 21,14 14,21 7,14" fill="#1a3a6e"/>
</pattern>
</defs>
<!-- White card background -->
<rect x="2" y="2" width="252" height="380" rx="14" ry="14" fill="#FAFAF8"/>
<!-- Red outer border -->
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="none" stroke="#CC1111" stroke-width="4"/>
<!-- Navy diamond pattern inset -->
<rect x="16" y="16" width="224" height="352" rx="8" ry="8" fill="url(#dp)"/>
<!-- Thin red frame around pattern -->
<rect x="16" y="16" width="224" height="352" rx="8" ry="8"
fill="none" stroke="#CC1111" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 924 B

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 13,4 10,7 10,10 C 10,12 11,13 12,14 C 9,14 4,17 4,21 C 4,24 7,27 10,27 C 12,27 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 20,27 22,27 C 25,27 28,24 28,21 C 28,17 23,14 20,14 C 21,13 22,12 22,10 C 22,7 19,4 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,2 L 30,16 L 16,30 L 2,16 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#CC1111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,28 C 8,22 2,17 2,11 C 2,7 5,4 9,4 C 12,4 14,6 16,9 C 18,6 20,4 23,4 C 27,4 30,7 30,11 C 30,17 24,22 16,28 Z" fill="#CC1111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="11" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">10</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">2</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">3</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">4</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">5</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">6</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">7</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">8</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">9</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">A</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">J</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">K</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="384" viewBox="0 0 256 384">
<rect x="2" y="2" width="252" height="380" rx="14" ry="14"
fill="#FAFAF8" stroke="#AAAAAA" stroke-width="1.5"/>
<!-- Top-left corner: rank label + small suit -->
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Centre: large suit, 64x64 in 256x384 card -->
<g transform="translate(96 160) scale(2)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
<!-- Bottom-right corner: mirrored via 180° rotation around card centre -->
<g transform="rotate(180 128 192)">
<text x="14" y="44" font-family="Fira Mono" font-size="36" font-weight="700"
fill="#111111">Q</text>
<g transform="translate(14 50) scale(0.625)">
<path d="M16,4 C 9,9 2,14 2,21 C 2,25 5,28 9,28 C 13,28 14,26 14,24 L 13,30 L 19,30 L 18,24 C 18,26 19,28 23,28 C 27,28 30,25 30,21 C 30,14 23,9 16,4 Z" fill="#111111"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,64 @@
(
meta: (
id: "classic",
name: "Classic",
author: "Ferrous Solitaire",
version: "1.0.0",
card_aspect: (2, 3),
),
back: "back.svg",
faces: {
"clubs_ace": "clubs_ace.svg",
"clubs_2": "clubs_2.svg",
"clubs_3": "clubs_3.svg",
"clubs_4": "clubs_4.svg",
"clubs_5": "clubs_5.svg",
"clubs_6": "clubs_6.svg",
"clubs_7": "clubs_7.svg",
"clubs_8": "clubs_8.svg",
"clubs_9": "clubs_9.svg",
"clubs_10": "clubs_10.svg",
"clubs_jack": "clubs_jack.svg",
"clubs_queen": "clubs_queen.svg",
"clubs_king": "clubs_king.svg",
"diamonds_ace": "diamonds_ace.svg",
"diamonds_2": "diamonds_2.svg",
"diamonds_3": "diamonds_3.svg",
"diamonds_4": "diamonds_4.svg",
"diamonds_5": "diamonds_5.svg",
"diamonds_6": "diamonds_6.svg",
"diamonds_7": "diamonds_7.svg",
"diamonds_8": "diamonds_8.svg",
"diamonds_9": "diamonds_9.svg",
"diamonds_10": "diamonds_10.svg",
"diamonds_jack": "diamonds_jack.svg",
"diamonds_queen": "diamonds_queen.svg",
"diamonds_king": "diamonds_king.svg",
"hearts_ace": "hearts_ace.svg",
"hearts_2": "hearts_2.svg",
"hearts_3": "hearts_3.svg",
"hearts_4": "hearts_4.svg",
"hearts_5": "hearts_5.svg",
"hearts_6": "hearts_6.svg",
"hearts_7": "hearts_7.svg",
"hearts_8": "hearts_8.svg",
"hearts_9": "hearts_9.svg",
"hearts_10": "hearts_10.svg",
"hearts_jack": "hearts_jack.svg",
"hearts_queen": "hearts_queen.svg",
"hearts_king": "hearts_king.svg",
"spades_ace": "spades_ace.svg",
"spades_2": "spades_2.svg",
"spades_3": "spades_3.svg",
"spades_4": "spades_4.svg",
"spades_5": "spades_5.svg",
"spades_6": "spades_6.svg",
"spades_7": "spades_7.svg",
"spades_8": "spades_8.svg",
"spades_9": "spades_9.svg",
"spades_10": "spades_10.svg",
"spades_jack": "spades_jack.svg",
"spades_queen": "spades_queen.svg",
"spades_king": "spades_king.svg",
},
)

Before

Width:  |  Height:  |  Size: 956 B

After

Width:  |  Height:  |  Size: 956 B

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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