Files
electric-cherry/src/lib/ui/components/ServerCard.svelte
T
josh a2d4a38925 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.
2026-09-14 19:48:48 -04:00

328 lines
9.7 KiB
Svelte

<script lang="ts">
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;
betaId: number;
}
interface Props {
name: string;
players?: number | null;
map?: string | null;
gametype?: string | null;
roster?: RosterPlayer[] | null;
health?: Health;
lastSeenAt?: string | null;
lastError?: string | null;
held?: Held[];
lastRestartedAt?: string | null;
restarting?: boolean;
activeSince?: string | null;
inactiveSince?: string | null;
gameStartedAt?: string | null;
gameEndedAt?: string | null;
}
let {
name,
players = null,
map = null,
gametype = null,
roster = null,
health = 'offline',
lastSeenAt = null,
lastError = null,
held = [],
lastRestartedAt = null,
restarting = false,
activeSince = null,
inactiveSince = null,
gameStartedAt = null,
gameEndedAt = null
}: Props = $props();
function ago(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
let topRoster = $derived(
(roster ?? [])
.slice()
.sort((a, b) => b.score - a.score)
.slice(0, 3)
);
let showRoster = $derived(health !== 'offline');
let mapLabel = $derived(map ?? '—');
let gtLabel = $derived(gametypeLabel(gametype));
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
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>
<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">
<StatusPip {health} />
<strong class="name">{name}</strong>
</div>
{#if health === 'offline'}
<Badge variant="danger" uppercase>offline</Badge>
{:else}
<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" aria-hidden="true">·</span>
<span class="gt">{gtLabel}</span>
{/if}
</div>
{#if showTiming}
<div class="timing">
{#if activeElapsed}
<div class="metric">
<span class="metric-label">Active</span>
<span class="metric-value">{activeElapsed}</span>
</div>
{:else if inactiveElapsed}
<div class="metric">
<span class="metric-label">Inactive</span>
<span class="metric-value">{inactiveElapsed}</span>
</div>
{/if}
{#if gameElapsed}
<div class="metric">
<span class="metric-label">Match</span>
<span class="metric-value">{gameElapsed}</span>
</div>
{/if}
</div>
{/if}
{#if showRoster}
{#if topRoster.length > 0}
<ul class="roster">
{#each topRoster as p (p.guid ?? p.name)}
<li>
<span class="pname">{p.name || '—'}</span>
<span class="pscore">{p.score}</span>
<span class="pping">{p.ping}ms</span>
</li>
{/each}
</ul>
{:else}
<div class="lobby-empty">Lobby is empty</div>
{/if}
{/if}
{#if held.length > 0}
<div class="held">
{#each held as h (h.target)}
<Badge variant="info" size="sm" title="held by beta {h.betaId}">β {h.target}</Badge>
{/each}
</div>
{/if}
<div class="foot">
{#if health === 'offline' && lastError}
<span class="err">{lastError}</span>
{:else if lastSeenAt}
<span>Last seen {ago(lastSeenAt)}</span>
{:else if lastRestartedAt}
<span>Restart {ago(lastRestartedAt)}</span>
{:else}
<span class="dim">No data</span>
{/if}
</div>
</Card>
<style>
/* Global tweaks scoped through :global() since Card is a child component. */
:global(.card.restarting) {
outline: 1px solid rgba(255, 176, 32, 0.28);
}
:global(.card.offline) {
opacity: 0.62;
}
.restart-bar {
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
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;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.name-row {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.name {
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players { flex-shrink: 0; }
.meta {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-1);
font-size: var(--text-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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: var(--space-5);
margin-top: var(--space-2);
}
.metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-label {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
}
.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 {
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: var(--space-1);
}
.foot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
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>