Redesign UI with new token system, primitives, and motion

Restraint-first design language: neutral gray surfaces + one blue accent
(cyan/amber/red reserved for state), 8pt spacing, softer radii, one
ambient + one raised shadow, standardized motion tokens. Alfa Slab One
demoted to BrandMark only; Inter carries all page copy.

New primitives (Button, Card, Badge, Stat, SectionHeader, Skeleton,
EmptyState, Tabs, Breadcrumbs, StatusPip, Field, Input, Select, Textarea)
plus motion utilities (tweenNumber, viewTransition) and a global toast
system. All eight route surfaces rewritten on top: Fleet gains an
overview hero, Server detail restructures into tabs, Deployments gets a
collapsible drift summary + per-server pipeline visual, and the
scripts/configs/plugins/betas flows adopt the new form primitives.

Sticky translucent nav with backdrop blur, view-transition cross-fades
between routes, staggered card entrance on Fleet, breathing status pip
halo, tabular-numeric player counts. Gmail-style g-key nav shortcuts.
Mobile stacks below 720px; prefers-reduced-motion neutralizes durations.
Legacy tokens preserved as aliases so any un-migrated code still renders.
This commit is contained in:
2026-09-14 19:48:48 -04:00
parent efd4a4fa66
commit a2d4a38925
34 changed files with 5093 additions and 3144 deletions
+152 -176
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import StatusDot from './StatusDot.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js';
import { gametypeLabel } from '$ui/gametype.js';
import { elapsedSince } from '$ui/duration.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';
interface Held {
target: string;
@@ -63,38 +66,54 @@
let mapLabel = $derived(map ?? '—');
let gtLabel = $derived(gametypeLabel(gametype));
// Live-ticking occupancy + match timers. Anchored to server-issued
// ISO timestamps and re-derived from a shared 1s ticker.
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
// Game timer only runs while the server is active. Belt-and-suspenders for
// the server-side clear on inactive transition.
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
let showTiming = $derived(
health !== 'offline' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
);
// Shared-element transition to /servers/{name} — Chromium only, others
// fall through to a plain nav.
let cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
</script>
<a class="card" href="/servers/{name}" class:restarting class:offline={health === 'offline'}>
<Card
href="/servers/{name}"
elevation="interactive"
padding="md"
class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''}
ariaLabel="Server {name}, {health}"
style={cardStyle}
>
{#if restarting}
<span class="restart-bar" aria-hidden="true"></span>
{/if}
<div class="head">
<div class="name-row">
<StatusDot {health} />
<StatusPip {health} />
<strong class="name">{name}</strong>
</div>
{#if health !== 'offline'}
<span class="players" class:has-players={players !== null && players > 0}>
{players ?? '—'}
</span>
{#if health === 'offline'}
<Badge variant="danger" uppercase>offline</Badge>
{:else}
<span class="players offline-badge">offline</span>
<div class="players" aria-live="polite">
<Stat
value={players ?? 0}
size="md"
tone={players !== null && players > 0 ? 'online' : 'primary'}
tween
/>
</div>
{/if}
</div>
<div class="meta">
<span class="map">{mapLabel}</span>
{#if gtLabel}
<span class="dot-sep">·</span>
<span class="dot" aria-hidden="true">·</span>
<span class="gt">{gtLabel}</span>
{/if}
</div>
@@ -102,21 +121,21 @@
{#if showTiming}
<div class="timing">
{#if activeElapsed}
<span class="timing-item active">
<span class="label">Active</span>
<span class="value">{activeElapsed}</span>
</span>
<div class="metric">
<span class="metric-label">Active</span>
<span class="metric-value">{activeElapsed}</span>
</div>
{:else if inactiveElapsed}
<span class="timing-item inactive">
<span class="label">Inactive</span>
<span class="value">{inactiveElapsed}</span>
</span>
<div class="metric">
<span class="metric-label">Inactive</span>
<span class="metric-value">{inactiveElapsed}</span>
</div>
{/if}
{#if gameElapsed}
<span class="timing-item game">
<span class="label">Game</span>
<span class="value">{gameElapsed}</span>
</span>
<div class="metric">
<span class="metric-label">Match</span>
<span class="metric-value">{gameElapsed}</span>
</div>
{/if}
</div>
{/if}
@@ -132,15 +151,15 @@
</li>
{/each}
</ul>
{:else if health !== 'offline'}
<div class="lobby-empty">lobby is empty</div>
{:else}
<div class="lobby-empty">Lobby is empty</div>
{/if}
{/if}
{#if held.length > 0}
<div class="held">
{#each held as h (h.target)}
<span class="chip" title="held by beta {h.betaId}">β {h.target}</span>
<Badge variant="info" size="sm" title="held by beta {h.betaId}">β {h.target}</Badge>
{/each}
</div>
{/if}
@@ -149,203 +168,160 @@
{#if health === 'offline' && lastError}
<span class="err">{lastError}</span>
{:else if lastSeenAt}
<span>last seen {ago(lastSeenAt)}</span>
<span>Last seen {ago(lastSeenAt)}</span>
{:else if lastRestartedAt}
<span>restart {ago(lastRestartedAt)}</span>
<span>Restart {ago(lastRestartedAt)}</span>
{:else}
<span class="dim">no data</span>
<span class="dim">No data</span>
{/if}
</div>
{#if restarting}
<div class="restarting-bar" aria-label="restarting"></div>
{/if}
</a>
</Card>
<style>
.card {
display: flex;
flex-direction: column;
gap: 14px;
background:
linear-gradient(180deg, var(--bg-card) 0%, var(--bg-nav) 100%);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-card);
color: var(--ink-100);
text-decoration: none;
transition: border-color 0.18s, box-shadow 0.18s, transform 0.18s;
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
/* Global tweaks scoped through :global() since Card is a child component. */
:global(.card.restarting) {
outline: 1px solid rgba(255, 176, 32, 0.28);
}
.card::before {
content: '';
:global(.card.offline) {
opacity: 0.62;
}
.restart-bar {
position: absolute;
inset: 0 0 auto 0;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.55;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: linear-gradient(
90deg,
transparent,
var(--state-warning),
var(--state-online),
transparent
);
background-size: 200% 100%;
animation: spark-slide 1.6s var(--ease-linear) infinite;
opacity: 0.9;
}
.card:hover {
border-color: var(--accent);
box-shadow: var(--accent-glow), var(--shadow-card);
transform: translateY(-1px);
}
.card.restarting { border-color: var(--amber-500); box-shadow: var(--amber-glow); }
.card.offline { opacity: 0.75; }
.head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
gap: var(--space-3);
}
.name-row {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.name-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
.name {
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: 600;
letter-spacing: 0.3px;
color: var(--cream);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players {
background: var(--bg-nav);
border: 1px solid var(--border);
padding: 4px 12px;
border-radius: 999px;
font-family: var(--font-mono);
font-size: var(--text-mono);
font-weight: 500;
color: var(--ink-500);
min-width: 38px;
text-align: center;
}
.players.has-players {
color: var(--spark-500);
border-color: var(--spark-500);
box-shadow: var(--spark-glow);
}
.players.offline-badge {
color: var(--danger-500);
border-color: rgba(255, 48, 80, 0.4);
font-family: var(--font-body);
font-size: var(--text-xs);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
}
.players { flex-shrink: 0; }
.meta {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: var(--text-mono);
color: var(--ink-300);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map { color: var(--cream-dim); }
.gt { color: var(--ink-300); }
.dot-sep { color: var(--ink-500); }
.roster {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.roster li {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
padding: 3px 0;
gap: var(--space-2);
margin-top: var(--space-1);
font-size: var(--text-sm);
}
.pname {
color: var(--cream);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pscore {
font-family: var(--font-mono);
color: var(--spark-500);
font-size: var(--text-mono-sm);
}
.pping {
font-family: var(--font-mono);
color: var(--ink-300);
font-size: var(--text-mono-sm);
min-width: 48px;
text-align: right;
}
.map { color: var(--text-secondary); font-weight: var(--weight-medium); }
.gt { color: var(--text-tertiary); }
.dot { color: var(--text-quaternary); }
.timing {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
gap: var(--space-5);
margin-top: var(--space-2);
}
.timing-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
.metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.timing-item .label {
color: var(--ink-500);
.metric-label {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.timing-item .value { color: var(--cream); }
.timing-item.active .value { color: var(--spark-500); }
.timing-item.inactive .value { color: var(--ink-300); }
.timing-item.game .value { color: var(--accent, var(--spark-500)); }
.metric-value {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.roster {
list-style: none;
padding: var(--space-3) 0 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
border-top: 1px solid var(--border-subtle);
}
.roster li {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--space-3);
align-items: center;
padding: 2px 0;
font-size: var(--text-sm);
}
.pname {
color: var(--text-primary);
font-weight: var(--weight-regular);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pscore,
.pping {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.pping { min-width: 44px; text-align: right; }
.lobby-empty {
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
color: var(--ink-500);
padding: 4px 0;
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.held { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
background: rgba(124, 192, 245, 0.10);
color: var(--cream);
border: 1px solid rgba(124, 192, 245, 0.28);
border-radius: var(--r-sm);
padding: 2px 8px;
font-size: var(--text-mono-sm);
font-family: var(--font-mono);
.held {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.foot {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
color: var(--ink-500);
letter-spacing: 0.3px;
align-items: center;
margin-top: auto;
padding-top: 6px;
}
.err { color: var(--danger-500); }
.dim { color: var(--ink-700); }
.restarting-bar {
height: 2px;
background: linear-gradient(90deg, transparent, var(--amber-500), var(--spark-500), transparent);
background-size: 200% 100%;
animation: spark-slide 1.2s infinite linear;
padding-top: var(--space-2);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.err { color: var(--state-danger); }
.dim { color: var(--text-quaternary); }
</style>