Clarify UI status and remove noisy live regions

This commit is contained in:
2026-10-07 18:47:02 -04:00
parent 45e9bcd4ce
commit e35b84475d
12 changed files with 172 additions and 108 deletions
@@ -63,9 +63,9 @@
border-color: var(--border-subtle);
}
.v-online {
background: rgba(124, 212, 255, 0.10);
background: color-mix(in srgb, var(--state-online) 10%, transparent);
color: var(--state-online);
border-color: rgba(124, 212, 255, 0.25);
border-color: color-mix(in srgb, var(--state-online) 25%, transparent);
}
.v-warning {
background: rgba(255, 176, 32, 0.10);
@@ -78,9 +78,9 @@
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);
background: color-mix(in srgb, var(--state-info) 10%, transparent);
color: var(--state-info);
border-color: color-mix(in srgb, var(--state-info) 28%, transparent);
}
.v-accent {
background: var(--accent-primary);
@@ -10,6 +10,7 @@
href?: string | null;
as?: 'div' | 'section' | 'article' | 'a';
ariaLabel?: string;
ariaLabelledby?: string;
class?: string;
style?: string;
children?: Snippet;
@@ -22,6 +23,7 @@
href = null,
as = 'div',
ariaLabel,
ariaLabelledby,
class: klass = '',
style,
children,
@@ -41,6 +43,7 @@
class="card p-{padding} e-{elevation} {klass}"
href={isLink ? href : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{role}
{tabindex}
{style}
@@ -7,16 +7,17 @@
icon?: Snippet;
actions?: Snippet;
spacious?: boolean;
headingLevel?: 2 | 3 | 4;
}
let { title, description = null, icon, actions, spacious = false }: Props = $props();
let { title, description = null, icon, actions, spacious = false, headingLevel = 2 }: Props = $props();
</script>
<div class="empty" class:spacious role="status">
<div class="empty" class:spacious>
{#if icon}
<div class="icon">{@render icon()}</div>
{/if}
<h3 class="title">{title}</h3>
<svelte:element this={`h${headingLevel}`} class="title">{title}</svelte:element>
{#if description}<p class="desc">{description}</p>{/if}
{#if actions}<div class="actions">{@render actions()}</div>{/if}
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import Badge from './Badge.svelte';
import StatusPip from './StatusPip.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>
<Badge variant={status.tone} {size}><StatusPip {state} size="sm" />{status.label}</Badge>
@@ -1,66 +1,40 @@
<script lang="ts">
import type { Health } from '$ui/stores/servers.js';
import { statusPresentation, statusShapes, type StatusTone } from '$ui/status.js';
interface Props {
health?: Health;
state?: string;
tone?: StatusTone;
size?: 'sm' | 'md';
label?: string | null;
}
let { health = 'offline', size = 'md', label = null }: Props = $props();
let toneClass = $derived(
health === 'online' ? 't-online' :
health === 'stale' || health === 'starting' || health === 'provisioning' ? 't-warning' :
't-danger'
);
let announceLabel = $derived(
label ?? (
health === 'online' ? 'online' :
health === 'starting' ? 'starting' :
health === 'provisioning' ? 'provisioning' :
health === 'stale' ? 'stale' :
'offline'
)
);
let { health = 'offline', state, tone, size = 'md', label = null }: Props = $props();
const presentation = $derived(statusPresentation(state ?? health));
const resolvedTone = $derived(tone ?? presentation.tone);
const shape = $derived(statusShapes[resolvedTone]);
</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>
<span class="pip s-{size} t-{resolvedTone} shape-{shape}" role={label ? 'img' : undefined} aria-label={label ?? undefined} aria-hidden={label ? undefined : true}></span>
<style>
.pip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
}
.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; }
.s-sm { width: 10px; height: 10px; }
.s-md { width: 12px; height: 12px; }
.t-online { color: var(--state-online); }
.t-warning { color: var(--state-warning); }
.t-danger { color: var(--state-danger); }
.t-info { color: var(--state-info); }
.t-neutral { color: var(--state-neutral); }
.shape-circle { border-radius: 50%; background: currentColor; }
.shape-square { border-radius: 2px; background: currentColor; }
.shape-triangle { background: currentColor; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
.shape-hollow, .shape-ring { border: 2px solid currentColor; border-radius: 50%; }
.shape-ring { border-right-color: transparent; animation: status-spin 1s linear infinite; }
@keyframes status-spin { to { transform: rotate(360deg); } }
</style>