diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css
index 3de4a9f..732c9d8 100644
--- a/clients/web/assets/style.css
+++ b/clients/web/assets/style.css
@@ -763,6 +763,202 @@ a:hover { text-decoration: underline; }
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
}
+/* ── Merged scoreboard (both players, above board) ──────────────────── */
+.merged-score-panel {
+ background: var(--ui-parchment);
+ border-radius: 5px;
+ padding: 0.5rem 1.25rem 0.45rem;
+ font-size: 0.88rem;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.25);
+ width: 100%;
+ border-top: 2px solid var(--ui-gold-dark);
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.score-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.score-row-name {
+ min-width: 120px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: baseline;
+ gap: 0.35rem;
+ overflow: hidden;
+}
+
+.you-tag {
+ font-family: var(--font-ui);
+ font-size: 0.7rem;
+ color: #887766;
+ font-style: italic;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* ── Jackpot points counter ─────────────────────────────────────────── */
+.pts-counter-wrap {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 72px;
+ flex-shrink: 0;
+ padding-bottom: 4px;
+}
+
+.pts-ghost-bar-track {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: rgba(0,0,0,0.07);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.pts-ghost-bar-fill {
+ height: 100%;
+ background: rgba(58,107,42,0.45);
+ border-radius: 2px;
+}
+
+.pts-ghost-bar-opp {
+ background: rgba(122,30,42,0.4);
+}
+
+.pts-counter-row {
+ display: flex;
+ align-items: baseline;
+ gap: 0.1rem;
+}
+
+.pts-counter {
+ font-family: var(--font-display);
+ font-size: 1.9rem;
+ font-weight: 600;
+ color: var(--ui-ink);
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ min-width: 1.4em;
+ text-align: right;
+}
+
+.pts-max {
+ font-family: var(--font-ui);
+ font-size: 0.7rem;
+ color: #998877;
+ line-height: 1;
+ padding-bottom: 2px;
+}
+
+/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */
+.merged-score-panel .peg-track {
+ gap: 4px;
+}
+
+.merged-score-panel .peg-hole {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 1.5px solid rgba(138,106,40,0.3);
+ background: rgba(0,0,0,0.06);
+ flex-shrink: 0;
+ transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s;
+}
+
+.merged-score-panel .peg-hole.filled {
+ background: #5aab38;
+ border-color: #3a7828;
+ box-shadow: 0 0 5px rgba(90,171,56,0.55);
+}
+
+.merged-score-panel .peg-hole.peg-opp.filled {
+ background: #c05030;
+ border-color: #8a3018;
+ box-shadow: 0 0 5px rgba(192,80,48,0.55);
+}
+
+/* Peg pop-in animation when a new hole is scored */
+@keyframes peg-pop {
+ 0% { transform: scale(0.15); opacity: 0; }
+ 45% { transform: scale(1.55); }
+ 70% { transform: scale(0.88); }
+ 100% { transform: scale(1.0); opacity: 1; }
+}
+
+.merged-score-panel .peg-hole.peg-new {
+ animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
+}
+
+/* Thin separator between the two player rows */
+.score-row-sep {
+ height: 1px;
+ background: rgba(0,0,0,0.07);
+ margin: 0.05rem 0;
+}
+
+/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */
+@keyframes hole-flash-in-out {
+ 0% { opacity: 0; transform: translateY(-3px); }
+ 14% { opacity: 1; transform: translateY(0); }
+ 65% { opacity: 1; }
+ 100% { opacity: 0; transform: translateY(2px); }
+}
+
+.hole-flash {
+ margin-left: auto;
+ flex-shrink: 0;
+ white-space: nowrap;
+ font-family: var(--font-display);
+ font-size: 0.88rem;
+ font-weight: 600;
+ color: var(--ui-green-accent);
+ letter-spacing: 0.05em;
+ animation: hole-flash-in-out 2.5s ease-out forwards;
+ pointer-events: none;
+}
+
+.hole-flash.hole-flash-bredouille {
+ color: var(--ui-gold-dark);
+}
+
+/* ── Game bottom strip — status, hints, buttons on cream ────────────── */
+.game-bottom-strip {
+ background: var(--ui-parchment);
+ border-radius: 5px;
+ padding: 0.55rem 1.25rem 0.65rem;
+ width: 100%;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.2);
+ border-top: 2px solid var(--ui-gold-dark);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.35rem;
+ min-height: 3.2rem;
+}
+
+/* Override text colours for the parchment background context */
+.game-bottom-strip .game-status {
+ color: var(--ui-ink);
+ text-shadow: none;
+ padding: 0;
+ font-size: 1.05rem;
+ width: auto;
+}
+
+.game-bottom-strip .game-sub-prompt {
+ color: #887766;
+ padding: 0;
+ width: auto;
+}
+
/* ── Board + side panel ─────────────────────────────────────────────── */
.board-and-panel {
position: relative;
@@ -983,6 +1179,30 @@ a:hover { text-decoration: underline; }
cursor: pointer;
}
+/* ── 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-panel {
background: var(--ui-parchment);
border-radius: 5px;
diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs
index 56cd39b..219ee4a 100644
--- a/clients/web/src/game/components/game_screen.rs
+++ b/clients/web/src/game/components/game_screen.rs
@@ -12,7 +12,7 @@ use crate::i18n::*;
use crate::portal::lobby::{qr_svg, room_url};
use super::board::Board;
-use super::score_panel::PlayerScorePanel;
+use super::score_panel::MergedScorePanel;
use super::scoring::ScoringPanel;
#[component]
@@ -149,10 +149,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Scoring notifications ──────────────────────────────────────────────────
let my_scored_event = state.my_scored_event.clone();
let opp_scored_event = state.opp_scored_event.clone();
- let hole_toast_info = my_scored_event
- .as_ref()
- .filter(|e| e.holes_gained > 0)
- .map(|e| (e.holes_total, e.bredouille));
+
+ // Values for MergedScorePanel — extracted before events are consumed.
+ // Don't animate points when a hole was gained (points wrap around 12).
+ let my_pts_earned: u8 = my_scored_event.as_ref()
+ .map_or(0, |e| if e.holes_gained == 0 { e.points_earned } else { 0 });
+ let opp_pts_earned: u8 = opp_scored_event.as_ref()
+ .map_or(0, |e| if e.holes_gained == 0 { e.points_earned } else { 0 });
+ let my_holes_gained_score: u8 = my_scored_event.as_ref().map_or(0, |e| e.holes_gained);
+ let opp_holes_gained_score: u8 = opp_scored_event.as_ref().map_or(0, |e| e.holes_gained);
+ let my_bredouille_flash: bool = my_scored_event.as_ref()
+ .map_or(false, |e| e.bredouille && e.holes_gained > 0);
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
@@ -199,12 +206,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
if last_moves.is_some() {
crate::game::sound::play_checker_move();
}
- // Scoring: hole takes priority over plain points.
+ // Scoring: hole fanfare plays immediately; per-point ticks are driven by
+ // MergedScorePanel's counter animation so play_points_scored is not called here.
if let Some(ref ev) = my_scored_event {
if ev.holes_gained > 0 {
crate::game::sound::play_hole_scored();
- } else {
- crate::game::sound::play_points_scored();
}
}
@@ -281,124 +287,120 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
})}
- // ── Opponent score (above board) ─────────────────────────────────
-
+ // ── Merged scoreboard (both players, above board) ────────────────
+
- // ── Status bar — full width, above board (§10b) ──────────────────
-
- {move || {
- if let Some(ref reason) = pause_reason {
- return String::from(match reason {
- PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
- PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
- PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
- PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
- });
- }
- let n = staged_moves.get().len();
- if is_move_stage {
- t_string!(i18n, select_move, n = n + 1)
- } else {
- String::from(match (&stage, is_my_turn, &turn_stage) {
- (SerStage::Ended, _, _) => t_string!(i18n, game_over),
- (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent),
- (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
- (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
- (SerStage::InGame, true, _) => t_string!(i18n, your_turn),
- (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
- })
- }
- }}
-
+ // ── Board ────────────────────────────────────────────────────────
+
- // ── Contextual sub-prompt (§8a) ──────────────────────────────────
- {move || {
- let hint: String = if waiting_for_confirm {
- t_string!(i18n, hint_continue).to_owned()
- } else if is_move_stage {
- t_string!(i18n, hint_move).to_owned()
- } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
- t_string!(i18n, hint_hold_or_go).to_owned()
- } else {
- String::new()
- };
- (!hint.is_empty()).then(|| view! { {hint}
})
- }}
-
- // ── Board + side panel ───────────────────────────────────────────
-
-
-
- // ── Side panel (scoring panels only) ─────────────────────────
-
- {my_scored_event.map(|event| view! {
-
- })}
- {opp_scored_event.map(|event| view! {
-
- })}
-
-
-
- // ── Action buttons below board (§10c) ────────────────────────────
-
- {waiting_for_confirm.then(|| view! {
-
{t!(i18n, continue_btn)}
- })}
- // Fallback Go button when no scoring panel (e.g. after reconnect)
- {show_hold_go.then(|| view! {
-
{t!(i18n, go)}
- })}
- {move || {
- // Show the empty-move button only when (0,0) is a valid
- // first or second move given what has already been staged.
- let staged = staged_moves.get();
- let show = is_move_stage && staged.len() < 2 && (
- valid_seqs_empty.is_empty() || match staged.len() {
- 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
- 1 => {
- let (f0, t0) = staged[0];
- valid_seqs_empty.iter()
- .filter(|(m1, _)| {
- m1.get_from() as u8 == f0
- && m1.get_to() as u8 == t0
- })
- .any(|(_, m2)| m2.get_from() == 0)
- }
- _ => false,
+ // ── Status, hints, and actions — cream strip below board (§10b/c) ─
+
+
+ {move || {
+ if let Some(ref reason) = pause_reason {
+ return String::from(match reason {
+ PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
+ PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
+ PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
+ PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
+ });
}
- );
- show.then(|| view! {
- {t!(i18n, empty_move)}
- })
+ let n = staged_moves.get().len();
+ if is_move_stage {
+ t_string!(i18n, select_move, n = n + 1)
+ } else {
+ String::from(match (&stage, is_my_turn, &turn_stage) {
+ (SerStage::Ended, _, _) => t_string!(i18n, game_over),
+ (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent),
+ (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll),
+ (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
+ (SerStage::InGame, true, _) => t_string!(i18n, your_turn),
+ (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
+ })
+ }
+ }}
+
+ {move || {
+ let hint: String = if waiting_for_confirm {
+ t_string!(i18n, hint_continue).to_owned()
+ } else if is_move_stage {
+ t_string!(i18n, hint_move).to_owned()
+ } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
+ t_string!(i18n, hint_hold_or_go).to_owned()
+ } else {
+ String::new()
+ };
+ (!hint.is_empty()).then(|| view! {
{hint}
})
}}
+
+ {waiting_for_confirm.then(|| view! {
+ {t!(i18n, continue_btn)}
+ })}
+ // Fallback Go button when no scoring panel (e.g. after reconnect)
+ {show_hold_go.then(|| view! {
+ {t!(i18n, go)}
+ })}
+ {move || {
+ let staged = staged_moves.get();
+ let show = is_move_stage && staged.len() < 2 && (
+ valid_seqs_empty.is_empty() || match staged.len() {
+ 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
+ 1 => {
+ let (f0, t0) = staged[0];
+ valid_seqs_empty.iter()
+ .filter(|(m1, _)| {
+ m1.get_from() as u8 == f0
+ && m1.get_to() as u8 == t0
+ })
+ .any(|(_, m2)| m2.get_from() == 0)
+ }
+ _ => false,
+ }
+ );
+ show.then(|| view! {
+ {t!(i18n, empty_move)}
+ })
+ }}
+
+ // ── Scoring detail panels — expand downward in the strip ──────
+ {my_scored_event.map(|event| view! {
+
+ })}
+ {opp_scored_event.map(|event| view! {
+
+ })}
- // ── Player score (below board) ────────────────────────────────────
-
-
// ── Pre-game ceremony overlay ─────────────────────────────────────
{is_ceremony.then(|| {
let pgr = pre_game_roll_data.unwrap_or(PreGameRollState {
@@ -483,16 +485,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}
})}
- // ── Hole toast (§6a) — board-centered overlay when a hole is won ──
- {hole_toast_info.map(|(holes_total, bredouille)| view! {
-
-
"Trou !"
-
{format!("{holes_total} / 12")}
- {bredouille.then(|| view! {
-
"× 2 bredouille"
- })}
-
- })}
}
}
diff --git a/clients/web/src/game/components/score_panel.rs b/clients/web/src/game/components/score_panel.rs
index 2ce14ef..35dda0f 100644
--- a/clients/web/src/game/components/score_panel.rs
+++ b/clients/web/src/game/components/score_panel.rs
@@ -1,5 +1,11 @@
use leptos::prelude::*;
use trictrac_store::Jan;
+#[cfg(target_arch = "wasm32")]
+use gloo_timers::future::TimeoutFuture;
+#[cfg(target_arch = "wasm32")]
+use wasm_bindgen_futures::spawn_local;
+#[cfg(target_arch = "wasm32")]
+use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use crate::i18n::*;
use crate::game::trictrac::types::PlayerScore;
@@ -23,47 +29,193 @@ pub fn jan_label(jan: &Jan) -> String {
}
}
+/// Merged scoreboard showing both players above the board.
+///
+/// - Two stacked rows for a clear race-to-12 visual comparison.
+/// - Points shown as an animated jackpot counter (ticks up on each new point).
+/// - Hole pegs are larger and use green (me) / red (opponent) instead of gold.
+/// - When a hole is gained, the new peg pops in and a brief non-blocking label
+/// appears instead of the old blocking toast popup.
#[component]
-pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
+pub fn MergedScorePanel(
+ my_score: PlayerScore,
+ opp_score: PlayerScore,
+ /// Points just earned this turn; 0 = no animation. Set to 0 when a hole
+ /// was gained (points wrap around 12, counter stays at end value).
+ #[prop(default = 0)] my_points_earned: u8,
+ #[prop(default = 0)] opp_points_earned: u8,
+ /// Non-zero when a new hole was just scored (triggers peg-pop animation).
+ #[prop(default = 0)] my_holes_gained: u8,
+ #[prop(default = 0)] opp_holes_gained: u8,
+ /// True when my hole was scored under bredouille (shows ×2 in the flash).
+ #[prop(default = false)] my_bredouille: bool,
+) -> impl IntoView {
let i18n = use_i18n();
- let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
- let points_val = format!("{}/12", score.points);
- let holes = score.holes;
- let can_bredouille = score.can_bredouille;
+ // ── Points counter signals ──────────────────────────────────────────────
+ // When no hole was gained: start from (current - earned) and tick up.
+ // When a hole was gained: points wrapped around 12, so skip the animation.
+ // On non-WASM there is no animation; start directly at the final value.
+ // Suppress the unused-variable warning for animation-only params.
+ #[cfg(not(target_arch = "wasm32"))]
+ let _ = (my_points_earned, opp_points_earned);
+ #[cfg(not(target_arch = "wasm32"))]
+ let my_pts_start = my_score.points;
+ #[cfg(target_arch = "wasm32")]
+ let my_pts_start = if my_holes_gained == 0 {
+ my_score.points.saturating_sub(my_points_earned)
+ } else {
+ my_score.points
+ };
+ let my_displayed_pts: RwSignal = RwSignal::new(my_pts_start);
- // 12 peg holes; filled up to `holes`
- let pegs: Vec = (1u8..=12)
+ #[cfg(not(target_arch = "wasm32"))]
+ let opp_pts_start = opp_score.points;
+ #[cfg(target_arch = "wasm32")]
+ let opp_pts_start = if opp_holes_gained == 0 {
+ opp_score.points.saturating_sub(opp_points_earned)
+ } else {
+ opp_score.points
+ };
+ let opp_displayed_pts: RwSignal = RwSignal::new(opp_pts_start);
+
+ // ── Jackpot counter animation (WASM only) ───────────────────────────────
+ #[cfg(target_arch = "wasm32")]
+ {
+ let my_pts_end = my_score.points;
+ if my_pts_start < my_pts_end {
+ let is_alive = Arc::new(AtomicBool::new(true));
+ let alive_c = is_alive.clone();
+ on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
+ spawn_local(async move {
+ for p in (my_pts_start + 1)..=my_pts_end {
+ TimeoutFuture::new(200).await;
+ if !is_alive.load(Ordering::Relaxed) { return; }
+ my_displayed_pts.set(p);
+ crate::game::sound::play_points_tick();
+ }
+ });
+ }
+ let opp_pts_end = opp_score.points;
+ if opp_pts_start < opp_pts_end {
+ let is_alive = Arc::new(AtomicBool::new(true));
+ let alive_c = is_alive.clone();
+ on_cleanup(move || alive_c.store(false, Ordering::Relaxed));
+ spawn_local(async move {
+ for p in (opp_pts_start + 1)..=opp_pts_end {
+ TimeoutFuture::new(200).await;
+ if !is_alive.load(Ordering::Relaxed) { return; }
+ opp_displayed_pts.set(p);
+ }
+ });
+ }
+ }
+
+ // ── Ghost bar widths (show the end value immediately — static reference) ─
+ let my_bar_style = format!("width:{}%", (my_score.points as u32 * 100 / 12).min(100));
+ let opp_bar_style = format!("width:{}%", (opp_score.points as u32 * 100 / 12).min(100));
+
+ // ── Hole peg tracks ─────────────────────────────────────────────────────
+ let my_holes = my_score.holes;
+ let opp_holes = opp_score.holes;
+
+ let my_pegs: Vec = (1u8..=12)
.map(|i| {
- let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" };
- view! {
}.into_any()
+ let filled = i <= my_holes;
+ let is_new = filled && i == my_holes && my_holes_gained > 0;
+ view! {
+
+
+ }.into_any()
})
.collect();
+ let opp_pegs: Vec = (1u8..=12)
+ .map(|i| {
+ let filled = i <= opp_holes;
+ let is_new = filled && i == opp_holes && opp_holes_gained > 0;
+ view! {
+
+
+ }.into_any()
+ })
+ .collect();
+
+ let my_name = my_score.name.clone();
+ let opp_name = opp_score.name.clone();
+ let my_can_bredouille = my_score.can_bredouille;
+ let opp_can_bredouille = opp_score.can_bredouille;
+
view! {
-
-
-
-
-
{t!(i18n, points_label)}
-
-
+
+
+ // ── My player row ───────────────────────────────────────────
+
+
+ {my_name}
+ {t!(i18n, you_suffix)}
+
+
+
+
+ {move || my_displayed_pts.get()}
+ "/12"
-
{points_val}
- {can_bredouille.then(|| view! {
-
"B"
- })}
-
-
{t!(i18n, holes_label)}
-
{pegs}
-
{format!("{holes}/12")}
+
{my_pegs}
+ {my_can_bredouille.then(|| view! {
+
+ "B"
+
+ })}
+ // Flash sits in the free space to the right of the pegs.
+ // margin-left:auto keeps it right-aligned inside the flex row
+ // without adding a new row, so the board never shifts down.
+ {(my_holes_gained > 0).then(|| {
+ let label = if my_bredouille {
+ format!("Trou {} · ×2 bredouille", my_holes)
+ } else {
+ format!("Trou {}", my_holes)
+ };
+ view! {
+
+ {label}
+
+ }
+ })}
+
+
+
+
+ // ── Opponent row ────────────────────────────────────────────
+
+
+ {opp_name}
+
+
+
+ {move || opp_displayed_pts.get()}
+ "/12"
+
+
+
{opp_pegs}
+ {opp_can_bredouille.then(|| view! {
+
+ "B"
+
+ })}
}
diff --git a/clients/web/src/game/components/scoring.rs b/clients/web/src/game/components/scoring.rs
index d1966be..69e1ca1 100644
--- a/clients/web/src/game/components/scoring.rs
+++ b/clients/web/src/game/components/scoring.rs
@@ -53,6 +53,9 @@ 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::
>()
@@ -120,31 +123,35 @@ pub fn ScoringPanel(
return;
}
hm.set(vec![]);
- peeked.set(true);
+ // 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: 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).
+ // ── Outer wrapper: overlay mode has slide/peek/reveal animation;
+ // inline mode suppresses all of that via scoring-panel-inline-wrap.