Clarify UI status and remove noisy live regions
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import ServerCard from '../../src/lib/ui/components/ServerCard.svelte';
|
||||
import EmptyState from '../../src/lib/ui/components/primitives/EmptyState.svelte';
|
||||
import StatusBadge from '../../src/lib/ui/components/primitives/StatusBadge.svelte';
|
||||
import StatusPip from '../../src/lib/ui/components/primitives/StatusPip.svelte';
|
||||
import { statusPresentation } from '../../src/lib/ui/status.js';
|
||||
import { uiNow } from '../../src/lib/ui/stores/servers.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
|
||||
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/?filter=occupied') }) }));
|
||||
|
||||
describe('status accessibility', () => {
|
||||
it.each([
|
||||
['online', 'Online', 'online', 'circle'],
|
||||
['starting', 'Starting', 'info', 'ring'],
|
||||
['provisioning', 'Provisioning', 'info', 'ring'],
|
||||
['stale', 'Not responding', 'warning', 'triangle'],
|
||||
['offline', 'Offline', 'danger', 'square'],
|
||||
['queued', 'Queued', 'neutral', 'hollow']
|
||||
])('pairs %s with a consistent word and non-color cue without creating a live region', (state, label, tone, shape) => {
|
||||
expect(statusPresentation(state)).toEqual({ label, tone, shape });
|
||||
const body = render(StatusBadge, { props: { state } }).body;
|
||||
expect(body).toContain(label);
|
||||
expect(body).toContain(`shape-${shape}`);
|
||||
expect(body).toContain(`v-${tone}`);
|
||||
expect(body).not.toMatch(/role="status"|aria-live=/);
|
||||
});
|
||||
|
||||
it('provides a static accessible name only when an isolated pip needs one', () => {
|
||||
const decorative = render(StatusPip, { props: { health: 'online' } }).body;
|
||||
expect(decorative).toContain('aria-hidden="true"');
|
||||
expect(decorative).not.toContain('role=');
|
||||
const labelled = render(StatusPip, { props: { health: 'online', label: 'Online' } }).body;
|
||||
expect(labelled).toContain('role="img"');
|
||||
expect(labelled).toContain('aria-label="Online"');
|
||||
expect(labelled).not.toContain('aria-hidden=');
|
||||
});
|
||||
|
||||
it('names the card link from its title and keeps its clock and roster out of live regions', () => {
|
||||
const receivedAt = new Date(get(uiNow) - 10_000).toISOString();
|
||||
const body = render(ServerCard, { props: {
|
||||
name: 't5_zombie_theater_1', health: 'online', players: 4,
|
||||
telemetry: { ...gameEvent('heartbeat', 30, 4), receivedAt },
|
||||
activeSince: receivedAt, gameStartedAt: receivedAt,
|
||||
roster: [{ num: 0, name: 'Player One', score: 30000, ping: 40, guid: '1' }]
|
||||
} }).body;
|
||||
const card = body.match(/<a\b[^>]*>/)?.[0] ?? '';
|
||||
const titleId = card.match(/aria-labelledby="([^"]+)"/)?.[1];
|
||||
expect(titleId).toBeTruthy();
|
||||
expect(body).toContain(`id="${titleId}"`);
|
||||
expect(card).not.toContain('aria-label=');
|
||||
expect(body).toContain('Online');
|
||||
expect(body).toContain('Round 30');
|
||||
expect(body).toContain('Player One');
|
||||
expect(body).not.toMatch(/aria-live=|role="status"/);
|
||||
});
|
||||
|
||||
it('decodes persisted errors and keeps unknown technical errors in diagnostics', () => {
|
||||
const props = { name: 't5_zombie_theater_1', health: 'offline' as const };
|
||||
const known = render(ServerCard, { props: { ...props, lastError: 'timeout:Agent received no reply.' } }).body;
|
||||
expect(known).toContain('Agent received no reply.');
|
||||
expect(known).not.toContain('timeout:');
|
||||
const unknown = render(ServerCard, { props: { ...props, lastError: 'unknown-error:raw diagnostic data' } }).body;
|
||||
expect(unknown).toContain('Open diagnostics for details.');
|
||||
expect(unknown).not.toContain('raw diagnostic data');
|
||||
});
|
||||
|
||||
it('uses a page-level heading by default and allows nested empty states without live announcements', () => {
|
||||
const props = { title: 'No campaigns', description: 'Campaigns apply file changes to your servers.' };
|
||||
const body = render(EmptyState, { props }).body;
|
||||
expect(body.replaceAll(/<!--.*?-->/g, '')).toMatch(/<h2\b[^>]*>No campaigns<\/h2>/);
|
||||
expect(body).not.toMatch(/role="status"|aria-live=/);
|
||||
expect(render(EmptyState, { props: { ...props, headingLevel: 3 } }).body).toMatch(/<h3\b/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user