diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index d8b3788..732c9d8 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -1154,105 +1154,66 @@ 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 { opacity: 0; transform: translateX(10px); } - to { opacity: 1; transform: translateX(0); } + from { transform: translateX(100%); } + to { transform: translateX(0); } } .scoring-panel-wrapper { pointer-events: auto; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 3px; - animation: scoring-panel-enter 0.3s ease-out; + 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)); } -/* "+" expand button: hidden while the panel is expanded */ -.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { - display: none; +.scoring-panel-wrapper.peeked { + transform: translateX(100%); } -/* Full panel card: hidden once minimised */ -.scoring-panel-wrapper.scoring-minimized .scoring-panel { - display: none; +.scoring-panel-wrapper.revealed { + transform: translateX(0); } -/* "+" 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; +.scoring-panel-wrapper.peeked:not(.revealed) { 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; } -.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; +/* ── 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: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 2px 8px rgba(0,0,0,0.22); + 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: 320px; + width: 100%; } .scoring-total { @@ -1295,6 +1256,11 @@ 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 ──────────────────────────────────────────────────── */ @@ -1435,26 +1401,6 @@ a:hover { text-decoration: underline; } animation: exit-glow 2s ease-in-out infinite; } -/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ -.exit-btn { - pointer-events: none; - opacity: 0.3; - transition: opacity 0.2s, transform 0.15s; -} -.exit-btn.exit-active { - pointer-events: auto; - cursor: pointer; - opacity: 1; - animation: exit-btn-pulse 1.4s ease-in-out infinite; -} -.exit-btn.exit-active:hover { - transform: scale(1.1); -} -@keyframes exit-btn-pulse { - 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } - 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } -} - .field.jan-hovered { --fc: rgba(190, 140, 35, 0.8) !important; } diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs index 7c57a28..1e4a5a3 100644 --- a/clients/web/src/game/components/board.rs +++ b/clients/web/src/game/components/board.rs @@ -294,14 +294,6 @@ pub fn Board( exit_field_test = |f| matches!(f, 1..=6); } - // Show a clickable exit sign outside the board when bearing off is possible. - let has_exit_move = valid_sequences - .iter() - .any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0); - let show_exit_btn = all_in_exit && is_move_stage && has_exit_move; - let seqs_exit_cls = valid_sequences.clone(); - let seqs_exit_click = valid_sequences.clone(); - // `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc). let fields_from = |nums: &[u8], is_top_row: bool| -> Vec { nums.iter() @@ -591,72 +583,6 @@ pub fn Board( .collect() }} - // Exit sign: circle+arrow outside the board, next to the last exit field. - // White exits to the right (top-right quarter); Black exits to the left (top-left). - {show_exit_btn.then(|| { - let (pos_style, line_x1, line_x2, head_pts): (&str, &str, &str, &str) = - if is_white { - ( - "position:absolute;right:-60px;top:15px;width:50px;height:50px", - "10", "31", "23,17 32,25 23,33", - ) - } else { - ( - "position:absolute;left:-60px;top:15px;width:50px;height:50px", - "40", "19", "27,17 18,25 27,33", - ) - }; - view! { -
seqs_exit_cls.is_empty() - || valid_dests_for(&seqs_exit_cls, &staged, origin) - .iter() - .any(|&d| d == 0), - None => false, - }; - if active { "exit-btn exit-active" } else { "exit-btn" } - } - on:click=move |_| { - if !is_move_stage { return; } - let staged = staged_moves.get_untracked(); - if staged.len() >= 2 { return; } - let Some(origin) = selected_origin.get_untracked() else { - return; - }; - let valid = seqs_exit_click.is_empty() - || valid_dests_for(&seqs_exit_click, &staged, origin) - .iter() - .any(|&d| d == 0); - if valid { - staged_moves.update(|v| v.push((origin, 0))); - selected_origin.set(None); - } - } - > - - - - - -
- } - .into_any() - })}
{label_bl}
diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 3d8f047..219ee4a 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -287,29 +287,16 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
})} - // ── 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! { - - })} -
-
+ // ── Merged scoreboard (both players, above board) ──────────────── + // ── 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 79fd433..69e1ca1 100644 --- a/clients/web/src/game/components/scoring.rs +++ b/clients/web/src/game/components/scoring.rs @@ -2,18 +2,15 @@ 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::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage}; use crate::i18n::*; +use crate::game::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage}; use super::score_panel::jan_label; @@ -51,19 +48,14 @@ 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::>() @@ -80,28 +72,91 @@ pub fn ScoringPanel( "scoring-panel" }; - // 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); + // ── 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. + // ── 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_auto = all_moves.clone(); - let all_moves_expand = all_moves.clone(); - let all_moves_enter = all_moves.clone(); + 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, + // 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! { @@ -170,22 +194,17 @@ pub fn ScoringPanel( let dismissed = RwSignal::new(false); view! {
- -