Unify UI workflows and streamline fleet management

This commit is contained in:
2026-09-18 14:00:28 -04:00
parent 9cf0bd87f4
commit f54cac8c50
63 changed files with 1706 additions and 1286 deletions
@@ -45,8 +45,9 @@
aria-label={ariaLabel}
{title}
href={disabled || loading ? undefined : href}
role="button"
tabindex="0"
tabindex={disabled || loading ? -1 : undefined}
aria-busy={loading}
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
>
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span>
@@ -60,6 +61,7 @@
{type}
{title}
aria-label={ariaLabel}
aria-busy={loading}
disabled={disabled || loading}
{onclick}
>
@@ -81,7 +83,8 @@
font-family: var(--font-body);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-normal);
line-height: 1;
line-height: 1.35;
min-height: 40px;
cursor: pointer;
text-decoration: none;
border: 1px solid transparent;
@@ -105,7 +108,7 @@
.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-sm { min-height: 32px; 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); }
+6 -12
View File
@@ -45,6 +45,7 @@
{tabindex}
{style}
{onclick}
onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }}
>
{@render children?.()}
</svelte:element>
@@ -56,7 +57,8 @@
display: flex;
flex-direction: column;
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 90%, transparent);
background: var(--surface);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
text-decoration: none;
transition:
@@ -77,12 +79,6 @@
/* 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; }
@@ -91,23 +87,21 @@
.p-lg { padding: var(--space-6); }
/* Elevation */
.e-flat { box-shadow: var(--shadow-ambient); }
.e-flat { box-shadow: none; }
.e-raised { box-shadow: var(--shadow-raised); }
.e-interactive {
box-shadow: var(--shadow-ambient);
box-shadow: none;
cursor: pointer;
}
.e-interactive:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-raised);
border-color: var(--border-strong);
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); }
@media (max-width: 480px) {
.p-md, .p-lg { padding: var(--space-4); }
}
@@ -0,0 +1,42 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
import Button from './Button.svelte';
let { open = $bindable(false), title, description, busy = false, wide = false, children, actions }:
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; children?: Snippet; actions?: Snippet } = $props();
const id = $props.id();
let dialog = $state<HTMLDialogElement>();
let returnFocus: HTMLElement | null = null;
$effect(() => {
if (!dialog) return;
if (open && !dialog.open) {
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialog.showModal();
} else if (!open && dialog.open) dialog.close();
});
function close() { if (!busy) open = false; }
async function closed() {
open = false;
await tick();
if (returnFocus?.isConnected) returnFocus.focus();
}
</script>
<dialog bind:this={dialog} class:wide aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
<div class="body">{@render children?.()}</div>
{#if actions}<footer>{@render actions()}</footer>{/if}
</dialog>
<style>
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
dialog.wide { width: min(880px, calc(100vw - 32px)); }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.body { padding: var(--space-6); }
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
</style>
@@ -6,20 +6,15 @@
description?: string | null;
icon?: Snippet;
actions?: Snippet;
spacious?: boolean;
}
let { title, description = null, icon, actions }: Props = $props();
let { title, description = null, icon, actions, spacious = false }: Props = $props();
</script>
<div class="empty" role="status">
<div class="empty" class:spacious 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}
@@ -32,12 +27,13 @@
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-16) var(--space-6);
padding: 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);
}
.spacious { padding: var(--space-12) var(--space-6); }
.icon {
color: var(--text-tertiary);
margin-bottom: var(--space-2);
+13 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { setContext, type Snippet } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
interface Props {
label?: string;
@@ -11,22 +12,28 @@
}
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
const id = $props.id();
setContext<FieldContext>(fieldContext, {
get labelId() { return `${id}-label`; },
get messageId() { return error || hint ? `${id}-message` : undefined; },
get invalid() { return !!error; }
});
</script>
<div class="field">
{#if label}
<label class="label" for={forId}>
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
{label}
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
</label>
</svelte:element>
{/if}
<div class="control" class:has-error={!!error}>
{@render children?.()}
</div>
{#if error}
<div class="msg msg-error" role="alert">{error}</div>
<div id={`${id}-message`} class="msg msg-error" role="alert">{error}</div>
{:else if hint}
<div class="msg msg-hint">{hint}</div>
<div id={`${id}-message`} class="msg msg-hint">{hint}</div>
{/if}
</div>
@@ -40,9 +47,7 @@
.label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
color: var(--text-secondary);
}
.req { color: var(--state-danger); margin-left: 3px; }
.control { display: flex; flex-direction: column; }
+10 -1
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Props {
value?: string;
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
@@ -13,6 +16,7 @@
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
accept?: string;
ariaLabel?: string;
}
let {
@@ -28,7 +32,8 @@
mono = false,
onchange,
oninput,
accept
accept,
ariaLabel
}: Props = $props();
</script>
@@ -44,6 +49,10 @@
{readonly}
{autocomplete}
{accept}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value
{onchange}
{oninput}
@@ -0,0 +1,8 @@
<script lang="ts">
import Button from './Button.svelte';
let { total, limit = $bindable(25), step = 25, label = 'items' }: { total: number; limit?: number; step?: number; label?: string } = $props();
</script>
{#if total > step}
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => limit += step}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
{/if}
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
@@ -37,7 +37,7 @@
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-8);
margin-bottom: 0;
}
.crumbs {
font-size: var(--text-xs);
+10 -1
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Option {
value: string;
label: string;
@@ -14,6 +17,7 @@
disabled?: boolean;
placeholder?: string;
onchange?: (e: Event) => void;
ariaLabel?: string;
}
let {
@@ -24,7 +28,8 @@
required = false,
disabled = false,
placeholder,
onchange
onchange,
ariaLabel
}: Props = $props();
</script>
@@ -35,6 +40,10 @@
{name}
{required}
{disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value
{onchange}
>
@@ -0,0 +1,7 @@
<script lang="ts">
import Badge from './Badge.svelte';
import { statusPresentation } from '../../status.js';
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
const status = $derived(statusPresentation(state));
</script>
<Badge variant={status.tone} {size}>{status.label}</Badge>
+23 -4
View File
@@ -12,9 +12,24 @@
onchange?: (id: string) => void;
ariaLabel?: string;
size?: 'sm' | 'md';
panelId?: string;
idPrefix?: string;
}
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md' }: Props = $props();
const uid = $props.id();
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
function onKey(event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const enabled = tabs.filter(t => !t.disabled);
if (!enabled.length) return;
const index = enabled.findIndex(t => t.id === value);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
select(enabled[next].id, false);
document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
}
function select(id: string, disabled: boolean | undefined) {
if (disabled) return;
@@ -30,7 +45,11 @@
class="tab"
class:active={value === t.id}
role="tab"
id={`${idPrefix}-${t.id}`}
aria-selected={value === t.id}
aria-controls={panelId}
tabindex={value === t.id ? 0 : -1}
onkeydown={onKey}
disabled={t.disabled}
onclick={() => select(t.id, t.disabled)}
>
@@ -52,7 +71,7 @@
gap: 2px;
padding: 4px;
background: color-mix(in oklab, var(--surface) 80%, transparent);
border-radius: var(--radius-pill);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.tab {
@@ -68,7 +87,7 @@
font-family: var(--font-body);
font-weight: var(--weight-medium);
cursor: pointer;
border-radius: var(--radius-pill);
border-radius: var(--radius-sm);
transition:
color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
@@ -97,7 +116,7 @@
background: var(--surface-overlay);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
font-size: var(--text-xs);
}
.tab.active .badge {
background: var(--accent-primary);
@@ -1,4 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Props {
value?: string;
placeholder?: string;
@@ -10,6 +13,7 @@
readonly?: boolean;
mono?: boolean;
oninput?: (e: Event) => void;
ariaLabel?: string;
}
let {
@@ -22,7 +26,8 @@
disabled = false,
readonly = false,
mono = false,
oninput
oninput,
ariaLabel
}: Props = $props();
</script>
@@ -36,6 +41,10 @@
{required}
{disabled}
{readonly}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value
{oninput}
></textarea>