Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -2,7 +2,13 @@
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
|
||||
import { metricPage, compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
|
||||
import {
|
||||
metricPage,
|
||||
compareMetricValues,
|
||||
metricDuration,
|
||||
metricNumber,
|
||||
metricPercent
|
||||
} from '$ui/metrics.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
@@ -10,45 +16,97 @@
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { maps, servers, view = 'maps' }: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
let {
|
||||
maps,
|
||||
servers,
|
||||
view = 'maps'
|
||||
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
|
||||
const mapColumns = [
|
||||
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'map', label: 'Map' },
|
||||
{ key: 'playerHours', label: 'Player-hours' },
|
||||
{ key: 'active', label: 'With players' },
|
||||
{ key: 'full', label: 'All lobbies full' },
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
const serverColumns = [
|
||||
{ key: 'server', label: 'Instance' }, { key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'observed', label: 'Observed time' }, { key: 'unknown', label: 'Unknown time' }, { key: 'details', label: 'Details' }
|
||||
{ key: 'server', label: 'Instance' },
|
||||
{ key: 'utilization', label: 'Time with players' },
|
||||
{ key: 'observed', label: 'Observed time' },
|
||||
{ key: 'unknown', label: 'Unknown time' },
|
||||
{ key: 'details', label: 'Details' }
|
||||
] as const;
|
||||
type MapSort = typeof mapColumns[number]['key'];
|
||||
type ServerSort = typeof serverColumns[number]['key'];
|
||||
type MapSort = (typeof mapColumns)[number]['key'];
|
||||
type ServerSort = (typeof serverColumns)[number]['key'];
|
||||
type Direction = 'asc' | 'desc';
|
||||
// Shallow navigation changes page.state, not page.url. Store the working
|
||||
// search string there so filters react immediately and survive Back/Forward.
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const params = $derived(new URLSearchParams(contextSearch));
|
||||
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
|
||||
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
|
||||
const mapSort = $derived(mapColumns.find(column => column.key !== 'details' && column.key === params.get('mapSort'))?.key ?? 'playerHours');
|
||||
const serverSort = $derived(serverColumns.find(column => column.key !== 'details' && column.key === params.get('serverSort'))?.key ?? 'utilization');
|
||||
const mapSort = $derived(
|
||||
mapColumns.find(
|
||||
(column) => column.key !== 'details' && column.key === params.get('mapSort')
|
||||
)?.key ?? 'playerHours'
|
||||
);
|
||||
const serverSort = $derived(
|
||||
serverColumns.find(
|
||||
(column) => column.key !== 'details' && column.key === params.get('serverSort')
|
||||
)?.key ?? 'utilization'
|
||||
);
|
||||
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
|
||||
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
}));
|
||||
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
|
||||
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
}));
|
||||
const serverDirection = $derived<Direction>(
|
||||
params.get('serverDirection') === 'asc' ? 'asc' : 'desc'
|
||||
);
|
||||
const maxPlayerHours = $derived(Math.max(0, ...maps.map((map) => map.playerHours)));
|
||||
const mapRows = $derived(
|
||||
maps
|
||||
.filter((map) =>
|
||||
`${map.game} ${gameName(map.game)} ${map.map}`
|
||||
.toLowerCase()
|
||||
.includes(mapQuery.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const result =
|
||||
mapSort === 'map'
|
||||
? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(
|
||||
mapValue(a, mapSort),
|
||||
mapValue(b, mapSort),
|
||||
mapDirection
|
||||
);
|
||||
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
|
||||
})
|
||||
);
|
||||
const serverRows = $derived(
|
||||
servers
|
||||
.filter((server) =>
|
||||
`${server.server} ${serverTitle(server.server)}`
|
||||
.toLowerCase()
|
||||
.includes(serverQuery.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const result =
|
||||
serverSort === 'server'
|
||||
? serverTitle(a.server).localeCompare(serverTitle(b.server)) *
|
||||
(serverDirection === 'asc' ? 1 : -1)
|
||||
: compareMetricValues(
|
||||
serverValue(a, serverSort),
|
||||
serverValue(b, serverSort),
|
||||
serverDirection
|
||||
);
|
||||
return result || b.observed - a.observed || a.server.localeCompare(b.server);
|
||||
})
|
||||
);
|
||||
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
|
||||
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
|
||||
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
|
||||
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
|
||||
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
|
||||
function gameName(game: string) {
|
||||
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
}
|
||||
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
|
||||
if (key === 'details') return null;
|
||||
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
|
||||
@@ -56,87 +114,388 @@
|
||||
}
|
||||
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
|
||||
if (key === 'details') return null;
|
||||
if (key === 'utilization') return server.observed > 0 ? server.active / server.observed : null;
|
||||
if (key === 'utilization')
|
||||
return server.observed > 0 ? server.active / server.observed : null;
|
||||
return server[key];
|
||||
}
|
||||
function updateContext(changes: Record<string, string>) {
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
|
||||
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
|
||||
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
|
||||
function sortMap(key: MapSort) {
|
||||
updateContext({
|
||||
mapSort: key,
|
||||
mapDirection:
|
||||
key === mapSort
|
||||
? mapDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: key === 'map'
|
||||
? 'asc'
|
||||
: 'desc'
|
||||
});
|
||||
}
|
||||
function sortServer(key: ServerSort) {
|
||||
updateContext({
|
||||
serverSort: key,
|
||||
serverDirection:
|
||||
key === serverSort
|
||||
? serverDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: key === 'server'
|
||||
? 'asc'
|
||||
: 'desc'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
|
||||
<div class="heading">
|
||||
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
|
||||
<div class="search"><Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}><Input type="search" value={view === 'maps' ? mapQuery : serverQuery} placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'} oninput={event => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
|
||||
<div class="search">
|
||||
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
|
||||
><Input
|
||||
type="search"
|
||||
value={view === 'maps' ? mapQuery : serverQuery}
|
||||
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
|
||||
oninput={(event) =>
|
||||
updateContext({
|
||||
[view === 'maps' ? 'mapQ' : 'serverQ']: (
|
||||
event.currentTarget as HTMLInputElement
|
||||
).value
|
||||
})}
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if currentPage.total}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'} tabindex="0"><table>
|
||||
<thead><tr>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
||||
<th scope="col" aria-sort={column.key === 'details' ? undefined : (view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? 'ascending' : 'descending' : undefined}>
|
||||
{#if column.key === 'details'}Details{:else}<button type="button" onclick={() => view === 'maps' ? sortMap(column.key as MapSort) : sortServer(column.key as ServerSort)}>{column.label}<span aria-hidden="true">{(view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? '↑' : '↓' : '↕'}</span></button>{/if}
|
||||
</th>
|
||||
{/each}</tr></thead>
|
||||
<tbody>
|
||||
{#if view === 'maps'}
|
||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row">{map.map}<small>{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''}</small></th>
|
||||
<td class="player-hours">{map.observed ? metricNumber(map.playerHours) : '—'}<div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td><details><summary><span class="sr-only">{map.map} </span>Details</summary><dl>
|
||||
<dt>Observed instance time</dt><dd>{metricDuration(map.observed)}</dd><dt>Unknown status</dt><dd>{metricDuration(map.unknown)}</dd><dt>All lobbies full</dt><dd>{map.capacityObserved ? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` : 'Capacity data unavailable'}</dd>
|
||||
</dl></details></td>
|
||||
</tr>{/each}
|
||||
{:else}
|
||||
{#each serverPage.rows as server (server.server)}<tr>
|
||||
<th scope="row">{#if server.currentRegistered}<a href={contextHref(`/servers/${encodeURIComponent(server.server)}`, `/metrics${contextSearch}`)} title="Open current instance with this name">{serverTitle(server.server)}</a>{:else}{serverTitle(server.server)}<small>Historical</small>{/if}</th>
|
||||
<td>{metricPercent(server.active, server.observed)}</td><td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
|
||||
<td><details><summary><span class="sr-only">{serverTitle(server.server)} </span>Details</summary><dl><dt>Recorded name</dt><dd><code>{server.server}</code></dd><dt>Time with players</dt><dd>{metricDuration(server.active)}</dd><dt>Idle</dt><dd>{metricPercent(server.idle, server.observed)} · {metricDuration(server.idle)}</dd></dl></details></td>
|
||||
</tr>{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{:else}<EmptyState title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}>{#snippet actions()}<Button size="sm" onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}>Clear search</Button>{/snippet}</EmptyState>{/if}
|
||||
<div class="footer"><span>{currentPage.start}–{currentPage.end} of {currentPage.total} {view === 'maps' ? 'maps' : 'instances'}</span>
|
||||
{#if currentPage.pages > 1}<div class="paging"><Button size="sm" disabled={currentPage.page === 1} onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}>Previous</Button><span>Page {currentPage.page} of {currentPage.pages}</span><Button size="sm" disabled={currentPage.page === currentPage.pages} onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}>Next</Button></div>{/if}
|
||||
<div
|
||||
class="table-wrap"
|
||||
role="region"
|
||||
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
|
||||
tabindex="0"
|
||||
>
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
>{#each view === 'maps' ? mapColumns : serverColumns as column}
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={column.key === 'details'
|
||||
? undefined
|
||||
: (view === 'maps' ? mapSort : serverSort) === column.key
|
||||
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined}
|
||||
>
|
||||
{#if column.key === 'details'}Details{:else}<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
view === 'maps'
|
||||
? sortMap(column.key as MapSort)
|
||||
: sortServer(column.key as ServerSort)}
|
||||
>{column.label}<span aria-hidden="true"
|
||||
>{(view === 'maps' ? mapSort : serverSort) ===
|
||||
column.key
|
||||
? (view === 'maps'
|
||||
? mapDirection
|
||||
: serverDirection) === 'asc'
|
||||
? '↑'
|
||||
: '↓'
|
||||
: '↕'}</span
|
||||
></button
|
||||
>{/if}
|
||||
</th>
|
||||
{/each}</tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#if view === 'maps'}
|
||||
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
|
||||
<th scope="row"
|
||||
>{map.map}<small
|
||||
>{gameName(map.game)}{map.observed < 1 ||
|
||||
map.capacityObserved < 1 ||
|
||||
map.unknown > map.observed / 4
|
||||
? ' · Limited evidence'
|
||||
: ''}</small
|
||||
></th
|
||||
>
|
||||
<td class="player-hours"
|
||||
>{map.observed ? metricNumber(map.playerHours) : '—'}
|
||||
<div class="comparison" aria-hidden="true">
|
||||
<i
|
||||
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
|
||||
></i>
|
||||
</div></td
|
||||
>
|
||||
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
|
||||
<td>{metricPercent(map.full, map.capacityObserved)}</td>
|
||||
<td
|
||||
><details>
|
||||
<summary
|
||||
><span class="sr-only">{map.map} </span>Details</summary
|
||||
>
|
||||
<dl>
|
||||
<dt>Observed instance time</dt>
|
||||
<dd>{metricDuration(map.observed)}</dd>
|
||||
<dt>Unknown status</dt>
|
||||
<dd>{metricDuration(map.unknown)}</dd>
|
||||
<dt>All lobbies full</dt>
|
||||
<dd>
|
||||
{map.capacityObserved
|
||||
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time`
|
||||
: 'Capacity data unavailable'}
|
||||
</dd>
|
||||
</dl>
|
||||
</details></td
|
||||
>
|
||||
</tr>{/each}
|
||||
{:else}
|
||||
{#each serverPage.rows as server (server.server)}<tr>
|
||||
<th scope="row"
|
||||
>{#if server.currentRegistered}<a
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(server.server)}`,
|
||||
`/metrics${contextSearch}`
|
||||
)}
|
||||
title="Open current instance with this name"
|
||||
>{serverTitle(server.server)}</a
|
||||
>{:else}{serverTitle(server.server)}<small>Historical</small
|
||||
>{/if}</th
|
||||
>
|
||||
<td>{metricPercent(server.active, server.observed)}</td><td
|
||||
>{metricDuration(server.observed)}</td
|
||||
><td>{metricDuration(server.unknown)}</td>
|
||||
<td
|
||||
><details>
|
||||
<summary
|
||||
><span class="sr-only"
|
||||
>{serverTitle(server.server)}
|
||||
</span>Details</summary
|
||||
>
|
||||
<dl>
|
||||
<dt>Recorded name</dt>
|
||||
<dd><code>{server.server}</code></dd>
|
||||
<dt>Time with players</dt>
|
||||
<dd>{metricDuration(server.active)}</dd>
|
||||
<dt>Idle</dt>
|
||||
<dd>
|
||||
{metricPercent(server.idle, server.observed)} · {metricDuration(
|
||||
server.idle
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</details></td
|
||||
>
|
||||
</tr>{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}<EmptyState
|
||||
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
|
||||
>{#snippet actions()}<Button
|
||||
size="sm"
|
||||
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
|
||||
>Clear search</Button
|
||||
>{/snippet}</EmptyState
|
||||
>{/if}
|
||||
<div class="footer">
|
||||
<span
|
||||
>{currentPage.start}–{currentPage.end} of {currentPage.total}
|
||||
{view === 'maps' ? 'maps' : 'instances'}</span
|
||||
>
|
||||
{#if currentPage.pages > 1}<div class="paging">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={currentPage.page === 1}
|
||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
|
||||
>Previous</Button
|
||||
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
|
||||
size="sm"
|
||||
disabled={currentPage.page === currentPage.pages}
|
||||
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
|
||||
>Next</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</div>
|
||||
{#if view === 'instances'}<p class="identity-note">Grouped by recorded name; links open the current instance, which may differ from the historical registration.</p>{/if}
|
||||
<details class="table-help"><summary>About these metrics</summary><p>{view === 'maps' ? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.' : 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}</p></details>
|
||||
{#if view === 'instances'}<p class="identity-note">
|
||||
Grouped by recorded name; links open the current instance, which may differ from the
|
||||
historical registration.
|
||||
</p>{/if}
|
||||
<details class="table-help">
|
||||
<summary>About these metrics</summary>
|
||||
<p>
|
||||
{view === 'maps'
|
||||
? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
|
||||
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
|
||||
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.search { flex: 0 1 240px; }
|
||||
.table-wrap { position: relative; width: 100%; overflow-x: auto; }
|
||||
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
|
||||
th:first-child { text-align: left; min-width: 160px; }
|
||||
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); white-space: nowrap; }
|
||||
thead button { display: inline-flex; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; white-space: nowrap; }
|
||||
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
tbody th { font-weight: var(--weight-medium); }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
|
||||
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; }
|
||||
.footer, .paging { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.footer { padding: 16px 20px; }
|
||||
details { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
summary { cursor: pointer; }
|
||||
dl { text-align: left; min-width: 170px; max-width: 300px; }
|
||||
dt { margin-top: 10px; } dd { margin: 4px 0; color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.identity-note { margin: 0; padding: 0 20px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.table-help { padding: 0 20px 16px; } .table-help p { max-width: 90ch; line-height: 1.6; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 640px) { th, td { padding: 10px; } .search { flex: 1 1 100%; } }
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.heading {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
margin: 0;
|
||||
}
|
||||
.search {
|
||||
flex: 0 1 240px;
|
||||
}
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
vertical-align: top;
|
||||
}
|
||||
th:first-child {
|
||||
text-align: left;
|
||||
min-width: 160px;
|
||||
}
|
||||
thead th {
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-sunken);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
thead button {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
padding: 5px 0;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
thead button:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.comparison {
|
||||
height: 3px;
|
||||
width: 90px;
|
||||
margin: 8px 0 0 auto;
|
||||
background: var(--surface-raised);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.comparison i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--chart-active);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.footer,
|
||||
.paging {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.footer {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
details {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
dl {
|
||||
text-align: left;
|
||||
min-width: 170px;
|
||||
max-width: 300px;
|
||||
}
|
||||
dt {
|
||||
margin-top: 10px;
|
||||
}
|
||||
dd {
|
||||
margin: 4px 0;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.identity-note {
|
||||
margin: 0;
|
||||
padding: 0 20px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.table-help {
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
.table-help p {
|
||||
max-width: 90ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user