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
+467 -223
View File
@@ -1,5 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from '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 EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface ServerRow {
server: string;
@@ -32,7 +37,6 @@
prior_version: number | null;
scripts: DepScript[];
}
interface DriftFile {
relPath: string;
size: number;
@@ -50,13 +54,15 @@
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
// Drift panel opens automatically when there's a problem; collapses when clean.
let driftOpen = $state(false);
let driftFilter = $state('');
async function loadDeployments(): Promise<void> {
const res = await fetch('/api/deployments');
const body = (await res.json()) as { deployments: Deployment[] };
deployments = body.deployments;
}
async function scanDrift(): Promise<void> {
scanning = true;
try {
@@ -76,14 +82,22 @@
return { untracked: u, missing: m };
});
// Auto-open on first scan if there's drift; leave user's toggle alone after.
let firstScan = $state(true);
$effect(() => {
if (drift && firstScan) {
firstScan = false;
if (drift.totalDrift > 0) driftOpen = true;
}
});
async function runDrift(): Promise<void> {
if (!drift || drift.totalDrift === 0) return;
const parts: string[] = [];
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`);
if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return;
running = true;
flash = null;
running = true; flash = null;
try {
const res = await fetch('/api/drift', {
method: 'POST',
@@ -91,14 +105,8 @@
body: JSON.stringify({})
});
const body = (await res.json()) as {
ok: boolean;
error?: string;
result?: {
deploymentId: string | null;
totalRemoved: number;
totalRestored?: number;
totalServers: number;
};
ok: boolean; error?: string;
result?: { deploymentId: string | null; totalRemoved: number; totalRestored?: number; totalServers: number };
};
if (!body.ok) { flash = body.error ?? 'drift run failed'; return; }
const removed = body.result?.totalRemoved ?? 0;
@@ -126,11 +134,6 @@
function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
}
function chipClass(state: string): string {
return `chip chip-${state}`;
}
function ago(iso: string): string {
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return 'just now';
@@ -138,6 +141,13 @@
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function stateBadgeVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
@@ -150,7 +160,6 @@
}
return `${d.scripts.length} scripts`;
}
// Legacy fallback: pre-multi-script rows still use the top-level cols.
if (d.script_name) return `${d.script_name} v${d.script_version ?? '?'}`;
return '';
}
@@ -159,261 +168,496 @@
ev.preventDefault();
ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote';
const msg = `Rollback ${summary} on ${d.servers.length} server(s)?`;
if (!confirm(msg)) return;
if (!confirm(`Rollback ${summary} on ${d.servers.length} server(s)?`)) return;
rollingBack = d.id;
flash = null;
try {
const res = await fetch(`/api/deployments/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = `rollback deployment opened`;
flash = 'Rollback deployment opened';
await loadDeployments();
} finally {
rollingBack = null;
}
}
// History grouped by day for a nice timeline read.
let historyByDay = $derived.by(() => {
const groups = new Map<string, Deployment[]>();
for (const d of history) {
const key = new Date(d.started_at).toDateString();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d);
}
return Array.from(groups.entries());
});
// Drift filtering — spare the operator's eyes on huge fleets.
let filteredDriftServers = $derived(
drift?.servers.filter((s) =>
!driftFilter || s.server.toLowerCase().includes(driftFilter.toLowerCase())
) ?? []
);
</script>
<h1>Deployments</h1>
<div class="page">
<SectionHeader
title="Deployments"
subtitle="Fleet-wide script deploys, drift reconciliation, and rollbacks."
/>
{#if loading}
<p class="empty">loading…</p>
{:else}
<section class="drift-panel">
<div class="drift-head">
<h2>Drift{drift ? ` · ${driftCounts.untracked} untracked, ${driftCounts.missing} missing` : ''}</h2>
<div class="drift-actions">
<button onclick={scanDrift} disabled={scanning || running}>{scanning ? 'scanning…' : 'rescan'}</button>
<button class="primary" onclick={runDrift} disabled={running || scanning || !drift || drift.totalDrift === 0}>
{running ? 'reconciling…' : 'reconcile drift'}
</button>
</div>
</div>
{#if drift === null}
<p class="empty">scan hasn't run yet</p>
{:else if drift.totalDrift === 0}
<p class="empty">inventory matches disk — no drift</p>
{:else}
{#each drift.servers as s (s.server)}
<div class="drift-server">
<div class="drift-server-head mono">{s.server} · {s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</div>
<div class="drift-files">
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
<span
class="drift-file mono {f.driftKind}"
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
>[{f.driftKind === 'missing' ? '+' : '−'} {f.kind}] {f.relPath}</span>
{/each}
</div>
{#if loading}
<div class="loading">Loading…</div>
{:else}
<!-- Drift summary — collapsed by default when there's no problem. -->
<Card padding="lg">
<button
type="button"
class="drift-summary"
aria-expanded={driftOpen}
onclick={() => (driftOpen = !driftOpen)}
>
<div class="drift-summary-l">
<span class="chev" class:open={driftOpen} aria-hidden="true">›</span>
<span class="drift-title">Drift</span>
{#if drift === null}
<Badge variant="neutral" size="sm">Not scanned</Badge>
{:else if drift.totalDrift === 0}
<Badge variant="online" size="sm">In sync</Badge>
{:else}
{#if driftCounts.untracked > 0}
<Badge variant="danger" size="sm">{driftCounts.untracked} untracked</Badge>
{/if}
{#if driftCounts.missing > 0}
<Badge variant="warning" size="sm">{driftCounts.missing} missing</Badge>
{/if}
<span class="drift-servers">
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span>
{/if}
</div>
{/each}
{/if}
{#if flash}<p class="flash">{flash}</p>{/if}
</section>
<div class="drift-summary-r" onclick={(e) => e.stopPropagation()} role="none">
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
Rescan
</Button>
<Button
variant="danger"
size="sm"
onclick={runDrift}
disabled={running || scanning || !drift || drift.totalDrift === 0}
loading={running}
>
Reconcile
</Button>
</div>
</button>
<section>
<h2>Active</h2>
{#if active.length === 0}
<p class="empty">nothing active</p>
{:else}
{#each active as d (d.id)}
{@render deployRow(d)}
{/each}
{/if}
</section>
{#if driftOpen}
<div class="drift-body">
{#if drift && drift.totalDrift > 0}
<input
class="drift-filter"
type="search"
placeholder="Filter by server name…"
bind:value={driftFilter}
/>
{#each filteredDriftServers as s (s.server)}
<div class="drift-server">
<div class="drift-server-head">
<span class="server-name">{s.server}</span>
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="drift-files">
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
<Badge
variant={f.driftKind === 'untracked' ? 'danger' : 'warning'}
size="sm"
mono
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
>
[{f.driftKind === 'missing' ? '+' : '−'} {f.kind}] {f.relPath}
</Badge>
{/each}
</div>
</div>
{/each}
{:else if drift?.totalDrift === 0}
<EmptyState title="Inventory matches disk" description="Nothing to reconcile." />
{:else}
<EmptyState title="Scan hasn't run yet" description="Click Rescan to look for drift." />
{/if}
</div>
{/if}
<section>
<h2>History (7 days)</h2>
{#if history.length === 0}
<p class="empty">no completed deployments in the retention window</p>
{:else}
{#each history as d (d.id)}
{@render deployRow(d)}
{/each}
{/if}
</section>
{/if}
{#if flash}
<div class="flash">{flash}</div>
{/if}
</Card>
<!-- Active pipelines -->
<section class="section">
<div class="section-head">
<h2>Active</h2>
<Badge variant="neutral" size="sm">{active.length}</Badge>
</div>
{#if active.length === 0}
<EmptyState
title="Nothing in flight"
description="Push scripts or open a beta to see deployments here."
/>
{:else}
<div class="deploy-list">
{#each active as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
{/if}
</section>
<!-- History timeline -->
<section class="section">
<div class="section-head">
<h2>History</h2>
<Badge variant="neutral" size="sm">{history.length}</Badge>
</div>
{#if history.length === 0}
<EmptyState
title="No completed deployments in the retention window"
description="Retention is 7 days by default."
/>
{:else}
{#each historyByDay as [day, rows] (day)}
<div class="day-group">
<div class="day-label">{day}</div>
<div class="deploy-list">
{#each rows as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
</div>
{/each}
{/if}
</section>
{/if}
</div>
{#snippet deployRow(d: Deployment)}
<a class="row" href="/deployments/{d.id}">
<div class="head">
<div class="head-l">
<a class="deploy-row" href="/deployments/{d.id}">
<div class="row-head">
<div class="row-head-l">
<span class="action">{d.action}</span>
{#if d.action === 'promote' || d.action === 'rollback'}
<span class="target mono">{scriptSummary(d)}</span>
<span class="target">{scriptSummary(d)}</span>
{/if}
{#if d.rolled_back_by}
<span class="tag rb">rolled back</span>
<Badge variant="neutral" size="sm">Rolled back</Badge>
{/if}
</div>
<div class="head-r">
<div class="row-head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
<button class="rb-btn" onclick={(e) => rollbackPromote(d, e)} disabled={rollingBack === d.id}>
{rollingBack === d.id ? 'rolling back…' : 'rollback'}
</button>
<Button
variant="danger"
size="sm"
onclick={(e) => rollbackPromote(d, e)}
disabled={rollingBack === d.id}
loading={rollingBack === d.id}
>
Rollback
</Button>
{/if}
<span class="when">{ago(d.started_at)}</span>
</div>
</div>
<div class="chips">
<!-- Mini pipeline visual: one dot per server, colored by state. -->
<div class="pipeline" aria-label="Server progress">
{#each d.servers as s (s.server)}
<span class={chipClass(s.restart_state)} title={s.error ?? ''}>
{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}
<span
class="node n-{s.restart_state}"
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
<span class="dot"></span>
<span class="node-label">{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
</span>
{/each}
</div>
<div class="id">{d.id}</div>
<div class="row-foot">
<span class="id">{d.id}</span>
<div class="counts">
{#each ['done','pending','failed','held','manual'] as state}
{@const n = d.servers.filter((s) => s.restart_state === state).length}
{#if n > 0}
<Badge variant={stateBadgeVariant(state)} size="sm">{n} {state}</Badge>
{/if}
{/each}
</div>
</div>
</a>
{/snippet}
<style>
h1 {
color: var(--cream);
font-family: var(--font-display);
font-size: var(--text-3xl);
letter-spacing: var(--tracking-display);
line-height: var(--leading-tight);
margin: 0 0 28px;
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
h2 {
margin: var(--gap-section) 0 16px;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-300);
}
.row {
display: block;
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 18px 20px;
margin-bottom: 12px;
color: var(--ink-100);
text-decoration: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.head { display: flex; justify-content: space-between; margin-bottom: 10px; align-items: center; gap: 12px; }
.head-l { display: flex; gap: 10px; align-items: baseline; }
.head-r { display: flex; gap: 10px; align-items: center; }
.action { font-family: var(--font-mono); font-size: var(--text-md); color: var(--cream); }
.target { color: var(--spark-500); font-size: var(--text-mono); }
.when { color: var(--ink-500); font-size: var(--text-mono); font-family: var(--font-mono); }
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); border: 1px solid transparent; }
.tag.rb { background: var(--bg-card); border-color: var(--border); color: var(--ink-500); }
.rb-btn {
background: var(--bg-nav);
border: 1px solid rgba(255, 48, 80, 0.4);
color: var(--danger-500);
border-radius: var(--r-sm);
padding: 6px 12px;
font-family: var(--font-body);
font-size: var(--text-xs);
cursor: pointer;
}
.rb-btn:hover:not(:disabled) { border-color: var(--danger-500); }
.rb-btn:disabled { opacity: 0.5; cursor: default; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.chip {
padding: 3px 9px;
border-radius: var(--r-sm);
font-size: var(--text-mono-sm);
font-family: var(--font-mono);
border: 1px solid transparent;
}
.chip-done {
background: rgba(124, 212, 255, 0.10);
border-color: rgba(124, 212, 255, 0.3);
color: var(--spark-500);
}
.chip-failed {
background: rgba(255, 48, 80, 0.1);
border-color: rgba(255, 48, 80, 0.3);
color: var(--danger-500);
}
.chip-pending {
background: rgba(255, 176, 32, 0.1);
border-color: rgba(255, 176, 32, 0.3);
color: var(--amber-500);
}
.chip-held {
background: rgba(124, 192, 245, 0.10);
border-color: rgba(124, 192, 245, 0.28);
color: var(--cream);
}
.chip-manual {
background: var(--bg-nav);
border-color: var(--border);
color: var(--ink-500);
}
.id {
color: var(--ink-700);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.empty {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-base);
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
.drift-panel {
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-panel);
margin-bottom: var(--gap-section);
/* Drift summary bar */
.drift-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
text-align: left;
font: inherit;
}
.drift-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
.drift-head h2 { margin: 0; }
.drift-actions { display: flex; gap: 8px; }
.drift-actions button {
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 8px 16px;
.drift-summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-md); }
.drift-summary-l {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
flex-wrap: wrap;
}
.drift-summary-r {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.drift-title {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.drift-servers {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.chev {
color: var(--text-tertiary);
font-size: var(--text-lg);
line-height: 1;
transition: transform var(--dur-base) var(--ease-standard);
display: inline-block;
}
.chev.open { transform: rotate(90deg); }
.drift-body {
margin-top: var(--space-5);
padding-top: var(--space-5);
border-top: 1px solid var(--border-subtle);
}
.drift-filter {
width: 100%;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease-standard);
}
.drift-actions button:hover:not(:disabled) { border-color: var(--accent); box-shadow: var(--accent-glow); }
.drift-actions button.primary {
background: linear-gradient(180deg, var(--danger-500), #a01e39);
border-color: rgba(255, 48, 80, 0.6);
color: var(--cream);
font-weight: 600;
.drift-filter:focus {
outline: none;
border-color: var(--accent-primary);
}
.drift-actions button:disabled { opacity: 0.4; cursor: default; }
.drift-server {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 14px;
margin: 8px 0;
padding: var(--space-3) var(--space-4);
margin: var(--space-2) 0;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.drift-server-head { color: var(--cream); font-size: var(--text-mono); margin-bottom: 6px; }
.drift-files { display: flex; flex-wrap: wrap; gap: 6px; }
.drift-file {
.drift-server-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.server-name { color: var(--text-primary); }
.server-count { color: var(--text-tertiary); font-size: var(--text-xs); }
.drift-files {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.flash {
margin-top: var(--space-3);
padding: var(--space-3) var(--space-4);
background: rgba(124, 212, 255, 0.06);
border: 1px solid rgba(124, 212, 255, 0.22);
border-radius: var(--radius-md);
color: var(--state-online);
font-size: var(--text-sm);
}
/* Sections */
.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);
letter-spacing: var(--tracking-tight);
}
.day-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.day-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.deploy-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Deployment rows */
.deploy-row {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
transition:
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.deploy-row:hover {
background: var(--surface-raised);
border-color: var(--border-strong);
}
.deploy-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.row-head-l {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
min-width: 0;
}
.row-head-r {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.action {
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.target {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.when {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
/* Pipeline visual */
.pipeline {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-2) 0;
}
.node {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px;
border-radius: var(--r-sm);
font-size: var(--text-mono-sm);
border: 1px solid transparent;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--surface-sunken);
}
.drift-file.untracked {
background: rgba(255, 48, 80, 0.08);
border-color: rgba(255, 48, 80, 0.25);
color: var(--danger-500);
.node .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-quaternary);
}
.drift-file.missing {
background: rgba(255, 176, 32, 0.08);
border-color: rgba(255, 176, 32, 0.3);
color: var(--amber-500);
.n-done .dot { background: var(--state-online); }
.n-pending .dot { background: var(--state-warning); }
.n-failed .dot { background: var(--state-danger); }
.n-held .dot { background: var(--blue-300); }
.n-manual .dot { background: var(--text-tertiary); }
.node-label { color: var(--text-secondary); }
.row-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.id {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-quaternary);
}
.counts {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.row-head, .row-foot { flex-direction: column; align-items: flex-start; }
.drift-summary { flex-direction: column; align-items: flex-start; }
.drift-summary-r { width: 100%; justify-content: flex-end; }
}
.flash { color: var(--spark-400); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
.mono { font-family: var(--font-mono); }
</style>