Compare commits

..

96 Commits

Author SHA1 Message Date
funman300 d49c478efa fix(ci): diagnose KEYSTORE_BASE64 decode failure
Android Build / build-apk (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
Android Release / build-release-apk (push) Has been cancelled
base64 -d is failing (1s step, keystore 0 bytes). Add diagnostics:
- Print secret length to check for truncation
- Capture base64 error output
- Print exit code and file size
- Use --decode instead of -d for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:37:17 -07:00
Gitea CI 29f9b9358e chore(deploy): bump image to 9ef5759f [skip ci] 2026-05-14 22:27:55 +00:00
funman300 9ef5759f40 fix(ci): fail fast on empty keystore before 7-min cargo build
Android Build / build-apk (push) Successful in 14m52s
Build and Deploy / build-and-push (push) Successful in 44s
Android Release / build-release-apk (push) Failing after 3m42s
If KEYSTORE_BASE64 is unset, base64 -d writes an empty file silently,
cargo ndk then spends ~7 min compiling all ABIs, and only then does
apksigner fail. Add a size check after decode so the job fails in
seconds with a clear error message instead of wasting a full build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:12:25 -07:00
funman300 9c9c0c76d3 fix(ci): restore 3-ABI release build now that LXC has 106 GB disk
Android Build / build-apk (push) Successful in 20m40s
Build and Deploy / build-and-push (push) Failing after 50s
Android Release / build-release-apk (push) Failing after 10m29s
The runner LXC was bumped from ~56 GB to 106 GB, giving ~70 GB of free
space — well above the ~40 GB a full 3-ABI release build needs. Revert
the disk-budget workarounds added in ab35fcf:

- Remove "Free disk space" step (no longer needed)
- Restore x86_64 target (arm64-v8a + armeabi-v7a + x86_64)
- Remove ABIS override so build script uses its full default set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:04:09 -07:00
Gitea CI d4fb9e36a8 chore(deploy): bump image to 32991301 [skip ci] 2026-05-14 21:00:48 +00:00
funman300 ab35fcf906 fix(ci): free disk space + drop x86_64 from release build to fix OOM
Android Build / build-apk (push) Successful in 16m44s
Build and Deploy / build-and-push (push) Failing after 30s
Android Release / build-release-apk (push) Failing after 12m5s
Run 181 (v0.25.0 tag) failed at "Build signed release APK" after ~7 min —
same disk-exhaustion pattern that hit the debug build. The debug workflow
was already fixed to arm64-v8a only; the release workflow still built all 3
ABIs and exceeded the runner's disk budget.

Changes:
- Add "Free disk space" step before system deps: removes /usr/local/lib/android,
  /usr/share/dotnet, /opt/ghc, /usr/local/share/boost (~10 GB reclaimed).
- Limit ABIS to arm64-v8a + armeabi-v7a (drops x86_64, which is emulator-only).
- Remove x86_64 from rustup target add to match.

arm64-v8a covers all modern Android devices; armeabi-v7a covers legacy ARM.
x86_64 can be re-added later if a simulator-targeted test build is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:53:48 -07:00
funman300 32991301dd fix(engine): restore Dark as default theme; migrate stale theme IDs
Android Build / build-apk (push) Successful in 12m19s
Build and Deploy / build-and-push (push) Successful in 55s
- default_theme_id() returns "dark" (was briefly "classic" after the
  rename commit 20b7a61)
- sanitized() migrates "default" and "classic" → "dark" so existing
  settings.json files are upgraded automatically on next launch
- Registry lists Dark first so the Settings picker opens with it at top
- Classic remains available as an option in the picker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:47:43 -07:00
Gitea CI c5fd928dcb chore(deploy): bump image to f6907671 [skip ci] 2026-05-14 20:20:49 +00:00
funman300 f6907671be fix(ci): pin upload-artifact to v3 for Gitea Actions compatibility
Android Build / build-apk (push) Successful in 12m49s
Build and Deploy / build-and-push (push) Successful in 47s
Android Release / build-release-apk (push) Failing after 10m11s
The disk-budget fix worked — debug APK now builds, signs, and verifies
in ~6 minutes on a single ABI. But the upload step failed with:

  GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
  and download-artifact@v4+ are not currently supported on GHES.

upload-artifact@v4 rewrote the upload path to use a new artifact
service hosted on github.com; Gitea's GHES-compatibility layer doesn't
implement that endpoint. v3 still uses the older chunked HTTP upload
API that Gitea supports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:07:09 -07:00
Gitea CI a54fff7257 chore(deploy): bump image to 533bcec2 [skip ci] 2026-05-14 20:01:12 +00:00
funman300 533bcec2d8 fix(ci): limit debug APK to arm64-v8a so apksigner has disk to write
Android Build / build-apk (push) Failing after 9m10s
Build and Deploy / build-and-push (push) Successful in 52s
The previous run got all the way through compile + link + zipalign and
then died inside apksigner with `IOException: No space left on device`.
Cross-compiling all three Android ABIs (arm64-v8a, armeabi-v7a, x86_64)
in debug mode blows target/ past 25 GB, and by the time apksigner is
streaming the signed APK to disk the runner has nothing left.

Two changes:

  1. build_android_apk.sh now reads `ABIS` from the environment (defaults
     to all three for backwards compat) and uses it to assemble the
     cargo-ndk `-t` flags.
  2. android-build.yml passes ABIS=arm64-v8a, since the debug artifact
     is consumed by adb-installing to a single arm64 device and the
     other two ABIs were dead weight.

Also frees \$STAGING/app-unsigned.apk right after zipalign so it's not
sitting next to the aligned APK and the output APK during signing.

Release workflow is untouched — release APKs still ship all three ABIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:51:17 -07:00
Gitea CI ba786f5a09 chore(deploy): bump image to 7ee7cb6d [skip ci] 2026-05-14 19:17:40 +00:00
funman300 7ee7cb6d93 ci(android): replace cargo-apk with cargo-ndk + manual APK assembly
Android Build / build-apk (push) Failing after 23m0s
Build and Deploy / build-and-push (push) Successful in 43s
cargo-apk 0.10 and its fork cargo-apk2 both failed to discover the
installed Android platform in this Gitea runner, despite ANDROID_HOME,
platforms;android-34, build-tools, and NDK all being present, readable,
and pointed at correctly. We never isolated whether the bug is in the
shared ndk-build crate's discovery logic or in the runner's env-var
propagation through cargo subcommand exec, so this commit stops fighting
either tool and assembles the APK from explicit toolchain steps instead:

  cargo ndk          -> per-ABI .so files
  aapt2 compile/link -> manifest + resources -> base APK
  zip                -> bundle native libs into lib/<abi>/
  zipalign           -> 4-byte alignment
  apksigner          -> v2/v3 signing (debug keystore for CI, real for release)

The pipeline lives in scripts/build_android_apk.sh so it's reproducible
locally (same env vars, same commands). AndroidManifest.xml is now
checked in under solitaire_app/android/ and mirrors what cargo-apk would
have generated from [package.metadata.android] — keep them in sync if
either is changed. Local `cargo apk build` still works on developer
machines where cargo-apk is happy; CI just stops depending on it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:53:55 -07:00
funman300 14324b09ef ci(android): switch from cargo-apk 0.10.0 to cargo-apk2
Android Build / build-apk (push) Failing after 3m49s
Build and Deploy / build-and-push (push) Failing after 23s
cargo-apk 0.10.0 has been unable to discover an installed Android
platform in this runner environment despite ANDROID_HOME, NDK,
build-tools, and platforms;android-34 all being present and readable.
cargo-apk2 is the maintained community fork on crates.io that reads
the same `[package.metadata.android]` block, so the solitaire_app
Cargo.toml needs no changes. Cache keys updated to apk2- so we don't
restore the broken cargo-apk binary from prior runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:43:50 -07:00
Gitea CI 124f1f5cf5 chore(deploy): bump image to a6a73b5f [skip ci] 2026-05-14 18:38:04 +00:00
funman300 a6a73b5f36 fix(ci): add permission and env diagnostics to build step
Android Build / build-apk (push) Failing after 3m47s
Build and Deploy / build-and-push (push) Successful in 36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:33:54 -07:00
Gitea CI b84fe79806 chore(deploy): bump image to 3248f00d [skip ci] 2026-05-14 18:32:22 +00:00
funman300 3248f00d66 fix(ci): deeper SDK verification — find android.jar actual location
Android Build / build-apk (push) Failing after 3m41s
Build and Deploy / build-and-push (push) Successful in 25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:28:17 -07:00
Gitea CI c680a043ae chore(deploy): bump image to d0ab7ed9 [skip ci] 2026-05-14 18:26:23 +00:00
funman300 d0ab7ed97b fix(ci): add SDK verification step to diagnose platforms-not-found
Android Build / build-apk (push) Failing after 3m28s
Build and Deploy / build-and-push (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:22:11 -07:00
Gitea CI 1144a96757 chore(deploy): bump image to eba1f66b [skip ci] 2026-05-14 18:18:13 +00:00
funman300 ac6668cee7 fix(ci): apply template-expansion pattern to release workflow
Android Build / build-apk (push) Failing after 2m59s
Build and Deploy / build-and-push (push) Failing after 46s
Mirror the fix from android-build.yml: rename ANDROID_HOME -> ANDROID_SDK
in the env block to avoid the Docker-image-baked ANDROID_HOME overriding
the workflow value in run scripts. Use ${{ env.ANDROID_SDK }} template
expressions throughout, and explicitly export ANDROID_HOME/ANDROID_NDK_HOME
before cargo-apk build so it finds the SDK at the right path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:17:58 -07:00
funman300 eba1f66b45 fix(ci): use template-expanded paths in run scripts to bypass Docker ENV
Android Build / build-apk (push) Failing after 3m6s
Build and Deploy / build-and-push (push) Successful in 31s
Replace shell variable $ANDROID_HOME references in run blocks with
${{ env.ANDROID_SDK }} template expressions. Gitea runner v1 may not
override Docker-image-baked ENV vars via docker exec; template expansion
happens at workflow compilation time, so the literal path is hardcoded
into the script before the shell runs it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:14:34 -07:00
Gitea CI 90959728b1 chore(deploy): bump image to 8b30f877 [skip ci] 2026-05-14 18:11:34 +00:00
funman300 8b30f8778b fix(ci): use fresh /opt/android-sdk path to avoid container ENV conflict
Android Build / build-apk (push) Failing after 3m9s
Build and Deploy / build-and-push (push) Successful in 50s
Remove SDK detection logic and install directly to /opt/android-sdk,
matching the release workflow. The container Docker image has ANDROID_HOME
baked in at /usr/local/lib/android/sdk; installing there with sudo while
cargo-apk resolves ANDROID_HOME from the image ENV created a divergence.
Using a controlled path we own eliminates that class of conflict entirely.
Add SDK cache shared with the release workflow (same key prefix v2-).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:07:46 -07:00
Gitea CI d6a7924f14 chore(deploy): bump image to 4db43fb3 [skip ci] 2026-05-14 18:02:16 +00:00
funman300 4db43fb3fb fix(ci): replace ANDROID_SDK_ROOT with ANDROID_HOME in release workflow
Android Build / build-apk (push) Failing after 3m49s
Build and Deploy / build-and-push (push) Successful in 54s
ANDROID_SDK_ROOT was never set; zipalign and apksigner were resolving
to empty paths and would fail. All three occurrences replaced with
ANDROID_HOME which is defined in the workflow env block.

Also adds sudo to the cache-miss SDK install (mkdir/mv/sdkmanager) to
match the debug workflow pattern — /opt/android-sdk requires root on
a fresh runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:56:42 -07:00
funman300 01d6b27e61 fix(ci): detect existing container SDK before installing, set ANDROID_HOME via GITHUB_ENV
Android Build / build-apk (push) Failing after 3m50s
Build and Deploy / build-and-push (push) Failing after 33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:53:14 -07:00
funman300 3cffbc2c51 feat(engine): embed classic theme into binary like dark theme
Classic SVGs and manifest are now compiled in via include_bytes!(),
making the theme available on all platforms (desktop, Android) without
requiring filesystem assets. Removes the now-redundant Dockerfile COPY
of solitaire_engine/assets/themes/classic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:53:14 -07:00
Gitea CI 2ef25934ac chore(deploy): bump image to bb670d6c [skip ci] 2026-05-14 17:51:40 +00:00
funman300 bb670d6cc6 fix(ci): drop ANDROID_SDK_ROOT, pass --sdk_root to sdkmanager explicitly
Android Build / build-apk (push) Failing after 3m19s
Build and Deploy / build-and-push (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:47:52 -07:00
Gitea CI 76911c57c9 chore(deploy): bump image to 8391235a [skip ci] 2026-05-14 17:45:46 +00:00
funman300 8391235a1a fix(ci): check android.jar existence in platform dir
Android Build / build-apk (push) Failing after 3m25s
Build and Deploy / build-and-push (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:41:57 -07:00
funman300 2f3a6b9586 fix(ci): dump env at build time to diagnose ANDROID_HOME visibility
Android Build / build-apk (push) Failing after 3m23s
Build and Deploy / build-and-push (push) Failing after 40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:37:37 -07:00
Gitea CI 4d20b70809 chore(deploy): bump image to bfadcf0e [skip ci] 2026-05-14 17:32:04 +00:00
funman300 bfadcf0e0d fix(ci): add SDK layout debug step to diagnose platforms-not-found error
Android Build / build-apk (push) Failing after 3m19s
Build and Deploy / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:28:10 -07:00
Gitea CI 356dbebe57 chore(deploy): bump image to c90c7831 [skip ci] 2026-05-14 17:28:04 +00:00
funman300 c90c783177 fix(ci): set ANDROID_HOME/NDK_HOME in workflow env block instead of GITHUB_ENV
Android Build / build-apk (push) Failing after 3m23s
Build and Deploy / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:23:58 -07:00
Gitea CI bbf4b2c14a chore(deploy): bump image to 1f46785b [skip ci] 2026-05-14 17:19:04 +00:00
funman300 62be72e918 fix(ci): bust SDK cache key to force fresh SDK install after prior broken cache
Android Build / build-apk (push) Failing after 3m16s
Build and Deploy / build-and-push (push) Failing after 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:18:57 -07:00
funman300 1f46785b31 fix(ci): add apt-get update before package install to fix exit code 100
Android Build / build-apk (push) Failing after 3m50s
Build and Deploy / build-and-push (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:14:32 -07:00
Gitea CI 2e5d82f83c chore(deploy): bump image to 396ba6bc [skip ci] 2026-05-14 17:12:21 +00:00
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
132 changed files with 2404 additions and 287 deletions
+131
View File
@@ -0,0 +1,131 @@
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: /opt/android-sdk
NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0"
PLATFORM: "android-34"
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"
# ── System dependencies ────────────────────────────────────────────
- name: Install system dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y openjdk-17-jdk-headless unzip zip
# ── Android SDK (shared cache key with release workflow) ──────────
- name: Cache Android SDK
uses: actions/cache@v4
id: sdk-cache
with:
path: ${{ env.ANDROID_SDK }}
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
- name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true'
run: |
sudo mkdir -p ${{ env.ANDROID_SDK }}/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
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} \
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
"platforms;${{ env.PLATFORM }}" \
"ndk;${{ env.NDK_VERSION }}"
# ── 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-ndk binary
uses: actions/cache@v4
id: ndk-tool-cache
with:
path: ~/.cargo/bin/cargo-ndk
key: cargo-ndk-${{ 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') }}-
- name: Install cargo-ndk
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
run: cargo install cargo-ndk --locked
# ── Build APK ──────────────────────────────────────────────────────
# Debug CI only builds arm64-v8a — full three-ABI debug builds blow
# past the runner's disk budget (~25 GB of target/ + intermediate
# APKs caused apksigner to OOM-on-disk in the previous run). Release
# CI still ships all three ABIs from android-release.yml.
- name: Build debug APK
env:
ANDROID_HOME: ${{ env.ANDROID_SDK }}
ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
PLATFORM: ${{ env.PLATFORM }}
PROFILE: debug
ABIS: arm64-v8a
run: ./scripts/build_android_apk.sh
# ── Artifact ───────────────────────────────────────────────────────
# Pinned to v3 because Gitea Actions doesn't implement the github.com
# artifact service that upload-artifact@v4+ requires; v3 uses the
# older chunked HTTP API that Gitea's GHES-compatibility layer
# supports.
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: solitaire-quest-debug-${{ steps.meta.outputs.sha }}
path: target/debug/apk/solitaire-quest.apk
retention-days: 30
+160
View File
@@ -0,0 +1,160 @@
name: Android Release
on:
push:
tags:
- 'v*.*.*'
env:
ANDROID_SDK: /opt/android-sdk
NDK_VERSION: "25.2.9519653"
BUILD_TOOLS_VERSION: "34.0.0"
PLATFORM: "android-34"
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"
# ── System dependencies ────────────────────────────────────────────
- name: Install system dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y openjdk-17-jdk-headless unzip zip jq
# ── Android SDK (shared cache key with debug workflow) ─────────────
- name: Cache Android SDK
uses: actions/cache@v4
id: sdk-cache
with:
path: ${{ env.ANDROID_SDK }}
key: v2-android-sdk-ndk${{ env.NDK_VERSION }}-bt${{ env.BUILD_TOOLS_VERSION }}
- name: Install Android SDK + NDK
if: steps.sdk-cache.outputs.cache-hit != 'true'
run: |
sudo mkdir -p ${{ env.ANDROID_SDK }}/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
sudo mv /tmp/cmdtools/cmdline-tools ${{ env.ANDROID_SDK }}/cmdline-tools/latest
yes | sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} --licenses > /dev/null 2>&1 || true
sudo ${{ env.ANDROID_SDK }}/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=${{ env.ANDROID_SDK }} \
"build-tools;${{ env.BUILD_TOOLS_VERSION }}" \
"platforms;${{ env.PLATFORM }}" \
"ndk;${{ env.NDK_VERSION }}"
# ── 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-ndk binary
uses: actions/cache@v4
id: ndk-tool-cache
with:
path: ~/.cargo/bin/cargo-ndk
key: cargo-ndk-${{ 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') }}-
- name: Install cargo-ndk
if: steps.ndk-tool-cache.outputs.cache-hit != 'true'
run: cargo install cargo-ndk --locked
# ── Build & sign with release keystore ─────────────────────────────
- name: Decode keystore
run: |
secret_len=$(echo -n "${{ secrets.KEYSTORE_BASE64 }}" | wc -c)
echo "KEYSTORE_BASE64 secret length: ${secret_len} chars"
set +e
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/solitaire-release.jks 2>/tmp/b64_err.txt
b64_exit=$?
set -e
size=$(wc -c < /tmp/solitaire-release.jks)
echo "base64 exit code: ${b64_exit}, keystore size: ${size} bytes"
[ -s /tmp/b64_err.txt ] && echo "base64 error: $(cat /tmp/b64_err.txt)" || true
[ "$size" -gt 0 ] || { echo "ERROR: KEYSTORE_BASE64 is empty or invalid base64"; exit 1; }
- name: Build signed release APK
env:
ANDROID_HOME: ${{ env.ANDROID_SDK }}
ANDROID_NDK_HOME: ${{ env.ANDROID_SDK }}/ndk/${{ env.NDK_VERSION }}
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOLS_VERSION }}
PLATFORM: ${{ env.PLATFORM }}
PROFILE: release
KEYSTORE: /tmp/solitaire-release.jks
KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASS: ${{ secrets.KEY_PASS }}
APK_OUT: ferrous-solitaire-${{ steps.meta.outputs.tag }}.apk
run: ./scripts/build_android_apk.sh
# ── Publish to Gitea release ───────────────────────────────────────
- name: Create Gitea release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
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"
+5
View File
@@ -16,3 +16,8 @@ data/
*.jks.bak *.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
+8
View File
@@ -12,6 +12,14 @@ spec:
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
+1 -2
View File
@@ -11,7 +11,6 @@ resources:
- mariadb-deployment.yaml - mariadb-deployment.yaml
- mariadb-service.yaml - mariadb-service.yaml
- matomo-pvc.yaml - matomo-pvc.yaml
- matomo-secret.yaml
- matomo-deployment.yaml - matomo-deployment.yaml
- matomo-service.yaml - matomo-service.yaml
- ingress-analytics.yaml - ingress-analytics.yaml
@@ -21,4 +20,4 @@ resources:
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: 3e006a1e newTag: 9ef5759f
+21 -27
View File
@@ -17,18 +17,20 @@ spec:
spec: spec:
containers: containers:
- name: matomo - name: matomo
image: bitnami/matomo:5 image: matomo:5.10.0
env: env:
- name: MATOMO_DATABASE_HOST - name: MATOMO_DATABASE_HOST
value: mariadb value: mariadb
- name: MATOMO_DATABASE_PORT_NUMBER - name: MATOMO_DATABASE_PORT
value: "3306" value: "3306"
- name: MATOMO_DATABASE_NAME - name: MATOMO_DATABASE_ADAPTER
value: PDO\MYSQL
- name: MATOMO_DATABASE_DBNAME
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: matomo-secret name: matomo-secret
key: MYSQL_DATABASE key: MYSQL_DATABASE
- name: MATOMO_DATABASE_USER - name: MATOMO_DATABASE_USERNAME
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: matomo-secret name: matomo-secret
@@ -38,40 +40,32 @@ spec:
secretKeyRef: secretKeyRef:
name: matomo-secret name: matomo-secret
key: MYSQL_PASSWORD key: MYSQL_PASSWORD
- name: MATOMO_USERNAME # Traefik terminates SSL; tell Matomo to trust X-Forwarded-* headers
value: admin - name: MATOMO_GENERAL_ASSUME_SECURE_PROTOCOL
- name: MATOMO_PASSWORD value: "1"
valueFrom: - name: MATOMO_GENERAL_PROXY_CLIENT_HEADERS
secretKeyRef: value: HTTP_X_FORWARDED_FOR
name: matomo-secret - name: MATOMO_GENERAL_PROXY_HOST_HEADERS
key: MATOMO_ADMIN_PASSWORD value: HTTP_X_FORWARDED_HOST
- name: MATOMO_EMAIL
value: funman300@gmail.com
- name: MATOMO_WEBSITE_NAME
value: "Solitaire Quest"
- name: MATOMO_WEBSITE_HOST
value: "https://klondike.aleshym.co"
- name: MATOMO_HOST
value: analytics.aleshym.co
- name: MATOMO_ENABLE_PROXY_URI_HEADER
value: "yes"
ports: ports:
- containerPort: 8080 - containerPort: 80
volumeMounts: volumeMounts:
- name: matomo-data - name: matomo-data
mountPath: /bitnami/matomo mountPath: /var/www/html
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /index.php path: /matomo.php
port: 8080 port: 80
initialDelaySeconds: 60 initialDelaySeconds: 60
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 5
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /index.php path: /matomo.php
port: 8080 port: 80
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 5
resources: resources:
requests: requests:
cpu: 100m cpu: 100m
-13
View File
@@ -1,13 +0,0 @@
# Credentials for MariaDB and the Matomo admin account.
# Regenerate with: python3 -c "import secrets; print(secrets.token_urlsafe(18))"
apiVersion: v1
kind: Secret
metadata:
name: matomo-secret
namespace: solitaire
stringData:
MYSQL_ROOT_PASSWORD: "jspRn-QU18sZhB55FR-JfrMJ"
MYSQL_DATABASE: matomo
MYSQL_USER: matomo
MYSQL_PASSWORD: "ZxDp648UuL5fsN7eQI23E7ue"
MATOMO_ADMIN_PASSWORD: "J6QUtbroK4Z7zao4Dnl0J7e2"
+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"
+1 -1
View File
@@ -9,4 +9,4 @@ spec:
ports: ports:
- name: http - name: http
port: 80 port: 80
targetPort: 8080 targetPort: 80
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Build a self-signed Android APK from solitaire_app's cdylib targets.
#
# Replaces the cargo-apk pipeline with explicit cargo-ndk + aapt2 + apksigner
# steps. The CI runner was hitting an SDK-discovery bug inside cargo-apk's
# ndk-build crate that we couldn't isolate; running each Android toolchain
# step explicitly gives us a debuggable pipeline.
#
# Required environment:
# ANDROID_HOME Path to Android SDK root
# ANDROID_NDK_HOME Path to the specific NDK version
# BUILD_TOOLS_VERSION e.g. "34.0.0"
# PLATFORM e.g. "android-34"
#
# Optional environment:
# PROFILE "debug" (default) | "release"
# ABIS Space-separated Android ABIs to build (default:
# "arm64-v8a armeabi-v7a x86_64"). Reduce in CI to
# fit the runner's disk budget — a full three-ABI
# debug build can exceed 25 GB of target/ output.
# APK_OUT Output APK path (default: target/$PROFILE/apk/solitaire-quest.apk)
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
# KEY_ALIAS Key alias (default: "androiddebugkey")
# KEY_PASS Key password (default: same as KEYSTORE_PASS)
#
# Outputs:
# $APK_OUT Signed, zipaligned APK
set -euo pipefail
: "${ANDROID_HOME:?ANDROID_HOME must be set}"
: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}"
: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}"
: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}"
PROFILE="${PROFILE:-debug}"
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/solitaire-quest.apk}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION"
PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar"
MANIFEST="solitaire_app/android/AndroidManifest.xml"
RES_DIR="solitaire_app/res"
ASSETS_DIR="assets"
# --- sanity ----------------------------------------------------------------
for f in "$BT/aapt2" "$BT/zipalign" "$BT/apksigner" "$PLATFORM_JAR" "$MANIFEST"; do
[ -e "$f" ] || { echo "missing: $f"; exit 1; }
done
STAGING="$(mktemp -d)"
trap 'rm -rf "$STAGING"' EXIT
mkdir -p "$STAGING/lib" "$STAGING/compiled-res"
# --- 1. native libraries via cargo-ndk -------------------------------------
# `-o $STAGING/lib` lays out files as $STAGING/lib/<abi>/libsolitaire_app.so
# which is the directory structure the APK expects under lib/.
CARGO_NDK_ARGS=( --platform 26 -o "$STAGING/lib" )
for abi in $ABIS; do
CARGO_NDK_ARGS+=( -t "$abi" )
done
CARGO_NDK_ARGS+=( build --package solitaire_app --lib )
if [ "$PROFILE" = "release" ]; then
CARGO_NDK_ARGS+=( --release )
fi
echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}"
cargo ndk "${CARGO_NDK_ARGS[@]}"
# --- 2. compile + link resources and manifest ------------------------------
if [ -d "$RES_DIR" ]; then
echo ">>> aapt2 compile resources"
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
fi
LINK_ARGS=(
link
-o "$STAGING/app-unsigned.apk"
-I "$PLATFORM_JAR"
--manifest "$MANIFEST"
)
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
# Add compiled resources if any
shopt -s nullglob
RES_FLATS=( "$STAGING/compiled-res"/*.flat )
shopt -u nullglob
if [ ${#RES_FLATS[@]} -gt 0 ]; then
LINK_ARGS+=( "${RES_FLATS[@]}" )
fi
echo ">>> aapt2 link"
"$BT/aapt2" "${LINK_ARGS[@]}"
# --- 3. add native libraries to the APK ------------------------------------
echo ">>> bundle native libraries"
( cd "$STAGING" && zip -r -q app-unsigned.apk lib/ )
# --- 4. zipalign -----------------------------------------------------------
echo ">>> zipalign"
"$BT/zipalign" -p -f 4 "$STAGING/app-unsigned.apk" "$STAGING/app-aligned.apk"
# Free the unsigned intermediate now — apksigner reads $app-aligned.apk and
# writes $APK_OUT, and the runner's disk is tight after a multi-ABI build.
rm -f "$STAGING/app-unsigned.apk"
# --- 5. sign ---------------------------------------------------------------
if [ -z "${KEYSTORE:-}" ]; then
# Generate a deterministic debug keystore on the fly.
KEYSTORE="$STAGING/debug.keystore"
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
echo ">>> generating debug keystore at $KEYSTORE"
keytool -genkeypair -v \
-keystore "$KEYSTORE" \
-storepass "$KEYSTORE_PASS" \
-alias "$KEY_ALIAS" \
-keypass "$KEY_PASS" \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US" > /dev/null
fi
KEYSTORE_PASS="${KEYSTORE_PASS:-android}"
KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}"
KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}"
mkdir -p "$(dirname "$APK_OUT")"
echo ">>> apksigner sign -> $APK_OUT"
"$BT/apksigner" sign \
--ks "$KEYSTORE" \
--ks-pass "pass:$KEYSTORE_PASS" \
--ks-key-alias "$KEY_ALIAS" \
--key-pass "pass:$KEY_PASS" \
--out "$APK_OUT" \
"$STAGING/app-aligned.apk"
echo ">>> verify"
"$BT/apksigner" verify --verbose "$APK_OUT"
echo ">>> done: $APK_OUT"
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Mirrors what cargo-apk would generate from [package.metadata.android]
in solitaire_app/Cargo.toml. Kept in-tree so the CI workflow can drive
aapt2 directly without going through cargo-apk's brittle SDK discovery.
Keep in sync with:
* Cargo.toml: package, min_sdk_version, target_sdk_version,
uses_feature, uses_permission, application label/icon,
activity orientation
* [lib].name (currently "solitaire_app") — matches the
`android.app.lib_name` meta-data value below, which is the
shared object name without the `lib` prefix or `.so` suffix.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.solitairequest.app"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk
android:minSdkVersion="26"
android:targetSdkVersion="34" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="true" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Ferrous Solitaire"
android:icon="@mipmap/ic_launcher"
android:hasCode="false">
<activity
android:name="android.app.NativeActivity"
android:exported="true"
android:screenOrientation="portrait"
android:configChanges="orientation|keyboardHidden|screenSize|keyboard|navigation|screenLayout">
<meta-data
android:name="android.app.lib_name"
android:value="solitaire_app" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+13 -6
View File
@@ -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. `"dark"` and `"classic"` 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"` or `"classic"`
/// Older `settings.json` files default cleanly to `"default"` via /// are migrated to `"dark"` by [`Settings::sanitized`].
/// `#[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
@@ -273,7 +272,7 @@ fn default_music_volume() -> f32 {
} }
fn default_theme_id() -> String { fn default_theme_id() -> String {
"default".to_string() "dark".to_string()
} }
/// Default tooltip-hover dwell delay in seconds. Mirrors /// Default tooltip-hover dwell delay in seconds. Mirrors
@@ -396,6 +395,13 @@ impl Settings {
/// their respective ranges after deserialization or hand-editing of /// their respective ranges after deserialization or hand-editing of
/// `settings.json`. /// `settings.json`.
pub fn sanitized(self) -> Self { pub fn sanitized(self) -> Self {
// Migrate stale theme IDs: "default" was removed when the theme was
// renamed to "dark"; "classic" was briefly the default before "dark"
// was restored as the shipped default.
let selected_theme_id = match self.selected_theme_id.as_str() {
"default" | "classic" => "dark".to_string(),
_ => self.selected_theme_id,
};
Self { Self {
sfx_volume: self.sfx_volume.clamp(0.0, 1.0), sfx_volume: self.sfx_volume.clamp(0.0, 1.0),
music_volume: self.music_volume.clamp(0.0, 1.0), music_volume: self.music_volume.clamp(0.0, 1.0),
@@ -408,6 +414,7 @@ impl Settings {
replay_move_interval_secs: self replay_move_interval_secs: self
.replay_move_interval_secs .replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS), .clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
selected_theme_id,
..self ..self
} }
} }
@@ -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

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

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

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