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:
+144
-54
@@ -1,9 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import { statusByServer } from '$ui/stores/servers.js';
|
||||
import { deployments } from '$ui/stores/pipeline.js';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
|
||||
interface HeldRow { target: string; betaId: number; }
|
||||
interface ServerRow {
|
||||
@@ -40,7 +48,6 @@
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Live server view — merge SSE data over the initial GET
|
||||
function liveFor(name: string, fallback: ServerRow) {
|
||||
const live = $statusByServer.get(name);
|
||||
if (!live) {
|
||||
@@ -72,27 +79,92 @@
|
||||
gameEndedAt: live.gameEndedAt
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregate fleet metrics — pulled from the same SSE store cards use, so
|
||||
// the hero and grid stay perfectly consistent.
|
||||
let liveViews = $derived(servers.map((s) => liveFor(s.name, s)));
|
||||
let totalPlayers = $derived(
|
||||
liveViews.reduce((sum, v) => sum + (v.players ?? 0), 0)
|
||||
);
|
||||
let activeCount = $derived(
|
||||
liveViews.filter((v) => v.health === 'online').length
|
||||
);
|
||||
let totalCount = $derived(servers.length);
|
||||
let inFlightDeployments = $derived(
|
||||
Array.from($deployments.values()).filter((d) => {
|
||||
for (const s of d.servers.values()) if (s.state === 'pending') return true;
|
||||
return false;
|
||||
}).length
|
||||
);
|
||||
let activeRatio = $derived(totalCount > 0 ? activeCount / totalCount : 0);
|
||||
</script>
|
||||
|
||||
<section class="left">
|
||||
<header class="hdr">
|
||||
<div class="title-block">
|
||||
<h1>Fleet</h1>
|
||||
<span class="count">{servers.length} server{servers.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="hdr-actions">
|
||||
<button class="deploy" onclick={() => (deployOpen = true)}>Deploy new server</button>
|
||||
</div>
|
||||
</header>
|
||||
<section class="page">
|
||||
<!-- Fleet Overview — the first thing the eye lands on: three big numbers
|
||||
that tell the operator the state of the world in under half a second. -->
|
||||
<div class="hero">
|
||||
<Card padding="lg" elevation="flat">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-cell">
|
||||
<Stat
|
||||
label="Players online"
|
||||
value={totalPlayers}
|
||||
size="xl"
|
||||
tone={totalPlayers > 0 ? 'online' : 'primary'}
|
||||
tween
|
||||
/>
|
||||
</div>
|
||||
<div class="hero-cell">
|
||||
<Stat
|
||||
label="Active servers"
|
||||
value={`${activeCount}/${totalCount}`}
|
||||
size="xl"
|
||||
tone="primary"
|
||||
tween={false}
|
||||
/>
|
||||
<div class="ratio-bar" aria-hidden="true">
|
||||
<div class="ratio-fill" style="width: {activeRatio * 100}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-cell">
|
||||
<a class="deploy-link" href="/deployments">
|
||||
<Stat
|
||||
label="Deployments in flight"
|
||||
value={inFlightDeployments}
|
||||
size="xl"
|
||||
tone={inFlightDeployments > 0 ? 'accent' : 'primary'}
|
||||
tween
|
||||
/>
|
||||
<span class="link-hint">View →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<SectionHeader
|
||||
title="Fleet"
|
||||
subtitle={totalCount === 1 ? '1 server' : `${totalCount} servers`}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="primary" size="md" onclick={() => (deployOpen = true)}>
|
||||
Deploy new server
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<DeployServerDialog bind:open={deployOpen} />
|
||||
|
||||
{#if servers.length === 0}
|
||||
<p class="empty">no servers registered yet — check SERVERS_ROOT</p>
|
||||
<EmptyState
|
||||
title="No servers registered yet"
|
||||
description="Point SERVERS_ROOT at your fleet directory and refresh to hydrate the dashboard."
|
||||
/>
|
||||
{:else}
|
||||
<div class="cards">
|
||||
{#each servers as s (s.name)}
|
||||
{#each servers as s, i (s.name)}
|
||||
{@const live = liveFor(s.name, s)}
|
||||
<div in:fly={{ y: 12, duration: 320, delay: Math.min(i * 40, 320), easing: cubicOut }}>
|
||||
<ServerCard
|
||||
name={s.name}
|
||||
players={live.players}
|
||||
@@ -108,60 +180,78 @@
|
||||
gameStartedAt={live.gameStartedAt}
|
||||
gameEndedAt={live.gameEndedAt}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hdr {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.hero-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ratio-bar {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
background: var(--surface-overlay);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ratio-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-primary);
|
||||
transition: width var(--dur-slow) var(--ease-standard);
|
||||
}
|
||||
|
||||
.deploy-link {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: var(--space-3);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-md);
|
||||
transition: opacity var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.title-block { display: flex; align-items: baseline; gap: 18px; }
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--cream);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-500);
|
||||
letter-spacing: 0.5px;
|
||||
.deploy-link:hover { opacity: 0.85; }
|
||||
.deploy-link:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.link-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: var(--gap-cards);
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
}
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--r-md);
|
||||
.cards > div { display: flex; flex-direction: column; }
|
||||
.cards > div > :global(*) { flex: 1; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
}
|
||||
.hdr-actions { display: flex; gap: 10px; }
|
||||
.deploy {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
color: var(--cream);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 18px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
@media (max-width: 640px) {
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
.deploy:hover { box-shadow: var(--accent-glow); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user