diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 525b7c5..0f0a209 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -176,7 +176,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let last_moves = state.last_moves; - // §6e — fields where a battue (hit) was scored; ripple animation shown there. + // fields where a battue (hit) was scored; ripple animation shown there. let hit_fields: Vec = { let is_hit_jan = |jan: &Jan| { matches!( @@ -337,7 +337,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { hit_fields=hit_fields /> - // ── Status, hints, and actions — cream strip below board (§10b/c) ─ + // ── Status, hints, and actions — cream strip below board ─
{move || { diff --git a/clients/web/src/game/components/scoring.rs b/clients/web/src/game/components/scoring.rs index 79fd433..a3f939b 100644 --- a/clients/web/src/game/components/scoring.rs +++ b/clients/web/src/game/components/scoring.rs @@ -80,8 +80,7 @@ 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. + // minimized: starts false (expanded) let minimized = RwSignal::new(false); // Collect all moves from all jans for automatic arrow display. @@ -97,6 +96,43 @@ pub fn ScoringPanel( let hovered_ctx = use_context::>>(); let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect(); + // 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![]); + }); + } + view! {