feat(client_web): slide dice jans right panel

This commit is contained in:
Henri Bourcereau 2026-04-11 14:36:15 +02:00
parent 4550b1d66a
commit 72c5e16ea3
4 changed files with 197 additions and 55 deletions

1
Cargo.lock generated
View file

@ -1397,6 +1397,7 @@ dependencies = [
"futures",
"getrandom 0.3.4",
"gloo-storage",
"gloo-timers",
"leptos",
"leptos_i18n",
"rand 0.9.2",

View file

@ -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"] }

View file

@ -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;

View file

@ -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::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
@ -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![]);
}
}
>
<span class="jan-label">{move || jan_label(&jan)}</span>
<span class="jan-tag">{move || if is_double {
@ -58,11 +61,88 @@ 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::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// 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! {
// ── 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).
<div
class="scoring-panel-wrapper"
class:peeked=move || peeked.get()
class:revealed=move || revealed.get()
// Click toggles revealed↔peeked when the panel is in its peeked state.
on:click=move |_| {
if peeked.get_untracked() {
revealed.update(|r| *r = !*r);
}
// Show arrows when clicking to open, clear when clicking to close.
if let Some(hm) = hovered_ctx {
if !revealed.get_untracked() {
hm.set(all_moves_click.clone());
} else {
hm.set(vec![]);
}
}
}
on:mouseenter=move |_| {
// Show all event moves as arrows while the cursor is inside.
if let Some(hm) = hovered_ctx {
hm.set(all_moves_enter.clone());
}
}
on:mouseleave=move |_| {
if let Some(hm) = hovered_ctx {
hm.set(vec![]);
}
}
>
<div class=panel_class>
<div class="scoring-total">
{move || if is_opponent {
@ -90,12 +170,15 @@ pub fn ScoringPanel(
let dismissed = RwSignal::new(false);
view! {
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
<button class="btn btn-secondary" on:click=move |_| {
// stop_propagation so these buttons don't also toggle the panel
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
dismissed.set(true);
}>
{t!(i18n, hold)}
</button>
<button class="btn btn-primary" on:click=move |_| {
<button class="btn btn-primary" on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>
{t!(i18n, go)}
@ -104,5 +187,6 @@ pub fn ScoringPanel(
}
})}
</div>
</div>
}
}