diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index dedd734..d8b3788 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -1154,66 +1154,105 @@ a:hover { text-decoration: underline; } .game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + /* ── Scoring notification panel (§6b) ───────────────────────────────── */ @keyframes scoring-panel-enter { - from { transform: translateX(100%); } - to { transform: translateX(0); } + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } } .scoring-panel-wrapper { 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)); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; } -.scoring-panel-wrapper.peeked { - transform: translateX(100%); +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; } -.scoring-panel-wrapper.revealed { - transform: translateX(0); +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; } -.scoring-panel-wrapper.peeked:not(.revealed) { +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; } -/* ── Inline scoring panel (in bottom strip, expands downward) ───────── */ -@keyframes scoring-expand-in { - from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } -} - -.scoring-panel-wrapper.scoring-panel-inline-wrap { - animation: none; - filter: none; - transform: none !important; - pointer-events: auto; - cursor: default; - width: 100%; -} - -.scoring-panel-wrapper.scoring-panel-inline-wrap .scoring-panel { - animation: scoring-expand-in 0.28s ease-out; - background: transparent; - box-shadow: none; - border-radius: 0; - border-top: 1px solid rgba(0,0,0,0.07); - padding: 0.4rem 0; +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; } +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } +/* ── Inner scoring 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); + box-shadow: 0 2px 8px rgba(0,0,0,0.22); border-left: 3px solid var(--ui-green-accent); display: flex; flex-direction: column; gap: 4px; - width: 100%; + width: 320px; } .scoring-total { @@ -1256,11 +1295,6 @@ a:hover { text-decoration: underline; } right: auto; left: calc(100% + 1rem); } - .scoring-panel-wrapper.peeked, - .scoring-panel-wrapper.revealed { - transform: none; - cursor: default; - } } /* ── Board wrapper ──────────────────────────────────────────────────── */ diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 219ee4a..3d8f047 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -287,16 +287,29 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { })} - // ── Merged scoreboard (both players, above board) ──────────────── - + // ── Merged scoreboard + scoring panels (above board) ───────────── + // score-area is position:relative so the scoring-panels-container + // can be absolute-positioned at the right of the hole counter. +
+ + // Scoring detail panels — stacked at the right, overlapping if needed. +
+ {my_scored_event.map(|event| view! { + + })} + {opp_scored_event.map(|event| view! { + + })} +
+
// ── Board ──────────────────────────────────────────────────────── impl IntoView { }) }} - // ── Scoring detail panels — expand downward in the strip ────── - {my_scored_event.map(|event| view! { - - })} - {opp_scored_event.map(|event| view! { - - })} // ── Pre-game ceremony overlay ───────────────────────────────────── diff --git a/clients/web/src/game/components/scoring.rs b/clients/web/src/game/components/scoring.rs index 69e1ca1..79fd433 100644 --- a/clients/web/src/game/components/scoring.rs +++ b/clients/web/src/game/components/scoring.rs @@ -2,15 +2,18 @@ use futures::channel::mpsc::UnboundedSender; #[cfg(target_arch = "wasm32")] use gloo_timers::future::TimeoutFuture; use leptos::prelude::*; +#[cfg(target_arch = "wasm32")] +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use trictrac_store::CheckerMove; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::spawn_local; -#[cfg(target_arch = "wasm32")] -use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; use crate::app::NetCommand; -use crate::i18n::*; use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage}; +use crate::i18n::*; use super::score_panel::jan_label; @@ -48,14 +51,19 @@ fn scoring_jan_row(entry: JanEntry) -> impl IntoView { } } +/// Scoring detail panel, shown to the right of the hole counter in the merged +/// score panel area. +/// +/// Lifecycle: +/// 1. Mounts expanded — shows all jan details and draws board arrows. +/// 2. After 3.4 s the arrows clear and the panel auto-minimises to a small "+" +/// button (unless Hold/Go buttons are still needed). +/// 3. The "+" / "−" buttons let the player toggle between states at any time. #[component] pub fn ScoringPanel( event: ScoredEvent, turn_stage: SerTurnStage, #[prop(default = false)] is_opponent: bool, - /// When true the panel renders inline in the bottom strip instead of as - /// a right-side overlay: no slide-in animation, no peek/reveal behaviour. - #[prop(default = false)] inline: bool, ) -> impl IntoView { let i18n = use_i18n(); let cmd_tx = use_context::>() @@ -72,91 +80,28 @@ pub fn ScoringPanel( "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); + // minimized: starts false (expanded), becomes true after 3.4 s unless + // the Hold/Go choice still needs the player's attention. + let minimized = RwSignal::new(false); - // ── Collect all moves from all jans for automatic arrow display ──────── + // 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 all_moves_auto = all_moves.clone(); + let all_moves_expand = 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, - // then after 3.4 s slide to peek and clear the arrows. - // - // Two important constraints: - // 1. The initial hm.set() must be deferred (spawn_local, not sync in body) - // to avoid writing a reactive signal mid-render while Board reads it — - // that triggers Leptos's cycle guard → `unreachable` WASM panic. - // 2. The cancellation flag must be Rc>, NOT RwSignal. - // RwSignal is a NodeId into Leptos's arena; the arena slot is freed - // when ScoringPanel's owner drops (on every GameScreen remount). If the - // 3.4 s future outlives the component and calls is_alive.get_untracked() - // on a freed slot, that also panics with `unreachable`. Rc> - // is reference-counted outside the arena and stays valid for as long as - // the future holds onto it. - #[cfg(target_arch = "wasm32")] - if let Some(hm) = hovered_ctx { - let is_alive = Arc::new(AtomicBool::new(true)); - let is_alive_cleanup = is_alive.clone(); - // on_cleanup requires Send + Sync; Arc satisfies both. - on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed)); - - spawn_local(async move { - // Show arrows (runs in the next microtask, after render settles). - hm.set(all_moves); - - TimeoutFuture::new(3_400).await; - // Guard: component may have been destroyed while we were waiting. - // is_alive was set to false by on_cleanup, which runs before Leptos - // frees the signal arena slots — so peeked is still valid iff this - // returns true. - if !is_alive.load(Ordering::Relaxed) { - return; - } - hm.set(vec![]); - // Inline panels are always visible; only the overlay variant peeks. - if !inline { - peeked.set(true); - } - }); - } - let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect(); view! { - // ── Outer wrapper: overlay mode has slide/peek/reveal animation; - // inline mode suppresses all of that via scoring-panel-inline-wrap.
+ // "+" expand button — shown only when minimised (CSS hides it otherwise). + + + // Full panel — hidden when minimised via CSS.
-
- {move || if is_opponent { - t_string!(i18n, opp_scored_pts, n = points_earned) - } else { - t_string!(i18n, scored_pts, n = points_earned) - }} +
+
+ {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! { @@ -194,17 +170,22 @@ pub fn ScoringPanel( let dismissed = RwSignal::new(false); view! {
- // stop_propagation so these buttons don't also toggle the panel - -