Focus player records and add squad leaderboards

This commit is contained in:
2026-10-07 19:14:33 -04:00
parent bab78038dc
commit bcecc42dd1
22 changed files with 569 additions and 430 deletions
+100 -93
View File
@@ -12,15 +12,17 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RecordSource from '$ui/components/RecordSource.svelte';
const number = new Intl.NumberFormat('en-US');
const pageSize = leaderboardPageSize;
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }];
const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed'];
const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'gamesPlayed'];
const squadSizes = [{ key: '1', label: 'Solo' }, { key: '2', label: 'Duo' }, { key: '3', label: 'Trio' }, { key: '4', label: 'Four players' }];
const descriptions: Record<PlayerStatMetric, string> = {
highestRound: 'The highest round reached in a recorded game.',
highestRound: 'The highest recorded round, including players who joined mid-game.',
kills: 'Cumulative kills across recorded games.',
roundsSurvived: 'Round progression during observed participation, added across games.',
headshots: 'Cumulative headshots across recorded games.',
@@ -41,9 +43,10 @@
let loading = $state(false);
let error = $state('');
let search = $state(untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100)));
let checkedAt = $state<Date | null>(null);
let checkedAt = $state<Date | null>(untrack(() => initial.leaderboard ? new Date() : null));
let pendingUpdate = $state<LeaderboardResponse | null>(null);
let expandedPlayers = $state<string[]>([]);
let filtersOpen = $state(false);
let showAllStats = $state(false);
let tableRegion = $state<HTMLDivElement>();
let controller: AbortController | undefined;
let requestId = 0;
@@ -53,6 +56,8 @@
const game = $derived(games.some(item => item.key === $page.url.searchParams.get('game')) ? $page.url.searchParams.get('game')! : '');
const map = $derived($page.url.searchParams.get('map') ?? '');
const players = $derived(squadSizes.some(size => size.key === $page.url.searchParams.get('players')) ? $page.url.searchParams.get('players')! : '');
const playersLabel = $derived(squadSizes.find(size => size.key === players)?.label ?? 'All squad sizes');
const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100));
const metric = $derived(leaderboardMetrics.find(item => item.key === $page.url.searchParams.get('metric'))?.key ?? 'highestRound');
const offset = $derived(Math.max(0, Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0))));
@@ -60,11 +65,13 @@
const mapName = $derived(maps.find(item => item.key === map)?.name);
const scope = $derived(mapName ?? (game ? games.find(item => item.key === game)!.label : 'All games & maps'));
const metricLabel = $derived(leaderboardMetrics.find(item => item.key === metric)!.label);
const columns = $derived([metric, ...defaultColumns.filter(key => key !== metric)]);
const columns = $derived([metric, ...(showAllStats ? playerStatMetrics.map(item => item.key) : defaultColumns).filter(key => key !== metric)]);
const filterCount = $derived([game, map, players, query].filter(Boolean).length);
const apiUrl = $derived.by(() => {
const params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) });
if (game) params.set('game', game);
if (map) params.set('map', map);
params.set('map', map);
if (players) params.set('players', players);
if (query) params.set('q', query);
return `/api/leaderboard?${params}`;
});
@@ -76,8 +83,8 @@
const report = initial.leaderboard;
untrack(() => {
++requestId; controller?.abort(); data = report; maps = report.maps;
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = null;
pendingUpdate = null; expandedPlayers = []; updates.reset(apiUrl, report);
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = new Date();
pendingUpdate = null; updates.reset(apiUrl, report);
});
});
@@ -107,8 +114,7 @@
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
const result: LeaderboardResponse = await response.json();
if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return;
const inspecting = expandedPlayers.length > 0 || !!tableRegion?.contains(document.activeElement)
|| !!tableRegion?.querySelector('details[open]');
const inspecting = !!tableRegion?.contains(document.activeElement);
const next = updates.receive(url, result, inspecting);
if (!next) return;
data = next.current;
@@ -126,25 +132,18 @@
}
function applyUpdate() {
expandedPlayers = [];
tableRegion?.querySelectorAll('details[open]').forEach(detail => detail.removeAttribute('open'));
data = updates.apply();
maps = data.maps;
pendingUpdate = null;
}
function togglePlayer(guid: string) {
expandedPlayers = expandedPlayers.includes(guid)
? expandedPlayers.filter(value => value !== guid) : [...expandedPlayers, guid];
}
function updateFilters(changes: Record<string, string>) {
function updateFilters(changes: Record<string, string | null>) {
++requestId; controller?.abort(); loading = false;
// Keep rapid changes together while the preceding navigation settles.
const url = new URL(pendingUrl ?? $page.url);
url.searchParams.delete('offset');
for (const [key, value] of Object.entries(changes)) {
if (value) url.searchParams.set(key, value);
if (value || key === 'map' && value !== null) url.searchParams.set(key, value);
else url.searchParams.delete(key);
}
pendingUrl = url;
@@ -156,7 +155,7 @@
function clearFilters() {
search = '';
updateFilters({ game: '', map: '', q: '' });
updateFilters({ game: '', map: null, players: '', q: '' });
}
</script>
@@ -166,40 +165,49 @@
</svelte:head>
<section class="page">
<SectionHeader title="Leaderboard" subtitle="All-time player records.">
{#snippet actions()}
<Button size="sm" loading={loading} disabled={changingFilters} onclick={() => void refresh(apiUrl)}>Refresh</Button>
{/snippet}
<SectionHeader title="Leaderboard" subtitle="Records by map and starting squad.">
{#snippet actions()}<div class="mobile-filter-trigger"><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>{/snippet}
</SectionHeader>
<div class="filters">
<div class="field"><label for="leaderboard-game">Game</label>
<select id="leaderboard-game" value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: '' })}>
<option value="">All games</option>
{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
{#snippet filterFields(suffix: string)}
<div class="filters">
<div class="field"><label for={`leaderboard-game${suffix}`}>Game</label>
<select id={`leaderboard-game${suffix}`} value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: null })}>
<option value="">All games</option>{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
</div>
<div class="field"><label for={`leaderboard-map${suffix}`}>Map</label>
<select id={`leaderboard-map${suffix}`} value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if}
{#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
</select>
</div>
<div class="field"><label for={`leaderboard-players${suffix}`}>Players</label>
<select id={`leaderboard-players${suffix}`} value={players} onchange={event => updateFilters({ players: event.currentTarget.value })} aria-describedby={`players-help${suffix}`}>
<option value="">All squad sizes</option>{#each squadSizes as size}<option value={size.key}>{size.label}</option>{/each}
</select><span id={`players-help${suffix}`} class="sr-only">Players recorded at the start of each match. Matches with unknown starting counts appear only under All squad sizes.</span>
</div>
<div class="field"><label for={`leaderboard-metric${suffix}`}>Rank by</label>
<select id={`leaderboard-metric${suffix}`} value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
</div>
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); if (suffix) filtersOpen = false; }} role="search">
<label for={`leaderboard-search${suffix}`}>Find a player</label>
<input id={`leaderboard-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" aria-describedby={`search-help${suffix}`} /><span id={`search-help${suffix}`} class="sr-only">Press Enter to search. Player ranks are preserved.</span>
</form>
</div>
<div class="field"><label for="leaderboard-map">Map</label>
<select id="leaderboard-map" value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if}
{#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game.toUpperCase()}` : ''}</option>{/each}
</select>
</div>
<div class="field"><label for="leaderboard-metric">Rank by</label>
<select id="leaderboard-metric" value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
</div>
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search">
<label for="leaderboard-search">Find a player</label>
<input id="leaderboard-search" type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" aria-describedby="search-help" /><span id="search-help" class="sr-only">Press Enter to search. Player ranks are preserved.</span>
</form>
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<Dialog bind:open={filtersOpen} title="Leaderboard filters" description="Choose a map, starting squad size, and ranking." drawer>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={clearFilters}>Reset filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
</Dialog>
<div class="scope-row">
<div><span class="scope">{scope}</span><span class="all-time">All time</span></div>
{#if game || map || query}<button class="text-button" onclick={clearFilters}>Clear filters</button>{/if}
<div><span class="scope">{scope}</span><span class="all-time">{playersLabel}</span><span class="all-time">All time</span></div>
{#if game || players || query}<button class="text-button" onclick={clearFilters}>Reset filters</button>{/if}
</div>
{#if error}
@@ -215,13 +223,13 @@
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Unique players</small></div>
</section>
<section class="rankings" aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
<section class="rankings" class:show-all-stats={showAllStats} aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
<div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2>
{#if isEfficiencyMetric(metric)}<p>{efficiencyRequirement(metric)} within the selected game and map.</p>{/if}
<details class="metric-help"><summary>About this ranking</summary><p>{description(metric)} Equal values share a rank.</p></details>
<p class="metric-description">{description(metric)} Equal values share a rank.</p>
</div>
<div class="refresh-status" role="status">{changingFilters ? 'Updating filters…' : loading ? 'Checking…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : 'Checks every 15 seconds'}</div>
<div class="ranking-controls"><label class="all-stats"><input type="checkbox" bind:checked={showAllStats} /> Show all stats</label>{#if !error}<Freshness updatedAt={checkedAt?.getTime() ?? null} intervalLabel="Updates every 15 seconds" />{/if}</div>
</div>
{#if pendingUpdate}<div class="pending-update" role="status"><button class="text-button" onclick={applyUpdate} disabled={changingFilters}>Updated rankings available — apply update</button></div>{/if}
{#if data.entries.length}
@@ -244,7 +252,7 @@
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player">
<a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{entry.name}</a>
<button class="text-button" aria-expanded={expandedPlayers.includes(entry.guid)} aria-controls={`player-details-${entry.guid}`} onclick={() => togglePlayer(entry.guid)}>{expandedPlayers.includes(entry.guid) ? 'Hide details' : 'Details'}<span class="sr-only"> for {entry.name}</span></button>
</th>
{#each columns as key (key)}
<td class="stat" class:selected={key === metric}>
@@ -252,24 +260,12 @@
{#if isEfficiencyMetric(key)}
<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>
{/if}
{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}
<small class="record-map">{entry.records[key].mapName}</small>
<details class="record-details">
<summary>Record details<span class="sr-only"> for {entry.name}</span></summary>
<RecordSource evidence={entry.records[key]} disclosure={false} returnTo={`${$page.url.pathname}${$page.url.search}`} />
</details>
{#if key === metric && (key === 'highestRound' || key === 'bestKills' || key === 'bestScore')}
<RecordSource evidence={entry.records[key]} metric={metricLabel} returnTo={`${$page.url.pathname}${$page.url.search}`} />
{/if}
</td>
{/each}
</tr>
{#if expandedPlayers.includes(entry.guid)}
<tr class="player-details" id={`player-details-${entry.guid}`}><td colspan={columns.length + 2}>
<div class="identity">GUID <CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
<dl>{#each playerStatMetrics.filter(item => !columns.includes(item.key)) as stat (stat.key)}
<div><dt>{stat.label}</dt><dd>{formatValue(entry, stat.key)}</dd></div>
{/each}</dl>
</td></tr>
{/if}
{/each}
</tbody>
</table>
@@ -282,9 +278,9 @@
<EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}>
{#snippet actions()}<Button size="sm" onclick={() => updateFilters({ metric: 'highestRound' })}>Rank by highest round</Button>{/snippet}
</EmptyState>
{:else if query || game || map || offset}
<EmptyState spacious title="No players found" description="Try a different game, map, or player name.">
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
{:else if query || game || map || players || offset}
<EmptyState spacious title="No players found" description="Try a different game, map, starting squad size, or player name.">
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Reset filters</Button>{/snippet}
</EmptyState>
{:else}
<EmptyState spacious title="Your first records start here" description="Player stats appear after a game ends, once the player has been observed at round 2 or later." />
@@ -294,11 +290,12 @@
<div class="loading" role="status">Loading player records…</div>
{/if}
{#if data}<details class="mobile-totals"><summary>Selection totals</summary><dl><div><dt>Highest round</dt><dd>{number.format(data.summary.highestRound)}</dd></div><div><dt>Total kills</dt><dd>{number.format(data.summary.kills)}</dd></div><div><dt>Total rounds</dt><dd>{number.format(data.summary.roundsSurvived)}</dd></div><div><dt>Games tracked</dt><dd>{number.format(data.summary.matches)}</dd></div><div><dt>Players</dt><dd>{number.format(data.summary.players)}</dd></div></dl></details>{/if}
<details class="tracking-notes">
<summary>How these records work</summary>
<div>
<p>Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name changes keep a player's records together. The latest reported name across both games appears here and on the player's profile, regardless of filters.</p>
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game and map filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p>
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game, map, and starting squad-size filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p>
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
@@ -308,24 +305,18 @@
<style>
.overview { display: grid; grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.overview > div { display: flex; flex-direction: column; justify-content: center; gap: var(--space-2); padding: 24px; border-right: 1px solid var(--border-subtle); min-width: 0; }
.overview > div { display: flex; flex-direction: column; justify-content: center; gap: var(--space-1); padding: var(--space-4); border-right: 1px solid var(--border-subtle); min-width: 0; }
.overview > div:last-child { border-right: 0; }
.overview span, .overview small { color: var(--text-secondary); font-size: var(--text-xs); }
.overview strong { font-size: clamp(28px, 3vw, 40px); line-height: 1.15; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; }
.overview .record { background: linear-gradient(125deg, color-mix(in srgb, var(--accent-primary) 16%, transparent), transparent); }
.record strong { color: var(--accent-primary-hover); font-size: clamp(36px, 4vw, 52px); }
.metric-help, .record-details { color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); margin-top: 4px; white-space: normal; }
.overview strong { font-size: var(--text-xl); line-height: 1.15; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; }
.overview .record { background: var(--surface-raised); }
.record strong { color: var(--text-primary); font-size: var(--text-2xl); }
summary { cursor: pointer; }
.metric-help p { margin-top: 8px; }
.record-map { display: block; margin-top: 4px; color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); white-space: normal; }
.pending-update { padding: 0 var(--space-5) var(--space-3); }
.player-details td { background: var(--surface-sunken); }
.player-details dl { display: flex; flex-wrap: wrap; gap: 16px 32px; margin: 12px 0 0; }
.player-details dt { color: var(--text-secondary); font-size: var(--text-xs); }
.player-details dd { margin: 4px 0 0; font-variant-numeric: tabular-nums; }
.text-button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.page { display: flex; flex-direction: column; gap: var(--space-5); }
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); }
.filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
.search { grid-column: 1 / -1; }
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
label { color: var(--text-secondary); font-size: var(--text-xs); }
input, select, button { font: inherit; }
@@ -340,21 +331,20 @@
.rankings-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); padding: var(--space-5); }
h2 { margin: 0 0 var(--space-1); font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
.rankings-header p { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); max-width: 70ch; }
.refresh-status { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; text-align: right; }
.table-scroll { max-width: 100%; overflow-x: auto; }
.table-scroll { position: relative; max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); }
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); }
thead th { background: var(--surface-sunken); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
.rank { width: 65px; text-align: center; color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.rank span { display: inline-grid; min-width: 30px; height: 30px; place-items: center; }
.rank .podium { border-radius: var(--radius-sm); color: var(--accent-primary-hover); background: color-mix(in srgb, var(--accent-primary) 12%, transparent); font-weight: var(--weight-semibold); }
.rank .podium { border-radius: var(--radius-sm); color: var(--text-primary); background: var(--surface-raised); font-weight: var(--weight-semibold); }
.player { position: sticky; left: 0; z-index: 1; min-width: 180px; max-width: 320px; text-align: left; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.stat { text-align: right; white-space: nowrap; }
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.stat.selected { color: var(--accent-primary-hover); }
td.stat { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.stat.selected { color: var(--text-primary); }
th.stat.selected { color: var(--accent-primary-hover); }
.sample { display: block; max-width: 22rem; margin-top: var(--space-2); white-space: normal; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; color: var(--text-secondary); }
td.selected { font-weight: var(--weight-semibold); }
th button { display: inline-flex; gap: 6px; align-items: center; background: transparent; border: 0; padding: 3px 0; cursor: pointer; color: inherit; white-space: nowrap; }
@@ -370,6 +360,13 @@
.tracking-notes > div { max-width: 100ch; }
.tracking-notes p { margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.ranking-controls { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.all-stats { display: flex; align-items: center; gap: var(--space-1); white-space: nowrap; font-size: var(--text-xs); }
.all-stats input { width: 16px; height: 16px; min-height: 0; accent-color: var(--accent-primary); }
.mobile-filter-trigger, .mobile-totals { display: none; }
.mobile-totals { font-size: var(--text-xs); color: var(--text-secondary); }
.mobile-totals dl { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.mobile-totals dd { margin: var(--space-1) 0 0; color: var(--text-primary); font-variant-numeric: tabular-nums; }
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 800px) {
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -382,10 +379,20 @@
.page { gap: var(--space-4); }
.filters { gap: var(--space-3); }
.search { grid-column: 1 / -1; }
.filters > .field:nth-child(3) { grid-column: 1 / -1; }
.desktop-filters, .overview { display: none; }
.mobile-filter-trigger, .mobile-totals { display: block; }
.scope-row { font-size: var(--text-sm); }
.scope-row > div { gap: var(--space-1) var(--space-2); }
.all-time { padding-left: var(--space-2); }
.filters { grid-template-columns: minmax(0, 1fr); }
.ranking-controls { justify-content: flex-start; }
.metric-description { display: none; }
.rankings:not(.show-all-stats) .stat:not(.selected) { display: none; }
.rank { width: 36px; }
.rank span { min-width: 20px; }
.player { position: static; min-width: 90px; max-width: 140px; }
.selected :global(.record-source) { max-width: 150px; }
.rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); }
.refresh-status { text-align: left; }
.player { min-width: 180px; max-width: 220px; }
th, td { padding: var(--space-3); }
.pagination { padding: var(--space-4); }
}