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