From 20134ce46823c6bb953946e763e71624911a641a Mon Sep 17 00:00:00 2001
From: Henri Bourcereau
Date: Sat, 2 May 2026 11:11:39 +0200
Subject: [PATCH] chore: remove old web-game & web-user-portal crates
---
Cargo.lock | 35 -
Cargo.toml | 2 -
README.md | 4 +-
clients/web-game/Cargo.toml | 40 -
clients/web-game/Trunk.toml | 2 -
clients/web-game/assets/diceroll.mp3 | Bin 32896 -> 0 bytes
clients/web-game/assets/style.css | 1213 -----------------
clients/web-game/index.html | 12 -
clients/web-game/locales/en.json | 61 -
clients/web-game/locales/fr.json | 61 -
clients/web-game/src/app.rs | 726 ----------
clients/web-game/src/components/board.rs | 594 --------
.../src/components/connecting_screen.rs | 9 -
clients/web-game/src/components/die.rs | 53 -
.../web-game/src/components/game_screen.rs | 470 -------
.../web-game/src/components/login_screen.rs | 115 --
clients/web-game/src/components/mod.rs | 11 -
.../web-game/src/components/score_panel.rs | 70 -
clients/web-game/src/components/scoring.rs | 209 ---
clients/web-game/src/main.rs | 13 -
clients/web-game/src/sound.rs | 182 ---
clients/web-game/src/trictrac/backend.rs | 487 -------
clients/web-game/src/trictrac/bot_local.rs | 43 -
clients/web-game/src/trictrac/mod.rs | 3 -
clients/web-game/src/trictrac/types.rs | 256 ----
clients/web-user-portal/Cargo.toml | 17 -
clients/web-user-portal/Trunk.toml | 2 -
clients/web-user-portal/assets/style.css | 103 --
clients/web-user-portal/index.html | 11 -
clients/web-user-portal/src/api.rs | 191 ---
clients/web-user-portal/src/app.rs | 67 -
clients/web-user-portal/src/main.rs | 7 -
clients/web-user-portal/src/pages/game.rs | 95 --
clients/web-user-portal/src/pages/home.rs | 152 ---
clients/web-user-portal/src/pages/mod.rs | 3 -
clients/web-user-portal/src/pages/profile.rs | 137 --
36 files changed, 2 insertions(+), 5454 deletions(-)
delete mode 100644 clients/web-game/Cargo.toml
delete mode 100644 clients/web-game/Trunk.toml
delete mode 100644 clients/web-game/assets/diceroll.mp3
delete mode 100644 clients/web-game/assets/style.css
delete mode 100644 clients/web-game/index.html
delete mode 100644 clients/web-game/locales/en.json
delete mode 100644 clients/web-game/locales/fr.json
delete mode 100644 clients/web-game/src/app.rs
delete mode 100644 clients/web-game/src/components/board.rs
delete mode 100644 clients/web-game/src/components/connecting_screen.rs
delete mode 100644 clients/web-game/src/components/die.rs
delete mode 100644 clients/web-game/src/components/game_screen.rs
delete mode 100644 clients/web-game/src/components/login_screen.rs
delete mode 100644 clients/web-game/src/components/mod.rs
delete mode 100644 clients/web-game/src/components/score_panel.rs
delete mode 100644 clients/web-game/src/components/scoring.rs
delete mode 100644 clients/web-game/src/main.rs
delete mode 100644 clients/web-game/src/sound.rs
delete mode 100644 clients/web-game/src/trictrac/backend.rs
delete mode 100644 clients/web-game/src/trictrac/bot_local.rs
delete mode 100644 clients/web-game/src/trictrac/mod.rs
delete mode 100644 clients/web-game/src/trictrac/types.rs
delete mode 100644 clients/web-user-portal/Cargo.toml
delete mode 100644 clients/web-user-portal/Trunk.toml
delete mode 100644 clients/web-user-portal/assets/style.css
delete mode 100644 clients/web-user-portal/index.html
delete mode 100644 clients/web-user-portal/src/api.rs
delete mode 100644 clients/web-user-portal/src/app.rs
delete mode 100644 clients/web-user-portal/src/main.rs
delete mode 100644 clients/web-user-portal/src/pages/game.rs
delete mode 100644 clients/web-user-portal/src/pages/home.rs
delete mode 100644 clients/web-user-portal/src/pages/mod.rs
delete mode 100644 clients/web-user-portal/src/pages/profile.rs
diff --git a/Cargo.lock b/Cargo.lock
index e557059..517e14f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1449,26 +1449,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
-[[package]]
-name = "client_web"
-version = "0.1.0"
-dependencies = [
- "backbone-lib",
- "futures",
- "getrandom 0.3.4",
- "gloo-net 0.5.0",
- "gloo-storage",
- "gloo-timers",
- "leptos",
- "leptos_i18n",
- "rand 0.9.3",
- "serde",
- "serde_json",
- "trictrac-store",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
[[package]]
name = "cmake"
version = "0.1.58"
@@ -9245,21 +9225,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "web-user-portal"
-version = "0.1.0"
-dependencies = [
- "gloo-net 0.5.0",
- "js-sys",
- "leptos",
- "leptos_router",
- "serde",
- "serde_json",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
[[package]]
name = "webpki-roots"
version = "0.26.11"
diff --git a/Cargo.toml b/Cargo.toml
index 94a1c6b..3c70d45 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,8 +6,6 @@ members = [
"clients/cli",
"clients/backbone-lib",
"clients/web",
- "clients/web-game",
- "clients/web-user-portal",
"server/protocol",
"server/relay-server",
"bot",
diff --git a/README.md b/README.md
index 4e7789f..ca4c0de 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ just build-relay
just run-relay # listens on :8080
# Run the game (separate terminal)
-just dev-game
+just dev
```
Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name.
@@ -52,7 +52,7 @@ The game state is defined by the `GameState` struct in _store/src/game.rs_. The
### multiplayer game
-Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
+Packages "clients/backbone-lib", "clients/web/game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/).
The system consists of:
diff --git a/clients/web-game/Cargo.toml b/clients/web-game/Cargo.toml
deleted file mode 100644
index 578be7c..0000000
--- a/clients/web-game/Cargo.toml
+++ /dev/null
@@ -1,40 +0,0 @@
-[package]
-name = "client_web"
-version = "0.1.0"
-edition = "2021"
-
-[package.metadata.leptos-i18n]
-default = "en"
-locales = ["en", "fr"]
-
-[dependencies]
-leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
-trictrac-store = { path = "../../store" }
-backbone-lib = { path = "../backbone-lib" }
-leptos = { version = "0.7", features = ["csr"] }
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1"
-futures = "0.3"
-rand = "0.9"
-gloo-storage = "0.3"
-
-[target.'cfg(target_arch = "wasm32")'.dependencies]
-wasm-bindgen-futures = "0.4"
-gloo-net = { version = "0.5", features = ["http"] }
-gloo-timers = { version = "0.3", features = ["futures"] }
-# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
-# Must be a direct dependency (not just transitive) for the feature to take effect.
-getrandom = { version = "0.3", features = ["wasm_js"] }
-web-sys = { version = "0.3", features = [
- "RequestCredentials",
- "AudioContext",
- "AudioParam",
- "AudioNode",
- "AudioDestinationNode",
- "AudioScheduledSourceNode",
- "GainNode",
- "OscillatorNode",
- "OscillatorType",
- "BaseAudioContext",
- "HtmlAudioElement",
-] }
diff --git a/clients/web-game/Trunk.toml b/clients/web-game/Trunk.toml
deleted file mode 100644
index bae5297..0000000
--- a/clients/web-game/Trunk.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[serve]
-port = 9091
diff --git a/clients/web-game/assets/diceroll.mp3 b/clients/web-game/assets/diceroll.mp3
deleted file mode 100644
index b16adff42da3003b35d59566b353a5db2fa0c7b7..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 32896
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
diff --git a/clients/web-game/assets/style.css b/clients/web-game/assets/style.css
deleted file mode 100644
index 341be19..0000000
--- a/clients/web-game/assets/style.css
+++ /dev/null
@@ -1,1213 +0,0 @@
-/* ── Google Fonts ───────────────────────────────────────────────────── */
-@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap');
-
-/* ── Design tokens ──────────────────────────────────────────────────── */
-:root {
- --board-felt: #1d3d28;
- --board-rail: #2a1508;
- --field-ivory: #f0e6c8;
- --field-burgundy: #7a1e2a;
- --field-corner: #b8900a;
- --checker-white: #f5edd8;
- --checker-black: #1a0f06;
- --checker-ring: #c8a448;
- --ui-parchment: #f2e8d0;
- --ui-parchment-dark: #e4d8b8;
- --ui-ink: #2a1a08;
- --ui-gold: #c8a448;
- --ui-gold-dark: #8a6a28;
- --ui-green-accent: #3a6b2a;
- --ui-red-accent: #7a1e2a;
- --font-display: 'Cormorant Garamond', Georgia, serif;
- --font-ui: 'Jost', system-ui, sans-serif;
-}
-
-/* ── Reset & base ──────────────────────────────────────────────────── */
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-body {
- font-family: var(--font-ui);
- background: #8a7050;
- background-image:
- radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
- radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%),
- repeating-linear-gradient(
- 45deg, transparent, transparent 3px,
- rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px
- );
- display: flex;
- justify-content: center;
- padding: 1.5rem;
- min-height: 100vh;
-}
-
-.hidden { display: none !important; }
-
-/* ── Login card (§11) ───────────────────────────────────────────────── */
-.login-card {
- width: 340px;
- margin-top: 5vh;
- border-radius: 8px;
- overflow: hidden;
- box-shadow:
- 0 20px 60px rgba(0,0,0,0.55),
- 0 0 0 1px rgba(200,164,72,0.35),
- 0 0 0 5px rgba(42,21,8,0.9),
- 0 0 0 6px rgba(200,164,72,0.2);
- background: var(--ui-parchment);
-}
-
-/* Decorative header — row of triangular flèches like the actual board */
-.login-card-header {
- height: 52px;
- background: var(--board-felt);
- position: relative;
- overflow: hidden;
-}
-
-.login-board-stripe {
- position: absolute;
- inset: 0;
- /* Alternating burgundy/ivory triangles pointing down from the top */
- background:
- repeating-linear-gradient(
- 90deg,
- var(--field-burgundy) 0, var(--field-burgundy) 50%,
- var(--field-ivory) 50%, var(--field-ivory) 100%
- );
- background-size: 34px 100%;
- /* Clip into downward-pointing triangles */
- clip-path: polygon(
- 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%,
- 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%,
- 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%,
- 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%,
- 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%,
- 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%,
- 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%,
- 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%,
- 97.06% 100%, 100% 0%
- );
- opacity: 0.9;
-}
-
-.login-card-body {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0;
- padding: 1.5rem 2rem 2rem;
-}
-
-.login-lang-switcher {
- align-self: flex-end;
- margin-bottom: 0.75rem;
-}
-
-/* Override lang-switcher colours for the parchment card */
-.login-card .lang-switcher button {
- color: var(--ui-ink);
- border-color: rgba(42,21,8,0.2);
- opacity: 0.5;
-}
-.login-card .lang-switcher button.lang-active {
- opacity: 1;
- background: rgba(42,21,8,0.08);
- border-color: rgba(42,21,8,0.35);
-}
-
-.login-title {
- font-family: var(--font-display);
- font-size: 3.25rem;
- font-weight: 600;
- color: var(--ui-ink);
- letter-spacing: 0.12em;
- text-align: center;
- line-height: 1;
- margin-bottom: 0.3rem;
-}
-
-.login-subtitle {
- font-family: var(--font-display);
- font-size: 0.85rem;
- color: rgba(42,26,8,0.55);
- text-align: center;
- letter-spacing: 0.06em;
- font-style: italic;
- margin-bottom: 1.25rem;
-}
-.login-subtitle sup {
- font-size: 0.65em;
- vertical-align: super;
-}
-
-.login-ornament {
- color: var(--ui-gold);
- font-size: 1rem;
- opacity: 0.7;
- margin-bottom: 1.25rem;
- letter-spacing: 0.3em;
- text-align: center;
-}
-
-.error-msg {
- color: #c03030;
- font-size: 0.85rem;
- text-align: center;
- margin-bottom: 0.5rem;
-}
-
-.login-input {
- width: 100%;
- padding: 0.55rem 0.85rem;
- font-size: 0.95rem;
- font-family: var(--font-ui);
- border: 1px solid rgba(138,106,40,0.4);
- border-radius: 5px;
- background: rgba(255,252,240,0.8);
- color: var(--ui-ink);
- outline: none;
- transition: border-color 0.15s, box-shadow 0.15s;
- margin-bottom: 1rem;
-}
-.login-input:focus {
- border-color: var(--ui-gold);
- box-shadow: 0 0 0 3px rgba(200,164,72,0.2);
-}
-
-.login-actions {
- display: flex;
- flex-direction: column;
- gap: 0.55rem;
- width: 100%;
-}
-
-/* Login buttons styled as embossed wooden tiles */
-.login-btn {
- width: 100%;
- padding: 0.65rem 1rem;
- font-family: var(--font-ui);
- font-size: 0.9rem;
- font-weight: 500;
- letter-spacing: 0.04em;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
- position: relative;
-}
-.login-btn:disabled { opacity: 0.35; cursor: default; }
-.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); }
-.login-btn:not(:disabled):active { transform: translateY(0); }
-
-.login-btn-primary {
- background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%);
- color: #e8f0e0;
- box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12);
-}
-.login-btn-secondary {
- background: linear-gradient(160deg, #3a2010 0%, #241408 100%);
- color: #e4d4b4;
- box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08);
-}
-.login-btn-bot {
- background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%);
- color: #d0e0f0;
- box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08);
-}
-
-/* ── Connecting screen ──────────────────────────────────────────────── */
-.connecting {
- font-family: var(--font-display);
- font-size: 1.4rem;
- font-style: italic;
- margin-top: 4rem;
- text-align: center;
- color: var(--ui-parchment);
- text-shadow: 0 1px 4px rgba(0,0,0,0.4);
-}
-
-/* ── Game-action buttons ─────────────────────────────────────────────── */
-.btn {
- padding: 0.5rem 1.25rem;
- font-size: 0.95rem;
- font-family: var(--font-ui);
- font-weight: 500;
- letter-spacing: 0.03em;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- transition: opacity 0.15s, box-shadow 0.15s;
-}
-.btn:disabled { opacity: 0.4; cursor: default; }
-.btn-primary { background: var(--ui-green-accent); color: #fff; }
-.btn-secondary { background: var(--board-rail); color: #e8d8b8; }
-.btn-bot { background: #2a5a7a; color: #fff; }
-.btn:not(:disabled):hover {
- opacity: 0.9;
- box-shadow: 0 2px 6px rgba(0,0,0,0.25);
-}
-
-/* ── Game container ─────────────────────────────────────────────────── */
-/* No width: 100% — let it size to content (the board wrapper, ~832px).
- This keeps the board pinned at the same horizontal position whether or
- not the side panel is visible, and aligns the status bar / score panels
- with the board rather than with the viewport edge. */
-.game-container {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0.6rem;
-}
-
-/* ── Language switcher (in-game) ────────────────────────────────────── */
-.lang-switcher { display: flex; gap: 0.25rem; }
-
-.lang-switcher button {
- font-size: 0.7rem;
- font-family: var(--font-ui);
- letter-spacing: 0.05em;
- padding: 0.15rem 0.4rem;
- border: 1px solid rgba(200,164,72,0.3);
- border-radius: 3px;
- background: transparent;
- cursor: pointer;
- color: var(--ui-parchment);
- opacity: 0.55;
-}
-.lang-switcher button.lang-active {
- opacity: 1;
- font-weight: 500;
- background: rgba(200,164,72,0.15);
- border-color: rgba(200,164,72,0.6);
-}
-
-/* ── Top bar ─────────────────────────────────────────────────────────── */
-.top-bar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- width: 100%;
- color: var(--ui-parchment);
- font-size: 0.85rem;
- opacity: 0.8;
-}
-
-.quit-link {
- font-size: 0.8rem;
- color: var(--ui-parchment);
- text-decoration: underline;
- text-underline-offset: 2px;
- cursor: pointer;
- opacity: 0.7;
-}
-.quit-link:hover { opacity: 1; }
-
-/* ── Game status bar (§10b) — above board ───────────────────────────── */
-.game-status {
- font-family: var(--font-display);
- font-size: 1.2rem;
- font-style: italic;
- color: var(--ui-parchment);
- text-align: center;
- letter-spacing: 0.04em;
- padding: 0.2rem 1rem 0;
- width: 100%;
- text-shadow: 0 1px 4px rgba(0,0,0,0.4);
-}
-
-/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */
-.game-sub-prompt {
- font-family: var(--font-ui);
- font-size: 0.72rem;
- color: rgba(240,228,192,0.5);
- text-align: center;
- letter-spacing: 0.04em;
- padding: 0.15rem 1rem 0;
- width: 100%;
-}
-
-/* ── Player score panel ─────────────────────────────────────────────── */
-/* Horizontal banner: name on the left, score bars expanding to fill the
- board width — no more empty right half on large screens. */
-.player-score-panel {
- background: var(--ui-parchment);
- border-radius: 5px;
- padding: 0.45rem 1.25rem;
- font-size: 0.88rem;
- box-shadow: 0 2px 6px rgba(0,0,0,0.25);
- width: 100%;
- border-top: 2px solid var(--ui-gold-dark);
- display: flex;
- align-items: center;
- gap: 1.5rem;
-}
-
-.player-score-header {
- flex-shrink: 0;
- min-width: 90px;
-}
-
-.player-name {
- font-family: var(--font-display);
- font-weight: 600;
- font-size: 1.05rem;
- color: var(--ui-ink);
- letter-spacing: 0.02em;
-}
-
-/* Bars sit side-by-side (points | holes) filling remaining width */
-.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; }
-
-.score-bar-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- flex: 1;
-}
-
-.score-bar-label {
- font-size: 0.75rem;
- color: #665544;
- width: 3rem;
- text-align: right;
- flex-shrink: 0;
-}
-
-/* ── Points bar ─────────────────────────────────────────────────────── */
-.score-bar {
- flex: 1;
- max-width: 220px;
- height: 8px;
- background: rgba(0,0,0,0.1);
- border-radius: 4px;
- overflow: hidden;
- flex-shrink: 0;
-}
-
-.score-bar-fill {
- height: 100%;
- border-radius: 4px;
- transition: width 0.35s ease-out;
-}
-
-.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); }
-
-.score-bar-value {
- font-size: 0.75rem;
- color: #665544;
- min-width: 2.5rem;
- font-variant-numeric: tabular-nums;
-}
-
-/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */
-.peg-track {
- display: flex;
- align-items: center;
- gap: 3px;
- flex-shrink: 0;
-}
-
-.peg-hole {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- border: 1.5px solid rgba(138,106,40,0.45);
- background: rgba(0,0,0,0.06);
- flex-shrink: 0;
- transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s;
-}
-
-.peg-hole.filled {
- background: var(--ui-gold);
- border-color: var(--ui-gold-dark);
- box-shadow: 0 0 4px rgba(200,164,72,0.6);
-}
-
-.bredouille-badge {
- font-size: 0.62rem;
- font-weight: 500;
- color: #fff8e0;
- background: linear-gradient(135deg, #c88800, #8a5800);
- border: 1px solid rgba(200,164,72,0.5);
- border-radius: 3px;
- padding: 0.1em 0.4em;
- letter-spacing: 0.06em;
- cursor: default;
- box-shadow: 0 1px 3px rgba(0,0,0,0.25);
-}
-
-/* ── Board + side panel ─────────────────────────────────────────────── */
-/* .board-and-panel is sized to the board wrapper only; the side panel is
- positioned absolutely so it floats to the right without pushing the
- board and breaking its horizontal alignment. */
-.board-and-panel {
- position: relative;
-}
-
-/* The side panel is anchored to the board's RIGHT edge. Scoring panel
- wrappers inside it initially overlap the board; they slide to a peek
- strip after a few seconds, and reveal fully on hover. */
-.side-panel {
- position: absolute;
- right: -8px;
- top: 10px;
- z-index: 20;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- padding-top: 0.15rem;
- pointer-events: none; /* pass board clicks through the empty area */
-}
-
-.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
-
-/* ── Dice bar ───────────────────────────────────────────────────────── */
-.dice-bar {
- display: flex;
- align-items: center;
- gap: 0.6rem;
- padding: 0.4rem 0.6rem;
- background: rgba(42,21,8,0.15);
- border-radius: 6px;
- border: 1px solid rgba(200,164,72,0.2);
- width: fit-content;
-}
-
-/* ── Die face (SVG) ─────────────────────────────────────────────────── */
-
-/* §5a — vigorous tumble: die bounces in from a random rotation */
-@keyframes die-tumble {
- 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; }
- 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; }
- 45% { transform: rotate(-10deg) scale(0.91); }
- 62% { transform: rotate(6deg) scale(1.06); }
- 76% { transform: rotate(-3deg) scale(0.98); }
- 88% { transform: rotate(1.5deg) scale(1.01); }
- 100% { transform: rotate(0deg) scale(1); opacity: 1; }
-}
-
-.die-face {
- filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3));
- animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both;
-}
-
-.die-face rect {
- fill: #fffef0;
- stroke: #2a1a00;
- stroke-width: 2;
- transition: fill 0.18s, stroke 0.18s;
-}
-.die-face circle {
- fill: #1a0a00;
- transition: fill 0.18s;
-}
-
-/* Bar die slot — centered in the board bar */
-.bar-die-slot {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-/* Double glow (§5c) */
-.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; }
-.die-face.die-double {
- filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3));
-}
-
-.die-face.die-used { animation: none; opacity: 0.55; }
-.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; }
-.die-face.die-used circle { fill: #9a8a70; }
-
-.die-face .die-question { fill: #1a0a00; font-family: sans-serif; }
-.die-face.die-used .die-question { fill: #9a8a70; }
-
-/* ── Jan panel ──────────────────────────────────────────────────────── */
-.jan-panel {
- display: flex;
- flex-direction: column;
- gap: 2px;
- background: var(--ui-parchment);
- border-radius: 5px;
- padding: 0.4rem 0.9rem;
- font-size: 0.88rem;
- box-shadow: 0 1px 4px rgba(0,0,0,0.15);
- min-width: 260px;
- border-top: 2px solid rgba(138,106,40,0.35);
-}
-
-.jan-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 2px 4px;
- border-radius: 3px;
-}
-.jan-expandable { cursor: pointer; }
-.jan-expandable:hover { background: rgba(0,0,0,0.05); }
-
-.jan-positive { color: #1a5c1a; }
-.jan-negative { color: #8b1a1a; }
-
-.jan-label { flex: 1; }
-.jan-tag {
- font-size: 0.72rem;
- padding: 0.1em 0.4em;
- border-radius: 3px;
- background: rgba(0,0,0,0.07);
- color: #665544;
- white-space: nowrap;
-}
-.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; }
-
-.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; }
-.jan-moves.hidden { display: none; }
-.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; }
-
-/* ── Game-over overlay (§12) ────────────────────────────────────────── */
-.game-over-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.65);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
-}
-
-@keyframes game-over-appear {
- from { transform: translateY(-24px) scale(0.94); opacity: 0; }
- to { transform: translateY(0) scale(1); opacity: 1; }
-}
-
-.game-over-box {
- background: var(--ui-parchment);
- border-radius: 8px;
- padding: 2.5rem 3rem;
- text-align: center;
- box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark);
- display: flex;
- flex-direction: column;
- gap: 1.1rem;
- min-width: 300px;
- animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1);
-}
-
-.game-over-box h2 {
- font-family: var(--font-display);
- font-size: 2rem;
- font-weight: 600;
- color: var(--ui-ink);
- letter-spacing: 0.06em;
-}
-
-.game-over-winner {
- font-family: var(--font-display);
- font-size: 1.25rem;
- color: var(--ui-green-accent);
- font-style: italic;
-}
-
-/* Final score ledger */
-.game-over-score {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0.75rem;
- padding: 0.6rem 1rem;
- background: rgba(0,0,0,0.05);
- border-radius: 5px;
- border: 1px solid rgba(138,106,40,0.2);
-}
-
-.game-over-score-name {
- font-family: var(--font-display);
- font-size: 0.9rem;
- color: #665544;
- font-style: italic;
- max-width: 80px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.game-over-score-nums {
- font-family: var(--font-display);
- font-size: 2.25rem;
- font-weight: 600;
- color: var(--ui-ink);
- letter-spacing: 0.08em;
- line-height: 1;
-}
-
-.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; }
-
-/* ── Scoring notification panel (§6b) ───────────────────────────────── */
-
-/* ── Wrapper: handles slide-in → peek → reveal lifecycle ──────────────
- The wrapper starts off-screen right (translateX(100%)), slides in on
- mount via animation, then Leptos adds .peeked after 3.4s to slide it
- back to a 28px peek strip. */
-@keyframes scoring-panel-enter {
- from { transform: translateX(100%); }
- to { transform: translateX(0); }
-}
-
-.scoring-panel-wrapper {
- /* width: 290px; */
- pointer-events: auto;
- animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94);
- transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
- filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38));
-}
-
-/* Peeked: slide right by the full panel width so the board is 100% clear.
- The panel's left portion stays visible in whatever free space exists to
- the right of the board. */
-.scoring-panel-wrapper.peeked {
- transform: translateX(100%);
-}
-
-/* Click on the visible left strip → .revealed slides it back over the board.
- A second click removes .revealed and returns to the peeked position. */
-.scoring-panel-wrapper.revealed {
- transform: translateX(0);
-}
-
-/* Pointer cursor on the peeked (clickable) strip */
-.scoring-panel-wrapper.peeked:not(.revealed) {
- cursor: pointer;
-}
-
-/* ── Inner panel card ─────────────────────────────────────────────────── */
-.scoring-panel {
- background: var(--ui-parchment);
- border-radius: 5px;
- padding: 0.45rem 0.85rem;
- font-size: 0.84rem;
- box-shadow: 0 1px 4px rgba(0,0,0,0.15);
- border-left: 3px solid var(--ui-green-accent);
- display: flex;
- flex-direction: column;
- gap: 4px;
- width: 100%;
-}
-
-.scoring-total {
- font-family: var(--font-display);
- font-weight: 600;
- font-size: 1rem;
- color: #1a5c1a;
- white-space: nowrap;
-}
-
-.scoring-jan-row {
- display: flex;
- align-items: center;
- gap: 0.4rem;
- padding: 2px 3px;
- border-radius: 3px;
- cursor: default;
- white-space: nowrap;
-}
-.scoring-jan-row:hover { background: rgba(0,0,0,0.05); }
-
-.scoring-panel-opp { border-left-color: var(--board-rail); }
-.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); }
-
-.scoring-hole {
- display: flex;
- align-items: center;
- gap: 0.4rem;
- font-weight: 600;
- color: var(--ui-red-accent);
- margin-top: 3px;
- padding-top: 4px;
- border-top: 1px solid rgba(0,0,0,0.1);
-}
-
-.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; }
-
-/* ── Large-screen layout: panel in free space, no peek needed ─────────
- Threshold: board (832) + body-padding (48) + panel-gap (16) + panel (290)
- + symmetric left margin = 1492 px.
- At this width the panel fits entirely to the right of the board. */
-@media (min-width: 1492px) {
- .side-panel {
- right: auto;
- left: calc(100% + 1rem); /* outside board, no overlap */
- }
- /* Already fully visible in free space — peeked/revealed are no-ops. */
- .scoring-panel-wrapper.peeked,
- .scoring-panel-wrapper.revealed {
- transform: none;
- cursor: default;
- }
-}
-
-/* ── Board wrapper ──────────────────────────────────────────────────── */
-.board-wrapper {
- display: flex;
- flex-direction: column;
- gap: 3px;
-}
-
-/* ── Zone labels (§2a) — aligned with board quarters ───────────────── */
-/* Board border(4) + padding(4) = 8px inset each side */
-.zone-labels-row {
- display: flex;
- gap: 4px;
- padding: 0 8px;
-}
-
-.zone-label {
- font-family: var(--font-display);
- font-size: 0.57rem;
- font-style: italic;
- color: rgba(240,228,192,0.48);
- letter-spacing: 0.1em;
- text-align: center;
- pointer-events: none;
- line-height: 1;
-}
-
-.zone-label-quarter { width: 370px; flex-shrink: 0; }
-.zone-label-bar { width: 68px; flex-shrink: 0; }
-
-/* ── Board ──────────────────────────────────────────────────────────── */
-.board {
- background: var(--board-felt);
- border: 4px solid var(--board-rail);
- border-radius: 6px;
- padding: 4px;
- display: flex;
- flex-direction: column;
- gap: 4px;
- user-select: none;
- box-shadow:
- 0 6px 16px rgba(0,0,0,0.5),
- inset 0 1px 0 rgba(255,255,255,0.04);
- position: relative;
-}
-
-.board-row { display: flex; gap: 4px; }
-.board-quarter { display: flex; gap: 2px; }
-
-.board-bar {
- width: 68px;
- background: var(--board-rail);
- border-radius: 4px;
- box-shadow: inset 0 0 6px rgba(0,0,0,0.5);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- overflow: visible;
-}
-
-.board-center-bar {
- height: 12px;
- background: var(--board-rail);
- border-radius: 2px;
- box-shadow: inset 0 0 4px rgba(0,0,0,0.4);
-}
-
-/* ── Fields (§1) ────────────────────────────────────────────────────── */
-/*
- * Each field is a transparent rectangle over the felt.
- * The triangular flèche is drawn by ::before using clip-path.
- * --fc controls the triangle colour; z-index:-1 keeps the triangle
- * behind checkers; isolation:isolate confines the negative z-index.
- */
-.field {
- --fc: var(--field-ivory); /* default triangle colour */
- width: 60px;
- height: 180px;
- background: transparent; /* felt shows through between triangle tips */
- isolation: isolate; /* stacking context for z-index:-1 ::before */
- border-radius: 3px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-end;
- padding: 4px 2px;
- position: relative;
-}
-
-/* Bot-row triangle: wide base at bottom, tip at top */
-.field::before {
- content: '';
- position: absolute;
- inset: 0;
- z-index: -1; /* behind checkers & corner crown */
- background: var(--fc);
- clip-path: polygon(0% 100%, 50% 0%, 100% 100%);
- transition: background 0.12s;
-}
-
-/* Top-row triangle: wide base at top, tip at bottom */
-.top-row .field::before {
- clip-path: polygon(0% 0%, 100% 0%, 50% 100%);
-}
-
-.top-row .field { justify-content: flex-start; }
-
-/* ── Zone alternating colours (§2b) ────────────────────────────────── */
-/* petit-jan and grand-jan: burgundy / ivory */
-.board-quarter .field.zone-petit:nth-child(odd),
-.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); }
-.board-quarter .field.zone-petit:nth-child(even),
-.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); }
-
-/* Opponent's grand-jan — deep slate-blue / silvery-green ivory.
- Previously #1e3d32 was nearly identical to the felt (#1d3d28); now using
- a clearly distinguishable cool blue that reads well against the green. */
-.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; }
-.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; }
-
-/* Jan de retour — warmer: amber-brown / warm amber ivory */
-.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
-.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
-
-/* ── Rest corner — before .clickable so green wins when interactive ── */
-/* .field.corner { --fc: var(--field-corner) !important; } */
-
-/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */
-.field.corner::after {
- content: '♛';
- position: absolute;
- z-index: -1;
- bottom: 22px;
- font-size: 0.7rem;
- color: rgba(255,248,210,0.38);
- pointer-events: none;
- line-height: 1;
-}
-.top-row .field.corner::after { bottom: auto; top: 22px; }
-
-/* Corner pulse (§8d) — filter respects the triangle shape */
-@keyframes corner-pulse {
- 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); }
- 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); }
-}
-.field.corner.corner-available {
- animation: corner-pulse 1.5s ease-in-out infinite;
-}
-
-/* ── Exit-eligible highlight (§8c) — filter glow on triangle ───────── */
-@keyframes exit-glow {
- 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); }
- 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); }
-}
-.field.exit-eligible {
- animation: exit-glow 2s ease-in-out infinite;
-}
-
-/* ── §6c — Jan hover field highlight ────────────────────────────────── */
-.field.jan-hovered {
- --fc: rgba(190, 140, 35, 0.8) !important;
-}
-
-/* ── §6e — Hit (battue) ripple ──────────────────────────────────────── */
-@keyframes hit-ripple {
- from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; }
- to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; }
-}
-.hit-ripple {
- position: absolute;
- left: 50%;
- width: 36px;
- height: 36px;
- border-radius: 50%;
- border: 2px solid rgba(200, 164, 72, 0.9);
- pointer-events: none;
- animation: hit-ripple 0.5s ease-out forwards;
-}
-.hit-ripple-top { top: 26px; }
-.hit-ripple-bot { bottom: 26px; }
-
-/* ── Interactive states — after .corner to take visual priority ─────── */
-.field.clickable {
- cursor: pointer;
- --fc: #8fc840 !important;
-}
-.field.clickable:hover { --fc: #74aa28 !important; }
-.field.selected {
- --fc: #5a8a18 !important;
- outline: 2px solid rgba(255,255,255,0.3);
- outline-offset: -2px;
-}
-
-/* ── Field numbers ──────────────────────────────────────────────────── */
-.field-num {
- font-size: 0.58rem;
- color: rgba(0,0,0,0.28);
- position: absolute;
- bottom: 3px;
- line-height: 1;
- font-variant-numeric: tabular-nums;
-}
-
-.board-quarter .field.zone-petit:nth-child(odd) .field-num,
-.board-quarter .field.zone-grand:nth-child(odd) .field-num,
-.board-quarter .field.zone-retour:nth-child(odd) .field-num,
-.board-quarter .field.zone-opponent:nth-child(odd) .field-num {
- color: rgba(240,215,190,0.38);
-}
-
-.field.corner .field-num { color: rgba(255,248,200,0.4); }
-
-.top-row .field-num { bottom: auto; top: 3px; }
-
-/* ── Checkers ───────────────────────────────────────────────────────── */
-.checker-stack {
- display: flex;
- flex-direction: column;
- align-items: center;
-}
-
-.checker {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 0.78rem;
- font-weight: 600;
- flex-shrink: 0;
-}
-
-.checker + .checker { margin-top: -4px; }
-
-.checker.white {
- background-image:
- radial-gradient(ellipse 50% 35% at 36% 30%,
- rgba(255,255,255,0.65) 0%, transparent 100%),
- radial-gradient(circle,
- transparent 68%, rgba(160,130,70,0.22) 68.5%,
- rgba(160,130,70,0.22) 71.5%, transparent 72%),
- radial-gradient(circle,
- transparent 43%, rgba(160,130,70,0.17) 43.5%,
- rgba(160,130,70,0.17) 46.5%, transparent 47%),
- radial-gradient(circle at 38% 32%,
- #ffffff 0%, var(--checker-white) 52%, #c0b288 100%);
- border: 1.8px solid var(--checker-ring);
- box-shadow:
- 0 2px 6px rgba(0,0,0,0.4),
- inset 0 -1px 3px rgba(0,0,0,0.15);
- color: #443322;
-}
-
-.checker.black {
- background-image:
- radial-gradient(ellipse 40% 28% at 36% 30%,
- rgba(110,65,30,0.38) 0%, transparent 100%),
- radial-gradient(circle,
- transparent 68%, rgba(200,164,72,0.18) 68.5%,
- rgba(200,164,72,0.18) 71.5%, transparent 72%),
- radial-gradient(circle,
- transparent 43%, rgba(200,164,72,0.13) 43.5%,
- rgba(200,164,72,0.13) 46.5%, transparent 47%),
- radial-gradient(circle at 38% 32%,
- #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%);
- border: 1.8px solid var(--checker-ring);
- box-shadow:
- 0 2px 6px rgba(0,0,0,0.55),
- inset 0 -1px 3px rgba(0,0,0,0.4);
- color: #c8b898;
-}
-
-/* ── Hole toast (§6a) ───────────────────────────────────────────────── */
-@keyframes toast-rise {
- from { transform: translate(-50%, -40%); opacity: 0; }
- to { transform: translate(-50%, -50%); opacity: 1; }
-}
-@keyframes toast-fade {
- from { opacity: 1; }
- to { opacity: 0; pointer-events: none; }
-}
-
-.hole-toast {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: rgba(22,10,2,0.93);
- border: 2px solid var(--ui-gold);
- border-radius: 8px;
- padding: 1.5rem 3.5rem;
- text-align: center;
- z-index: 50;
- pointer-events: none;
- box-shadow:
- 0 12px 40px rgba(0,0,0,0.65),
- 0 0 0 1px rgba(200,164,72,0.25),
- inset 0 1px 0 rgba(200,164,72,0.1);
- animation:
- toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1),
- toast-fade 0.5s ease-in 1.4s forwards;
-}
-
-.hole-toast-title {
- font-family: var(--font-display);
- font-size: 3.25rem;
- font-weight: 600;
- color: var(--ui-gold);
- letter-spacing: 0.1em;
- line-height: 1;
-}
-
-.hole-toast-count {
- font-family: var(--font-display);
- font-size: 1.1rem;
- color: rgba(200,164,72,0.68);
- margin-top: 0.35rem;
- letter-spacing: 0.06em;
-}
-
-.hole-toast-bredouille {
- font-family: var(--font-ui);
- font-size: 0.75rem;
- letter-spacing: 0.08em;
- color: rgba(200,164,72,0.55);
- margin-top: 0.4rem;
- text-transform: uppercase;
-}
-
-/* ── Bredouille toast variant (§6d) — gold shimmer, larger entrance ─── */
-@keyframes bredouille-shimmer {
- 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); }
- 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); }
-}
-.hole-toast.hole-toast-bredouille {
- border-width: 2.5px;
- border-color: var(--ui-gold);
- padding: 2rem 4rem;
- animation:
- toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1),
- bredouille-shimmer 0.9s ease-in-out 0.3s 2,
- toast-fade 0.5s ease-in 2.2s forwards;
-}
-.hole-toast.hole-toast-bredouille .hole-toast-title {
- font-size: 3.75rem;
-}
-.hole-toast.hole-toast-bredouille .hole-toast-bredouille {
- font-size: 0.85rem;
- color: rgba(200,164,72,0.8);
- letter-spacing: 0.14em;
-}
-
-/* ── §4a — Checker slide animation ─────────────────────────────────── */
-@keyframes checker-slide-in {
- from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); }
- to { transform: none; }
-}
-/* Only the arriving (outermost) checker animates; --slide-dx/dy are set
- as inline styles on that element at render time, so no flash occurs. */
-.checker.arriving {
- animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94);
-}
-/* Lift the field that owns an arriving checker above its siblings so the
- checker doesn't slide under adjacent fields (isolation:isolate traps
- z-index within each field's stacking context). */
-.field:has(.checker.arriving) {
- isolation: auto;
- z-index: 10;
- position: relative;
-}
-
-/* ── Checker lift on selected field (§4b) ───────────────────────────── */
-.field.selected .checker-stack {
- transform: translateY(-5px);
- filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6));
- transition: transform 0.12s ease-out, filter 0.12s ease-out;
-}
-
-/* ── Action buttons below board (§10c) ──────────────────────────────── */
-.board-actions {
- display: flex;
- gap: 0.55rem;
- justify-content: center;
- align-items: center;
- flex-wrap: wrap;
- min-height: 2rem; /* reserve height so layout doesn't shift when buttons appear */
-}
-
-/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */
-.ceremony-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.65);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
-}
-
-.ceremony-box {
- background: var(--ui-parchment);
- border-radius: 8px;
- padding: 2.5rem 3rem;
- text-align: center;
- box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark);
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 1.4rem;
- min-width: 300px;
- animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1);
-}
-
-.ceremony-box h2 {
- font-family: var(--font-display);
- font-size: 1.8rem;
- font-weight: 600;
- color: var(--ui-ink);
- letter-spacing: 0.06em;
-}
-
-.ceremony-dice {
- display: flex;
- gap: 3rem;
- align-items: flex-end;
-}
-
-.ceremony-die-slot {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0.5rem;
-}
-
-.ceremony-die-label {
- font-family: var(--font-ui);
- font-size: 0.85rem;
- color: var(--ui-ink);
- font-weight: 500;
-}
-
-.ceremony-tie {
- font-family: var(--font-display);
- font-size: 1rem;
- color: var(--ui-red-accent);
- font-style: italic;
-}
-
-
-.auth-badge {
- font-size: 0.8rem;
- text-align: center;
- padding: 0.35rem 0.6rem;
- border-radius: 5px;
-}
-.auth-badge--in { background: rgba(96,165,250,0.15); color: #93c5fd; }
-.auth-badge--out { background: rgba(148,163,184,0.1); color: #64748b; }
-.auth-badge a { color: #60a5fa; }
-
-.playing-as {
- font-size: 0.8rem;
- color: #64748b;
- text-align: center;
-}
diff --git a/clients/web-game/index.html b/clients/web-game/index.html
deleted file mode 100644
index 7399dbc..0000000
--- a/clients/web-game/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
- Trictrac
-
-
-
-
-
-
diff --git a/clients/web-game/locales/en.json b/clients/web-game/locales/en.json
deleted file mode 100644
index c29121d..0000000
--- a/clients/web-game/locales/en.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "room_name_placeholder": "Room name",
- "create_room": "Create Room",
- "join_room": "Join Room",
- "connecting": "Connecting…",
- "game_over": "Game over",
- "waiting_for_opponent": "Waiting for opponent…",
- "your_turn_roll": "Your turn — roll the dice",
- "hold_or_go": "Hold or Go?",
- "select_move": "Move a checker ({{ n }} of 2)",
- "your_turn": "Your turn",
- "opponent_turn": "Opponent's turn",
- "room_label": "Room: {{ id }}",
- "quit": "Quit",
- "roll_dice": "Roll dice",
- "go": "Go",
- "empty_move": "Empty move",
- "you_suffix": " (you)",
- "points_label": "Points",
- "holes_label": "Holes",
- "bredouille_title": "Can bredouille",
- "jan_double": "double",
- "jan_simple": "simple",
- "jan_filled_quarter": "Quarter filled",
- "jan_true_hit_small": "True hit (small jan)",
- "jan_true_hit_big": "True hit (big jan)",
- "jan_true_hit_corner": "True hit (opp. corner)",
- "jan_first_exit": "First to exit",
- "jan_six_tables": "Six tables",
- "jan_two_tables": "Two tables",
- "jan_mezeas": "Mezeas",
- "jan_false_hit_small": "False hit (small jan)",
- "jan_false_hit_big": "False hit (big jan)",
- "jan_contre_two": "Contre two tables",
- "jan_contre_mezeas": "Contre mezeas",
- "jan_helpless_man": "Helpless man",
- "play_vs_bot": "Play vs Bot",
- "vs_bot_label": "vs Bot",
- "you_win": "You win!",
- "opp_wins": "{{ name }} wins!",
- "play_again": "Play again",
- "after_opponent_roll": "Opponent rolled",
- "after_opponent_go": "Opponent chose to continue",
- "after_opponent_move": "Opponent moved — your turn",
- "after_opponent_pre_game_roll": "Opponent rolled — your turn",
- "pre_game_roll_title": "Who goes first?",
- "pre_game_roll_btn": "Roll",
- "pre_game_roll_tie": "Tie! Roll again",
- "pre_game_roll_your_die": "Your die",
- "pre_game_roll_opp_die": "Opponent's die",
- "continue_btn": "Continue",
- "scored_pts": "+{{ n }} pts",
- "hole_made": "Hole! {{ holes }}/12",
- "bredouille_applied": "Bredouille!",
- "hold": "Hold",
- "opp_scored_pts": "Opponent +{{ n }} pts",
- "opp_hole_made": "Opponent hole! {{ holes }}/12",
- "hint_move": "Click a highlighted field to move a checker",
- "hint_hold_or_go": "Hold to keep points — Go to reset the setting",
- "hint_continue": "Click Continue when ready"
-}
diff --git a/clients/web-game/locales/fr.json b/clients/web-game/locales/fr.json
deleted file mode 100644
index 93f76e5..0000000
--- a/clients/web-game/locales/fr.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "room_name_placeholder": "Nom de la salle",
- "create_room": "Créer une salle",
- "join_room": "Rejoindre",
- "connecting": "Connexion en cours…",
- "game_over": "Partie terminée",
- "waiting_for_opponent": "En attente de l'adversaire…",
- "your_turn_roll": "À votre tour — lancez les dés",
- "hold_or_go": "Tenir ou s'en aller ?",
- "select_move": "Déplacez une dame ({{ n }} sur 2)",
- "your_turn": "Votre tour",
- "opponent_turn": "Tour de l'adversaire",
- "room_label": "Salle : {{ id }}",
- "quit": "Quitter",
- "roll_dice": "Lancer les dés",
- "go": "S'en aller",
- "empty_move": "Mouvement impossible",
- "you_suffix": " (vous)",
- "points_label": "Points",
- "holes_label": "Trous",
- "bredouille_title": "Peut faire bredouille",
- "jan_double": "double",
- "jan_simple": "simple",
- "jan_filled_quarter": "Remplissage",
- "jan_true_hit_small": "Battage à vrai (petit jan)",
- "jan_true_hit_big": "Battage à vrai (grand jan)",
- "jan_true_hit_corner": "Battage coin adverse",
- "jan_first_exit": "Premier sorti",
- "jan_six_tables": "Jan de six tables",
- "jan_two_tables": "Jan de deux tables",
- "jan_mezeas": "Jan de mézéas",
- "jan_false_hit_small": "Battage à faux (petit jan)",
- "jan_false_hit_big": "Battage à faux (grand jan)",
- "jan_contre_two": "Contre jan de deux tables",
- "jan_contre_mezeas": "Contre jan de mezeas",
- "jan_helpless_man": "Dame impuissante",
- "play_vs_bot": "Jouer contre le bot",
- "vs_bot_label": "contre le bot",
- "you_win": "Vous avez gagné !",
- "opp_wins": "{{ name }} gagne !",
- "play_again": "Rejouer",
- "after_opponent_roll": "L'adversaire a lancé les dés",
- "after_opponent_go": "L'adversaire s'en va",
- "after_opponent_move": "L'adversaire a joué — à vous",
- "after_opponent_pre_game_roll": "L'adversaire a lancé — à vous",
- "pre_game_roll_title": "Qui joue en premier ?",
- "pre_game_roll_btn": "Lancer",
- "pre_game_roll_tie": "Égalité ! Relancez",
- "pre_game_roll_your_die": "Votre dé",
- "pre_game_roll_opp_die": "Dé adverse",
- "continue_btn": "Continuer",
- "scored_pts": "+{{ n }} pts",
- "hole_made": "Trou ! {{ holes }}/12",
- "bredouille_applied": "Bredouille !",
- "hold": "Tenir",
- "opp_scored_pts": "Adversaire +{{ n }} pts",
- "opp_hole_made": "Trou adverse ! {{ holes }}/12",
- "hint_move": "Cliquez un champ surligné pour déplacer",
- "hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
- "hint_continue": "Cliquez Continuer quand vous êtes prêt"
-}
diff --git a/clients/web-game/src/app.rs b/clients/web-game/src/app.rs
deleted file mode 100644
index ce355f4..0000000
--- a/clients/web-game/src/app.rs
+++ /dev/null
@@ -1,726 +0,0 @@
-use futures::channel::mpsc;
-use futures::{FutureExt, StreamExt};
-use gloo_storage::{LocalStorage, Storage};
-use leptos::prelude::*;
-use leptos::task::spawn_local;
-use serde::{Deserialize, Serialize};
-
-use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
-use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate};
-
-use crate::components::{ConnectingScreen, GameScreen, LoginScreen};
-use crate::i18n::I18nContextProvider;
-use crate::trictrac::backend::TrictracBackend;
-use crate::trictrac::bot_local::bot_decide;
-use crate::trictrac::types::{
- GameDelta, JanEntry, PlayerAction, ScoredEvent, SerStage, SerTurnStage, ViewState,
-};
-use trictrac_store::CheckerMove;
-
-use std::collections::VecDeque;
-
-const RELAY_URL: &str = "ws://localhost:8080/ws";
-const GAME_ID: &str = "trictrac";
-const STORAGE_KEY: &str = "trictrac_session";
-
-// In debug builds trunk serves on 9091, relay is on 8080.
-// In release the game is served by the relay itself — use relative paths.
-#[cfg(debug_assertions)]
-const HTTP_BASE: &str = "http://localhost:8080";
-#[cfg(not(debug_assertions))]
-const HTTP_BASE: &str = "";
-
-/// The state the UI needs to render the game screen.
-#[derive(Clone, PartialEq)]
-pub struct GameUiState {
- pub view_state: ViewState,
- /// 0 = host, 1 = guest
- pub player_id: u16,
- pub room_id: String,
- pub is_bot_game: bool,
- /// True when this state is a buffered snapshot awaiting player confirmation.
- pub waiting_for_confirm: bool,
- /// Why we are paused — drives the status-bar message in GameScreen.
- pub pause_reason: Option,
- /// Points scored by this player in the transition to this state (if any).
- pub my_scored_event: Option,
- pub opp_scored_event: Option,
- /// Checker moves to animate on this render. None when board is unchanged.
- pub last_moves: Option<(CheckerMove, CheckerMove)>,
-}
-
-/// Reason the UI is paused waiting for the player to click Continue.
-#[derive(Clone, Debug, PartialEq)]
-pub enum PauseReason {
- AfterOpponentRoll,
- AfterOpponentGo,
- AfterOpponentMove,
- /// Opponent rolled their die in the pre-game ceremony.
- AfterOpponentPreGameRoll,
-}
-
-/// Which screen is currently shown.
-#[derive(Clone, PartialEq)]
-pub enum Screen {
- Login { error: Option },
- Connecting,
- Playing(GameUiState),
-}
-
-/// Commands sent from UI event handlers into the network task.
-pub enum NetCommand {
- CreateRoom {
- room: String,
- },
- JoinRoom {
- room: String,
- },
- Reconnect {
- relay_url: String,
- game_id: String,
- room_id: String,
- token: u64,
- host_state: Option>,
- },
- PlayVsBot,
- Action(PlayerAction),
- Disconnect,
-}
-
-/// Stored in localStorage to reconnect after a page refresh.
-#[derive(Serialize, Deserialize)]
-struct StoredSession {
- relay_url: String,
- game_id: String,
- room_id: String,
- token: u64,
- #[serde(default)]
- is_host: bool,
- #[serde(default)]
- view_state: Option,
-}
-
-#[derive(Deserialize)]
-struct MeResponse {
- username: String,
-}
-
-fn save_session(session: &StoredSession) {
- LocalStorage::set(STORAGE_KEY, session).ok();
-}
-
-fn load_session() -> Option {
- LocalStorage::get::(STORAGE_KEY).ok()
-}
-
-fn clear_session() {
- LocalStorage::delete(STORAGE_KEY);
-}
-
-/// Fire-and-forget: tell the relay server who won. Only called by the host.
-async fn submit_game_result(room_code: String, game_state: ViewState) {
- let [score_pl1, score_pl2] = game_state.scores;
- let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes);
- let outcomes = if score_pl1.holes < score_pl2.holes {
- [("0", "loss"), ("1", "win")]
- } else if score_pl2.holes < score_pl1.holes {
- [("0", "win"), ("1", "loss")]
- } else {
- [("0", "draw"), ("1", "draw")]
- };
- let body = serde_json::json!({
- "room_code": room_code,
- "game_id": GAME_ID,
- "result": result_str,
- "outcomes": std::collections::HashMap::from(outcomes),
- });
- let _ = gloo_net::http::Request::post(&format!("{HTTP_BASE}/games/result"))
- .credentials(web_sys::RequestCredentials::Include)
- .json(&body)
- .unwrap()
- .send()
- .await;
-}
-
-#[component]
-pub fn App() -> impl IntoView {
- let stored = load_session();
- let initial_screen = if stored.is_some() {
- Screen::Connecting
- } else {
- Screen::Login { error: None }
- };
- let screen = RwSignal::new(initial_screen);
-
- // Auth: fetch once and expose to all child components via context.
- let auth_username: RwSignal
}>
-
-
-
-
-
-
- }
-}
-
-#[component]
-fn Nav() -> impl IntoView {
- let auth = use_context::().unwrap();
-
- let logout = move |_| {
- wasm_bindgen_futures::spawn_local(async move {
- let _ = api::post_logout().await;
- auth.user.set(None);
- });
- };
-
- view! {
-
- }
-}
diff --git a/clients/web-user-portal/src/main.rs b/clients/web-user-portal/src/main.rs
deleted file mode 100644
index 64a2eb2..0000000
--- a/clients/web-user-portal/src/main.rs
+++ /dev/null
@@ -1,7 +0,0 @@
-mod api;
-mod app;
-mod pages;
-
-fn main() {
- leptos::mount::mount_to_body(app::App);
-}
diff --git a/clients/web-user-portal/src/pages/game.rs b/clients/web-user-portal/src/pages/game.rs
deleted file mode 100644
index 226be3e..0000000
--- a/clients/web-user-portal/src/pages/game.rs
+++ /dev/null
@@ -1,95 +0,0 @@
-use leptos::prelude::*;
-use leptos_router::{components::A, hooks::use_params_map};
-
-use crate::api::{self, GameDetail, Participant};
-
-#[component]
-pub fn GamePage() -> impl IntoView {
- let params = use_params_map();
- let id_str = move || params.read().get("id").unwrap_or_default();
-
- let detail = LocalResource::new(move || {
- let s = id_str();
- async move {
- let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?;
- api::get_game_detail(id).await
- }
- });
-
- view! {
-
- {move || match detail.get().map(|sw| sw.take()) {
- None => view! {
"Loading…"
}.into_any(),
- Some(Err(e)) => view! {
{ e }
}.into_any(),
- Some(Ok(g)) => view! {
}.into_any(),
- }}
-
- }
-}
-
-#[component]
-fn GameDetailView(game: GameDetail) -> impl IntoView {
- let started = api::format_ts(game.started_at);
- let ended = game.ended_at.map(api::format_ts).unwrap_or_else(|| "ongoing".into());
-
- view! {
-
-
"Game " { game.room_code.clone() }
-
- "Started: " { started.clone() } " · Ended: " { ended }
-
-
-
"Players"
-
-
-
- | "Player" |
- "Username" |
- "Outcome" |
-
-
-
- {game.participants.iter().map(|p| {
- view! { }
- }).collect_view()}
-
-
-
- {game.result.as_ref().map(|r| view! {
-
-
"Result data"
-
- { r.clone() }
-
-
- })}
-
- }
-}
-
-#[component]
-fn ParticipantRow(participant: Participant) -> impl IntoView {
- let outcome_class = match participant.outcome.as_deref() {
- Some("win") => "outcome-win",
- Some("loss") => "outcome-loss",
- Some("draw") => "outcome-draw",
- _ => "",
- };
- let outcome_text = participant.outcome.clone().unwrap_or_else(|| "—".into());
- let name = participant.username.clone();
-
- view! {
-
- | "Player " { participant.player_id } |
-
- {match name {
- Some(u) => view! {
- { u }
- }.into_any(),
- None => view! { "anonymous" }.into_any(),
- }}
- |
- { outcome_text } |
-
- }
-}
diff --git a/clients/web-user-portal/src/pages/home.rs b/clients/web-user-portal/src/pages/home.rs
deleted file mode 100644
index e7dd14f..0000000
--- a/clients/web-user-portal/src/pages/home.rs
+++ /dev/null
@@ -1,152 +0,0 @@
-use leptos::prelude::*;
-use leptos_router::hooks::use_navigate;
-
-use crate::api;
-use crate::app::AuthState;
-
-#[component]
-pub fn HomePage() -> impl IntoView {
- let auth = use_context::().unwrap();
- let navigate = use_navigate();
-
- // Redirect to own profile when already logged in.
- Effect::new(move |_| {
- if let Some(u) = auth.user.get() {
- navigate(&format!("/profile/{}", u.username), Default::default());
- }
- });
-
- let tab = RwSignal::new("login");
-
- view! {
-
-
-
-
-
- {move || if tab.get() == "login" {
- view! {
}.into_any()
- } else {
- view! {
}.into_any()
- }}
-
- }
-}
-
-#[component]
-fn LoginForm() -> impl IntoView {
- let auth = use_context::().unwrap();
- let navigate = use_navigate();
-
- let username = RwSignal::new(String::new());
- let password = RwSignal::new(String::new());
- let error = RwSignal::new(String::new());
- let pending = RwSignal::new(false);
-
- let submit = move |ev: leptos::ev::SubmitEvent| {
- ev.prevent_default();
- if pending.get() { return; }
- pending.set(true);
- error.set(String::new());
- let u = username.get();
- let p = password.get();
- let navigate = navigate.clone();
- wasm_bindgen_futures::spawn_local(async move {
- match api::post_login(&u, &p).await {
- Ok(me) => {
- let dest = format!("/profile/{}", me.username);
- auth.user.set(Some(me));
- navigate(&dest, Default::default());
- }
- Err(e) => {
- error.set(e);
- pending.set(false);
- }
- }
- });
- };
-
- view! {
-
- }
-}
-
-#[component]
-fn RegisterForm() -> impl IntoView {
- let auth = use_context::().unwrap();
- let navigate = use_navigate();
-
- let username = RwSignal::new(String::new());
- let email = RwSignal::new(String::new());
- let password = RwSignal::new(String::new());
- let error = RwSignal::new(String::new());
- let pending = RwSignal::new(false);
-
- let submit = move |ev: leptos::ev::SubmitEvent| {
- ev.prevent_default();
- if pending.get() { return; }
- pending.set(true);
- error.set(String::new());
- let u = username.get();
- let e = email.get();
- let p = password.get();
- let navigate = navigate.clone();
- wasm_bindgen_futures::spawn_local(async move {
- match api::post_register(&u, &e, &p).await {
- Ok(me) => {
- let dest = format!("/profile/{}", me.username);
- auth.user.set(Some(me));
- navigate(&dest, Default::default());
- }
- Err(err) => {
- error.set(err);
- pending.set(false);
- }
- }
- });
- };
-
- view! {
-
- }
-}
diff --git a/clients/web-user-portal/src/pages/mod.rs b/clients/web-user-portal/src/pages/mod.rs
deleted file mode 100644
index e4a454b..0000000
--- a/clients/web-user-portal/src/pages/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub mod game;
-pub mod home;
-pub mod profile;
diff --git a/clients/web-user-portal/src/pages/profile.rs b/clients/web-user-portal/src/pages/profile.rs
deleted file mode 100644
index f93059a..0000000
--- a/clients/web-user-portal/src/pages/profile.rs
+++ /dev/null
@@ -1,137 +0,0 @@
-use leptos::prelude::*;
-use leptos_router::{components::A, hooks::use_params_map};
-
-use crate::api::{self, GameSummary, UserProfile};
-
-#[component]
-pub fn ProfilePage() -> impl IntoView {
- let params = use_params_map();
- let username = move || params.read().get("username").unwrap_or_default();
-
- let profile = LocalResource::new(move || {
- let u = username();
- async move { api::get_user_profile(&u).await }
- });
-
- view! {
-
- {move || match profile.get().map(|sw| sw.take()) {
- None => view! {
"Loading…"
}.into_any(),
- Some(Err(e)) => view! {
{ e }
}.into_any(),
- Some(Ok(p)) => view! {
}.into_any(),
- }}
-
- }
-}
-
-#[component]
-fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
- let page = RwSignal::new(0i64);
- let games = LocalResource::new(move || {
- let u = username.clone();
- let p = page.get();
- async move { api::get_user_games(&u, p).await }
- });
-
- let joined = crate::api::format_ts(profile.created_at);
-
- view! {
- { profile.username.clone() }
- "Joined: " { joined }
-
-
-
-
{ profile.total_games }
-
"Games"
-
-
-
{ profile.wins }
-
"Wins"
-
-
-
{ profile.losses }
-
"Losses"
-
-
-
{ profile.draws }
-
"Draws"
-
-
-
-
-
"Game History"
- {move || match games.get().map(|sw| sw.take()) {
- None => view! {
"Loading…"
}.into_any(),
- Some(Err(e)) => view! {
{ e }
}.into_any(),
- Some(Ok(r)) => {
- if r.games.is_empty() {
- view! {
"No games recorded yet."
}.into_any()
- } else {
- view! {
}.into_any()
- }
- }
- }}
-
- }
-}
-
-#[component]
-fn GamesTable(games: Vec, page: RwSignal) -> impl IntoView {
- let rows = games.clone();
- let has_next = games.len() == 20;
-
- view! {
-
-
-
- | "Room" |
- "Started" |
- "Ended" |
- "Outcome" |
- "Detail" |
-
-
-
- {rows.into_iter().map(|g| {
- let started = crate::api::format_ts(g.started_at);
- let ended = g.ended_at.map(crate::api::format_ts).unwrap_or_else(|| "—".into());
- let outcome_class = match g.outcome.as_deref() {
- Some("win") => "outcome-win",
- Some("loss") => "outcome-loss",
- Some("draw") => "outcome-draw",
- _ => "",
- };
- let outcome_text = g.outcome.clone().unwrap_or_else(|| "—".into());
- view! {
-
- | { g.room_code.clone() } |
- { started } |
- { ended } |
- { outcome_text } |
-
- "View"
- |
-
- }
- }).collect_view()}
-
-
-
- {move || if page.get() > 0 {
- view! {
-
- }.into_any()
- } else {
- view! { }.into_any()
- }}
- "Page " { move || page.get() + 1 }
- {if has_next {
- view! {
-
- }.into_any()
- } else {
- view! { }.into_any()
- }}
-
- }
-}