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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user