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:
@@ -2,35 +2,28 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import DiffViewer from '$ui/components/DiffViewer.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
interface FileRow {
|
||||
id: number;
|
||||
server: string;
|
||||
verb: string;
|
||||
rel_path: string;
|
||||
sha256_before: string | null;
|
||||
sha256_after: string | null;
|
||||
size_before: number | null;
|
||||
size_after: number | null;
|
||||
id: number; server: string; verb: string; rel_path: string;
|
||||
sha256_before: string | null; sha256_after: string | null;
|
||||
size_before: number | null; size_after: number | null;
|
||||
}
|
||||
interface ServerRow {
|
||||
deployment_id: string;
|
||||
server: string;
|
||||
files_changed: number;
|
||||
restart_state: string;
|
||||
players: number | null;
|
||||
checked_at: string | null;
|
||||
restarted_at: string | null;
|
||||
attempts: number;
|
||||
error: string | null;
|
||||
deployment_id: string; server: string; files_changed: number;
|
||||
restart_state: string; players: number | null;
|
||||
checked_at: string | null; restarted_at: string | null;
|
||||
attempts: number; error: string | null;
|
||||
}
|
||||
interface Detail {
|
||||
deployment: {
|
||||
id: string;
|
||||
action: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
triggered_by: string | null;
|
||||
id: string; action: string; started_at: string;
|
||||
finished_at: string | null; triggered_by: string | null;
|
||||
};
|
||||
servers: ServerRow[];
|
||||
files: FileRow[];
|
||||
@@ -60,8 +53,7 @@
|
||||
openFile = { id: f.id, ...body };
|
||||
}
|
||||
|
||||
// Group files by server for the layout
|
||||
let byServer = $derived(() => {
|
||||
let byServer = $derived.by(() => {
|
||||
if (!detail) return new Map<string, FileRow[]>();
|
||||
const m = new Map<string, FileRow[]>();
|
||||
for (const f of detail.files) {
|
||||
@@ -70,203 +62,295 @@
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function stateVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
|
||||
return s === 'done' ? 'online' :
|
||||
s === 'failed' ? 'danger' :
|
||||
s === 'pending' ? 'warning' :
|
||||
s === 'held' ? 'info' :
|
||||
'neutral';
|
||||
}
|
||||
function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
|
||||
return v === 'added' ? 'online' :
|
||||
v === 'updated' ? 'info' :
|
||||
v === 'removed' ? 'danger' :
|
||||
v === 'held' ? 'warning' :
|
||||
'neutral';
|
||||
}
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if detail}
|
||||
<h1>{detail.deployment.action}</h1>
|
||||
<p class="meta">
|
||||
started {detail.deployment.started_at}
|
||||
{#if detail.deployment.finished_at} · finished {detail.deployment.finished_at}{/if}
|
||||
· <code>{detail.deployment.id}</code>
|
||||
</p>
|
||||
{@const action = detail.deployment.action}
|
||||
<div class="page">
|
||||
<SectionHeader title={action} subtitle={detail.deployment.id}>
|
||||
{#snippet crumbs()}
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Deployments', href: '/deployments' },
|
||||
{ label: action }
|
||||
]} />
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<h2>Servers</h2>
|
||||
<div class="server-grid">
|
||||
{#each detail.servers as s (s.server)}
|
||||
<div class="srv srv-{s.restart_state}">
|
||||
<div class="name">{s.server}</div>
|
||||
<div class="line">state: {s.restart_state}</div>
|
||||
{#if s.players !== null}<div class="line">players: {s.players}</div>{/if}
|
||||
{#if s.error}<div class="line err">{s.error}</div>{/if}
|
||||
{#if s.restarted_at}<div class="line dim">restarted {s.restarted_at}</div>{/if}
|
||||
<div class="line dim">attempts: {s.attempts}</div>
|
||||
<Card padding="lg">
|
||||
<div class="meta-grid">
|
||||
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
|
||||
<div class="kv"><span class="k">Finished</span><span class="v mono">{fmt(detail.deployment.finished_at)}</span></div>
|
||||
<div class="kv"><span class="k">Triggered by</span><span class="v mono">{detail.deployment.triggered_by ?? '—'}</span></div>
|
||||
<div class="kv"><span class="k">Servers</span><span class="v mono">{detail.servers.length}</span></div>
|
||||
</div>
|
||||
{/each}
|
||||
</Card>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>Servers</h2>
|
||||
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
|
||||
</div>
|
||||
<div class="server-list">
|
||||
{#each detail.servers as s (s.server)}
|
||||
<div class="srv-row">
|
||||
<div class="srv-l">
|
||||
<a class="srv-name" href="/servers/{s.server}">{s.server}</a>
|
||||
{#if s.error}
|
||||
<span class="err">{s.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="srv-r">
|
||||
<Badge variant={stateVariant(s.restart_state)} size="sm" uppercase>{s.restart_state}</Badge>
|
||||
{#if s.players !== null && s.players > 0}
|
||||
<span class="players">{s.players}p</span>
|
||||
{/if}
|
||||
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
|
||||
{#if s.restarted_at}
|
||||
<span class="when">restarted {fmt(s.restarted_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>Files</h2>
|
||||
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
|
||||
</div>
|
||||
{#if detail.files.length === 0}
|
||||
<EmptyState title="No file changes recorded" description="This deployment didn't touch any tracked artifacts." />
|
||||
{:else}
|
||||
{#each [...byServer.entries()] as [srv, files] (srv)}
|
||||
<div class="file-group">
|
||||
<h3>{srv}</h3>
|
||||
<div class="file-rows">
|
||||
{#each files as f (f.id)}
|
||||
<button class="file-row" onclick={() => loadDiff(f)}>
|
||||
<Badge variant={verbVariant(f.verb)} size="sm" uppercase>{f.verb}</Badge>
|
||||
<span class="path">{f.rel_path}</span>
|
||||
<span class="sizes">
|
||||
{#if f.size_before !== null}{f.size_before} B{/if}
|
||||
{#if f.size_before !== null && f.size_after !== null} → {/if}
|
||||
{#if f.size_after !== null}{f.size_after} B{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if openFile}
|
||||
<div
|
||||
class="diff-scrim"
|
||||
onclick={() => (openFile = null)}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close diff"
|
||||
>
|
||||
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
|
||||
<div class="diff-head">
|
||||
<span class="diff-title">{openFile.relPath}</span>
|
||||
<Button variant="ghost" size="sm" onclick={() => (openFile = null)}>Close</Button>
|
||||
</div>
|
||||
<DiffViewer
|
||||
before={openFile.before}
|
||||
after={openFile.after}
|
||||
verb={openFile.verb}
|
||||
relPath={openFile.relPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2>Files</h2>
|
||||
{#each [...byServer().entries()] as [srv, files] (srv)}
|
||||
<div class="file-group">
|
||||
<h3>{srv}</h3>
|
||||
{#each files as f (f.id)}
|
||||
<button class="file-row" onclick={() => loadDiff(f)}>
|
||||
<span class="verb verb-{f.verb}">{f.verb}</span>
|
||||
<code>{f.rel_path}</code>
|
||||
<span class="sizes">
|
||||
{#if f.size_before !== null}{f.size_before}b{/if}
|
||||
{#if f.size_before !== null && f.size_after !== null} → {/if}
|
||||
{#if f.size_after !== null}{f.size_after}b{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if openFile}
|
||||
<div class="diff-scrim" onclick={() => (openFile = null)} onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }} role="button" tabindex="-1">
|
||||
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
|
||||
<button class="close" onclick={() => (openFile = null)}>close</button>
|
||||
<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty">loading…</p>
|
||||
<div class="loading">Loading…</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
color: var(--cream);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
letter-spacing: 1.5px;
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0 0 8px;
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
h2 {
|
||||
margin: var(--gap-section) 0 14px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--ink-300);
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--space-16);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
h3 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: var(--text-md);
|
||||
color: var(--cream);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.meta {
|
||||
color: var(--ink-500);
|
||||
font-size: var(--text-mono);
|
||||
margin: 0 0 28px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.server-grid {
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.srv {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 16px;
|
||||
.kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.srv-done { border-color: rgba(124, 212, 255, 0.35); }
|
||||
.srv-failed { border-color: rgba(255, 48, 80, 0.35); }
|
||||
.srv-pending { border-color: rgba(255, 176, 32, 0.35); }
|
||||
.srv-held { border-color: rgba(124, 192, 245, 0.3); }
|
||||
.srv-manual { border-color: var(--ink-500); }
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--cream);
|
||||
.kv .k {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.kv .v { font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.line { font-size: var(--text-mono); color: var(--ink-300); font-family: var(--font-mono); line-height: var(--leading-mono); }
|
||||
.line.dim { color: var(--ink-500); }
|
||||
.line.err { color: var(--danger-500); }
|
||||
.file-group { margin-bottom: 20px; }
|
||||
|
||||
.server-list { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.srv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.srv-l { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; }
|
||||
.srv-r { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.srv-name {
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.srv-name:hover { color: var(--accent-primary-hover); }
|
||||
.err { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-danger); }
|
||||
.players { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-online); }
|
||||
.attempts, .when {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.file-group {
|
||||
margin-top: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.file-group h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.file-rows { display: flex; flex-direction: column; gap: 2px; }
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
color: var(--ink-100);
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
transition: background-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.file-row:hover { border-color: var(--accent); }
|
||||
.verb {
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-xs);
|
||||
.file-row:hover { background: var(--surface-raised); }
|
||||
.file-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.path {
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.verb-added {
|
||||
background: rgba(124, 212, 255, 0.10);
|
||||
border-color: rgba(124, 212, 255, 0.3);
|
||||
color: var(--spark-500);
|
||||
}
|
||||
.verb-updated {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
color: var(--cream);
|
||||
}
|
||||
.verb-removed {
|
||||
background: rgba(255, 48, 80, 0.1);
|
||||
border-color: rgba(255, 48, 80, 0.3);
|
||||
color: var(--danger-500);
|
||||
}
|
||||
.verb-held {
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
border-color: rgba(255, 176, 32, 0.3);
|
||||
color: var(--amber-500);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.sizes {
|
||||
margin-left: auto;
|
||||
color: var(--ink-500);
|
||||
font-size: var(--text-mono-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
code { font-family: var(--font-mono); color: var(--ink-100); font-size: var(--text-mono); }
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Diff modal */
|
||||
.diff-scrim {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(6, 11, 22, 0.85);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.diff-modal {
|
||||
max-width: 1000px;
|
||||
width: 92%;
|
||||
width: min(1000px, 92vw);
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: var(--pad-panel);
|
||||
box-shadow: var(--shadow-panel);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
.close {
|
||||
float: right;
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 14px;
|
||||
font-family: var(--font-body);
|
||||
.diff-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.diff-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.close:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user