diff --git a/client_web/src/components/scoring.rs b/client_web/src/components/scoring.rs
index ab44ec4..4a19a81 100644
--- a/client_web/src/components/scoring.rs
+++ b/client_web/src/components/scoring.rs
@@ -1,6 +1,12 @@
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;
+#[cfg(target_arch = "wasm32")]
+use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use crate::app::NetCommand;
use crate::i18n::*;
@@ -8,6 +14,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 +35,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 +63,147 @@ 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,
+ // 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![]);
+ 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)
- }}
-
+ // ── 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)
+ }}
+
+ // stop_propagation so these buttons don't also toggle the panel
+
+
+
+ }
+ })}
+
}
}
diff --git a/client_web/src/main.rs b/client_web/src/main.rs
index 209ae60..f0952a0 100644
--- a/client_web/src/main.rs
+++ b/client_web/src/main.rs
@@ -2,6 +2,7 @@ leptos_i18n::load_locales!();
mod app;
mod components;
+mod sound;
mod trictrac;
use app::App;
diff --git a/client_web/src/sound.rs b/client_web/src/sound.rs
new file mode 100644
index 0000000..4e2c815
--- /dev/null
+++ b/client_web/src/sound.rs
@@ -0,0 +1,171 @@
+//! Synthesised sound effects using the Web Audio API.
+//!
+//! All public functions are no-ops on non-WASM targets so callers need no
+//! `#[cfg]` guards themselves.
+
+#[cfg(target_arch = "wasm32")]
+mod inner {
+ use std::cell::RefCell;
+ use web_sys::{AudioContext, OscillatorType};
+
+ thread_local! {
+ static CTX: RefCell