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
@@ -0,0 +1,89 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Variant = 'neutral' | 'online' | 'warning' | 'danger' | 'info' | 'accent';
type Size = 'sm' | 'md';
interface Props {
variant?: Variant;
size?: Size;
uppercase?: boolean;
mono?: boolean;
title?: string;
children?: Snippet;
}
let {
variant = 'neutral',
size = 'sm',
uppercase = false,
mono = false,
title,
children
}: Props = $props();
</script>
<span
class="badge v-{variant} s-{size}"
class:uppercase
class:mono
{title}
>
{@render children?.()}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-weight: var(--weight-medium);
line-height: 1;
white-space: nowrap;
font-variant-numeric: tabular-nums;
border: 1px solid transparent;
}
.uppercase {
text-transform: uppercase;
letter-spacing: var(--tracking-label);
font-weight: var(--weight-semibold);
}
.mono {
font-family: var(--font-mono);
}
.s-sm { padding: 3px 8px; font-size: var(--text-xs); }
.s-md { padding: 5px 12px; font-size: var(--text-sm); }
.v-neutral {
background: var(--surface-raised);
color: var(--text-secondary);
border-color: var(--border-subtle);
}
.v-online {
background: rgba(124, 212, 255, 0.10);
color: var(--state-online);
border-color: rgba(124, 212, 255, 0.25);
}
.v-warning {
background: rgba(255, 176, 32, 0.10);
color: var(--state-warning);
border-color: rgba(255, 176, 32, 0.28);
}
.v-danger {
background: rgba(255, 86, 112, 0.10);
color: var(--state-danger);
border-color: rgba(255, 86, 112, 0.28);
}
.v-info {
background: rgba(124, 192, 245, 0.10);
color: var(--blue-300);
border-color: rgba(124, 192, 245, 0.28);
}
.v-accent {
background: var(--accent-primary);
color: var(--text-on-accent);
}
</style>
@@ -0,0 +1,55 @@
<script lang="ts">
interface Crumb {
label: string;
href?: string | null;
}
interface Props {
items: Crumb[];
}
let { items }: Props = $props();
</script>
<nav class="crumbs" aria-label="Breadcrumb">
<ol>
{#each items as c, i (i + c.label)}
<li>
{#if c.href && i < items.length - 1}
<a href={c.href}>{c.label}</a>
{:else}
<span aria-current={i === items.length - 1 ? 'page' : undefined}>{c.label}</span>
{/if}
{#if i < items.length - 1}<span class="sep" aria-hidden="true">›</span>{/if}
</li>
{/each}
</ol>
</nav>
<style>
.crumbs ol {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.crumbs li {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.crumbs a {
color: var(--text-tertiary);
text-decoration: none;
transition: color var(--dur-fast) var(--ease-standard);
}
.crumbs a:hover { color: var(--text-primary); }
.crumbs a:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
.crumbs [aria-current='page'] { color: var(--text-primary); }
.sep { color: var(--text-quaternary); }
</style>
@@ -0,0 +1,162 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'lg';
interface Props {
variant?: Variant;
size?: Size;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
href?: string | null;
title?: string;
ariaLabel?: string;
onclick?: (e: MouseEvent) => void;
children?: Snippet;
leading?: Snippet;
trailing?: Snippet;
}
let {
variant = 'secondary',
size = 'md',
type = 'button',
disabled = false,
loading = false,
href = null,
title,
ariaLabel,
onclick,
children,
leading,
trailing
}: Props = $props();
let isLink = $derived(href !== null && href !== undefined);
</script>
{#if isLink}
<a
class="btn v-{variant} s-{size}"
class:is-loading={loading}
aria-disabled={disabled || loading}
aria-label={ariaLabel}
{title}
href={disabled || loading ? undefined : href}
role="button"
tabindex="0"
>
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span>
{#if trailing}<span class="slot slot-trailing">{@render trailing()}</span>{/if}
{#if loading}<span class="spinner" aria-hidden="true"></span>{/if}
</a>
{:else}
<button
class="btn v-{variant} s-{size}"
class:is-loading={loading}
{type}
{title}
aria-label={ariaLabel}
disabled={disabled || loading}
{onclick}
>
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span>
{#if trailing}<span class="slot slot-trailing">{@render trailing()}</span>{/if}
{#if loading}<span class="spinner" aria-hidden="true"></span>{/if}
</button>
{/if}
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-normal);
line-height: 1;
cursor: pointer;
text-decoration: none;
border: 1px solid transparent;
transition:
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard),
color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
white-space: nowrap;
user-select: none;
position: relative;
}
.btn:focus { outline: none; }
.btn:focus-visible { box-shadow: var(--focus-ring); }
.btn:disabled,
.btn[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
/* Sizes */
.s-sm { padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
/* Variants */
.v-primary {
background: var(--accent-primary);
color: var(--text-on-accent);
}
.v-primary:hover:not(:disabled):not([aria-disabled='true']) {
background: var(--accent-primary-hover);
color: var(--gray-950);
}
.v-secondary {
background: var(--surface-raised);
color: var(--text-primary);
border-color: var(--border-subtle);
}
.v-secondary:hover:not(:disabled):not([aria-disabled='true']) {
background: var(--surface-overlay);
border-color: var(--border-strong);
}
.v-ghost {
background: transparent;
color: var(--text-secondary);
}
.v-ghost:hover:not(:disabled):not([aria-disabled='true']) {
background: var(--surface-raised);
color: var(--text-primary);
}
.v-danger {
background: transparent;
color: var(--state-danger);
border-color: rgba(255, 86, 112, 0.32);
}
.v-danger:hover:not(:disabled):not([aria-disabled='true']) {
background: rgba(255, 86, 112, 0.12);
border-color: var(--state-danger);
}
/* Loading */
.is-loading .label,
.is-loading .slot { visibility: hidden; }
.spinner {
position: absolute;
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
@@ -0,0 +1,110 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Padding = 'none' | 'sm' | 'md' | 'lg';
type Elevation = 'flat' | 'raised' | 'interactive';
interface Props {
padding?: Padding;
elevation?: Elevation;
href?: string | null;
as?: 'div' | 'section' | 'article' | 'a';
ariaLabel?: string;
class?: string;
style?: string;
children?: Snippet;
onclick?: (e: MouseEvent) => void;
}
let {
padding = 'md',
elevation = 'flat',
href = null,
as = 'div',
ariaLabel,
class: klass = '',
style,
children,
onclick
}: Props = $props();
let isLink = $derived(href !== null && href !== undefined);
let tag = $derived(isLink ? 'a' : as);
// When the container isn't a link but does have a click handler, assign
// a role so screen readers announce it as interactive (a11y_no_static).
let role = $derived(!isLink && onclick ? 'button' : undefined);
let tabindex = $derived(!isLink && onclick ? 0 : undefined);
</script>
<svelte:element
this={tag}
class="card p-{padding} e-{elevation} {klass}"
href={isLink ? href : undefined}
aria-label={ariaLabel}
{role}
{tabindex}
{style}
{onclick}
>
{@render children?.()}
</svelte:element>
<style>
.card {
position: relative;
display: flex;
flex-direction: column;
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 90%, transparent);
color: var(--text-primary);
text-decoration: none;
transition:
transform var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-base) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
}
/* Inner highlight — a single hairline of light at the top edge. */
.card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Backdrop blur only where the browser supports it — some rendering
backends downgrade poorly, and we don't want blurred noise. */
@supports (backdrop-filter: blur(4px)) {
.card {
backdrop-filter: blur(10px) saturate(1.2);
-webkit-backdrop-filter: blur(10px) saturate(1.2);
}
}
/* Padding scale */
.p-none { padding: 0; }
.p-sm { padding: var(--space-4); }
.p-md { padding: var(--space-5); }
.p-lg { padding: var(--space-6); }
/* Elevation */
.e-flat { box-shadow: var(--shadow-ambient); }
.e-raised { box-shadow: var(--shadow-raised); }
.e-interactive {
box-shadow: var(--shadow-ambient);
cursor: pointer;
}
.e-interactive:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-raised);
background: color-mix(in oklab, var(--surface-raised) 92%, transparent);
}
.e-interactive:focus-visible {
outline: none;
box-shadow: var(--focus-ring), var(--shadow-raised);
}
.e-interactive:active { transform: translateY(-1px); }
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
description?: string | null;
icon?: Snippet;
actions?: Snippet;
}
let { title, description = null, icon, actions }: Props = $props();
</script>
<div class="empty" role="status">
{#if icon}
<div class="icon">{@render icon()}</div>
{:else}
<div class="icon default">
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 4" />
</svg>
</div>
{/if}
<h3 class="title">{title}</h3>
{#if description}<p class="desc">{description}</p>{/if}
{#if actions}<div class="actions">{@render actions()}</div>{/if}
</div>
<style>
.empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-16) var(--space-6);
gap: var(--space-3);
color: var(--text-secondary);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 60%, transparent);
}
.icon {
color: var(--text-tertiary);
margin-bottom: var(--space-2);
}
.title {
margin: 0;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.desc {
margin: 0;
font-size: var(--text-sm);
color: var(--text-tertiary);
max-width: 44ch;
}
.actions {
margin-top: var(--space-4);
display: flex;
gap: var(--space-2);
}
</style>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label?: string;
hint?: string | null;
error?: string | null;
required?: boolean;
forId?: string;
children?: Snippet;
}
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
</script>
<div class="field">
{#if label}
<label class="label" for={forId}>
{label}
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
</label>
{/if}
<div class="control" class:has-error={!!error}>
{@render children?.()}
</div>
{#if error}
<div class="msg msg-error" role="alert">{error}</div>
{:else if hint}
<div class="msg msg-hint">{hint}</div>
{/if}
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
}
.req { color: var(--state-danger); margin-left: 3px; }
.control { display: flex; flex-direction: column; }
.msg {
font-size: var(--text-xs);
}
.msg-hint { color: var(--text-tertiary); }
.msg-error { color: var(--state-danger); }
</style>
@@ -0,0 +1,102 @@
<script lang="ts">
interface Props {
value?: string;
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
placeholder?: string;
id?: string;
name?: string;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
autocomplete?: import('svelte/elements').FullAutoFill;
mono?: boolean;
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
accept?: string;
}
let {
value = $bindable(''),
type = 'text',
placeholder,
id,
name,
required = false,
disabled = false,
readonly = false,
autocomplete,
mono = false,
onchange,
oninput,
accept
}: Props = $props();
</script>
<input
class="input"
class:mono
{type}
{id}
{name}
{placeholder}
{required}
{disabled}
{readonly}
{autocomplete}
{accept}
bind:value
{onchange}
{oninput}
/>
<style>
.input {
width: 100%;
padding: 9px 12px;
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);
line-height: 1.4;
transition:
border-color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.input.mono { font-family: var(--font-mono); }
.input::placeholder { color: var(--text-quaternary); }
.input:hover { border-color: var(--border-strong); }
.input:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15);
}
.input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input[type='file'] {
padding: 6px;
cursor: pointer;
}
.input[type='file']::file-selector-button {
margin-right: var(--space-3);
padding: 6px 12px;
background: var(--surface-raised);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
transition: background-color var(--dur-fast) var(--ease-standard);
}
.input[type='file']::file-selector-button:hover {
background: var(--surface-overlay);
}
</style>
@@ -0,0 +1,86 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string | null;
eyebrow?: string | null;
size?: 'md' | 'lg';
crumbs?: Snippet;
actions?: Snippet;
}
let {
title,
subtitle = null,
eyebrow = null,
size = 'md',
crumbs,
actions
}: Props = $props();
</script>
<header class="section-header s-{size}">
{#if crumbs}<div class="crumbs">{@render crumbs()}</div>{/if}
<div class="row">
<div class="titles">
{#if eyebrow}<div class="eyebrow">{eyebrow}</div>{/if}
<h1 class="title">{title}</h1>
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
</div>
{#if actions}<div class="actions">{@render actions()}</div>{/if}
</div>
</header>
<style>
.section-header {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-8);
}
.crumbs {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-6);
}
.titles { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.eyebrow {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--accent-primary);
}
.title {
margin: 0;
font-family: var(--font-body);
font-weight: var(--weight-bold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
}
.s-md .title { font-size: var(--text-2xl); }
.s-lg .title { font-size: var(--text-3xl); }
.subtitle {
margin: 0;
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
@media (max-width: 640px) {
.row { flex-direction: column; align-items: flex-start; gap: var(--space-4); }
.actions { width: 100%; }
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
value?: string;
options: Option[];
id?: string;
name?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
onchange?: (e: Event) => void;
}
let {
value = $bindable(''),
options,
id,
name,
required = false,
disabled = false,
placeholder,
onchange
}: Props = $props();
</script>
<div class="wrap">
<select
class="select"
{id}
{name}
{required}
{disabled}
bind:value
{onchange}
>
{#if placeholder}
<option value="" disabled>{placeholder}</option>
{/if}
{#each options as o (o.value)}
<option value={o.value} disabled={o.disabled}>{o.label}</option>
{/each}
</select>
<span class="chev" aria-hidden="true">▾</span>
</div>
<style>
.wrap {
position: relative;
display: block;
}
.select {
width: 100%;
appearance: none;
padding: 9px 32px 9px 12px;
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);
line-height: 1.4;
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.select:hover { border-color: var(--border-strong); }
.select:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15);
}
.select:disabled { opacity: 0.5; cursor: not-allowed; }
.chev {
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
font-size: var(--text-xs);
}
</style>
@@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
width?: string;
height?: string;
radius?: 'sm' | 'md' | 'lg' | 'pill';
block?: boolean;
}
let {
width = '100%',
height = '1em',
radius = 'sm',
block = false
}: Props = $props();
let style = $derived(`width:${width};height:${height};`);
</script>
<span class="sk r-{radius}" class:block {style}></span>
<style>
.sk {
display: inline-block;
background:
linear-gradient(
90deg,
var(--surface-raised) 0%,
var(--surface-overlay) 50%,
var(--surface-raised) 100%
);
background-size: 200% 100%;
animation: shimmer 1.6s var(--ease-linear) infinite;
}
.block { display: block; }
.r-sm { border-radius: var(--radius-sm); }
.r-md { border-radius: var(--radius-md); }
.r-lg { border-radius: var(--radius-lg); }
.r-pill { border-radius: var(--radius-pill); }
</style>
@@ -0,0 +1,102 @@
<script lang="ts">
import { tweenedNumber } from '$ui/motion/tweenNumber.js';
import type { Snippet } from 'svelte';
type Size = 'sm' | 'md' | 'lg' | 'xl';
type Tone = 'primary' | 'accent' | 'online' | 'warning' | 'danger';
interface Props {
label?: string;
value: number | string | null | undefined;
unit?: string;
size?: Size;
tone?: Tone;
tween?: boolean;
format?: (n: number) => string;
trailing?: Snippet;
}
let {
label,
value,
unit,
size = 'md',
tone = 'primary',
tween = true,
format,
trailing
}: Props = $props();
// Only numeric values can be tweened. Strings/nulls render as-is.
let numeric = $derived(typeof value === 'number' ? value : null);
// Start at 0 — the $effect below jumps to the real initial value on
// first paint, which is the intended entrance animation.
let tweened = tweenedNumber(0);
$effect(() => {
if (numeric !== null) tweened.set(numeric);
});
let displayValue = $derived.by(() => {
if (value === null || value === undefined) return '—';
if (typeof value === 'string') return value;
const n = tween ? $tweened : value;
return format ? format(n) : Math.round(n).toString();
});
</script>
<div class="stat s-{size} t-{tone}">
{#if label}
<div class="label">{label}</div>
{/if}
<div class="value-row">
<span class="value">{displayValue}</span>
{#if unit}<span class="unit">{unit}</span>{/if}
{#if trailing}<span class="trailing">{@render trailing()}</span>{/if}
</div>
</div>
<style>
.stat {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.label {
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
}
.value-row {
display: inline-flex;
align-items: baseline;
gap: var(--space-2);
}
.value {
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
}
.unit {
font-size: var(--text-sm);
color: var(--text-tertiary);
font-weight: var(--weight-medium);
}
.trailing { display: inline-flex; }
.s-sm .value { font-size: var(--text-md); }
.s-md .value { font-size: var(--text-xl); }
.s-lg .value { font-size: var(--text-2xl); }
.s-xl .value { font-size: var(--text-3xl); }
.t-primary .value { color: var(--text-primary); }
.t-accent .value { color: var(--accent-primary); }
.t-online .value { color: var(--state-online); }
.t-warning .value { color: var(--state-warning); }
.t-danger .value { color: var(--state-danger); }
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { Health } from '$ui/stores/servers.js';
interface Props {
health?: Health;
size?: 'sm' | 'md';
label?: string | null;
}
let { health = 'offline', size = 'md', label = null }: Props = $props();
let toneClass = $derived(
health === 'online' ? 't-online' :
health === 'stale' ? 't-warning' :
't-danger'
);
let announceLabel = $derived(
label ?? (
health === 'online' ? 'online' :
health === 'stale' ? 'stale' :
'offline'
)
);
</script>
<span class="pip s-{size} {toneClass}" role="status" aria-label={announceLabel}>
<span class="halo" aria-hidden="true"></span>
<span class="dot" aria-hidden="true"></span>
</span>
<style>
.pip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.s-sm { width: 8px; height: 8px; }
.s-md { width: 10px; height: 10px; }
.dot {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
border-radius: 50%;
}
/* Halo — opacity+scale only, GPU-cheap, no box-shadow ring. */
.halo {
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
}
.t-online .dot { background: var(--state-online); box-shadow: 0 0 4px rgba(124, 212, 255, 0.55); }
.t-online .halo { background: var(--state-online); animation: pip-breathe 2.4s var(--ease-standard) infinite; }
.t-warning .dot { background: var(--state-warning); }
.t-danger .dot { background: var(--state-danger); opacity: 0.7; }
</style>
@@ -0,0 +1,101 @@
<script lang="ts">
interface Tab {
id: string;
label: string;
badge?: string | number | null;
disabled?: boolean;
}
interface Props {
tabs: Tab[];
value: string;
onchange?: (id: string) => void;
ariaLabel?: string;
size?: 'sm' | 'md';
}
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md' }: Props = $props();
function select(id: string, disabled: boolean | undefined) {
if (disabled) return;
value = id;
onchange?.(id);
}
</script>
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel}>
{#each tabs as t (t.id)}
<button
type="button"
class="tab"
class:active={value === t.id}
role="tab"
aria-selected={value === t.id}
disabled={t.disabled}
onclick={() => select(t.id, t.disabled)}
>
<span class="lbl">{t.label}</span>
{#if t.badge !== undefined && t.badge !== null && t.badge !== ''}
<span class="badge">{t.badge}</span>
{/if}
</button>
{/each}
</div>
<style>
.tabs {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px;
background: color-mix(in oklab, var(--surface) 80%, transparent);
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
}
.tab {
appearance: none;
display: inline-flex;
align-items: center;
gap: var(--space-2);
background: transparent;
border: none;
color: var(--text-tertiary);
font-family: var(--font-body);
font-weight: var(--weight-medium);
cursor: pointer;
border-radius: var(--radius-pill);
transition:
color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
}
.s-sm .tab { padding: 6px 12px; font-size: var(--text-xs); }
.s-md .tab { padding: 8px 16px; font-size: var(--text-sm); }
.tab:hover:not(:disabled):not(.active) { color: var(--text-primary); }
.tab:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.tab:disabled { opacity: 0.4; cursor: not-allowed; }
.tab.active {
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-pill);
background: var(--surface-overlay);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
}
.tab.active .badge {
background: var(--accent-primary);
color: var(--text-on-accent);
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
interface Props {
value?: string;
placeholder?: string;
id?: string;
name?: string;
rows?: number;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
mono?: boolean;
oninput?: (e: Event) => void;
}
let {
value = $bindable(''),
placeholder,
id,
name,
rows = 4,
required = false,
disabled = false,
readonly = false,
mono = false,
oninput
}: Props = $props();
</script>
<textarea
class="textarea"
class:mono
{id}
{name}
{placeholder}
{rows}
{required}
{disabled}
{readonly}
bind:value
{oninput}
></textarea>
<style>
.textarea {
width: 100%;
padding: var(--space-3) var(--space-3);
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);
line-height: var(--leading-body);
resize: vertical;
transition:
border-color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
.textarea::placeholder { color: var(--text-quaternary); }
.textarea:hover { border-color: var(--border-strong); }
.textarea:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15);
}
.textarea:disabled { opacity: 0.5; cursor: not-allowed; }
</style>