diff --git a/Cargo.lock b/Cargo.lock
index 07b7830..e557059 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1449,6 +1449,26 @@ 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"
@@ -5339,16 +5359,6 @@ dependencies = [
"unicase",
]
-[[package]]
-name = "minicov"
-version = "0.3.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
-dependencies = [
- "cc",
- "walkdir",
-]
-
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -8717,7 +8727,6 @@ dependencies = [
"trictrac-store",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-bindgen-test",
"web-sys",
]
@@ -9169,45 +9178,6 @@ dependencies = [
"unicode-ident",
]
-[[package]]
-name = "wasm-bindgen-test"
-version = "0.3.68"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea"
-dependencies = [
- "async-trait",
- "cast",
- "js-sys",
- "libm",
- "minicov",
- "nu-ansi-term",
- "num-traits",
- "oorandom",
- "serde",
- "serde_json",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "wasm-bindgen-test-macro",
- "wasm-bindgen-test-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-test-macro"
-version = "0.3.68"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "wasm-bindgen-test-shared"
-version = "0.2.118"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472"
-
[[package]]
name = "wasm-encoder"
version = "0.244.0"
@@ -9275,6 +9245,21 @@ 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 3c70d45..94a1c6b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,8 @@ 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 ca4c0de..4e7789f 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
+just dev-game
```
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
-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/).
+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/).
The system consists of:
diff --git a/clients/web-game/Cargo.toml b/clients/web-game/Cargo.toml
new file mode 100644
index 0000000..578be7c
--- /dev/null
+++ b/clients/web-game/Cargo.toml
@@ -0,0 +1,40 @@
+[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
new file mode 100644
index 0000000..bae5297
--- /dev/null
+++ b/clients/web-game/Trunk.toml
@@ -0,0 +1,2 @@
+[serve]
+port = 9091
diff --git a/clients/web-game/assets/diceroll.mp3 b/clients/web-game/assets/diceroll.mp3
new file mode 100644
index 0000000..b16adff
Binary files /dev/null and b/clients/web-game/assets/diceroll.mp3 differ
diff --git a/clients/web-game/assets/style.css b/clients/web-game/assets/style.css
new file mode 100644
index 0000000..341be19
--- /dev/null
+++ b/clients/web-game/assets/style.css
@@ -0,0 +1,1213 @@
+/* ── 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
new file mode 100644
index 0000000..7399dbc
--- /dev/null
+++ b/clients/web-game/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Trictrac
+
+
+
+
+
+
diff --git a/clients/web-game/locales/en.json b/clients/web-game/locales/en.json
new file mode 100644
index 0000000..c29121d
--- /dev/null
+++ b/clients/web-game/locales/en.json
@@ -0,0 +1,61 @@
+{
+ "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
new file mode 100644
index 0000000..93f76e5
--- /dev/null
+++ b/clients/web-game/locales/fr.json
@@ -0,0 +1,61 @@
+{
+ "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
new file mode 100644
index 0000000..ce355f4
--- /dev/null
+++ b/clients/web-game/src/app.rs
@@ -0,0 +1,726 @@
+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