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