Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+385
-74
@@ -36,62 +36,154 @@
|
||||
const report = $derived(snapshot ?? data);
|
||||
const format = $derived(createTimeFormatter(report.scope.timeZone));
|
||||
const captured = $derived(report.summary.observed + report.summary.unknown);
|
||||
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
|
||||
const view = $derived(new URLSearchParams(contextSearch).get('view') === 'instances' ? 'instances' : new URLSearchParams(contextSearch).get('view') === 'insights' ? 'insights' : 'maps');
|
||||
const contextSearch = $derived(
|
||||
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
|
||||
);
|
||||
const view = $derived(
|
||||
new URLSearchParams(contextSearch).get('view') === 'instances'
|
||||
? 'instances'
|
||||
: new URLSearchParams(contextSearch).get('view') === 'insights'
|
||||
? 'insights'
|
||||
: 'maps'
|
||||
);
|
||||
function changeView(value: string) {
|
||||
const url = new URL(page.url); url.search = contextSearch; url.searchParams.set('view', value);
|
||||
const url = new URL(page.url);
|
||||
url.search = contextSearch;
|
||||
url.searchParams.set('view', value);
|
||||
replaceState(url, { ...page.state, metricsTableSearch: url.search });
|
||||
}
|
||||
const gameOptions = $derived([{ value: '', label: 'All games' }, ...[...new Set([...report.scopeOptions.games, ...(report.scope.game ? [report.scope.game] : [])])].map(game => ({ value: game, label: gameName(game) }))]);
|
||||
const gameOptions = $derived([
|
||||
{ value: '', label: 'All games' },
|
||||
...[
|
||||
...new Set([
|
||||
...report.scopeOptions.games,
|
||||
...(report.scope.game ? [report.scope.game] : [])
|
||||
])
|
||||
].map((game) => ({ value: game, label: gameName(game) }))
|
||||
]);
|
||||
const mapOptions = $derived([
|
||||
{ value: '', label: 'All maps' },
|
||||
...report.scopeOptions.maps.filter(map => !report.scope.game || map.game === report.scope.game).map(map => ({ value: JSON.stringify([map.game, map.map]), label: `${map.map}${report.scope.game ? '' : ` · ${gameName(map.game)}`}` })),
|
||||
...(report.scope.map && !report.scopeOptions.maps.some(map => map.game === report.scope.game && map.map === report.scope.map) ? [{ value: JSON.stringify([report.scope.game, report.scope.map]), label: report.scope.map }] : [])
|
||||
...report.scopeOptions.maps
|
||||
.filter((map) => !report.scope.game || map.game === report.scope.game)
|
||||
.map((map) => ({
|
||||
value: JSON.stringify([map.game, map.map]),
|
||||
label: `${map.map}${report.scope.game ? '' : ` · ${gameName(map.game)}`}`
|
||||
})),
|
||||
...(report.scope.map &&
|
||||
!report.scopeOptions.maps.some(
|
||||
(map) => map.game === report.scope.game && map.map === report.scope.map
|
||||
)
|
||||
? [
|
||||
{
|
||||
value: JSON.stringify([report.scope.game, report.scope.map]),
|
||||
label: report.scope.map
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
const mapValue = $derived(report.scope.map ? JSON.stringify([report.scope.game, report.scope.map]) : '');
|
||||
const scopeLabel = $derived(report.scope.map ? `${report.scope.map} · ${gameName(report.scope.game!)}` : report.scope.game ? gameName(report.scope.game) : 'All games and maps');
|
||||
const mapValue = $derived(
|
||||
report.scope.map ? JSON.stringify([report.scope.game, report.scope.map]) : ''
|
||||
);
|
||||
const scopeLabel = $derived(
|
||||
report.scope.map
|
||||
? `${report.scope.map} · ${gameName(report.scope.game!)}`
|
||||
: report.scope.game
|
||||
? gameName(report.scope.game)
|
||||
: 'All games and maps'
|
||||
);
|
||||
const refresh = createMetricsRefresh({
|
||||
getQuery: () => {
|
||||
const params = new URLSearchParams({ days: String(data.days), timeZone: data.scope.timeZone });
|
||||
const params = new URLSearchParams({
|
||||
days: String(data.days),
|
||||
timeZone: data.scope.timeZone
|
||||
});
|
||||
if (data.scope.game) params.set('game', data.scope.game);
|
||||
if (data.scope.map) params.set('map', data.scope.map);
|
||||
return params.toString();
|
||||
},
|
||||
onData: value => { snapshot = value; checkedAt = Date.now(); },
|
||||
onError: value => { refreshError = value; },
|
||||
onLoading: value => { refreshing = value; }
|
||||
onData: (value) => {
|
||||
snapshot = value;
|
||||
checkedAt = Date.now();
|
||||
},
|
||||
onError: (value) => {
|
||||
refreshError = value;
|
||||
},
|
||||
onLoading: (value) => {
|
||||
refreshing = value;
|
||||
}
|
||||
});
|
||||
beforeNavigate(() => {
|
||||
refresh.cancel();
|
||||
scopeNavigating = true;
|
||||
});
|
||||
afterNavigate(() => {
|
||||
scopeNavigating = false;
|
||||
attemptedPreference = null;
|
||||
});
|
||||
beforeNavigate(() => { refresh.cancel(); scopeNavigating = true; });
|
||||
afterNavigate(() => { scopeNavigating = false; attemptedPreference = null; });
|
||||
$effect(() => {
|
||||
// Navigation owns the new report; an older background request cannot replace it.
|
||||
void data;
|
||||
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; checkedAt = Date.now(); });
|
||||
untrack(() => {
|
||||
refresh.cancel();
|
||||
snapshot = null;
|
||||
refreshError = null;
|
||||
checkedAt = Date.now();
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
// Layout initializes the saved preference on mount; react when it arrives.
|
||||
const preferredZone = $timeZone;
|
||||
if (mounted && !scopeNavigating && !new URLSearchParams(contextSearch).has('timeZone') && preferredZone !== data.scope.timeZone && preferredZone !== attemptedPreference) {
|
||||
untrack(() => { attemptedPreference = preferredZone; void changeScope({ timeZone: preferredZone }, true); });
|
||||
if (
|
||||
mounted &&
|
||||
!scopeNavigating &&
|
||||
!new URLSearchParams(contextSearch).has('timeZone') &&
|
||||
preferredZone !== data.scope.timeZone &&
|
||||
preferredZone !== attemptedPreference
|
||||
) {
|
||||
untrack(() => {
|
||||
attemptedPreference = preferredZone;
|
||||
void changeScope({ timeZone: preferredZone }, true);
|
||||
});
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const stop = pollVisible(() => scopeNavigating ? undefined : refresh.refresh(), 60_000, false);
|
||||
return () => { stop(); refresh.cancel(); };
|
||||
const stop = pollVisible(
|
||||
() => (scopeNavigating ? undefined : refresh.refresh()),
|
||||
60_000,
|
||||
false
|
||||
);
|
||||
return () => {
|
||||
stop();
|
||||
refresh.cancel();
|
||||
};
|
||||
});
|
||||
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();
|
||||
}
|
||||
async function changeScope(changes: Record<string, string>, replace = false) {
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
if (!params.has('timeZone')) params.set('timeZone', report.scope.timeZone);
|
||||
for (const [key, value] of Object.entries(changes)) { if (value) params.set(key, value); else params.delete(key); }
|
||||
params.delete('mapPage'); params.delete('serverPage');
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
}
|
||||
params.delete('mapPage');
|
||||
params.delete('serverPage');
|
||||
refresh.cancel();
|
||||
scopeError = '';
|
||||
scopeNavigating = true;
|
||||
try { await goto(`/metrics?${params}`, { keepFocus: true, noScroll: true, replaceState: replace }); }
|
||||
catch { scopeError = 'Could not change the report scope. The previous report is still shown.'; }
|
||||
finally { scopeNavigating = false; }
|
||||
try {
|
||||
await goto(`/metrics?${params}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
replaceState: replace
|
||||
});
|
||||
} catch {
|
||||
scopeError = 'Could not change the report scope. The previous report is still shown.';
|
||||
} finally {
|
||||
scopeNavigating = false;
|
||||
}
|
||||
}
|
||||
function changeMap(value: string) {
|
||||
if (!value) return changeScope({ map: '' });
|
||||
@@ -101,7 +193,8 @@
|
||||
function rangeHref(days: number): string {
|
||||
const params = new URLSearchParams(contextSearch);
|
||||
params.set('days', String(days));
|
||||
params.delete('mapPage'); params.delete('serverPage');
|
||||
params.delete('mapPage');
|
||||
params.delete('serverPage');
|
||||
params.set('timeZone', report.scope.timeZone);
|
||||
return `/metrics?${params}`;
|
||||
}
|
||||
@@ -114,18 +207,50 @@
|
||||
<div class="toolbar">
|
||||
<nav aria-label="Metrics time range">
|
||||
{#each [1, 7, 30] as days}
|
||||
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
<a
|
||||
href={rangeHref(days)}
|
||||
aria-current={report.days === days ? 'page' : undefined}
|
||||
data-sveltekit-keepfocus
|
||||
data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
|
||||
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
|
||||
<div class="refresh-controls">{#if scopeNavigating}<span role="status">Updating report…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every minute" />{/if}</div>
|
||||
<Field label="Game"
|
||||
><Select
|
||||
value={report.scope.game ?? ''}
|
||||
options={gameOptions}
|
||||
disabled={scopeNavigating}
|
||||
onchange={(event) =>
|
||||
changeScope({
|
||||
game: (event.currentTarget as HTMLSelectElement).value,
|
||||
map: ''
|
||||
})}
|
||||
/></Field
|
||||
>
|
||||
<Field label="Map"
|
||||
><Select
|
||||
value={mapValue}
|
||||
options={mapOptions}
|
||||
disabled={scopeNavigating}
|
||||
onchange={(event) => changeMap((event.currentTarget as HTMLSelectElement).value)}
|
||||
/></Field
|
||||
>
|
||||
<div class="refresh-controls">
|
||||
{#if scopeNavigating}<span role="status">Updating report…</span
|
||||
>{:else if !refreshError}<Freshness
|
||||
updatedAt={checkedAt}
|
||||
intervalLabel="Updates every minute"
|
||||
/>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
|
||||
{#if refreshError}
|
||||
<div class="refresh-error" role="alert">
|
||||
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small></div>
|
||||
<div>
|
||||
<strong>Could not refresh metrics.</strong>
|
||||
{refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small>
|
||||
</div>
|
||||
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -133,68 +258,254 @@
|
||||
{#if captured > 0}<MetricsSummary {report} />{/if}
|
||||
{#if captured === 0}
|
||||
<Card>
|
||||
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this scope'}
|
||||
<EmptyState
|
||||
title={report.latestSampleAt === null
|
||||
? 'No retained observations yet'
|
||||
: report.servers.length > 0
|
||||
? 'Waiting for observed time'
|
||||
: 'No captured time in this scope'}
|
||||
description={report.latestSampleAt === null
|
||||
? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.'
|
||||
: report.servers.length > 0 ? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
|
||||
: 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}>
|
||||
? report.registeredServerCount > 0
|
||||
? 'Leave the manager running to collect server status and build a history.'
|
||||
: 'Register a server to begin collecting utilization history.'
|
||||
: report.servers.length > 0
|
||||
? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
|
||||
: 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
|
||||
{#if report.scope.game || report.scope.map}<Button size="sm" onclick={() => changeScope({ game: '', map: '' })}>View all games</Button>{/if}
|
||||
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
|
||||
{#if report.latestSampleAt !== null && report.days < 30}<Button
|
||||
href={rangeHref(30)}
|
||||
size="sm">View 30 days</Button
|
||||
>{/if}
|
||||
{#if report.scope.game || report.scope.map}<Button
|
||||
size="sm"
|
||||
onclick={() => changeScope({ game: '', map: '' })}
|
||||
>View all games</Button
|
||||
>{/if}
|
||||
{#if report.registeredServerCount === 0}<Button href="/" size="sm"
|
||||
>View fleet</Button
|
||||
>{/if}
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
</Card>
|
||||
{:else}
|
||||
{#if report.registeredServerCount === 0}
|
||||
<p class="notice">No servers are currently registered. This report shows retained history; new samples resume when servers are registered.</p>
|
||||
<p class="notice">
|
||||
No servers are currently registered. This report shows retained history; new samples
|
||||
resume when servers are registered.
|
||||
</p>
|
||||
{:else if report.latestSampleAt !== null && report.now - report.latestSampleAt > 5 * 60_000}
|
||||
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
|
||||
<p class="notice">
|
||||
No new collection samples in the last five minutes. This report still includes the
|
||||
retained observations shown below.
|
||||
</p>
|
||||
{/if}
|
||||
{#if report.summary.observed === 0}<p class="notice">All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.</p>{/if}
|
||||
{#if report.summary.observed === 0}<p class="notice">
|
||||
All captured server status is unknown. A dash means occupancy could not be
|
||||
calculated; it does not mean servers were empty.
|
||||
</p>{/if}
|
||||
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
|
||||
{/if}
|
||||
{#if captured > 0}
|
||||
<MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
|
||||
<Tabs tabs={[{ id: 'maps', label: 'Maps' }, { id: 'instances', label: 'Instances' }, { id: 'insights', label: 'Hosting insights' }]} value={view} onchange={changeView} ariaLabel="Metrics breakdown" idPrefix="metrics-views" panelId="metrics-breakdown" />
|
||||
<MetricsHeatmap
|
||||
heatmap={report.heatmap}
|
||||
disabled={scopeNavigating}
|
||||
onTimeZoneChange={(value) => changeScope({ timeZone: value })}
|
||||
/>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'maps', label: 'Maps' },
|
||||
{ id: 'instances', label: 'Instances' },
|
||||
{ id: 'insights', label: 'Hosting insights' }
|
||||
]}
|
||||
value={view}
|
||||
onchange={changeView}
|
||||
ariaLabel="Metrics breakdown"
|
||||
idPrefix="metrics-views"
|
||||
panelId="metrics-breakdown"
|
||||
/>
|
||||
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
|
||||
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables maps={report.maps} servers={report.servers} {view} />{/if}
|
||||
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables
|
||||
maps={report.maps}
|
||||
servers={report.servers}
|
||||
{view}
|
||||
/>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<details class="methodology">
|
||||
<summary>About this report</summary>
|
||||
<div class="context">
|
||||
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
|
||||
<span>Scope last collected <strong>{report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)}</strong></span>
|
||||
<span>Fleet last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)}</strong></span>
|
||||
<span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
|
||||
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
|
||||
</div>
|
||||
<p>Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. Percentage points show the difference between two percentages. Equal windows do not guarantee equal observation coverage.</p>
|
||||
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
|
||||
<p>Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
|
||||
<p>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</p>
|
||||
<p>Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.</p>
|
||||
<div class="context">
|
||||
{#if captured > 0}<span
|
||||
><strong>{metricDuration(report.summary.observed)}</strong> known server time</span
|
||||
><span
|
||||
><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span
|
||||
>{/if}
|
||||
<span
|
||||
>Scope last collected <strong
|
||||
>{report.scopeLatestSampleAt === null
|
||||
? 'No retained samples'
|
||||
: format.dateTime(report.scopeLatestSampleAt)}</strong
|
||||
></span
|
||||
>
|
||||
<span
|
||||
>Fleet last collected <strong
|
||||
>{report.latestSampleAt === null
|
||||
? 'No retained samples'
|
||||
: format.dateTime(report.latestSampleAt)}</strong
|
||||
></span
|
||||
>
|
||||
<span
|
||||
>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span
|
||||
>
|
||||
{#if report.scopeFirstSampleAt !== null}<span
|
||||
>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span
|
||||
>{/if}
|
||||
</div>
|
||||
<p>
|
||||
Previous window: {format.period(report.previous.since)} – {format.period(
|
||||
report.previous.until
|
||||
)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(
|
||||
report.summary.observed
|
||||
)} current. Percentage points show the difference between two percentages. Equal windows do
|
||||
not guarantee equal observation coverage.
|
||||
</p>
|
||||
<p>
|
||||
Time with players is occupied server time divided by known server time. Known time
|
||||
includes occupied and idle observations; unknown status and collection gaps are excluded
|
||||
from that percentage.
|
||||
</p>
|
||||
<p>
|
||||
Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection
|
||||
gaps are excluded. History is retained for 31 days. Player-hours include all players reported
|
||||
by the server and are estimates, not individual session records.
|
||||
</p>
|
||||
<p>
|
||||
Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)}. Daily buckets are 24-hour periods, not local calendar days. The first and latest
|
||||
buckets can be partial. Percentage bars divide occupied, idle, and unknown time by
|
||||
captured time; the headline occupancy percentage uses known time only.
|
||||
</p>
|
||||
<p>
|
||||
Historical server rows are grouped by name and may include multiple registrations using
|
||||
that name. Map groups preserve the recorded game and map label.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-5); min-width: 0; }
|
||||
.toolbar { display: flex; align-items: end; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.toolbar :global(.field) { flex: 1 1 180px; max-width: 300px; }
|
||||
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
|
||||
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
|
||||
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
nav a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.range { margin: 0; }
|
||||
.refresh-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
|
||||
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
|
||||
.notice { margin: 0; border-left: 2px solid var(--state-warning); padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
|
||||
.methodology { padding: var(--space-3) 2px; }
|
||||
.methodology summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
|
||||
@media (max-width: 540px) { .toolbar { align-items: stretch; } .toolbar :global(.field) { flex-basis: 100%; max-width: none; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar :global(.field) {
|
||||
flex: 1 1 180px;
|
||||
max-width: 300px;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
nav a {
|
||||
padding: 9px 12px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
nav a[aria-current] {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
nav a:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.range,
|
||||
.context,
|
||||
.methodology,
|
||||
.notice {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.range {
|
||||
margin: 0;
|
||||
}
|
||||
.refresh-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--space-5);
|
||||
row-gap: var(--space-1);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.refresh-error {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
border: 1px solid var(--state-warning);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.refresh-error small {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.notice {
|
||||
margin: 0;
|
||||
border-left: 2px solid var(--state-warning);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.methodology {
|
||||
padding: var(--space-3) 2px;
|
||||
}
|
||||
.methodology summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.methodology p {
|
||||
max-width: 110ch;
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
.toolbar :global(.field) {
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.refresh-controls {
|
||||
margin-left: 0;
|
||||
}
|
||||
.refresh-error {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user