Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+566 -114
View File
@@ -6,7 +6,14 @@
import { goto } from '$app/navigation';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import type { MatchHistoryResponse } from '$lib/match-history.js';
import { matchApiParams, matchHistoryHref, matchPageSize, readMatchQuery, type MatchHistoryQuery, type MatchSort } from '$lib/match-history-query.js';
import {
matchApiParams,
matchHistoryHref,
matchPageSize,
readMatchQuery,
type MatchHistoryQuery,
type MatchSort
} from '$lib/match-history-query.js';
import { matchInstanceLabel } from '$ui/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
@@ -22,7 +29,7 @@
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
let retry = $state(0);
let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let checkedAt = $state<number | null>(untrack(() => (initial ? Date.now() : null)));
let tableRegion = $state<HTMLDivElement>();
let pendingUrl: URL | undefined;
let navigationId = 0;
@@ -30,32 +37,60 @@
const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url);
params.set('limit', String(matchPageSize));
const endpoint = server === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(server)}/matches`;
const endpoint =
server === undefined
? '/api/matches'
: `/api/servers/${encodeURIComponent(server)}/matches`;
return `${endpoint}?${params}`;
});
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
const filterCount = $derived([filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length);
const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status));
const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }];
let loadedUrl = $state(untrack(() => (initial ? apiUrl : '')));
const filterCount = $derived(
[filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length
);
const hasFilters = $derived(
!!(filters.game || filters.map || filters.q || filters.days || filters.status)
);
const sorts: Array<{ key: MatchSort; label: string }> = [
{ key: 'started', label: 'Started' },
{ key: 'players', label: 'Players' },
{ key: 'duration', label: 'Duration' },
{ key: 'round', label: 'Round' }
];
$effect(() => { search = filters.q; });
$effect(() => {
search = filters.q;
});
$effect(() => {
const report = initial;
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
if (report)
untrack(() => {
data = report;
loadedUrl = apiUrl;
loading = false;
error = '';
checkedAt = Date.now();
});
});
$effect(() => {
const url = apiUrl;
retry;
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
return pollVisible(async signal => {
void retry;
untrack(() => {
if (loadedUrl !== url) loading = true;
error = '';
});
return pollVisible(async (signal) => {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load match history.');
const body: MatchHistoryResponse = await response.json();
if (signal.aborted || url !== apiUrl) return;
// Do not move keyboard users to another match while they are following a link.
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
loadedUrl = url; loading = false; error = ''; checkedAt = Date.now();
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement))
data = body;
loadedUrl = url;
loading = false;
error = '';
checkedAt = Date.now();
} catch (cause) {
if (signal.aborted || url !== apiUrl) return;
error = cause instanceof Error ? cause.message : 'Could not load match history.';
@@ -69,130 +104,547 @@
pendingUrl = new URL(href, $page.url);
const id = ++navigationId;
void goto(href, { noScroll: true, keepFocus: true })
.catch(() => { error = 'Could not update the match filters. Please try again.'; })
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
.catch(() => {
error = 'Could not update the match filters. Please try again.';
})
.finally(() => {
if (id === navigationId) pendingUrl = undefined;
});
}
function sort(key: MatchSort) {
update({
sort: key,
direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc'
});
}
function reset() {
update({ game: '', map: '', q: '', days: '', status: '' });
}
function sort(key: MatchSort) { update({ sort: key, direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc' }); }
function reset() { update({ game: '', map: '', q: '', days: '', status: '' }); }
</script>
<section class="history" aria-label="Match history">
{#snippet filterFields(suffix: string)}
<div class="filters">
{#if server === undefined}<label for={`match-game${suffix}`}>Game<select id={`match-game${suffix}`} value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>{/if}
<label for={`match-map${suffix}`}>Map<select id={`match-map${suffix}`} value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
</select></label>
{#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
<label for={`match-search${suffix}`}>Server<input id={`match-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Find a server…" aria-describedby={`match-search-help${suffix}`} /></label>
<span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
</form>{/if}
<label for={`match-days${suffix}`}>Date range<select id={`match-days${suffix}`} value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
<label for={`match-status${suffix}`}>Status<select id={`match-status${suffix}`} value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
{#if server === undefined}<label for={`match-game${suffix}`}
>Game<select
id={`match-game${suffix}`}
value={filters.game}
onchange={(event) => update({ game: event.currentTarget.value, map: '' })}
><option value="">All games</option><option value="t5">Black Ops I</option
><option value="t6">Black Ops II</option></select
></label
>{/if}
<label for={`match-map${suffix}`}
>Map<select
id={`match-map${suffix}`}
value={filters.map}
onchange={(event) => update({ map: event.currentTarget.value })}
>
<option value="">All maps</option>
{#if filters.map && !data?.maps.some((map) => map.key === filters.map)}<option
value={filters.map}>{filters.map}</option
>{/if}
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}
>{map.name}{!filters.game && server === undefined
? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}`
: ''}</option
>{/each}
</select></label
>
{#if server === undefined}<form
role="search"
onsubmit={(event) => {
event.preventDefault();
update({ q: search.trim() });
if (suffix) filtersOpen = false;
}}
>
<label for={`match-search${suffix}`}
>Server<input
id={`match-search${suffix}`}
type="search"
bind:value={search}
maxlength="100"
placeholder="Find a server…"
aria-describedby={`match-search-help${suffix}`}
/></label
>
<span id={`match-search-help${suffix}`} class="sr-only"
>Press Enter to search by server name.</span
>
</form>{/if}
<label for={`match-days${suffix}`}
>Date range<select
id={`match-days${suffix}`}
value={filters.days}
onchange={(event) => update({ days: event.currentTarget.value })}
><option value="0">All time</option><option value="7">Last 7 days</option
><option value="30">Last 30 days</option><option value="90">Last 90 days</option
></select
></label
>
<label for={`match-status${suffix}`}
>Status<select
id={`match-status${suffix}`}
value={filters.status}
onchange={(event) => update({ status: event.currentTarget.value })}
><option value="">All statuses</option><option value="completed"
>Completed</option
><option value="active">In progress</option><option value="interrupted"
>Interrupted</option
></select
></label
>
{#if suffix}
<label for={`match-sort${suffix}`}>Sort by<select id={`match-sort${suffix}`} value={filters.sort} onchange={event => update({ sort: event.currentTarget.value })}>{#each sorts as item}<option value={item.key}>{item.label}</option>{/each}</select></label>
<label for={`match-order${suffix}`}>Order<select id={`match-order${suffix}`} value={filters.direction} onchange={event => update({ direction: event.currentTarget.value })}><option value="desc">Highest or newest first</option><option value="asc">Lowest or oldest first</option></select></label>
<label for={`match-sort${suffix}`}
>Sort by<select
id={`match-sort${suffix}`}
value={filters.sort}
onchange={(event) => update({ sort: event.currentTarget.value })}
>{#each sorts as item}<option value={item.key}>{item.label}</option
>{/each}</select
></label
>
<label for={`match-order${suffix}`}
>Order<select
id={`match-order${suffix}`}
value={filters.direction}
onchange={(event) => update({ direction: event.currentTarget.value })}
><option value="desc">Highest or newest first</option><option value="asc"
>Lowest or oldest first</option
></select
></label
>
{/if}
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<div class="mobile-filters"><span>{data?.total ?? '—'} matches{filters.map ? ` · ${data?.maps.find(map => map.key === filters.map)?.name ?? 'Selected map'}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
<Dialog bind:open={filtersOpen} title="Match filters" description="Choose the matches to show." drawer>
<div class="mobile-filters">
<span
>{data?.total ?? '—'} matches{filters.map
? ` · ${data?.maps.find((map) => map.key === filters.map)?.name ?? 'Selected map'}`
: ''}</span
><Button size="sm" onclick={() => (filtersOpen = true)}
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
>
</div>
<Dialog
bind:open={filtersOpen}
title="Match filters"
description="Choose the matches to show."
drawer
>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button
variant="primary"
onclick={() => (filtersOpen = false)}>Show results</Button
>{/snippet}
</Dialog>
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
<div class="context">
<span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span
>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button
>{/if}{#if !error}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every 10 seconds"
/>{/if}
</div>
{#if error}<div class="error" role="alert">
{error}
<Button size="sm" onclick={() => retry++}>Retry</Button>
</div>{/if}
{#if loading}<EmptyState title="Loading matches…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
{:else if loadedUrl !== apiUrl && error}<EmptyState
title="Match history unavailable"
description="Retry to load this selection."
/>
{:else if data?.matches.length}
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="table-scroll" bind:this={tableRegion} tabindex="0" role="region" aria-label="Recorded matches">
<div
class="table-scroll"
bind:this={tableRegion}
tabindex="0"
role="region"
aria-label="Recorded matches"
>
<table>
<caption class="sr-only">Matches sorted by {sorts.find(item => item.key === filters.sort)?.label.toLowerCase()}, {filters.direction === 'desc' ? 'highest or newest' : 'lowest or oldest'} first</caption>
<thead><tr>
<th scope="col" aria-sort={filters.sort === 'started' ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort('started')}>Started{filters.sort === 'started' ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
<th scope="col">Match</th><th scope="col">Status</th>
{#each sorts.filter(item => item.key !== 'started') as item}
<th scope="col" class="numeric" aria-sort={filters.sort === item.key ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort(item.key)} title={item.key === 'players' ? 'Unique players observed throughout the match' : item.key === 'round' ? 'Final round, or highest observed round for unfinished matches' : 'Recorded duration; unknown durations sort last'} aria-describedby={item.key === 'players' ? 'match-player-help' : undefined}>{item.label}{filters.sort === item.key ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
{/each}
</tr></thead>
<tbody>{#each data.matches as match (match.id)}
<tr>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"><RelativeTime value={match.startedAt} /> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
</div>{/if}
</td>
<td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
<td class="numeric">{match.uniquePlayers}</td>
<td class="numeric"><span title={match.durationMs === null ? 'Duration was not recorded or the match has not completed.' : undefined}>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</span></td>
<td class="numeric">{match.status === 'completed' ? match.finalRound ?? '—' : match.observedRound ?? '—'}{#if match.status !== 'completed'}<small>Highest observed</small>{/if}</td>
</tr>
{/each}</tbody>
<caption class="sr-only"
>Matches sorted by {sorts
.find((item) => item.key === filters.sort)
?.label.toLowerCase()}, {filters.direction === 'desc'
? 'highest or newest'
: 'lowest or oldest'} first</caption
>
<thead
><tr>
<th
scope="col"
aria-sort={filters.sort === 'started'
? filters.direction === 'desc'
? 'descending'
: 'ascending'
: 'none'}
><button onclick={() => sort('started')}
>Started{filters.sort === 'started'
? filters.direction === 'desc'
? ' ↓'
: ' ↑'
: ''}</button
></th
>
<th scope="col">Match</th><th scope="col">Status</th>
{#each sorts.filter((item) => item.key !== 'started') as item}
<th
scope="col"
class="numeric"
aria-sort={filters.sort === item.key
? filters.direction === 'desc'
? 'descending'
: 'ascending'
: 'none'}
><button
onclick={() => sort(item.key)}
title={item.key === 'players'
? 'Unique players observed throughout the match'
: item.key === 'round'
? 'Final round, or highest observed round for unfinished matches'
: 'Recorded duration; unknown durations sort last'}
aria-describedby={item.key === 'players'
? 'match-player-help'
: undefined}
>{item.label}{filters.sort === item.key
? filters.direction === 'desc'
? ' ↓'
: ' ↑'
: ''}</button
></th
>
{/each}
</tr></thead
>
<tbody
>{#each data.matches as match (match.id)}
<tr>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
{#if match.status === 'completed'}<a
class="match-link"
href={contextHref(`/matches/${match.id}`, $page.url)}
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a
>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"
><RelativeTime value={match.startedAt} /> · {match.status ===
'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}{match.durationMs !== null
? ` · ${formatDuration(match.durationMs)}`
: ''}</small
>
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span
>{#if match.server_deleted_at}<span>
· Historical instance</span
>{/if}
</div>{/if}
</td>
<td
><span
class="status"
class:active={match.status === 'active'}
class:interrupted={match.status === 'interrupted'}
>{match.status === 'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}</span
></td
>
<td class="numeric">{match.uniquePlayers}</td>
<td class="numeric"
><span
title={match.durationMs === null
? 'Duration was not recorded or the match has not completed.'
: undefined}
>{match.durationMs === null
? '—'
: formatDuration(match.durationMs)}</span
></td
>
<td class="numeric"
>{match.status === 'completed'
? (match.finalRound ?? '—')
: (match.observedRound ??
'—')}{#if match.status !== 'completed'}<small
>Highest observed</small
>{/if}</td
>
</tr>
{/each}</tbody
>
</table>
</div>
<div class="pagination"><span>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit + data.matches.length} of {data.total} matches</span>
{#if data.total > data.limit}<div><Button size="sm" disabled={data.page <= 1} href={matchHistoryHref($page.url, { page: String(data.page - 1) })}>Previous</Button><Button size="sm" disabled={data.page * data.limit >= data.total} href={matchHistoryHref($page.url, { page: String(data.page + 1) })}>Next</Button></div>{/if}
<div class="pagination">
<span
>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit +
data.matches.length} of {data.total} matches</span
>
{#if data.total > data.limit}<div>
<Button
size="sm"
disabled={data.page <= 1}
href={matchHistoryHref($page.url, { page: String(data.page - 1) })}
>Previous</Button
><Button
size="sm"
disabled={data.page * data.limit >= data.total}
href={matchHistoryHref($page.url, { page: String(data.page + 1) })}
>Next</Button
>
</div>{/if}
</div>
{:else if !error}<EmptyState title={hasFilters ? 'No matches found' : 'No matches recorded yet'} description={hasFilters ? 'Try another game, map, instance, or date range.' : 'Matches appear when instances report gameplay.'}>
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
</EmptyState>{/if}
<span id="match-player-help" class="sr-only">Players counts unique players observed throughout the match.</span>
<details class="about"><summary>About match history</summary><p>Players counts unique players across all reports. A dash means the value is unavailable, not zero. Duration requires a recorded start and completed ending. Round shows the final round for completed matches and the highest observed round for unfinished matches. Open a completed match for exact timestamps and starting and ending player counts.</p></details>
{:else if !error}<EmptyState
title={hasFilters ? 'No matches found' : 'No matches recorded yet'}
description={hasFilters
? 'Try another game, map, instance, or date range.'
: 'Matches appear when instances report gameplay.'}
>
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}
>Clear filters</Button
>{/if}{/snippet}
</EmptyState>{/if}
<span id="match-player-help" class="sr-only"
>Players counts unique players observed throughout the match.</span
>
<details class="about">
<summary>About match history</summary>
<p>
Players counts unique players across all reports. A dash means the value is unavailable,
not zero. Duration requires a recorded start and completed ending. Round shows the final
round for completed matches and the highest observed round for unfinished matches. Open
a completed match for exact timestamps and starting and ending player counts.
</p>
</details>
</section>
<style>
.history { display: grid; gap: var(--space-4); min-width: 0; }
.filters { display: flex; align-items: flex-start; flex-wrap: wrap; gap: var(--space-3); }
label, form { flex: 1; min-width: 145px; }
label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); }
input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
summary { cursor: pointer; color: var(--text-secondary); }
.context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.pagination > div { display: flex; gap: var(--space-2); }
.table-scroll { position: relative; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
th { color: var(--text-secondary); font-size: var(--text-xs); background: var(--surface-sunken); }
th button, .text-button { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; padding: 4px 0; }
.text-button { color: var(--accent-primary-hover); }
td { font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover, tbody tr:focus-within { background: var(--surface-raised); }
.numeric { text-align: right; }
.match { min-width: 200px; white-space: normal; }
.match-link { color: var(--accent-primary-hover); text-decoration: none; }
.match-link, .match-name { font-weight: var(--weight-medium); }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
.status { color: var(--text-secondary); }
.status.active { color: var(--accent-primary-hover); }
.status.interrupted { color: var(--state-warning); }
.about { font-size: var(--text-xs); color: var(--text-secondary); }
.about p { max-width: 90ch; line-height: 1.6; }
.error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.mobile-filters, .mobile-match-context { display: none; }
.history {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.filters {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
}
label,
form {
flex: 1;
min-width: 145px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
input,
select {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
}
summary {
cursor: pointer;
color: var(--text-secondary);
}
.context,
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.pagination > div {
display: flex;
gap: var(--space-2);
}
.table-scroll {
position: relative;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.table-scroll:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
font-size: var(--text-sm);
}
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
th {
color: var(--text-secondary);
font-size: var(--text-xs);
background: var(--surface-sunken);
}
th button,
.text-button {
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
padding: 4px 0;
}
.text-button {
color: var(--accent-primary-hover);
}
td {
font-variant-numeric: tabular-nums;
}
tr:last-child td {
border-bottom: 0;
}
tbody tr:hover,
tbody tr:focus-within {
background: var(--surface-raised);
}
.numeric {
text-align: right;
}
.match {
min-width: 200px;
white-space: normal;
}
.match-link {
color: var(--accent-primary-hover);
text-decoration: none;
}
.match-link,
.match-name {
font-weight: var(--weight-medium);
}
a:hover {
text-decoration: underline;
}
a:focus-visible,
button:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.instance,
small {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.status {
color: var(--text-secondary);
}
.status.active {
color: var(--accent-primary-hover);
}
.status.interrupted {
color: var(--state-warning);
}
.about {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.about p {
max-width: 90ch;
line-height: 1.6;
}
.error {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--state-warning);
font-size: var(--text-sm);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.mobile-filters,
.mobile-match-context {
display: none;
}
@media (max-width: 640px) {
.desktop-filters { display: none; }
.mobile-match-context { display: block; }
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
.filters { display: grid; grid-template-columns: minmax(0, 1fr); }
.filters > label, .filters > form { min-width: 0; }
.context { gap: var(--space-1) var(--space-2); }
th, td { padding: 10px 12px; }
th:nth-child(1), td:nth-child(1) { display: none; }
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
.match { min-width: 130px; }
.desktop-filters {
display: none;
}
.mobile-match-context {
display: block;
}
.mobile-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-sm);
}
.filters {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.filters > label,
.filters > form {
min-width: 0;
}
.context {
gap: var(--space-1) var(--space-2);
}
th,
td {
padding: 10px 12px;
}
th:nth-child(1),
td:nth-child(1) {
display: none;
}
th:nth-child(3),
td:nth-child(3),
th:nth-child(5),
td:nth-child(5) {
display: none;
}
.match {
min-width: 130px;
}
}
</style>