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
+157 -46
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onNavigate, goto } from '$app/navigation';
import { startPipelineStream } from '$ui/stores/pipeline.js';
import { startServerStream } from '$ui/stores/servers.js';
import { page } from '$app/stores';
import BrandMark from '$ui/components/BrandMark.svelte';
import Toaster from '$ui/components/Toaster.svelte';
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
// Side-effect import — puts @fontsource CSS on Vite's module graph so
// the referenced woff/woff2 files get emitted into the build.
import '$ui/styles/fonts.js';
@@ -14,27 +17,83 @@
onMount(() => {
startPipelineStream();
startServerStream();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments,
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
const shortcuts: Record<string, string> = {
f: '/', d: '/deployments', b: '/betas',
s: '/scripts', c: '/configs', p: '/plugins'
};
let armed = false;
let armTimer: ReturnType<typeof setTimeout> | null = null;
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (!armed && e.key === 'g') {
armed = true;
if (armTimer) clearTimeout(armTimer);
armTimer = setTimeout(() => { armed = false; }, 1000);
return;
}
if (armed) {
armed = false;
if (armTimer) { clearTimeout(armTimer); armTimer = null; }
const dest = shortcuts[e.key];
if (dest) {
e.preventDefault();
goto(dest);
}
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer);
};
});
// Route changes cross-fade via the browser's View Transitions API.
// Non-Chromium browsers fall through to plain navigation.
onNavigate(enableViewTransitions);
const tabs = [
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' },
{ href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') },
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
];
</script>
<div class="shell">
<nav>
<nav aria-label="Primary">
<a class="brand-link" href="/" aria-label="Electric Cherry home">
<BrandMark size="md" />
<BrandMark size="sm" />
</a>
<div class="tabs">
<a href="/" class:on={$page.url.pathname === '/'}>Fleet</a>
<a href="/scripts" class:on={$page.url.pathname.startsWith('/scripts')}>Scripts</a>
<a href="/configs" class:on={$page.url.pathname.startsWith('/configs')}>Configs</a>
<a href="/plugins" class:on={$page.url.pathname.startsWith('/plugins')}>Plugins</a>
<a href="/deployments" class:on={$page.url.pathname.startsWith('/deployments')}>Deployments</a>
<a href="/betas" class:on={$page.url.pathname.startsWith('/betas')}>Betas</a>
<div class="tabs" role="tablist">
{#each tabs as t (t.href)}
{@const on = t.match($page.url.pathname)}
<a
href={t.href}
class:on
role="tab"
aria-selected={on}
aria-current={on ? 'page' : undefined}
>
<span class="lbl">{t.label}</span>
</a>
{/each}
</div>
<div class="strut" aria-hidden="true"></div>
</nav>
<main>
{@render children()}
</main>
<Toaster />
</div>
<style>
@@ -42,76 +101,128 @@
:global(html, body) {
margin: 0;
background: var(--bg-page);
color: var(--ink-100);
background: var(--surface-sunken);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
:global(*) { box-sizing: border-box; }
:global(a) { color: var(--accent-hi); }
:global(a) { color: var(--accent-primary-hover); }
:global(::selection) {
background: var(--accent);
color: var(--cream);
background: var(--accent-primary);
color: var(--text-on-accent);
}
:global(:focus-visible) {
outline: none;
}
/* Ambient background — a faint radial gradient hints at depth without
any lines. Fixed to the viewport so scrolling stays quiet. */
:global(body::before) {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(1200px 700px at 20% -10%, rgba(46, 134, 209, 0.10), transparent 60%),
radial-gradient(900px 600px at 90% 100%, rgba(124, 192, 245, 0.05), transparent 60%);
z-index: -1;
}
.shell { min-height: 100vh; }
nav {
position: sticky;
top: 0;
z-index: 20;
display: flex;
gap: 24px;
padding: var(--pad-nav-y) var(--pad-nav-x);
align-items: center;
background:
linear-gradient(180deg, rgba(46, 134, 209, 0.18) 0%, transparent 100%),
var(--bg-nav);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 0 0 rgba(46, 134, 209, 0.25);
gap: var(--space-6);
height: 60px;
padding: 0 var(--space-8);
background: color-mix(in oklab, var(--surface) 75%, transparent);
border-bottom: 1px solid var(--border-subtle);
}
@supports (backdrop-filter: blur(4px)) {
nav {
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
background: color-mix(in oklab, var(--surface) 62%, transparent);
}
}
.brand-link {
display: flex;
align-items: center;
text-decoration: none;
border-radius: var(--radius-md);
}
.brand-link:focus-visible { box-shadow: var(--focus-ring); }
.tabs {
display: flex;
gap: 6px;
margin-left: 20px;
align-items: center;
gap: 2px;
margin-left: var(--space-4);
}
.tabs a {
color: var(--ink-300);
text-decoration: none;
padding: 10px 18px;
border-radius: var(--r-sm);
font-size: var(--text-sm);
font-weight: 500;
letter-spacing: var(--tracking-nav);
position: relative;
transition: color 0.15s;
color: var(--text-tertiary);
text-decoration: none;
padding: 8px 14px;
border-radius: var(--radius-pill);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-normal);
transition:
color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
}
.tabs a:hover { color: var(--ink-100); }
.tabs a:hover { color: var(--text-primary); }
.tabs a:focus-visible { box-shadow: var(--focus-ring); }
.tabs a.on {
color: var(--cream);
}
.tabs a.on::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: -2px;
height: 2px;
background: var(--accent);
box-shadow: var(--accent-glow);
border-radius: 2px;
color: var(--text-primary);
background: var(--surface-raised);
/* Shared-element transition — pill slides smoothly between tabs
when navigating. Chromium only; falls back to instant swap. */
view-transition-name: nav-active;
}
.strut { margin-left: auto; }
main {
padding: 40px 32px 64px;
max-width: 1400px;
padding: var(--space-10) var(--space-10) var(--space-24);
max-width: 1440px;
margin: 0 auto;
view-transition-name: main;
}
/* Mobile — collapse nav to a bottom-anchored, thumb-reachable bar.
(Fully realized in Phase 8; this is the graceful base.) */
@media (max-width: 720px) {
nav {
gap: var(--space-3);
padding: 0 var(--space-4);
overflow-x: auto;
}
.tabs { margin-left: var(--space-2); }
main { padding: var(--space-6) var(--space-4) var(--space-16); }
}
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
:global(::view-transition-old(root)),
:global(::view-transition-new(root)) {
animation-duration: var(--dur-base);
animation-timing-function: var(--ease-standard);
}
:global(::view-transition-old(main)),
:global(::view-transition-new(main)) {
animation-duration: var(--dur-base);
animation-timing-function: var(--ease-standard);
}
</style>