Clarify UI status and remove noisy live regions

This commit is contained in:
2026-10-07 18:47:02 -04:00
parent 45e9bcd4ce
commit e35b84475d
12 changed files with 172 additions and 108 deletions
+7 -8
View File
@@ -21,18 +21,17 @@
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
color: var(--cream);
letter-spacing: var(--tracking-display);
font-family: 'Alfa Slab One', Georgia, serif;
color: var(--text-primary);
letter-spacing: 0.12em;
line-height: 1;
animation: cherry-fizz 6s ease-in-out infinite;
}
.mark-sm .word { font-size: 14px; letter-spacing: 1.5px; }
.mark-md .word { font-size: 18px; }
.mark-lg .word { font-size: 32px; letter-spacing: 3px; }
.mark-sm .word { font-size: var(--text-sm); letter-spacing: 1.5px; }
.mark-md .word { font-size: var(--text-md); }
.mark-lg .word { font-size: var(--text-2xl); letter-spacing: 3px; }
.bolt {
fill: var(--spark-500);
fill: var(--accent-primary-hover);
filter: drop-shadow(0 0 5px rgba(124, 212, 255, 0.75));
}
.mark-sm .bolt { width: 12px; height: 20px; }
+1 -1
View File
@@ -11,7 +11,7 @@
</script>
{#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh}
<div class="round" aria-live="polite">
<div class="round">
<strong>Round {report.round}</strong>
<span title={$timeFormat.dateTime(report.receivedAt)}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago</span>
</div>
+16 -18
View File
@@ -7,11 +7,12 @@
import { uiNow } from '$ui/stores/servers.js';
import { gametypeLabel } from '$ui/gametype.js';
import { elapsedSince } from '$ui/duration.js';
import { mapTitle, serverTitle, serverIdentity } from '$ui/server-labels.js';
import { mapTitle, serverIdentity } from '$ui/server-labels.js';
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
import Card from './primitives/Card.svelte';
import Badge from './primitives/Badge.svelte';
import Stat from './primitives/Stat.svelte';
import StatusPip from './primitives/StatusPip.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
interface Held {
target: string;
@@ -57,6 +58,7 @@
compactEmpty = false,
mapLabelOverride
}: Props = $props();
const id = $props.id();
function ago(iso: string | null, now: number): string {
if (!iso) return 'never';
@@ -74,7 +76,8 @@
.slice(0, 4)
);
let showRoster = $derived(health === 'online' || health === 'stale');
let title = $derived(serverTitle(name));
const state = $derived(restarting ? 'restarting' : health);
const error = $derived(decodeRconError(lastError));
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
let gtLabel = $derived(gametypeLabel(gametype));
@@ -99,7 +102,7 @@
elevation="interactive"
padding="md"
class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''}
ariaLabel="Server {title}, {health}"
ariaLabelledby={`${id}-title`}
style={cardStyle}
>
{#if restarting}
@@ -108,19 +111,12 @@
<div class="head">
<div class="name-row">
<StatusPip {health} />
<strong class="name"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
<strong class="name" id={`${id}-title`}><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
</div>
{#if health === 'provisioning'}
<Badge variant="info" uppercase>Provisioning</Badge>
{:else if health === 'starting'}
<Badge variant="info" uppercase>Starting</Badge>
{:else if health === 'offline'}
<Badge variant="danger" uppercase>offline</Badge>
{:else if health === 'stale'}
<Badge variant="warning">Stale</Badge>
{:else}
<div class="players" aria-live="polite">
<div class="status">
<StatusBadge {state} />
{#if health === 'online'}
<div class="players">
<Stat
value={players ?? '—'}
size="md"
@@ -128,7 +124,8 @@
tween
/>
</div>
{/if}
{/if}
</div>
</div>
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
@@ -193,7 +190,7 @@
<div class="foot">
{#if health === 'offline' && lastError}
<span class="err">{lastError}</span>
<span class="err" title={error ? rconFixHint(error.reason) : undefined}>{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}</span>
{:else if lastSeenAt}
<span>Last seen {ago(lastSeenAt, $uiNow)}</span>
{:else if lastRestartedAt}
@@ -254,6 +251,7 @@
min-width: 0;
}
.players { flex-shrink: 0; }
.status { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--space-2); }
.meta {
display: flex;
@@ -63,9 +63,9 @@
border-color: var(--border-subtle);
}
.v-online {
background: rgba(124, 212, 255, 0.10);
background: color-mix(in srgb, var(--state-online) 10%, transparent);
color: var(--state-online);
border-color: rgba(124, 212, 255, 0.25);
border-color: color-mix(in srgb, var(--state-online) 25%, transparent);
}
.v-warning {
background: rgba(255, 176, 32, 0.10);
@@ -78,9 +78,9 @@
border-color: rgba(255, 86, 112, 0.28);
}
.v-info {
background: rgba(124, 192, 245, 0.10);
color: var(--blue-300);
border-color: rgba(124, 192, 245, 0.28);
background: color-mix(in srgb, var(--state-info) 10%, transparent);
color: var(--state-info);
border-color: color-mix(in srgb, var(--state-info) 28%, transparent);
}
.v-accent {
background: var(--accent-primary);
@@ -10,6 +10,7 @@
href?: string | null;
as?: 'div' | 'section' | 'article' | 'a';
ariaLabel?: string;
ariaLabelledby?: string;
class?: string;
style?: string;
children?: Snippet;
@@ -22,6 +23,7 @@
href = null,
as = 'div',
ariaLabel,
ariaLabelledby,
class: klass = '',
style,
children,
@@ -41,6 +43,7 @@
class="card p-{padding} e-{elevation} {klass}"
href={isLink ? href : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{role}
{tabindex}
{style}
@@ -7,16 +7,17 @@
icon?: Snippet;
actions?: Snippet;
spacious?: boolean;
headingLevel?: 2 | 3 | 4;
}
let { title, description = null, icon, actions, spacious = false }: Props = $props();
let { title, description = null, icon, actions, spacious = false, headingLevel = 2 }: Props = $props();
</script>
<div class="empty" class:spacious role="status">
<div class="empty" class:spacious>
{#if icon}
<div class="icon">{@render icon()}</div>
{/if}
<h3 class="title">{title}</h3>
<svelte:element this={`h${headingLevel}`} class="title">{title}</svelte:element>
{#if description}<p class="desc">{description}</p>{/if}
{#if actions}<div class="actions">{@render actions()}</div>{/if}
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import Badge from './Badge.svelte';
import StatusPip from './StatusPip.svelte';
import { statusPresentation } from '../../status.js';
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
const status = $derived(statusPresentation(state));
</script>
<Badge variant={status.tone} {size}>{status.label}</Badge>
<Badge variant={status.tone} {size}><StatusPip {state} size="sm" />{status.label}</Badge>
@@ -1,66 +1,40 @@
<script lang="ts">
import type { Health } from '$ui/stores/servers.js';
import { statusPresentation, statusShapes, type StatusTone } from '$ui/status.js';
interface Props {
health?: Health;
state?: string;
tone?: StatusTone;
size?: 'sm' | 'md';
label?: string | null;
}
let { health = 'offline', size = 'md', label = null }: Props = $props();
let toneClass = $derived(
health === 'online' ? 't-online' :
health === 'stale' || health === 'starting' || health === 'provisioning' ? 't-warning' :
't-danger'
);
let announceLabel = $derived(
label ?? (
health === 'online' ? 'online' :
health === 'starting' ? 'starting' :
health === 'provisioning' ? 'provisioning' :
health === 'stale' ? 'stale' :
'offline'
)
);
let { health = 'offline', state, tone, size = 'md', label = null }: Props = $props();
const presentation = $derived(statusPresentation(state ?? health));
const resolvedTone = $derived(tone ?? presentation.tone);
const shape = $derived(statusShapes[resolvedTone]);
</script>
<span class="pip s-{size} {toneClass}" role="status" aria-label={announceLabel}>
<span class="halo" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span>
</span>
<span class="pip s-{size} t-{resolvedTone} shape-{shape}" role={label ? 'img' : undefined} aria-label={label ?? undefined} aria-hidden={label ? undefined : true}></span>
<style>
.pip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
}
.s-sm { width: 8px; height: 8px; }
.s-md { width: 10px; height: 10px; }
.dot {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
border-radius: 50%;
}
/* Halo — opacity+scale only, GPU-cheap, no box-shadow ring. */
.halo {
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
}
.t-online .dot { background: var(--state-online); box-shadow: 0 0 4px rgba(124, 212, 255, 0.55); }
.t-online .halo { background: var(--state-online); animation: pip-breathe 2.4s var(--ease-standard) infinite; }
.t-warning .dot { background: var(--state-warning); }
.t-danger .dot { background: var(--state-danger); opacity: 0.7; }
.s-sm { width: 10px; height: 10px; }
.s-md { width: 12px; height: 12px; }
.t-online { color: var(--state-online); }
.t-warning { color: var(--state-warning); }
.t-danger { color: var(--state-danger); }
.t-info { color: var(--state-info); }
.t-neutral { color: var(--state-neutral); }
.shape-circle { border-radius: 50%; background: currentColor; }
.shape-square { border-radius: 2px; background: currentColor; }
.shape-triangle { background: currentColor; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
.shape-hollow, .shape-ring { border: 2px solid currentColor; border-radius: 50%; }
.shape-ring { border-right-color: transparent; animation: status-spin 1s linear infinite; }
@keyframes status-spin { to { transform: rotate(360deg); } }
</style>
+17 -3
View File
@@ -1,7 +1,20 @@
export type StatusTone = 'neutral' | 'online' | 'warning' | 'danger' | 'info';
export type StatusShape = 'hollow' | 'circle' | 'triangle' | 'square' | 'ring';
export interface StatusPresentation {
label: string;
tone: StatusTone;
shape: StatusShape;
}
/** A second visual cue keeps state readable without relying on color. */
export const statusShapes: Record<StatusTone, StatusShape> = {
neutral: 'hollow', online: 'circle', warning: 'triangle', danger: 'square', info: 'ring'
};
const states: Record<string, [string, StatusTone]> = {
provisioning: ['Provisioning', 'info'],
online: ['Online', 'online'], offline: ['Offline', 'danger'], stale: ['Report outdated', 'warning'], starting: ['Starting', 'info'],
online: ['Online', 'online'], offline: ['Offline', 'danger'], stale: ['Not responding', 'warning'], starting: ['Starting', 'info'],
'host-offline': ['Host offline', 'danger'], 'not-responding': ['Not responding', 'warning'], stopped: ['Stopped', 'neutral'], unknown: ['Unknown', 'neutral'],
queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'],
deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'],
observing: ['Collecting test evidence', 'info'], 'rolling-back': ['Rolling back', 'info'],
@@ -15,7 +28,8 @@ const states: Record<string, [string, StatusTone]> = {
manual: ['Manual action needed', 'warning'], paused: ['Paused', 'warning'], inconclusive: ['Missing evidence', 'warning'],
untested: ['Needs testing', 'neutral'], testing: ['Beta active', 'info'], empty: ['No versions', 'neutral'], attention: ['Needs attention', 'warning']
};
export function statusPresentation(state: string): { label: string; tone: StatusTone } {
export function statusPresentation(state: string): StatusPresentation {
const result = states[state];
return result ? { label: result[0], tone: result[1] } : { label: (state.charAt(0).toUpperCase() + state.slice(1)).replaceAll('-', ' '), tone: 'neutral' };
const [label, tone] = result ?? [(state.charAt(0).toUpperCase() + state.slice(1)).replaceAll('-', ' ') || 'Unknown', 'neutral'];
return { label, tone, shape: statusShapes[tone] };
}
+5 -14
View File
@@ -46,10 +46,12 @@
--gray-050: #eef2f8;
/* State — reserved for actual state, never decoration. */
--state-online: #7cd4ff;
--state-online: #6ddb99;
--state-warning: #ffb020;
--state-danger: #ff5670;
--state-restarting: #7cc0f5;
--state-info: #7cc0f5;
--state-neutral: #a8b2c4;
--state-restarting: var(--state-info);
/* ---------- 2. SEMANTIC ALIASES ---------- */
@@ -222,14 +224,7 @@
--shadow-panel: var(--shadow-raised);
}
/* Keyframes — pared down.
Legacy names kept so existing components animate; the new StatusPip
uses `pip-breathe` (opacity-only, GPU-cheap). */
@keyframes pip-breathe {
0%, 100% { opacity: 0.35; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.4); }
}
/* Motion is reserved for active work and changes. */
@keyframes shimmer {
from { background-position: -200% 0; }
@@ -241,10 +236,6 @@
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 212, 255, 0.5); }
50% { box-shadow: 0 0 0 5px rgba(124, 212, 255, 0); }
}
@keyframes cherry-fizz {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1px); }
}
@keyframes spark-slide {
from { background-position: -100% 0; }
to { background-position: 200% 0; }
+13 -7
View File
@@ -209,8 +209,11 @@
<div class="view-toggle" aria-label="Display mode"><button class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
</div>{/if}
<div class="filter-row">
<div class="chips" aria-label="Filter servers">{#each filters as item}
<button class:selected={filter === item.id} aria-pressed={filter === item.id} onclick={() => filter = item.id}>{item.label}<span>{servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length}</span></button>
<div class="chips" role="radiogroup" aria-label="Filter servers">{#each filters as item}
<label class="filter-chip" class:selected={filter === item.id}>
<input type="radio" name="fleet-filter" value={item.id} bind:group={filter} aria-label={`${item.label}, ${servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length} servers`} />
{item.label}<span> {servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length}</span>
</label>
{/each}</div>
</div>
@@ -300,13 +303,16 @@
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
.search { flex: 1; min-width: min(280px, 100%); }
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
input, select { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
input::placeholder { color: var(--text-secondary); }
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
.chips button, .view-toggle button, .pin { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.filter-chip, .view-toggle button, .pin { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
.filter-chip { position: relative; display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.filter-chip input { position: absolute; width: 1px; height: 1px; min-height: 0; padding: 0; opacity: 0; }
.filter-chip:focus-within { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
.filter-chip span { opacity: .75; font-variant-numeric: tabular-nums; }
button.selected, .filter-chip.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
.map-group { display: flex; flex-direction: column; gap: 16px; }
@@ -340,5 +346,5 @@
@media (max-width: 900px) { .overview { grid-template-columns: repeat(2, 1fr); } .attention { flex-wrap: wrap; } }
@media (max-width: 600px) { .page { gap: 20px; } .overview { padding: 18px; gap: 22px 16px; } .search-row > label:not(.search) { flex: 1; min-width: 100px; } .search { flex-basis: 100%; } .group-heading { align-items: flex-start; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } }
@media (max-width: 600px) { .overview { padding: 10px 12px; gap: 8px 16px; } .search-row { flex-wrap: nowrap; } .search { flex-basis: auto; min-width: 0; } .view-options-toggle { flex-shrink: 0; } .chips { flex-wrap: nowrap; overflow-x: auto; padding: 3px; width: 100%; } .chips button { white-space: nowrap; flex-shrink: 0; } .attention { padding: 10px 12px; font-size: 13px; } .spotlight { padding:12px; gap:8px; } .spotlight > div { min-width:0; } .page { gap: 16px; } .group-heading p { display: none; } }
@media (max-width: 600px) { .overview { padding: 10px 12px; gap: 8px 16px; } .search-row { flex-wrap: nowrap; } .search { flex-basis: auto; min-width: 0; } .view-options-toggle { flex-shrink: 0; } .chips { flex-wrap: nowrap; overflow-x: auto; padding: 3px; width: 100%; } .filter-chip { white-space: nowrap; flex-shrink: 0; min-height: 44px; } .attention { padding: 10px 12px; font-size: 13px; } .spotlight { padding:12px; gap:8px; } .spotlight > div { min-width:0; } .page { gap: 16px; } .group-heading p { display: none; } }
</style>