From 72c5e16ea333bc5a2821249277de6a5d7eacda69 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 11 Apr 2026 14:36:15 +0200 Subject: [PATCH] feat(client_web): slide dice jans right panel --- Cargo.lock | 1 + client_web/Cargo.toml | 1 + client_web/assets/style.css | 76 ++++++++++-- client_web/src/components/scoring.rs | 174 ++++++++++++++++++++------- 4 files changed, 197 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b19ee85..265469e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,6 +1397,7 @@ dependencies = [ "futures", "getrandom 0.3.4", "gloo-storage", + "gloo-timers", "leptos", "leptos_i18n", "rand 0.9.2", diff --git a/client_web/Cargo.toml b/client_web/Cargo.toml index 3e648ea..f7b3957 100644 --- a/client_web/Cargo.toml +++ b/client_web/Cargo.toml @@ -20,6 +20,7 @@ gloo-storage = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" +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"] } diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 655bb1f..898cc0f 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -445,15 +445,19 @@ body { 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; - left: calc(100% + 1rem); + right: 0; top: 0; + z-index: 20; display: flex; flex-direction: column; - gap: 0.65rem; - width: 200px; + 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; } @@ -637,22 +641,55 @@ body { .game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } /* ── Scoring notification panel (§6b) ───────────────────────────────── */ -@keyframes score-panel-in { - from { transform: translateX(18px); opacity: 0; } - to { transform: translateX(0); opacity: 1; } + +/* ── 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. First hover adds .revealed for permanent + visibility. pointer-events: auto overrides the parent's none. */ +@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 (depends on viewport width). */ +.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.4rem 0.7rem; + 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: 3px; - animation: score-panel-in 0.22s ease-out; + gap: 4px; + width: 100%; } .scoring-total { @@ -660,15 +697,17 @@ body { font-weight: 600; font-size: 1rem; color: #1a5c1a; + white-space: nowrap; } .scoring-jan-row { display: flex; align-items: center; gap: 0.4rem; - padding: 1px 2px; + padding: 2px 3px; border-radius: 3px; cursor: default; + white-space: nowrap; } .scoring-jan-row:hover { background: rgba(0,0,0,0.05); } @@ -688,6 +727,23 @@ body { .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; diff --git a/client_web/src/components/scoring.rs b/client_web/src/components/scoring.rs index ab44ec4..f463969 100644 --- a/client_web/src/components/scoring.rs +++ b/client_web/src/components/scoring.rs @@ -1,6 +1,10 @@ use futures::channel::mpsc::UnboundedSender; +#[cfg(target_arch = "wasm32")] +use gloo_timers::future::TimeoutFuture; use leptos::prelude::*; use trictrac_store::CheckerMove; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::spawn_local; use crate::app::NetCommand; use crate::i18n::*; @@ -8,6 +12,10 @@ use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage}; use super::score_panel::jan_label; +/// One row in the scoring panel. Sets the hovered-moves context on enter +/// (so board shows arrows for that jan's moves), but does NOT clear on +/// leave — clearing is handled by the outer wrapper's mouseleave so that +/// arrows persist while the pointer moves between rows. fn scoring_jan_row(entry: JanEntry) -> impl IntoView { let i18n = use_i18n(); let hovered = use_context::>>(); @@ -25,11 +33,6 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView { h.set(moves_hover.clone()); } } - on:mouseleave=move |_| { - if let Some(h) = hovered { - h.set(vec![]); - } - } > {move || jan_label(&jan)} {move || if is_double { @@ -58,51 +61,132 @@ pub fn ScoringPanel( let holes_total = event.holes_total; let bredouille = event.bredouille; let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice; - let panel_class = if is_opponent { "scoring-panel scoring-panel-opp" } else { "scoring-panel" }; + let panel_class = if is_opponent { + "scoring-panel scoring-panel-opp" + } else { + "scoring-panel" + }; + + // ── Lifecycle signals ────────────────────────────────────────────────── + // peeked: added after 3.4 s (slide to peek strip) + // revealed: added on first hover of the peek strip (stay open permanently) + let peeked = RwSignal::new(false); + let revealed = RwSignal::new(false); + + // ── Collect all moves from all jans for automatic arrow display ──────── + let all_moves: Vec<(CheckerMove, CheckerMove)> = event + .jans + .iter() + .flat_map(|e| e.moves.iter().cloned()) + .collect(); + let all_moves_click = all_moves.clone(); + let all_moves_enter = all_moves.clone(); + + let hovered_ctx = use_context::>>(); + + // On mount: show all this event's moves as board arrows immediately. + // After 3.4 s (0.45 s slide-in + 3 s display + guard), slide to peek + // and clear arrows. A cancellation flag prevents stale tasks from + // interfering when the component is replaced by a new scored event. + if let Some(hm) = hovered_ctx { + hm.set(all_moves.clone()); + + #[cfg(target_arch = "wasm32")] + { + let is_alive = RwSignal::new(true); + on_cleanup(move || is_alive.set(false)); + + spawn_local(async move { + TimeoutFuture::new(3_400).await; + if !is_alive.get_untracked() { + return; + } + hm.set(vec![]); + peeked.set(true); + }); + } + } let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect(); view! { -
-
- {move || if is_opponent { - t_string!(i18n, opp_scored_pts, n = points_earned) - } else { - t_string!(i18n, scored_pts, n = points_earned) - }} -
- {jan_rows} - {(holes_gained > 0).then(|| view! { -
- {move || if is_opponent { - t_string!(i18n, opp_hole_made, holes = holes_total) - } else { - t_string!(i18n, hole_made, holes = holes_total) - }} - {bredouille.then(|| view! { - - {move || t_string!(i18n, bredouille_applied)} - - })} -
- })} - {show_hold_go.then(|| { - let dismissed = RwSignal::new(false); - view! { -
- - -
+ // ── Outer wrapper: owns the slide / peek / reveal animation ─────── + // pointer-events are on by default (parent .side-panel sets none, + // and .scoring-panel-wrapper overrides back to auto in CSS). +
+
+
+ {move || if is_opponent { + t_string!(i18n, opp_scored_pts, n = points_earned) + } else { + t_string!(i18n, scored_pts, n = points_earned) + }} +
+ {jan_rows} + {(holes_gained > 0).then(|| view! { +
+ {move || if is_opponent { + t_string!(i18n, opp_hole_made, holes = holes_total) + } else { + t_string!(i18n, hole_made, holes = holes_total) + }} + {bredouille.then(|| view! { + + {move || t_string!(i18n, bredouille_applied)} + + })} +
+ })} + {show_hold_go.then(|| { + let dismissed = RwSignal::new(false); + view! { +
+ // stop_propagation so these buttons don't also toggle the panel + + +
+ } + })} +
} }