diff --git a/src/lib/ui/components/BrandMark.svelte b/src/lib/ui/components/BrandMark.svelte index 7acf67c..6ed18f3 100644 --- a/src/lib/ui/components/BrandMark.svelte +++ b/src/lib/ui/components/BrandMark.svelte @@ -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; } diff --git a/src/lib/ui/components/RoundTelemetry.svelte b/src/lib/ui/components/RoundTelemetry.svelte index b4b05ac..8114c9b 100644 --- a/src/lib/ui/components/RoundTelemetry.svelte +++ b/src/lib/ui/components/RoundTelemetry.svelte @@ -11,7 +11,7 @@ {#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh} -
+
Round {report.round} Last reported {elapsedSince(report.receivedAt, $uiNow)} ago
diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 9dcdd7c..db3f251 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -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 @@
- - +
- {#if health === 'provisioning'} - Provisioning - {:else if health === 'starting'} - Starting - {:else if health === 'offline'} - offline - {:else if health === 'stale'} - Stale - {:else} -
+
+ + {#if health === 'online'} +
- {/if} + {/if} +
{#if gtLabel || showMap}
{#if showMap}{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}{/if}{#if gtLabel}{gtLabel}{/if}
{/if} @@ -193,7 +190,7 @@
{#if health === 'offline' && lastError} - {lastError} + {error?.detail || 'Server status is unavailable. Open diagnostics for details.'} {:else if lastSeenAt} Last seen {ago(lastSeenAt, $uiNow)} {: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; diff --git a/src/lib/ui/components/primitives/Badge.svelte b/src/lib/ui/components/primitives/Badge.svelte index 1649196..1003d7d 100644 --- a/src/lib/ui/components/primitives/Badge.svelte +++ b/src/lib/ui/components/primitives/Badge.svelte @@ -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); diff --git a/src/lib/ui/components/primitives/Card.svelte b/src/lib/ui/components/primitives/Card.svelte index c798a98..663f070 100644 --- a/src/lib/ui/components/primitives/Card.svelte +++ b/src/lib/ui/components/primitives/Card.svelte @@ -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} diff --git a/src/lib/ui/components/primitives/EmptyState.svelte b/src/lib/ui/components/primitives/EmptyState.svelte index db53876..e9c2ecc 100644 --- a/src/lib/ui/components/primitives/EmptyState.svelte +++ b/src/lib/ui/components/primitives/EmptyState.svelte @@ -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(); -
+
{#if icon}
{@render icon()}
{/if} -

{title}

+ {title} {#if description}

{description}

{/if} {#if actions}
{@render actions()}
{/if}
diff --git a/src/lib/ui/components/primitives/StatusBadge.svelte b/src/lib/ui/components/primitives/StatusBadge.svelte index deb6e21..475e981 100644 --- a/src/lib/ui/components/primitives/StatusBadge.svelte +++ b/src/lib/ui/components/primitives/StatusBadge.svelte @@ -1,7 +1,8 @@ -{status.label} +{status.label} diff --git a/src/lib/ui/components/primitives/StatusPip.svelte b/src/lib/ui/components/primitives/StatusPip.svelte index 25ad062..a43b065 100644 --- a/src/lib/ui/components/primitives/StatusPip.svelte +++ b/src/lib/ui/components/primitives/StatusPip.svelte @@ -1,66 +1,40 @@ - - - - + diff --git a/src/lib/ui/status.ts b/src/lib/ui/status.ts index 719fcee..52c1e88 100644 --- a/src/lib/ui/status.ts +++ b/src/lib/ui/status.ts @@ -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 = { + neutral: 'hollow', online: 'circle', warning: 'triangle', danger: 'square', info: 'ring' +}; + const states: Record = { 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 = { 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] }; } diff --git a/src/lib/ui/styles/tokens.css b/src/lib/ui/styles/tokens.css index 2c7e850..86b9f8d 100644 --- a/src/lib/ui/styles/tokens.css +++ b/src/lib/ui/styles/tokens.css @@ -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; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f537d31..aa31826 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -209,8 +209,11 @@
{/if}
-
{#each filters as item} - +
{#each filters as item} + {/each}
@@ -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; } } diff --git a/tests/unit/ui-status-accessibility.test.ts b/tests/unit/ui-status-accessibility.test.ts new file mode 100644 index 0000000..e67b332 --- /dev/null +++ b/tests/unit/ui-status-accessibility.test.ts @@ -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(/]*>/)?.[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(/]*>No campaigns<\/h2>/); + expect(body).not.toMatch(/role="status"|aria-live=/); + expect(render(EmptyState, { props: { ...props, headingLevel: 3 } }).body).toMatch(/