UI/Hud.razor

Razor UI component that renders the game's HUD. It draws leaderboards, live play area, opponent cursors/pips, skin panels, popups (how-to, anti-cheat test/sanction), pause controls and toasts, and fetches some server config/data to populate non-live boards.

Http CallsFile Access
🌐 /api/v1/config
@using System
@using System.Collections.Generic
@using System.Linq
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@using Splitclicker.Api
@using Splitclicker.Game
@using Skafinity
@namespace Splitclicker.UI
@inherits PanelComponent

@*
	The whole HUD as a SINGLE root PanelComponent. This matters: a ScreenPanel lays
	its child PanelComponent roots out in a flex row and ignores `position` set on
	those roots, so sibling panels can't pin/center themselves. The working pattern
	(see rotaliate's GameHud) is one root with `width/height: 100%` that genuinely
	fills the screen, with every piece an absolutely-positioned CHILD of it:

	  • session board   — pinned top-left
	  • a bounding box inset between the left/right boards — holds the status HUD
	    (centred) and the global button, which is small and JUMPS to a random spot
	    inside the box each time the round arms (whack-a-mole); "clicks sent" sits
	    pinned to the box bottom
	  • hourly board     — pinned top-right, just left of hours-won
	  • hours-won board  — pinned hard to the top-right edge
*@

<root>
	@{ var c = ClickController.Instance; }

	@* ── left column: the session board stacked above the all-time board, flowed in
	     a flex column so they can never overlap however many rows each grows to
	     (both are capped at DisplayLimit). Pinned top-left. ── *@
	<div class="left-col">

	@* ── session board: current game's cumulative standings ── *@
	<div class="board session">
		<div class="title">SESSION · TOP CLICKERS</div>
		<div class="divider"></div>
		@if ( SessionEntries.Count == 0 )
		{
			<div class="empty">No scores yet</div>
		}
		else
		{
			@for ( int i = 0; i < SessionEntries.Count && i < DisplayLimit; i++ )
			{
				var s = SessionEntries[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="dot @StatusClass( s )"></div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					@if ( s.BehindMs > 0 )
					{
						<div class="behind" title="lost the game by @(s.BehindMs)ms">+@(s.BehindMs)ms</div>
					}
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── all-time clickers board: lifetime total scoring clicks across every skin
	     (never resets) ── *@
	<div class="board alltime">
		<div class="title">ALL-TIME · TOP CLICKERS</div>
		<div class="divider"></div>
		@if ( _allTime.Count == 0 )
		{
			<div class="empty">@(_allTimeLoaded ? "No clicks recorded yet" : "loading…")</div>
		}
		else
		{
			@for ( int i = 0; i < _allTime.Count; i++ )
			{
				var s = _allTime[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="dot @StatusClass( s )"></div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	</div>

	@* ── GAME INFO opener: a wide labelled bar pinned bottom-left, mirroring the
	     MUSIC bar that sits beneath it. Toggles the floating "How to Play" popup;
	     the open/closed choice is persisted to local storage (PlayerData) so it
	     survives across sessions. The popup itself is rendered near the end of the
	     root so it overlays everything. ── *@
	@* ── PAUSE control: lets a player step away without being flagged AFK. Pinned
	     bottom-left ABOVE the GAME INFO bar. Green "PAUSE" at rest; red "PAUSED" while
	     away. Pressing it parks the player (the server withholds frames + drops them
	     from the round's N); pressing again rejoins on the next button. Also engaged
	     automatically when the server auto-parks an idle player. Hidden until connected. ── *@
	@if ( C != null && C.Phase != GamePhase.Connecting && C.Phase != GamePhase.Disconnected )
	{
		<div class="pause-fab @PauseFabClass()" onclick="@TogglePause">@PauseFabLabel()</div>
	}

	<div class="info-fab" onclick="@ToggleHowTo">GAME INFO</div>

	@* ── the music opener is the Skafinity library's own toggle button: it lives on the
	     MusicUI ScreenPanel and is styled (ShowFab/WideFab/FabLabel in the scene) into the
	     wide "MUSIC" bar pinned bottom-left under the GAME INFO bar, opening/closing its
	     own board. This HUD only reads MusicOpen (the panel's IsOpen) to stop painting the
	     game while the board is up. ── *@

	@* ── the live board + status HUD over it ── *@
	@* While the music board is open (MusicOpen) the board + status + "clicks sent" stop
	   drawing — the panel overlays from its own ScreenPanel, and the connection stays open
	   (we just don't paint the game underneath it). *@
	<div class="center" @ref="_box">
		@if ( !MusicOpen )
		{
			@* The scoring surface: one clickable button per live board slot, at its
			   server-placed position (board-% space, same as the pips). The board is the
			   only surface and exists only while armed — NOTHING during the arming wait or
			   for stray bad clicks. *@
			@if ( c != null && c.HasBoard )
			{
				@foreach ( var btn in c.LiveButtons )
				{
					<div class="board-btn" style="left: @(PipPct( btn.X ))%; top: @(PipPct( btn.Y ))%;"
					     onclick="@(() => OnPressSlot( btn.Slot ))"></div>
				}
			}

			@* Opponent cursors: a labelled amber dot per sampled opponent pointer. *@
			@foreach ( var cur in CursorsToShow() )
			{
				<div class="opp-cursor" style="left: @(PipPct( cur.X ))%; top: @(PipPct( cur.Y ))%;">
					<div class="opp-cursor-name">@cur.Name</div>
				</div>
			}

			@* Opponent click pips: each a half-size button that fades quickly (CSS
			   keyframe) at the clicker's normalized position with their name over it.
			   Positioned in the same box-% space as the global button, so a pip lands
			   where that player's button was when they clicked. *@
			@foreach ( var pip in PipsToShow() )
			{
				<div class="pip" style="left: @(PipPct( pip.X ))%; top: @(PipPct( pip.Y ))%;">
					<div class="pip-name">@pip.Name</div>
				</div>
			}

			@* Each line below is a SINGLE interpolated expression on purpose: mixing
			   literal text with razor expressions in one element emits several text nodes,
			   which s&box's panel reconciler reorders across rebuilds (the "arms in 2-6s"
			   -> "- arms in 26s" / vanishing-penalty bug). One expression = one stable node. *@
			<div class="hud">
				<div class="meta">
					<div class="players">@($"{c?.Players ?? 0} online")</div>
					<div class="sep">·</div>
					<div class="clicks">@($"{c?.ClicksToWin ?? 0} clicks exist for this round")</div>
				</div>
				<div class="round">@($"Round {c?.Round ?? 0} / {c?.Of ?? 0}")</div>
				<div class="state @PhaseClass()">@PhaseText()</div>
				@* live clicks-remaining counter: descends from N as the race fills,
				   driven by the server tick (clamped at 0). Only while the window is open. *@
				@if ( c?.Phase == GamePhase.Armed )
				{
					<div class="remaining">@($"{RemainingShown( c )} left")</div>
				}
				@if ( c?.Phase == GamePhase.Pending && (c?.ArmMaxSec ?? 0) > 0 )
				{
					<div class="arm">@($"arms in {c.ArmMinSec}–{c.ArmMaxSec}s")</div>
				}
				@* dev note: a host-pushed broadcast message, orange. Independent of round
				   phase; shows until the server sends an empty note. *@
				@if ( c != null && !string.IsNullOrEmpty( c.DevNote ) )
				{
					<div class="dev-note">@c.DevNote</div>
				}
			</div>

			<div class="clicks-sent">@($"clicks sent: {c?.ClicksSent ?? 0}")</div>
		}
	</div>

	@* ── top-clickers board: points scored during the current bounty window
	     (resets when the skin is won), pinned right (inner) ── *@
	<div class="board hourly">
		<div class="title">TOP CLICKERS · THIS BOUNTY</div>
		<div class="reset">resets when the bounty is won</div>
		<div class="divider"></div>
		@if ( !_hasBounty )
		{
			<div class="empty">No active bounty</div>
		}
		else if ( _hourly.Count == 0 )
		{
			<div class="empty">@(_hourlyLoaded ? "No clicks for this bounty yet" : "loading…")</div>
		}
		else
		{
			@for ( int i = 0; i < _hourly.Count; i++ )
			{
				var s = _hourly[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="dot @StatusClass( s )"></div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── hours-won board: UTC clock-hours topped during the current bounty window
	     (resets when the skin is won), pinned right (outer) ── *@
	<div class="board hourswon">
		<div class="title">HOURS WON · THIS BOUNTY</div>
		<div class="divider"></div>
		@if ( !_hasBounty )
		{
			<div class="empty">No active bounty</div>
		}
		else if ( _hoursWon.Count == 0 )
		{
			<div class="empty">@(_hoursWonLoaded ? "No hours won for this bounty yet" : "loading…")</div>
		}
		else
		{
			@for ( int i = 0; i < _hoursWon.Count; i++ )
			{
				var s = _hoursWon[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="dot @StatusClass( s )"></div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── games-won board: games topped during the current bounty window — this is
	     the board the bounty winner is read from (resets when the skin is won),
	     pinned bottom-right ── *@
	<div class="board sessionswon">
		<div class="title">GAMES WON · THIS BOUNTY</div>
		<div class="divider"></div>
		@if ( !_hasBounty )
		{
			<div class="empty">No active bounty</div>
		}
		else if ( _sessionsWon.Count == 0 )
		{
			<div class="empty">@(_sessionsWonLoaded ? "No games won for this bounty yet" : "loading…")</div>
		}
		else
		{
			@for ( int i = 0; i < _sessionsWon.Count; i++ )
			{
				var s = _sessionsWon[i];
				<div class="row @(IsMe( s ) ? "me" : "")">
					<div class="rank">@(i + 1)</div>
					<div class="dot @StatusClass( s )"></div>
					<div class="name clickable" onclick="@(() => CopyProfile( s ))">@DisplayName( s )</div>
					<div class="pts">@s.Points</div>
				</div>
			}
		}
	</div>

	@* ── leader's "skin": image served by the backend (selectable server-side),
	     pinned just left of the sessions-won board, captioned with the current
	     sessions-won leader's name. Above it, a countdown to when the winner is
	     locked in; once that passes, a prompt for Gamah to set a new skin. ── *@
	<div class="skin-panel">
		@if ( !_hasBounty )
		{
			@* No bounty is live: the config skin/countdown are a stale fallback, so show
			   nothing of it — just say there's no skin to win right now. *@
			<div class="no-bounty">
				<div class="no-bounty-title">No active bounty</div>
				<div class="no-bounty-sub">Gamah will set a new bounty to win soon — check back shortly</div>
			</div>
		}
		else
		{
		@if ( HasWinnerLock )
		{
			@if ( WinnerLockPassed() )
			{
				<div class="skin-locked">Gamah will set a new bounty to win when he sees this</div>
			}
			else
			{
				<div class="skin-countdown">
					<div class="cd-label">Time until winner is locked in</div>
					<div class="cd-time">@WinnerCountdown()</div>
				</div>
			}
		}
		<div class="skin-img" style="background-image: url( '@SkinImgUrl()' );"></div>
		@if ( _skin != null && _skin.DecodeOk )
		{
			@if ( _skin.ImageOk )
			{
				<div class="skin-name">@_skin.Name</div>
			}
			<div class="skin-stats">
				<div class="stat"><span class="k">Wear</span><span class="v">@_skin.WearName</span></div>
				<div class="stat"><span class="k">Float</span><span class="v">@FloatStr()</span></div>
				<div class="stat"><span class="k">Seed</span><span class="v">@_skin.PaintSeed</span></div>
			</div>
			<div class="wear-bar">
				<div class="seg fn"></div>
				<div class="seg mw"></div>
				<div class="seg ft"></div>
				<div class="seg ww"></div>
				<div class="seg bs"></div>
				<div class="wear-marker" style="left: @(MarkerPct())%;"></div>
			</div>
			@if ( !_skin.ImageOk )
			{
				<div class="skin-note">unable to fetch bounty from steam</div>
			}
		}
		else if ( _skin != null )
		{
			<div class="skin-note">unable to fetch bounty from steam</div>
		}
		@if ( !string.IsNullOrWhiteSpace( _inspectLink ) )
		{
			<div class="skin-copy clickable" onclick="@CopyInspect">Copy inspect link</div>
		}
		<div class="skin-caption">@($"{SkinLeaderName()}'s Bounty")</div>
		}
	</div>

	@* ── previous winner: a duplicate of the skin panel showing who won the last
	     bounty and the skin it was (decoded from its inspect link, same as the live
	     one). Shown once a bounty has settled; refreshed on a bounty_update push /
	     reconnect. The panel is pointer-events:none so it can't intercept a click
	     meant for the roaming button beneath it; only the winner line re-enables
	     clicks, to copy their Steam profile link. ── *@
	@if ( _prevBounty != null )
	{
		<div class="skin-panel previous">
			<div class="prev-header">PREVIOUS WINNER</div>
			@* The winner is the panel's identity — bold and (when we have a steamid)
			   clickable to copy their profile link, the only interactive bit (the rest
			   of the panel is pointer-events:none so it can't steal a button click). *@
			@if ( !string.IsNullOrEmpty( _prevBounty.WinnerSteamId ) )
			{
				<div class="prev-winner clickable" onclick="@CopyPrevWinner">@PrevWinnerName()</div>
			}
			else
			{
				<div class="prev-winner">@PrevWinnerName()</div>
			}
			<div class="prev-wins">@($"won {_prevBounty.WinnerWins} games")</div>
			<div class="skin-img" style="background-image: url( '@PrevSkinImgUrl()' );"></div>
			@if ( _prevSkin != null && _prevSkin.DecodeOk )
			{
				@if ( _prevSkin.ImageOk )
				{
					<div class="skin-name">@_prevSkin.Name</div>
				}
				<div class="skin-stats">
					<div class="stat"><span class="k">Wear</span><span class="v">@_prevSkin.WearName</span></div>
					<div class="stat"><span class="k">Float</span><span class="v">@PrevFloatStr()</span></div>
					<div class="stat"><span class="k">Seed</span><span class="v">@_prevSkin.PaintSeed</span></div>
				</div>
				<div class="wear-bar">
					<div class="seg fn"></div>
					<div class="seg mw"></div>
					<div class="seg ft"></div>
					<div class="seg ww"></div>
					<div class="seg bs"></div>
					<div class="wear-marker" style="left: @(PrevMarkerPct())%;"></div>
				</div>
			}
			else if ( !string.IsNullOrEmpty( _prevBounty.Label ) )
			{
				@* Only as a skin-name fallback when the link didn't decode — never as the
				   headline (the winner is the headline). *@
				<div class="skin-caption">@_prevBounty.Label</div>
			}
		</div>
	}

	@* ── per-round scores flash: a transient panel listing who scored the round that
	     just ended (the round's winners, in first-click order, with the points each
	     grabbed THIS round), shown briefly after every round_result then fading out.
	     Non-interactive (pointer-events:none) so it never eats a click. Each text line
	     is a single interpolated expression (the mixed-text-node reorder bug). ── *@
	@if ( FlashVisible )
	{
		<div class="round-flash">
			<div class="rf-panel">
				<div class="rf-title">@($"ROUND {_flashRound} SCORES")</div>
				<div class="divider"></div>
				@if ( _flashWinners.Count == 0 )
				{
					<div class="rf-empty">Nobody scored this round</div>
				}
				else
				{
					@for ( int i = 0; i < _flashWinners.Count && i < DisplayLimit; i++ )
					{
						var w = _flashWinners[i];
						<div class="rf-row @(IsMe( w ) ? "me" : "")">
							<div class="rf-rank">@(i + 1)</div>
							<div class="rf-name">@DisplayName( w )</div>
							<div class="rf-pts">@($"+{w.Points}")</div>
						</div>
					}
				}
			</div>
		</div>
	}

	@* ── transient "copied profile link" toast, top-center for ~a second ── *@
	@if ( ToastVisible )
	{
		<div class="toast">@_toast</div>
	}

	@* ── "How to Play" popup: a floating card centred over the screen (like the music
	     player), shown while _howtoOpen. A dim full-screen backdrop captures input so the
	     game pauses behind it; the ✕ (or the GAME INFO bar) closes it. Two sections: the
	     round-state primer and a rundown of the server-side anticheat checks (see
	     ../server/internal/game). Every text line is a single literal or one interpolated
	     expression (the mixed-text-node reorder bug). ── *@
	@if ( _howtoOpen )
	{
		<div class="howto-overlay">
			<div class="howto-card">
				<div class="howto-head">
					<div class="howto-title">HOW TO PLAY</div>
					<div class="howto-close" onclick="@ToggleHowTo">✕</div>
				</div>
				<div class="divider"></div>

				<div class="line">One global button, limited clicks, shared by everyone.</div>
				<div class="state-row"><span class="tag wait">WAIT…</span><span class="desc">Button is arming - DO NOT CLICK just because it's green</span></div>
				<div class="state-row"><span class="tag go">CLICK!</span><span class="desc">Button is live! One point per click.</span></div>
				<div class="state-row"><span class="tag over">ROUND OVER</span><span class="desc">All clicks used up, current score shown.</span></div>
				<div class="state-row"><span class="tag over">GAME OVER</span><span class="desc">Session winner picked, new game next!</span></div>
				<div class="line penalty-note">Clicking before the button is live is allowed but scores nothing — just wait for it to go green.</div>

				<div class="divider"></div>
				<div class="howto-subtitle">STEPPING AWAY</div>
				<div class="state-row"><span class="tag wait">PAUSE</span><span class="desc">Need to step away? Hit PAUSE (above GAME INFO). You drop out of the round and the leaderboard race and won't be flagged AFK. Hit it again to rejoin on the next button.</span></div>
				<div class="line penalty-note">Sit still and idle through a round and the server pauses you automatically — just hit PAUSE to come back when you're ready.</div>

				<div class="divider"></div>
				<div class="howto-subtitle">ANTI-CHEAT</div>
				<div class="line">Every round, the server watches for tell-tale bot behaviour. The first flags just bench you behind a quick math question before the button arms again.</div>
				<div class="state-row"><span class="tag over">TOO FAST</span><span class="desc">Two of your scoring clicks landed closer together than any human hand can manage.</span></div>
				<div class="state-row"><span class="tag over">TOO MANY</span><span class="desc">In a busy round you took far more than your fair share of the clicks (solo rounds are exempt — there's no one to share with).</span></div>
				<div class="state-row"><span class="tag over">SOLO FARM</span><span class="desc">You're miles ahead on the bounty with nobody around to race. Nothing's wrong, just check back when it's busier, or bring some friends so it counts.</span></div>
				<div class="state-row"><span class="tag over">RUNAWAY</span><span class="desc">You out-clicked a runner-up who was genuinely competing by an impossible margin (beating an idle player is fine).</span></div>
				<div class="state-row"><span class="tag over">AFK</span><span class="desc">Don't sit still during the wait — move your cursor while the button is arming.</span></div>
				<div class="state-row"><span class="tag over">BUSTED</span><span class="desc">You scored without ever moving your cursor.</span></div>
				<div class="line penalty-note">Keep getting flagged in one bounty and it escalates: too many flags puts you on a timed cooldown, and a few more after that sidelines you until the bounty is won. Play fair and none of this ever fires — these only catch automation.</div>

				<div class="divider"></div>
				@* Single interpolated string (mixed literal + @() text nodes reorder on
				   rebuild — see CLAUDE.md). Click copies the invite; toast confirms. *@
				<div class="discord-line clickable" onclick="@CopyDiscord">@($"Think a flag was wrong, or want a heads-up on upcoming changes? Click here to copy our Discord invite ({DiscordInvite}) and come say hi.")</div>
			</div>
		</div>
	}

	@* ── parked / away gate: shown while the player has stepped away — either they hit
	     PAUSE or the server auto-parked them off an afk_idle verdict. A dim overlay
	     captures input so nothing underneath registers; the server withholds every game
	     frame until they rejoin, so there's nothing to play here. The PAUSE bar (which
	     stays visible above GAME INFO) is the single place to rejoin. ── *@
	@* Suppressed while a panel is open: an open how-to/music panel is itself the "away"
	   surface, and the park-overlay (z:190) would otherwise stack its RESUME card on top of
	   the popup (z:160). Closing the panel auto-rejoins a panel-park, so the overlay only
	   ever shows for a real PAUSE / server afk-park with no panel up. ── *@
	@if ( C?.Parked == true && !_howtoOpen && !MusicOpen )
	{
		<div class="park-overlay">
			<div class="park-card">
				<div class="park-title">PAUSED — AWAY</div>
				<div class="park-sub">You've stepped away, so you're out of the round and off the leaderboard race — no AFK flags while you're paused.</div>
				@* While a RESUME is in flight the server defers the rejoin to the next arming
				   boundary, so we stay on this surface showing "rejoining next round" until the
				   next round_pending drops us to STAND BY. Otherwise show the RESUME button —
				   a child of the overlay card (painted above the backdrop) so it's reliably
				   clickable, unlike the bottom-left PAUSE bar, which s&box hit-tests under this
				   full-screen overlay regardless of z-index. ── *@
				@if ( C.Resuming )
				{
					<div class="park-hint">Rejoining — you'll be back on the next button…</div>
				}
				else
				{
					<div class="park-hint">Press RESUME to rejoin — you'll be back in on the next button.</div>
					<div class="park-resume" onclick="@TogglePause">RESUME</div>
				}
			</div>
		</div>
	}

	@* ── anticheat test gate: shown when the player failed an end-of-round check and
	     is benched. A full-screen dim overlay (captures input) with the question and
	     an answer box; answering correctly un-benches them and the server arms them
	     again. Every text line is a single literal or one interpolated expression
	     (the mixed-text-node reorder bug). ── *@
	@if ( C?.HasTest == true )
	{
		<div class="test-overlay">
			<div class="test-card">
				<div class="test-title">Gamah Anti-Cheat v0.67.69.420</div>
				<div class="test-sub">You probably know what you did so do some math now instead...</div>
				@if ( !string.IsNullOrEmpty( C.TestMessage ) )
				{
					<div class="test-reason">@C.TestMessage</div>
				}
				<div class="test-prompt">@($"{C.TestPrompt} = ?")</div>
				<TextEntry @ref="_testEntry" placeholder="answer" class="test-input" maxlength="12" />
				<div class="test-submit" onclick="@SubmitTest">SUBMIT</div>
			</div>
		</div>
	}

	@* ── anticheat sanction gate: shown when the player is past the test rung — on a
	     timed cooldown or ignored for the rest of the bounty. Same dim overlay, but no
	     test to answer: just the reason and a countdown to when the state lifts (reuses
	     the bounty-timer HH:MM:SS format). Each line is a single interpolated
	     expression (the mixed-text-node reorder bug). ── *@
	@if ( !string.IsNullOrEmpty( C?.SanctionState ) )
	{
		<div class="test-overlay">
			<div class="test-card sanction">
				<div class="test-title">Gamah Anti-Cheat v0.67.69.420</div>
				<div class="test-reason">@(C.SanctionMessage)</div>
				<div class="sanction-label">@(C.SanctionState == "ignored" ? "Back in the game when the bounty is won" : "Cooldown ends in")</div>
				<div class="sanction-time">@SanctionCountdown()</div>
			</div>
		</div>
	}
</root>

<style>
	/* The root genuinely fills the screen, so every absolutely-positioned child
	   below anchors against the full screen rect. pointer-events:none here; only
	   the button re-enables them. */
	root {
		width: 100%;
		height: 100%;
		pointer-events: none;
	}

	/* ── boards: shared look; each pinned via its own position class ── */
	.board {
		position: absolute;
		top: 18px;
		width: 280px;
		flex-direction: column;
		background-color: rgba(3, 13, 7, 0.85);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 10px;
		padding: 12px 14px;
		gap: 3px;
	}

	/* left column: session board stacked above the all-time board, pinned top-left.
	   The two boards flow in this flex column (their own absolute pin is cancelled
	   below) so they grow downward independently and can never overlap each other. */
	.left-col {
		position: absolute;
		top: 18px;
		left: 14px;
		flex-direction: column;
		gap: 14px;
	}
	.left-col .board { position: relative; top: auto; left: auto; right: auto; }

	/* GAME INFO opener: a wide labelled bar pinned bottom-left (top:auto cancels the
	   shared pins), lifted clear of the bottom edge so the MUSIC bar sits beneath it.
	   Mirrors the music FAB's wide-bar geometry/colours so the two read as a pair.
	   Re-enables pointer events against the pointer-events:none root. */
	.info-fab {
		position: absolute;
		left: 14px;
		bottom: 66px;
		width: 300px;
		height: 44px;
		justify-content: center;
		align-items: center;
		font-size: 15px;
		font-weight: bold;
		letter-spacing: 1px;
		color: rgba(255, 255, 255, 0.85);
		background-color: rgba(3, 13, 7, 0.85);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 8px;
		cursor: pointer;
		pointer-events: all;
		z-index: 150;
	}

	.info-fab:hover {
		color: #ffffff;
		border-color: rgba(6, 214, 160, 0.6);
	}

	/* PAUSE control: same wide-bar geometry as the GAME INFO / MUSIC bars, pinned one
	   slot ABOVE the GAME INFO bar. Green "PAUSE" at rest; red "PAUSED" while away. */
	.pause-fab {
		position: absolute;
		left: 14px;
		bottom: 120px;
		width: 300px;
		height: 44px;
		justify-content: center;
		align-items: center;
		font-size: 15px;
		font-weight: bold;
		letter-spacing: 1px;
		color: #04130b;
		background-color: rgba(6, 214, 160, 0.9);
		border: 1.5px solid rgba(6, 214, 160, 0.55);
		border-radius: 8px;
		cursor: pointer;
		pointer-events: all;
		/* The PARK direction (not parked → away) happens here, with no overlay up. While
		   PARKED the full-screen overlay sits over this bar (s&box hit-tests the overlay
		   on top regardless of z-index), so rejoin is the overlay's RESUME button, not this
		   bar — the bar then just reads "PAUSED" as a status. */
		z-index: 150;
	}

	.pause-fab:hover { background-color: rgba(6, 214, 160, 1); }

	.pause-fab.paused {
		color: #ffffff;
		background-color: rgba(214, 40, 40, 0.9);
		border-color: rgba(214, 40, 40, 0.65);
	}

	.pause-fab.paused:hover { background-color: rgba(214, 40, 40, 1); }

	/* "PAUSING…": a manual PAUSE pressed mid-armed, waiting for the next arming boundary
	   to take effect (amber, between the green rest and red paused states). */
	.pause-fab.pending {
		color: #04130b;
		background-color: rgba(255, 209, 102, 0.92);
		border-color: rgba(255, 209, 102, 0.6);
	}

	.pause-fab.pending:hover { background-color: rgba(255, 209, 102, 1); }

	/* ── parked / away overlay: a dim full-screen backdrop (captures input so nothing
	   underneath registers) with a centred card. Sits above the how-to popup, below the
	   anticheat test gate (z:200). The PAUSE bar is lifted above this backdrop (z:195) so
	   it stays clickable — it's the one way to rejoin. ── */
	.park-overlay {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		align-items: center;
		justify-content: center;
		background-color: rgba(0, 0, 0, 0.72);
		pointer-events: all;
		z-index: 190;
	}

	.park-card {
		flex-direction: column;
		flex-shrink: 0;
		width: 520px;
		padding: 26px 30px;
		align-items: center;
		background-color: rgba(3, 13, 7, 0.98);
		border: 1.5px solid rgba(214, 40, 40, 0.55);
		border-radius: 12px;
		gap: 10px;
		pointer-events: all;
	}

	.park-title {
		font-size: 26px;
		font-weight: bold;
		letter-spacing: 2px;
		color: #ff6b6b;
	}

	.park-sub {
		font-size: 16px;
		color: rgba(255, 255, 255, 0.85);
		text-align: center;
	}

	.park-hint {
		font-size: 15px;
		color: rgba(6, 214, 160, 0.95);
		text-align: center;
	}

	/* RESUME button on the parked overlay card — the reliable rejoin control. */
	.park-resume {
		margin-top: 8px;
		padding: 12px 40px;
		justify-content: center;
		align-items: center;
		font-size: 18px;
		font-weight: bold;
		letter-spacing: 2px;
		color: #04130b;
		background-color: rgba(6, 214, 160, 0.92);
		border: 1.5px solid rgba(6, 214, 160, 0.6);
		border-radius: 8px;
		cursor: pointer;
		pointer-events: all;
	}

	.park-resume:hover { background-color: rgba(6, 214, 160, 1); }

	/* ── "How to Play" popup: a dim full-screen backdrop (captures input) with a
	   floating card centred over it, like the music player. Sits below the anticheat
	   test gate (z:200) but above everything else. ── */
	.howto-overlay {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		align-items: center;
		justify-content: center;
		background-color: rgba(0, 0, 0, 0.7);
		pointer-events: all;
		z-index: 160;
	}

	/* Sized to its content — NOT scrollable. An overflow:scroll range becomes
	   drag-pannable in s&box (you can drag the card around inside the overlay), so
	   the card grows to fit instead, like the music board. The content is lean
	   enough to fit the 1080-tall reference ScreenPanel scales to. */
	.howto-card {
		flex-direction: column;
		flex-shrink: 0;
		width: 560px;
		padding: 22px 28px;
		background-color: rgba(3, 13, 7, 0.98);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 12px;
		gap: 3px;
		pointer-events: all;
	}

	.howto-card .howto-head {
		flex-direction: row;
		align-items: center;
		justify-content: space-between;
		margin-bottom: 4px;
	}

	.howto-card .howto-title {
		font-size: 22px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.9);
		letter-spacing: 1px;
	}

	.howto-card .howto-subtitle {
		font-size: 16px;
		font-weight: bold;
		color: #06d6a0;
		letter-spacing: 1px;
		margin-top: 2px;
		margin-bottom: 4px;
	}

	.howto-card .howto-close {
		width: 34px;
		height: 34px;
		justify-content: center;
		align-items: center;
		font-size: 20px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.6);
		border-radius: 6px;
		cursor: pointer;
	}

	.howto-card .howto-close:hover {
		color: #ffffff;
		background-color: rgba(255, 255, 255, 0.08);
	}

	.howto-card .divider {
		height: 1px;
		background-color: rgba(255, 255, 255, 0.1);
		margin-top: 6px;
		margin-bottom: 8px;
	}

	.howto-card .line {
		font-size: 14px;
		color: rgba(255, 255, 255, 0.6);
		margin-bottom: 6px;
		white-space: normal;
	}

	.howto-card .penalty-note {
		margin-bottom: 0;
		margin-top: 2px;
	}

	.howto-card .state-row {
		flex-direction: row;
		align-items: center;
		gap: 10px;
		margin-bottom: 5px;
	}

	.howto-card .tag {
		width: 110px;
		flex-shrink: 0;
		font-size: 13px;
		font-weight: bold;
		letter-spacing: 0.5px;
	}

	.howto-card .tag.wait { color: #06d6a0; }
	.howto-card .tag.go { color: #ffffff; }
	.howto-card .tag.over { color: #e63946; }

	.howto-card .desc {
		flex-grow: 1;
		font-size: 13px;
		color: rgba(255, 255, 255, 0.6);
		white-space: normal;
	}

	/* click-to-copy Discord invite blurb (there's no in-game URL open; Clipboard
	   works, so it copies). Discord blurple, underlines on hover. */
	.howto-card .discord-line {
		font-size: 13px;
		font-weight: bold;
		color: #8b93f5;
		white-space: normal;
		pointer-events: all;
		cursor: pointer;
	}
	.howto-card .discord-line:hover { color: #ffffff; text-decoration: underline; }

	/* The music opener lives on the Skafinity panel's own ScreenPanel (see scene
	   ShowFab/WideFab/FabLabel), not here. */
	.board.hourly { right: 308px; } /* 14 (edge) + 280 (hours-won) + 14 (gap) */
	.board.hourswon { right: 14px; }
	/* sessions-won pins to the free bottom-right corner (top:auto cancels the
	   shared top:18px) so the three right-hand boards never crowd the button. */
	.board.sessionswon { top: auto; bottom: 18px; right: 14px; }

	.board .title {
		font-size: 14px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.85);
		letter-spacing: 1px;
		justify-content: center;
		margin-bottom: 4px;
	}

	.board .reset {
		font-size: 11px;
		color: rgba(255, 255, 255, 0.5);
		justify-content: center;
		margin-top: -2px;
		margin-bottom: 4px;
	}

	.board .divider {
		height: 1px;
		background-color: rgba(255, 255, 255, 0.1);
		margin-bottom: 5px;
	}

	.board .row {
		flex-direction: row;
		align-items: center;
		font-size: 13px;
		color: rgba(255, 255, 255, 0.78);
		padding: 2px 5px;
		border-radius: 4px;
	}

	.board .rank { width: 24px; color: rgba(255, 255, 255, 0.4); font-size: 12px; }
	/* anticheat status dot: green live / yellow cooldown / red ignored. */
	.board .dot {
		width: 8px;
		height: 8px;
		flex-shrink: 0;
		margin-right: 7px;
		border-radius: 50%;
		background-color: #06d6a0;
	}
	.board .dot.live { background-color: #06d6a0; }
	.board .dot.cooldown { background-color: #ffd166; }
	.board .dot.ignored { background-color: #e63946; }
	.board .name { flex-grow: 1; overflow: hidden; white-space: nowrap; }
	.board .pts { color: #06d6a0; font-weight: bold; }
	.board .row.me { background-color: rgba(6, 214, 160, 0.15); }

	/* the tie-break margin ("+12ms"): how far this player trailed the one above
	   them on the same score. Dim amber, just left of the points. */
	.board .behind { color: #ffb37a; font-size: 11px; margin-right: 6px; flex-shrink: 0; }

	/* names are clickable (copy the player's Steam profile link); re-enable
	   pointer events on just these against the pointer-events:none root. */
	.board .name.clickable { pointer-events: all; cursor: pointer; }
	.board .name.clickable:hover { color: #ffffff; text-decoration: underline; }

	/* hours-won is the gold board */
	.board.hourswon .pts { color: #ffd166; }
	.board.hourswon .row.me { background-color: rgba(255, 209, 102, 0.15); }

	/* sessions-won is the blue board */
	.board.sessionswon .pts { color: #4cc9f0; }
	.board.sessionswon .row.me { background-color: rgba(76, 201, 240, 0.15); }

	/* all-time clickers: the violet board (stacked under the session board in the
	   left column; positioning handled by .left-col). */
	.board.alltime .pts { color: #c77dff; }
	.board.alltime .row.me { background-color: rgba(199, 125, 255, 0.15); }

	/* the leader's "skin": bundled image + caption, pinned just left of the
	   sessions-won board (308 = 14 edge + 280 board + 14 gap), bottom-aligned. */
	.skin-panel {
		position: absolute;
		bottom: 18px;
		right: 308px;
		width: 200px;
		flex-direction: column;
		align-items: center;
		background-color: rgba(3, 13, 7, 0.85);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 10px;
		padding: 10px;
		gap: 6px;
	}

	/* countdown to the winner-lock time, sitting above the image. */
	.skin-countdown {
		flex-direction: column;
		align-items: center;
		gap: 1px;
	}

	.skin-countdown .cd-label {
		font-size: 12px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.6);
		text-align: center;
		justify-content: center;
		letter-spacing: 0.5px;
	}

	.skin-countdown .cd-time {
		font-size: 22px;
		font-weight: bold;
		color: #ffd166;
		justify-content: center;
		letter-spacing: 1px;
	}

	/* shown in place of the countdown once the winner-lock time has passed. */
	.skin-locked {
		font-size: 13px;
		font-weight: bold;
		color: #ff9f1c;
		text-align: center;
		justify-content: center;
	}

	/* shown in place of the whole skin/countdown when no bounty is active (the
	   config skin/countdown would otherwise be a stale fallback). */
	.no-bounty {
		flex-direction: column;
		align-items: center;
		justify-content: center;
		padding: 24px 12px;
		gap: 8px;
	}

	.no-bounty-title {
		font-size: 20px;
		font-weight: bold;
		color: #ffd166;
		text-align: center;
		justify-content: center;
		letter-spacing: 0.5px;
	}

	.no-bounty-sub {
		font-size: 12px;
		color: rgba(255, 255, 255, 0.6);
		text-align: center;
		justify-content: center;
	}

	/* the image (served by the backend) scales to fit the panel width
	   (contain keeps its aspect ratio); the url is bound inline from config. */
	.skin-img {
		width: 100%;
		height: 240px;
		background-repeat: no-repeat;
		background-position: center;
		background-size: contain;
	}

	.skin-caption {
		font-size: 15px;
		font-weight: bold;
		color: #4cc9f0;
		justify-content: center;
		text-align: center;
	}

	/* click-to-copy the bounty's CS2 inspect link (no in-game URL open; Clipboard
	   works, so it copies). Underlines on hover like the other clickable lines. */
	.skin-copy {
		font-size: 12px;
		font-weight: bold;
		color: #8b93f5;
		justify-content: center;
		text-align: center;
		pointer-events: all;
		cursor: pointer;
	}
	.skin-copy:hover { color: #ffffff; text-decoration: underline; }

	/* the decoded skin's name (from the inspect link's dataset lookup). */
	.skin-name {
		font-size: 14px;
		font-weight: bold;
		color: #ffffff;
		text-align: center;
		justify-content: center;
	}

	/* wear / float / seed rows decoded from the inspect link. */
	.skin-stats {
		width: 100%;
		flex-direction: column;
		gap: 2px;
	}

	.skin-stats .stat {
		flex-direction: row;
		justify-content: space-between;
		font-size: 12px;
	}

	.skin-stats .stat .k { color: rgba(255, 255, 255, 0.55); }
	.skin-stats .stat .v { color: #e0e0e0; font-weight: bold; }

	/* the FN→BS wear bar: five segments sized to the wear thresholds with a
	   marker line at the float position. */
	.wear-bar {
		position: relative;
		width: 100%;
		height: 8px;
		flex-direction: row;
		border-radius: 3px;
	}

	.wear-bar .seg { height: 100%; }
	.wear-bar .seg.fn { width: 7%;  background-color: #4cd964; }
	.wear-bar .seg.mw { width: 8%;  background-color: #a8e05f; }
	.wear-bar .seg.ft { width: 23%; background-color: #ffd166; }
	.wear-bar .seg.ww { width: 7%;  background-color: #ff9f1c; }
	.wear-bar .seg.bs { width: 55%; background-color: #ff4d4d; }

	.wear-marker {
		position: absolute;
		top: -2px;
		width: 2px;
		height: 12px;
		background-color: #ffffff;
		border: 1px solid rgba(0, 0, 0, 0.6);
	}

	/* shown when the inspect link couldn't be decoded or its image fetched. */
	.skin-note {
		font-size: 11px;
		font-weight: bold;
		color: #ff9f1c;
		text-align: center;
		justify-content: center;
	}

	/* previous-winner panel: a duplicate skin panel pinned just left of the live one
	   (308 + 200 + 20). Display-only — pointer-events:none so the roaming button
	   beneath stays fully clickable; a gold edge + slightly shorter image distinguish
	   it from the live "skin to win". */
	.skin-panel.previous {
		right: 528px;
		pointer-events: none;
		border-color: rgba(255, 209, 102, 0.35);
	}

	.skin-panel.previous .skin-img { height: 150px; }

	.prev-header {
		font-size: 12px;
		font-weight: bold;
		color: #ffd166;
		letter-spacing: 1px;
		text-align: center;
		justify-content: center;
	}

	.prev-winner {
		font-size: 16px;
		font-weight: bold;
		color: #ffffff;
		text-align: center;
		justify-content: center;
	}

	/* the winner line re-enables pointer events over the panel's pointer-events:none
	   (only this line is interactive — click to copy the winner's profile link). */
	.prev-winner.clickable { pointer-events: all; cursor: pointer; }
	.prev-winner.clickable:hover { color: #ffd166; text-decoration: underline; }

	.prev-wins {
		font-size: 12px;
		color: rgba(255, 255, 255, 0.6);
		text-align: center;
		justify-content: center;
	}

	.board .empty {
		justify-content: center;
		font-size: 12px;
		color: rgba(255, 255, 255, 0.35);
		margin-top: 4px;
	}

	/* ── center: the BOUNDING BOX the button roams inside, inset between the
	   left session board (right edge ≈ 308px) and the right board column (also
	   308px), with a little top/bottom breathing room. The button is positioned
	   absolutely within this box; the status HUD overlays it centred. ──

	   The box (and so the button + status HUD + music panel inside it) is kept ABOVE every
	   board at all times via z-index. The boards are declared after .center in the markup,
	   so without this they'd paint — and take clicks — on top of anything in the box. The
	   box is normally inset clear of every board, but the roaming button (and the music
	   button it can overlap at the bottom-left) must always win paint AND click priority
	   when they do intersect, so the box outranks them unconditionally. */
	.center {
		position: absolute;
		top: 24px;
		bottom: 24px;
		left: 308px;
		right: 308px;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		pointer-events: none;
		z-index: 100;
	}

	/* Opponent click pip: a half-size (70px vs the button's 140px) green circle that
	   pops in and fades over ~0.45s (matched to PipLifetime so it's gone before
	   ClickController prunes it). Positioned in the box's % space (left/top set inline),
	   with margin offsets of −half so the inline %, which is the clicker's button
	   CENTRE, places the pip centred there. pointer-events:none so it never eats a
	   click; below the penalty pops, above the button. */
	.pip {
		position: absolute;
		width: 70px;
		height: 70px;
		margin-left: -35px;
		margin-top: -35px;
		border-radius: 50%;
		background-color: #06d6a0;
		border: 2px solid rgba(255, 255, 255, 0.8);
		flex-direction: column;
		align-items: center;
		justify-content: center;
		pointer-events: none;
		z-index: 110;
		animation-name: pip-fade;
		animation-duration: 0.45s;
		animation-timing-function: ease-out;
		animation-fill-mode: forwards;
	}

	@@keyframes pip-fade {
		0%   { opacity: 0.9; transform: scale(0.6); }
		25%  { opacity: 0.85; transform: scale(1); }
		100% { opacity: 0; transform: scale(1.1); }
	}

	/* The clicker's name centred over their pip. */
	.pip-name {
		font-size: 13px;
		font-weight: bold;
		color: #ffffff;
		text-stroke: 1px #000000;
		text-align: center;
		white-space: nowrap;
	}

	/* v5 board button: one clickable green SQUARE per live slot. Positioned by its
	   server-placed centre (inline left/top %, same box-% space as the pips), centred via
	   −half margins. Square (not a circle) so the visual matches the actual click hitbox
	   AND the `touch` hitbox — s&box hit-tests the panel's box, so a circle would leave the
	   corners clickable-but-untouched and false-trip the no_hover check; it also matches the
	   server's square non-overlap spacing model (board.go posMinDist). Smaller than the
	   legacy single button since up to X share the box. No teleport — positions are
	   server-authoritative, so left/top can transition-free. */
	.board-btn {
		position: absolute;
		width: 96px;
		height: 96px;
		margin-left: -48px;
		margin-top: -48px;
		border-radius: 12px;
		background-color: #06d6a0; /* live green — the board only exists while armed */
		border: 4px solid rgba(255, 255, 255, 0.9);
		transition: transform 0.08s ease-out;
		pointer-events: all;
		cursor: pointer;
		z-index: 105;
	}
	.board-btn:active { transform: scale(0.9); }

	/* Opponent cursor: a small amber dot at another player's sampled pointer, with their
	   name beneath. Box-% space like the pips; never eats a click. */
	.opp-cursor {
		position: absolute;
		width: 16px;
		height: 16px;
		margin-left: -8px;
		margin-top: -8px;
		border-radius: 50%;
		background-color: rgba(255, 209, 102, 0.9);
		border: 2px solid rgba(0, 0, 0, 0.5);
		flex-direction: column;
		align-items: center;
		pointer-events: none;
		z-index: 108;
	}

	.opp-cursor-name {
		position: absolute;
		top: 16px;
		font-size: 13px;
		color: #ffd166;
		text-stroke: 1px rgba(0, 0, 0, 0.8);
		white-space: nowrap;
		pointer-events: none;
	}

	/* Live clicks-remaining counter, green, shown while the window is open. */
	.remaining {
		font-size: 40px;
		font-weight: bold;
		color: #06d6a0;
		text-stroke: 1px rgba(0, 0, 0, 0.5);
	}

	/* HUD pinned to the top edge of the screen, centred horizontally over the box;
	   the button roams below/behind it and clicks fall through. */
	.hud {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		flex-direction: column;
		align-items: center;
		justify-content: flex-start;
		gap: 10px;
		pointer-events: none;
	}

	.round {
		font-size: 22px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.85);
		letter-spacing: 1px;
	}

	.state {
		font-size: 54px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.9);
		letter-spacing: 3px;
	}

	.state.armed { color: #ffffff; }
	.state.offline { color: #ffd166; }

	.meta {
		flex-direction: row;
		gap: 10px;
		align-items: center;
		font-size: 18px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.85);
	}

	.meta .sep { color: rgba(255, 255, 255, 0.4); }

	.arm {
		font-size: 16px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.6);
		letter-spacing: 1px;
	}

	/* Host-pushed broadcast note: a large orange message centred in the status HUD. */
	.dev-note {
		font-size: 32px;
		font-weight: bold;
		color: #ff8c00;
	}

	/* The status HUD now floats centred on the dark box (the button roams away from
	   it), so the text stays white in every state — no on-button contrast override. */

	/* Pinned to the bottom of the bounding box, centred horizontally. */
	.clicks-sent {
		position: absolute;
		bottom: 0;
		left: 0;
		right: 0;
		justify-content: center;
		font-size: 22px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.65);
		letter-spacing: 1px;
		pointer-events: none;
	}

	/* transient confirmation that a profile link was copied; top-center, fades by
	   simply un-rendering after ToastDuration (see BuildHash). */
	.toast {
		position: absolute;
		top: 40px;
		left: 0;
		right: 0;
		justify-content: center;
		font-size: 20px;
		font-weight: bold;
		color: #4cc9f0;
		letter-spacing: 1px;
		pointer-events: none;
		/* Above every overlay (the .center box at 100, the how-to popup at 160, the
		   anticheat gate at 200) so the "copied" confirmation is visible no matter what's
		   open — e.g. copying the Discord invite from inside the how-to popup. */
		z-index: 210;
	}

	/* ── per-round scores flash: a card centred over the play area, shown for
	   FlashDuration after each round closes then fading out. The .round-flash layer
	   fills the screen and centres the card; the keyframe (pop in → hold → fade) runs
	   for the full 4s so the card unmounts as the fade ends. Non-interactive and above
	   the boards/button but below the how-to popup (160) and the anticheat gate (200).
	   The card is sized to its content (no scroll — an overflow range becomes
	   drag-pannable in s&box). */
	.round-flash {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		pointer-events: none;
		z-index: 140;
		animation-name: round-flash-anim;
		animation-duration: 4s;
		animation-timing-function: ease-out;
		animation-fill-mode: forwards;
	}

	@@keyframes round-flash-anim {
		0%   { opacity: 0; }
		6%   { opacity: 1; }
		82%  { opacity: 1; }
		100% { opacity: 0; }
	}

	/* the visible card: same dark panel look as the boards. */
	.round-flash .rf-panel {
		flex-direction: column;
		flex-shrink: 0;
		width: 360px;
		padding: 16px 20px;
		gap: 3px;
		background-color: rgba(3, 13, 7, 0.95);
		border: 1.5px solid rgba(255, 255, 255, 0.12);
		border-radius: 12px;
	}

	.round-flash .rf-title {
		font-size: 22px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.9);
		letter-spacing: 2px;
		justify-content: center;
		margin-bottom: 2px;
	}

	.round-flash .divider {
		height: 1px;
		background-color: rgba(255, 255, 255, 0.1);
		margin-bottom: 6px;
	}

	.round-flash .rf-row {
		flex-direction: row;
		align-items: center;
		font-size: 18px;
		color: rgba(255, 255, 255, 0.85);
		padding: 3px 6px;
		border-radius: 4px;
	}

	.round-flash .rf-rank { width: 30px; color: rgba(255, 255, 255, 0.4); font-size: 15px; }
	.round-flash .rf-name { flex-grow: 1; overflow: hidden; white-space: nowrap; }
	.round-flash .rf-pts { color: #06d6a0; font-weight: bold; }
	.round-flash .rf-row.me { background-color: rgba(6, 214, 160, 0.15); }

	.round-flash .rf-empty {
		justify-content: center;
		font-size: 15px;
		color: rgba(255, 255, 255, 0.5);
		padding: 4px 0;
	}

	/* ── anticheat test gate: a full-screen dim overlay that captures input (above
	   everything else) with a centred card holding the question + answer box. ── */
	.test-overlay {
		position: absolute;
		top: 0;
		left: 0;
		right: 0;
		bottom: 0;
		align-items: center;
		justify-content: center;
		background-color: rgba(0, 0, 0, 0.75);
		pointer-events: all; /* block the game beneath while benched */
		z-index: 200;
	}

	.test-card {
		flex-direction: column;
		align-items: center;
		gap: 14px;
		width: 420px;
		padding: 28px 32px;
		background-color: rgba(3, 13, 7, 0.98);
		border: 2px solid #e63946;
		border-radius: 14px;
	}

	.test-title {
		font-size: 26px;
		font-weight: bold;
		color: #e63946;
		letter-spacing: 2px;
		justify-content: center;
	}

	.test-sub {
		font-size: 14px;
		color: rgba(255, 255, 255, 0.6);
		text-align: center;
		justify-content: center;
		white-space: normal;
	}

	/* the specific check (or sanction) reason, in the anticheat red. */
	.test-reason {
		font-size: 16px;
		font-weight: bold;
		color: #ff8c69;
		text-align: center;
		justify-content: center;
		white-space: normal;
	}

	/* cooldown/ignored card: no answer box, just the reason + a countdown. The
	   amber border separates it from the red math-test card. */
	.test-card.sanction { border-color: #ffd166; }

	.sanction-label {
		font-size: 14px;
		font-weight: bold;
		color: rgba(255, 255, 255, 0.6);
		text-align: center;
		justify-content: center;
		letter-spacing: 0.5px;
	}

	.sanction-time {
		font-size: 40px;
		font-weight: bold;
		color: #ffd166;
		letter-spacing: 2px;
		justify-content: center;
	}

	.test-prompt {
		font-size: 40px;
		font-weight: bold;
		color: #ffffff;
		letter-spacing: 2px;
		justify-content: center;
	}

	/* Mirror the known-good TextEntry recipe (skafinity .tag-input / rotaliate
	   .chat-entry): an explicit height + plain `color: white`. Without a height the
	   text box collapses and the typed text is clipped (it's still captured — the
	   answer sends — just not painted); text-align on a TextEntry also hides it. */
	.test-input {
		width: 240px;
		height: 44px;
		padding: 0 14px;
		font-size: 24px;
		color: white;
		background-color: rgba(255, 255, 255, 0.08);
		border: 1.5px solid rgba(255, 255, 255, 0.3);
		border-radius: 8px;
		pointer-events: all;
	}

	.test-submit {
		font-size: 18px;
		font-weight: bold;
		color: #030d07;
		background-color: #06d6a0;
		letter-spacing: 1px;
		padding: 10px 28px;
		border-radius: 8px;
		cursor: pointer;
		pointer-events: all;
	}

	.test-submit:hover { background-color: #08f0b4; }
</style>

@code {
	const int DisplayLimit = 10;
	const float RefreshInterval = 20f;

	// Touch-hitbox geometry (reference px, the 1080-tall ScreenPanel space). CenterRefH is
	// the .center box's reference height (1080 − top:24 − bottom:24); BoardBtnRefHalf is the
	// .board-btn half-extent (96px square ⇒ 48). TouchPadRef DELIBERATELY enlarges the touch
	// hitbox beyond the visual/click box so the `touch` zone is a strict SUPERSET of the
	// clickable area: a click can land in the 4px border ring or a frame where the per-frame
	// sampler just missed the edge, and no_hover only targets EGREGIOUS cases (cursor nowhere
	// near the button), so erring bigger trades a hair of sensitivity for zero edge-click
	// false positives. Used to size the square hitbox in actual px when detecting a `touch`
	// enter (see OnUpdate).
	const float CenterRefH = 1080f - 24f - 24f;
	const float BoardBtnRefHalf = 48f;
	const float TouchPadRef = 32f;

	ClickController C => ClickController.Instance;

	// ── session: live standings pushed inside round_result/game_over (never fetched) ──
	static List<Standing> SessionEntries => ClickController.Instance?.Standings ?? new List<Standing>();

	// ── hourly + hours-won: fetched over HTTP, deliberately infrequently (once on
	//    connect, then when a round closes, throttled) so neither becomes a fan-in
	//    GET stampede. The hot click path never touches HTTP. ──
	List<Standing> _hourly = new();
	bool _hourlyLoaded, _hourlyFetching;
	RealTimeSince _hourlyLast;

	List<Standing> _hoursWon = new();
	bool _hoursWonLoaded, _hoursWonFetching;
	RealTimeSince _hoursWonLast;

	List<Standing> _sessionsWon = new();
	bool _sessionsWonLoaded, _sessionsWonFetching;
	RealTimeSince _sessionsWonLast;

	List<Standing> _allTime = new();
	bool _allTimeLoaded, _allTimeFetching;
	RealTimeSince _allTimeLast;

	GamePhase _lastPhase = GamePhase.Connecting;

	// ── the bounding box (.center), captured by @ref so the board buttons, opponent
	//    pips and cursor can be measured/normalized against it. The board buttons are
	//    server-placed (board-% space); the client draws no button of its own. ──
	Panel _box;

	// ── server-driven skin + winner-lock countdown (GET /api/v1/config) ──
	// _winnerLockMs is the winner-lock instant as Unix epoch ms (0 = unset, hides
	// the countdown); _skinUrl is the absolute URL the backend serves the current
	// "skin to win" image from. Both come from config so they retune without a
	// client rebuild. Epoch-ms (not a parsed DateTime) because the s&box sandbox
	// doesn't whitelist System.Globalization — the countdown is integer math.
	long _winnerLockMs;
	string _skinUrl = "";

	// True only while a bounty is actually active (config's has_bounty). When false the
	// skin/countdown above is the host config.json/env fallback (a stale "old" skin) and
	// the bounty-scoped boards have fallen back to all-time data — so the skin panel and
	// the "THIS SKIN" boards show a "no active bounty" state instead of that stale data.
	bool _hasBounty;

	// ── inspect-link skin (decoded locally + name/image resolved off the dataset) ──
	// Null until an inspect-linked bounty's config arrives and resolution completes;
	// its DecodeOk/ImageOk flags drive what the skin panel shows vs. the server image.
	SkinInspect.Skin _skin;

	// The active bounty's raw inspect link ("" when the skin is an uploaded image
	// only). Kept verbatim so the "copy inspect link" line can hand it to players to
	// paste into CS2, independent of whether it decoded/resolved locally.
	string _inspectLink = "";

	// ── previous winner (the just-settled bounty) ── a duplicate of the skin panel
	// showing who won the last skin and what it was. _prevBounty is the most recent
	// won bounty (null until fetched / when there's no history); _prevSkin is its
	// inspect link decoded locally (same as _skin); _prevSkinUrl is the per-bounty
	// image fallback. _lastBountyRefresh tracks ClickController.BountyRefreshSeq so a
	// hello/reconnect or a bounty_update push re-fetches both current and previous.
	PreviousBounty _prevBounty;
	SkinInspect.Skin _prevSkin;
	string _prevSkinUrl = "";
	int _lastBountyRefresh;

	// Image for every skin panel: a locally-bundled placeholder. Real CS2/Valve
	// skins are no longer surfaced and the image is no longer fetched from the
	// server — it ships with the client (see .sbproj Resources).
	const string TempgunImg = "media/tempgun.png";
	string SkinImgUrl() => TempgunImg;

	// Wear-bar marker position as a 0..100 percentage at the float value, clamped.
	int MarkerPct()
	{
		int pct = (int)((_skin?.Float ?? 0f) * 100f + 0.5f);
		return pct < 0 ? 0 : pct > 100 ? 100 : pct;
	}

	// Float formatted to 10 dp without culture-sensitive APIs (the sandbox doesn't
	// whitelist System.Globalization): integer part + zero-padded fractional digits.
	string FloatStr()
	{
		double d = _skin?.Float ?? 0.0;
		if ( d < 0 ) d = 0;
		const long Scale = 10_000_000_000L; // 10 fractional digits, like the float checker
		long scaled = (long)(d * Scale + 0.5);
		return $"{scaled / Scale}.{(scaled % Scale).ToString().PadLeft( 10, '0' )}";
	}

	// ── previous-winner panel helpers (mirror the current-skin ones, reading the
	//    previous bounty's decoded skin/image instead) ──
	string PrevSkinImgUrl() => TempgunImg;

	int PrevMarkerPct()
	{
		int pct = (int)((_prevSkin?.Float ?? 0f) * 100f + 0.5f);
		return pct < 0 ? 0 : pct > 100 ? 100 : pct;
	}

	string PrevFloatStr()
	{
		double d = _prevSkin?.Float ?? 0.0;
		if ( d < 0 ) d = 0;
		const long Scale = 10_000_000_000L;
		long scaled = (long)(d * Scale + 0.5);
		return $"{scaled / Scale}.{(scaled % Scale).ToString().PadLeft( 10, '0' )}";
	}

	// The previous winner's display name, tagged "(you)" when the local player won
	// it (matched by the public tag, the only id the client knows itself by).
	string PrevWinnerName()
	{
		if ( _prevBounty == null ) return "";
		var name = string.IsNullOrEmpty( _prevBounty.WinnerName ) ? "—" : _prevBounty.WinnerName;
		var myTag = ClickController.Instance?.Tag;
		if ( !string.IsNullOrEmpty( myTag ) && _prevBounty.WinnerTag == myTag ) name += " (you)";
		return name;
	}

	// ── anticheat test gate: the answer box (captured by @ref so SubmitTest can read
	//    it) shown in the overlay while the player is benched. ──
	TextEntry _testEntry;

	// Send the typed answer to the server and clear the box. The server replies with a
	// `test` cleared frame (correct → un-benched) or a fresh `test` (wrong); either way
	// ClickController updates HasTest/TestPrompt and the overlay re-renders.
	void SubmitTest()
	{
		var ans = _testEntry?.Text?.Trim();
		if ( string.IsNullOrEmpty( ans ) ) return;
		ClickController.Instance?.SubmitTestAnswer( ans );
		if ( _testEntry != null ) _testEntry.Text = "";
	}

	// ── "How to Play" popup: open state mirrors PlayerData.HowToPlayOpen (persisted to
	//    local storage), seeded once from it here so the player's choice survives across
	//    sessions. First-time players default to open (PlayerData default). ──
	bool _howtoOpen = PlayerData.Load().HowToPlayOpen;

	// True once WE auto-parked the player because a panel (the how-to popup or the music
	// board) is open — reading the rules or browsing music means they're not playing, so we
	// step them out of the round + the AFK pass instead of letting a still cursor flag them.
	// Latched only when our own park took, so a server afk-park or a manual PAUSE that
	// happens to coincide with a panel is never auto-resumed when the panel closes. Driven
	// from OnUpdate (PanelParkSync).
	bool _panelParked;

	// Toggle the popup and persist the new state to local storage so it sticks.
	void ToggleHowTo()
	{
		_howtoOpen = !_howtoOpen;
		var data = PlayerData.Load();
		data.HowToPlayOpen = _howtoOpen;
		data.Save();
		StateHasChanged();
	}

	// Keep the parked state in sync with the open panels (how-to popup / music board). Open
	// panel ⇒ park; once they're all closed ⇒ rejoin — but only if WE were the ones who
	// parked for a panel (_panelParked). A server afk-park or a manual PAUSE that overlaps a
	// panel is left alone (we never latched it), so closing the panel doesn't resume them.
	// Park is a no-op without a live socket (e.g. the first-launch popup before connect), so
	// we only latch once the park actually took, and retry every frame until it does.
	void PanelParkSync()
	{
		if ( C == null ) return;
		bool panelOpen = _howtoOpen || MusicOpen;
		if ( panelOpen )
		{
			// Request a park once. Don't re-issue while one is already parked OR pending: a
			// PAUSE pressed mid-armed is deferred to the next arming boundary (Parked stays
			// false, ParkPending true until then), and re-calling TogglePark would read that
			// as a RESUME and toggle it straight back off.
			if ( !C.Parked && !C.ParkPending )
			{
				C.TogglePark();
				if ( C.Parked || C.ParkPending ) _panelParked = true; // our park took (now or deferred)
				StateHasChanged();
			}
		}
		else if ( _panelParked )
		{
			// Panels closed: rejoin if we're parked or a park is still in flight.
			if ( C.Parked || C.ParkPending ) C.TogglePark();
			_panelParked = false;
			StateHasChanged();
		}
	}

	// Toggle the away/parked state from the PAUSE bar / parked-overlay RESUME. Delegates to
	// the controller (which parks immediately, defers a mid-armed park to the next arming
	// boundary, or requests a deferred rejoin) and re-renders so the bar + overlay update.
	void TogglePause()
	{
		ClickController.Instance?.TogglePark();
		StateHasChanged();
	}

	// PAUSE-bar label/class. "PAUSING…" (amber) while a mid-armed park waits for the next
	// arming boundary; "PAUSED" (red) once parked; "PAUSE" (green) at rest.
	string PauseFabLabel()
	{
		var c = C;
		if ( c == null ) return "PAUSE";
		if ( c.ParkPending ) return "PAUSING…";
		return c.Parked ? "PAUSED" : "PAUSE";
	}

	string PauseFabClass()
	{
		var c = C;
		if ( c == null ) return "";
		if ( c.ParkPending ) return "pending";
		return c.Parked ? "paused" : "";
	}

	// ── "copied profile link" toast: shown for ToastDuration after a name click ──
	const float ToastDuration = 1.2f;
	string _toast = "";
	RealTimeSince _toastSince = 1000f; // start expired
	bool ToastVisible => _toastSince < ToastDuration;

	// ── per-round scores flash: a transient panel of the round's scorers (the
	//    round_result `winners`, carrying the points each grabbed THAT round), shown
	//    for FlashDuration when a round closes. Triggered on the phase change into
	//    Result (the final round folds into game_over, whose GAME OVER standings stand
	//    in for its flash). A snapshot is taken so it survives the next round overwriting
	//    C.Winners. _flashSince starts expired so nothing shows until the first round. ──
	const float FlashDuration = 4f;
	List<Standing> _flashWinners = new();
	int _flashRound;
	RealTimeSince _flashSince = 1000f;
	bool FlashVisible => _flashSince < FlashDuration;

	// ── post-game board refresh: the server credits the session win and refreshes
	//    the board cache asynchronously (afterGame) AFTER it broadcasts game_over, so
	//    a fetch fired the instant game_over lands races ahead of that write and shows
	//    the games/hours-won boards a game behind ("lags by 1"). When a game ends we
	//    arm a one-shot refresh a short delay later — past the async write (a sub-second
	//    local DB write; the 5s intermission easily covers it) — so the just-finished
	//    game is reflected while the GAME OVER standings are still up. ──
	const float PostGameRefreshDelay = 2f;
	bool _postGamePending;
	RealTimeSince _postGameSince = 1000f;

	protected override void OnTreeFirstBuilt()
	{
		_ = LoadConfig();
		_ = LoadPreviousBounty();
		_ = RefreshHourly();
		_ = RefreshHoursWon();
		_ = RefreshSessionsWon();
		_ = RefreshAllTime();
	}

	// Fetch the winner-lock time + skin image URL (the active bounty). Re-run on
	// (re)connect and on a bounty_update push, so the panel always reflects the
	// current bounty. On failure the countdown simply doesn't show and the image
	// stays blank — no hard dependency.
	async Task LoadConfig()
	{
		var cfg = await ApiClient.GetConfig();
		if ( cfg == null ) return;
		_skinUrl = ApiClient.AbsoluteUrl( cfg.SkinUrl ) ?? "";
		_winnerLockMs = cfg.WinnerLockMs;
		_inspectLink = cfg.InspectLink ?? "";
		_hasBounty = cfg.HasBounty;
		// Clear the previous bounty's decoded skin so a re-fetch (rollover) doesn't
		// leave the old name/stats showing under the new skin — ResolveSkin refills it
		// when this bounty also has an inspect link; an image-only bounty keeps it null.
		_skin = null;
		StateHasChanged();

		// If the active bounty was set by an inspect link, decode it locally and
		// resolve its name/stats (cached dataset) in the background. We use the decoded
		// name + wear/float/seed, but the displayed image is always the local tempgun
		// placeholder (SkinImgUrl), never the Valve weapon image.
		if ( !string.IsNullOrWhiteSpace( cfg.InspectLink ) )
			_ = ResolveSkin( cfg.InspectLink );
	}

	// Decode the bounty's inspect link + resolve its name/stats, then re-render. On
	// any failure _skin is left with DecodeOk/ImageOk false so the panel shows the
	// "unable to fetch bounty from steam" note; the image is the tempgun placeholder
	// either way.
	async Task ResolveSkin( string link )
	{
		_skin = await SkinInspect.Resolve( link );
		StateHasChanged();
	}

	// Fetch the most recent settled bounty (the "previous winner") and, if it was an
	// inspect-link skin, decode its name/stats for the render (the image stays the
	// tempgun placeholder). Re-run alongside LoadConfig so a rollover surfaces the
	// just-settled winner immediately. The resolve is guarded by id so a slow decode
	// can't clobber a newer previous bounty.
	async Task LoadPreviousBounty()
	{
		var list = await ApiClient.GetPreviousBounties();
		var b = ( list != null && list.Count > 0 ) ? list[0] : null;
		_prevBounty = b;
		_prevSkin = null;
		_prevSkinUrl = b != null ? ( ApiClient.AbsoluteUrl( b.SkinUrl ) ?? "" ) : "";
		StateHasChanged();

		if ( b != null && !string.IsNullOrWhiteSpace( b.InspectLink ) )
		{
			var id = b.Id;
			var skin = await SkinInspect.Resolve( b.InspectLink );
			if ( _prevBounty != null && _prevBounty.Id == id ) // still the same previous bounty
			{
				_prevSkin = skin;
				StateHasChanged();
			}
		}
	}

	protected override void OnUpdate()
	{
		// The bounty rolled over (or we just (re)connected): re-fetch the active skin
		// and the previous winner so neither goes stale. Driven by the server push /
		// hello via ClickController.BountyRefreshSeq — no polling.
		int seq = C?.BountyRefreshSeq ?? 0;
		if ( seq != _lastBountyRefresh )
		{
			_lastBountyRefresh = seq;
			_ = LoadConfig();
			_ = LoadPreviousBounty();
		}

		// While a panel is open (the how-to popup — which pops up on first launch — or the
		// music board), the player is reading/browsing, not playing: their cursor sits still
		// and the server would flag them AFK. Auto-park them (drops them out of the round's N
		// + the AFK pass) and rejoin them when the panels close. See _panelParked.
		PanelParkSync();

		var phase = C?.Phase ?? GamePhase.Connecting;

		// Report our pointer to the server (throttled in SendCursor) so other players see our
		// roaming cursor AND so the server's AFK pass can tell a present player from an away
		// one. As of v7 this runs during BOTH the arming wait (Pending) and the live window
		// (Armed) — the server now watches arming-phase movement, not just armed. The .center
		// box (_box) is laid out throughout (it's a fixed absolutely-positioned container that
		// exists in every phase, with or without buttons), so its rect is valid during Pending
		// too. Mouse.Position and Box.Rect share the same screen-pixel space, so the pointer
		// normalizes to the box exactly like the buttons/pips (−1..1, 0 = centre). Re-render is
		// driven by BoardHash in BuildHash (board claims + cursor moves), so no per-frame
		// StateHasChanged needed here.
		if ( ( phase == GamePhase.Armed || phase == GamePhase.Pending ) && C != null )
		{
			var r = _box?.Box.Rect ?? default;
			if ( r.Width > 0f && r.Height > 0f )
			{
				var m = Sandbox.Mouse.Position;
				float nx = ( m.x - r.Center.x ) / ( r.Width / 2f );
				float ny = ( m.y - r.Center.y ) / ( r.Height / 2f );
				// Only report the cursor while it's actually ON the board (within -1..1).
				// A pointer resting OUTSIDE the board (over the side leaderboards) used to
				// clamp to the +-1 edge, and tiny hand jitter oscillating across that edge
				// made a physically-still cursor look like it moved a lot, defeating the
				// afk check. Off-board samples are simply not sent: a player parked off the
				// board reports no cursor (the server reads that as afk by the no-cursor
				// half), and on-board movement is measured cleanly with no edge artefact.
				if ( nx >= -1f && nx <= 1f && ny >= -1f && ny <= 1f )
					C.SendCursor( nx, ny );

				// Armed-only: fire a one-shot `touch` the instant the pointer enters a live
				// button's hitbox (ClickController de-dupes per window). The button is a 96px
				// CSS SQUARE; the touch hitbox is an axis-aligned box of half-extent
				// (BoardBtnRefHalf + TouchPadRef), deliberately a bit BIGGER than the visual/
				// click box so an edge click (the 4px border ring, or a frame the sampler just
				// missed) always counts as touched — no_hover only targets egregious "cursor
				// nowhere near" cases. ScreenPanel scales uniformly, so the half-extent is the
				// same on both axes in actual px. .center spans top:24/bottom:24 of the 1080
				// ref, so its reference height is CenterRefH; scale = actual box height ÷ that.
				if ( phase == GamePhase.Armed )
				{
					float half = ( BoardBtnRefHalf + TouchPadRef ) * ( r.Height / CenterRefH );
					foreach ( var btn in C.LiveButtons )
					{
						float cx = r.Center.x + btn.X * ( r.Width / 2f );
						float cy = r.Center.y + btn.Y * ( r.Height / 2f );
						if ( MathF.Abs( m.x - cx ) <= half && MathF.Abs( m.y - cy ) <= half )
							C.SendTouch( btn.Slot );
					}
				}
			}
		}

		if ( phase != _lastPhase )
		{
			// A round just closed: flash its scores. Snapshot the winners (the next
			// round overwrites C.Winners) and restart the flash timer. Only on Result —
			// the final round folds into GAME OVER, whose standings are its own display.
			if ( phase == GamePhase.Result )
			{
				_flashWinners = C?.Winners != null ? new List<Standing>( C.Winners ) : new();
				_flashRound = C?.Round ?? 0;
				_flashSince = 0f;
			}

			// Refresh the boards when a round just closed (player is reading scores
			// between presses), throttled so rapid result→pending churn can't spam.
			// GAME OVER is deliberately excluded: a fetch here races the server's async
			// post-game write (see below), so the games/hours-won boards would show a
			// game behind. Game-end refresh is handled by the delayed one-shot instead.
			if ( phase == GamePhase.Result )
			{
				if ( _hourlyLast > RefreshInterval ) _ = RefreshHourly();
				if ( _hoursWonLast > RefreshInterval ) _ = RefreshHoursWon();
				if ( _sessionsWonLast > RefreshInterval ) _ = RefreshSessionsWon();
				if ( _allTimeLast > RefreshInterval ) _ = RefreshAllTime();
			}

			// A game just ended: arm the one-shot post-game refresh so the boards pick
			// up this game's session win once the server's afterGame write has landed.
			if ( phase == GamePhase.GameOver )
			{
				_postGamePending = true;
				_postGameSince = 0f;
			}

			_lastPhase = phase;
			StateHasChanged();
		}

		// Fire the armed post-game refresh once the delay has elapsed (the server's
		// async session-win write is done by now), so the games/hours-won boards reflect
		// the just-finished game instead of lagging a game behind.
		if ( _postGamePending && _postGameSince >= PostGameRefreshDelay )
		{
			_postGamePending = false;
			_ = RefreshHourly();
			_ = RefreshHoursWon();
			_ = RefreshSessionsWon();
			_ = RefreshAllTime();
		}
	}

	async Task RefreshHourly()
	{
		if ( _hourlyFetching ) return;
		_hourlyFetching = true;
		_hourlyLast = 0;
		try
		{
			var board = await ApiClient.GetHourlyLeaderboard( DisplayLimit );
			_hourly = board ?? new List<Standing>();
			_hourlyLoaded = true;
			StateHasChanged();
		}
		finally { _hourlyFetching = false; }
	}

	async Task RefreshHoursWon()
	{
		if ( _hoursWonFetching ) return;
		_hoursWonFetching = true;
		_hoursWonLast = 0;
		try
		{
			var board = await ApiClient.GetHoursWonLeaderboard( DisplayLimit );
			_hoursWon = board ?? new List<Standing>();
			_hoursWonLoaded = true;
			StateHasChanged();
		}
		finally { _hoursWonFetching = false; }
	}

	async Task RefreshSessionsWon()
	{
		if ( _sessionsWonFetching ) return;
		_sessionsWonFetching = true;
		_sessionsWonLast = 0;
		try
		{
			var board = await ApiClient.GetSessionsWonLeaderboard( DisplayLimit );
			_sessionsWon = board ?? new List<Standing>();
			_sessionsWonLoaded = true;
			StateHasChanged();
		}
		finally { _sessionsWonFetching = false; }
	}

	async Task RefreshAllTime()
	{
		if ( _allTimeFetching ) return;
		_allTimeFetching = true;
		_allTimeLast = 0;
		try
		{
			var board = await ApiClient.GetAllTimeClickersLeaderboard( DisplayLimit );
			_allTime = board ?? new List<Standing>();
			_allTimeLoaded = true;
			StateHasChanged();
		}
		finally { _allTimeFetching = false; }
	}

	// Copy the clicked player's public Steam community profile link to the
	// clipboard and flash a short confirmation toast. SteamID64 is public, so the
	// link works for anyone; no-op if the row carries no id.
	void CopyProfile( Standing s )
	{
		if ( s == null || string.IsNullOrEmpty( s.SteamId ) ) return;
		var url = $"https://steamcommunity.com/profiles/{s.SteamId}";
		try { Clipboard.SetText( url ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
		_toast = $"Copied {DisplayName( s )}'s profile link";
		_toastSince = 0f;
		StateHasChanged();
	}

	// Copy the previous bounty winner's Steam profile link (same as CopyProfile, but
	// the winner is carried on the bounty record, not a Standing). No-op when the
	// settled bounty had no winner (empty window → no steamid).
	void CopyPrevWinner()
	{
		var sid = _prevBounty?.WinnerSteamId;
		if ( string.IsNullOrEmpty( sid ) ) return;
		var url = $"https://steamcommunity.com/profiles/{sid}";
		try { Clipboard.SetText( url ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
		var who = string.IsNullOrEmpty( _prevBounty.WinnerName ) ? "winner" : _prevBounty.WinnerName;
		_toast = $"Copied {who}'s profile link";
		_toastSince = 0f;
		StateHasChanged();
	}

	// Discord invite (shared with the rotaliate family). No in-game URL-open API, so
	// the how-to popup's blurb copies it to the clipboard instead. DiscordInvite is the
	// short display form; DiscordUrl is what lands on the clipboard.
	const string DiscordInvite = "discord.gg/GG8HWUfFpD";
	const string DiscordUrl = "https://discord.gg/GG8HWUfFpD";

	// Copy the Discord invite and flash the confirmation toast (visible above the
	// how-to popup — see the toast's z-index).
	void CopyDiscord()
	{
		try { Clipboard.SetText( DiscordUrl ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
		_toast = "Copied the Discord invite — see you there!";
		_toastSince = 0f;
		StateHasChanged();
	}

	// Copy the active bounty's CS2 inspect link to the clipboard so the player can
	// paste it into the game and inspect the exact skin. No-op when there's no link.
	void CopyInspect()
	{
		if ( string.IsNullOrWhiteSpace( _inspectLink ) ) return;
		try { Clipboard.SetText( _inspectLink ); }
		catch ( Exception e ) { Log.Warning( $"[Splitclicker] clipboard copy failed: {e.Message}" ); }
		_toast = "Copied the bounty's inspect link";
		_toastSince = 0f;
		StateHasChanged();
	}

	static bool IsMe( Standing s )
	{
		var tag = ClickController.Instance?.Tag;
		return !string.IsNullOrEmpty( tag ) && s.Tag == tag;
	}

	// Anticheat status dot class for a board row: green when live (the default),
	// yellow on a cooldown, red when ignored for the bounty. Empty/unknown ⇒ live.
	static string StatusClass( Standing s ) => s?.Status switch
	{
		"cooldown" => "cooldown",
		"ignored" => "ignored",
		_ => "live",
	};

	// Show the player's name (claimed username or Steam display name, resolved
	// server-side), never the opaque hex tag.
	static string DisplayName( Standing s ) =>
		!string.IsNullOrWhiteSpace( s.Username ) ? s.Username : "anonymous";

	// The "current leader" for the skin caption: the sessions-won #1, falling back
	// to the live session leader, then to a placeholder when nobody has scored.
	string SkinLeaderName()
	{
		if ( _sessionsWon.Count > 0 ) return DisplayName( _sessionsWon[0] );
		var session = SessionEntries;
		if ( session.Count > 0 ) return DisplayName( session[0] );
		return "Nobody";
	}

	// Whether a winner-lock time is configured at all (0 = unset → hide countdown).
	bool HasWinnerLock => _winnerLockMs > 0;

	static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

	// True once the winner-lock instant has passed (config loaded and now ≥ it).
	bool WinnerLockPassed() => HasWinnerLock && NowMs() >= _winnerLockMs;

	// Time left until the winner locks in, as HH:MM:SS (clamped at zero). Pure
	// integer math on epoch-ms — no DateTime parsing (sandbox-safe).
	string WinnerCountdown()
	{
		long left = _winnerLockMs - NowMs();
		if ( left < 0 ) left = 0;
		long s = left / 1000;
		return $"{s / 3600:D2}:{(s % 3600) / 60:D2}:{s % 60:D2}";
	}

	// Time left on the active anticheat sanction (cooldown end, or the bounty resolve
	// time for "ignored"), as HH:MM:SS clamped at zero — same integer epoch-ms math
	// as the winner countdown (sandbox-safe).
	string SanctionCountdown()
	{
		long left = ( C?.SanctionUntilMs ?? 0 ) - NowMs();
		if ( left < 0 ) left = 0;
		long s = left / 1000;
		return $"{s / 3600:D2}:{(s % 3600) / 60:D2}:{s % 60:D2}";
	}

	// A click on a board button, addressed by its slot id (NOT a captured button
	// object): resolve the button currently live at that slot and score it via its nonce.
	// Slots are never reused within a round, so a stale render-closure firing an old slot
	// simply resolves to null and no-ops here — it can never send a consumed nonce at a
	// button that's already been claimed/replaced. No teleport; the board is server-placed.
	void OnPressSlot( ushort slot )
	{
		var btn = ClickController.Instance?.FindButton( slot );
		if ( btn == null )
			return; // already claimed/replaced (or a stale closure) — clean miss, nothing sent

		ClickController.Instance?.SendButtonClick( btn );
		StateHasChanged();
	}

	// ── opponent pips (read from ClickController, rendered + faded by CSS) ──
	static readonly List<ClickController.PipButton> NoPips = new();
	IReadOnlyList<ClickController.PipButton> PipsToShow() => ClickController.Instance?.ActivePips ?? NoPips;

	// ── opponent cursors (read from ClickController, rendered as labelled dots) ──
	static readonly List<ClickController.CursorDot> NoCursors = new();
	IReadOnlyList<ClickController.CursorDot> CursorsToShow() => ClickController.Instance?.Cursors ?? NoCursors;

	// A cheap hash of the live board + opponent cursors so BuildHash re-renders when a
	// button is claimed/spawned or a cursor moves (positions quantized to ~box-% steps).
	int BoardHash()
	{
		var c = C;
		if ( c == null ) return 0;
		var h = new HashCode();
		h.Add( c.LiveButtons.Count );
		foreach ( var b in c.LiveButtons ) h.Add( b.Slot );
		h.Add( c.Cursors.Count );
		foreach ( var cur in c.Cursors )
		{
			h.Add( (int)(cur.X * 200f) );
			h.Add( (int)(cur.Y * 200f) );
		}
		return h.ToHashCode();
	}

	// Normalized −1..1 position → box left/top percentage (0..100), clamped so an
	// off-screen pip pins to the box edge rather than overflowing. Integer math keeps
	// the inline style locale-safe (no culture-sensitive float formatting).
	static int PipPct( float n ) => (int)Math.Clamp( ( n + 1f ) / 2f * 100f, 0f, 100f );

	// The live clicks-remaining counter, floored at 0 (a late tick can't show negative).
	static int RemainingShown( ClickController c ) => Math.Max( 0, c.RemainingThisRound );

	// ── music panel toggle ──
	// The settings board is the Skafinity library's own SkafinityMusicPanel (the green
	// drop-in), mounted on its OWN ScreenPanel GameObject — a PanelComponent can't be a
	// sibling child of this HUD's single root (see CLAUDE.md ScreenPanel note). It owns its
	// own opener (the wide "MUSIC" bar — ShowFab/WideFab/FabLabel in the scene) and its own
	// close, so the HUD never toggles it; it only READS IsOpen to stop painting the game
	// while the board is up. The optional inspector ref is just a fast-path; we fall back to
	// a scene lookup so a blank field can't leave the HUD blind to the board's state.
	[Property] public SkafinityMusicPanel MusicBoard { get; set; }

	SkafinityMusicPanel Board => MusicBoard ??= Scene?.GetAllComponents<SkafinityMusicPanel>().FirstOrDefault();

	// "Open" = the panel's board is showing. MusicOpen feeds BuildHash, so the panel's own
	// open/close (which flips IsOpen) re-renders the HUD and shows/hides the game beneath it.
	bool MusicOpen => Board?.IsOpen ?? false;

	string PhaseText() => C?.Phase switch
	{
		GamePhase.Armed => "CLICK!",
		GamePhase.Pending => "WAIT…",
		GamePhase.Waiting => "STAND BY…",
		GamePhase.Result => "ROUND OVER",
		GamePhase.GameOver => "GAME OVER",
		GamePhase.Disconnected => "RECONNECTING…",
		GamePhase.Connecting => "CONNECTING…",
		_ => "",
	};

	string PhaseClass() => C?.Phase switch
	{
		GamePhase.Armed => "armed",
		GamePhase.Disconnected => "offline",
		_ => "",
	};

	// Include the seconds-remaining so the countdown re-renders as it ticks, and
	// ToastVisible so the copied-link toast appears and clears itself on time.
	protected override int BuildHash() => HashCode.Combine(
		HashCode.Combine( C?.Phase ?? GamePhase.Connecting, C?.Round ?? 0, C?.Of ?? 0,
			C?.Players ?? 0, C?.ClicksToWin ?? 0, C?.ClicksSent ?? 0 ),
		HashCode.Combine( SessionEntries.Count, _hourly.Count, _hourlyLoaded, _hoursWon.Count, _hoursWonLoaded,
			C?.ArmMaxSec ?? 0, C?.Tag, (int)(DateTime.UtcNow - DateTime.UtcNow.Date).TotalSeconds ),
		HashCode.Combine( _sessionsWon.Count, _sessionsWonLoaded, ToastVisible, SkinLeaderName(), MusicOpen,
			_skinUrl, _winnerLockMs, HashCode.Combine( WinnerLockPassed(), _hasBounty ) ),
		HashCode.Combine( _allTime.Count, _allTimeLoaded,
			C?.HasTest ?? false, C?.TestPrompt ),
		_howtoOpen,
		// sanction overlay state (the per-second countdown tick is already covered by
		// the seconds term in the second combine above); plus the parked/away state — and
		// the deferred park-pending / resuming flags — so the PAUSE bar + parked overlay
		// repaint the instant any of them flips.
		HashCode.Combine( C?.TestMessage, C?.SanctionState, C?.SanctionUntilMs,
			C?.Parked ?? false, C?.ParkPending ?? false, C?.Resuming ?? false ),
		// round-scores flash: FlashVisible flips the panel off on time; round + count
		// rebuild it when a new round's scores come up.
		HashCode.Combine( FlashVisible, _flashRound, _flashWinners.Count ),
		// live-window tick: the descending counter (changes ~tick-rate while armed) and
		// the on-screen pip count (a pip spawning/expiring changes it) drive re-render;
		// plus the previous-winner panel state (which bounty, and whether its skin has
		// resolved) so it repaints when a rollover/refresh loads a new one.
		HashCode.Combine( C?.RemainingThisRound ?? 0, PipsToShow().Count,
			_prevBounty?.Id ?? 0L, _prevSkin != null, _prevSkinUrl, BoardHash() ) );
}